.md file to compare - side-by-side diff against chat-drive
chat-drive
What it does for you
Lets you kick off a task by simply typing what you want into chat.
What it produces
A recent result, so you can see the kind of work it returns.
loading…
How to get it
These run inside the Snappy workspace. Want this working in your business? I set skills like this up with you, in one focused week.
For developers how this skill is built, graded, and how it runs
at a glance- the short version
what's inside - the parts that make up a skill 3/4 present
A skill is just a few plain-text files. Only the main one is required. The rest are optional, added as the work needs them. This is what the skill is made of; how it runs is just below.
state/skills/chat-drive/SKILL.md
present
state/lib/chat-drive.ts
present
state/bin/chat-drive/
not present
state/skills/chat-drive/AGENTS.md
present
how it runs - the shared frame every skill uses 4/5 present
Every skill runs the same way. One part does the work, a separate part checks it, and a short loader hands the AI exactly what it needs for the job. Anything this skill doesn't use shows a one-line note saying why, on purpose, not by accident.
This skill doesn't fix its own gaps yet.
state/log/evals.ndjson what it has learned - fixes written back in over time sample
When a run hits something this skill didn't handle, the fix gets written back into the skill so it doesn't happen again. FIXED means it was corrected on the spot. LOGGED means it's queued for a bigger rewrite. Either way, the skill gets a little better and never makes the same mistake twice.
- Loading feedback rows…
how the work flows- who makes it, who checks it
SKILL.md- the skill, written out in plain English
chat-drive
Push text into the Snappy OS composer from any agent, anywhere. The text flows through the same OpenUI submit path the human types into: processMessage → /dispatch/chat → AG-UI stream → generative-UI cards rendered in the live React tree. Same store, same surface, same eyes.
This is the missing primitive for closed-loop Snappy OS dogfood: an agent can now type intent and watch real cards stream in, then audit by screenshot.
What it's for
- Dogfood loops. A subagent pushes a stress-test intent, screenshots the
result, grades the rendered card. The actor (push) and the auditor (read the screenshot) are necessarily distinct - the contract holds for free.
- Automated UX QA. Verify the welcome surface unmounts on first message,
user-pill alignment, dispatch-card variants, etc., end-to-end through the rendered DOM.
- Recursive subagent dispatch. A long-running agent can re-enter the chat
surface mid-task by pushing a follow-up intent. The chat is the agent's outbox.
When NOT to use it
- Anything that doesn't need the rendered UI. If you don't care about the
React tree, call the head-screen server's /dispatch/chat directly, or use the dispatch skill. Running through the chat surface adds streaming latency for no reason.
- As a synthesis transport. The bridge is a queue, not an RPC channel -
there's no callback when streaming finishes. Use /dispatch/chat directly when you need the response programmatically.
Steps
- Verify the head-screen server is up. The bridge endpoints live on it.
bash ~/projects/snappy-os/state/bin/head-screen/launch.sh # idempotent
- Verify Snappy OS is running and on screen so the polled push lands somewhere.
pgrep -af "/Applications/SnappyChat.app/Contents/MacOS/SnappyChat"
If it's not, build + install:
cd ~/projects/snappy-os-app/apps/snappy-os && bash scripts/build-app.sh --install
- Push the intent.
npx tsx -e "
import { dispatchInChatUI } from './state/lib/chat-drive.ts';
await dispatchInChatUI('what did the agents do today', { waitForFirstFrame: 12000 });
"
The default waitForFirstFrame is 8000ms. Pass a larger value when the target backend is slow (Claude Code: 12-15s; openrouter/gemini: 6-10s).
- Audit by screenshot. The bridge has no completion callback - actor ≠ auditor.
npx tsx -e "
import { captureScreen } from './state/lib/desktop.ts';
const path = await captureScreen('/tmp/chat-drive-verify.png');
console.log(path);
"
Then Read the PNG. Welcome surface unmounted + user pill on the right + assistant card streaming = bridge working.
Library API
state/lib/chat-drive.ts exports three functions. Importable from any TS agent code; also runnable as a CLI smoke.
export async function dispatchInChatUI(
text: string,
opts?: { waitForFirstFrame?: number } // default 8000ms
): Promise<void>;
export async function resetChatUI(
opts?: { waitMs?: number } // default 1500ms
): Promise<void>;
export async function chatDriveAvailable(): Promise<boolean>;
resetChatUI is for multi-scenario dogfood loops: clears the thread and brings the welcome surface back so the next dispatchInChatUI lands in a clean state. Same FIFO as text pushes (single ordering), distinct /chat-inject-control endpoint so wire shape is unambiguous.
CLI:
npx tsx state/lib/chat-drive.ts "say hello in three words"
HEAD_SCREEN_URL env var overrides the default http://127.0.0.1:3147.
Architecture (one paragraph)
dispatchInChatUI POSTs to the head-screen server's POST /chat-inject-push endpoint, which appends the text to a 50-slot in-memory FIFO with FIFO eviction on overflow. The snappy-os React app mounts a polling effect that hits GET /chat-inject-pop every 500ms; on hit, it locates whichever OpenUI composer is currently visible (welcome OR thread variant) and writes through React's native value setter to trigger the textarea's onChange, then clicks the submit button. From there, the real processMessage path takes over - same code as a human typing.
For QA probes and dogfood agents, include "newThread": true in the push body. The app resets to a fresh chat before dispatching that item so probe traffic does not land in Robert's active thread. Omit it only when the intent is deliberately a follow-up in the current conversation.
Eval
Actor: dispatchInChatUI(text) - pushes onto the queue. Auditor: the audit harness re-reads the lib's exported function shape (present, async, two parameters); the user-facing audit is "is the rendered card correct" via screenshot, deliberately outside the lib.
Eval kind: shape. Mechanical: import the lib, assert dispatchInChatUI and chatDriveAvailable exist as functions, type-check passes. Logged as the skill's eval row in state/log/evals.ndjson.
Pitfalls
- The head-screen server must be alive. The bridge IS the head-screen
server. If /healthz doesn't answer, push will throw. chatDriveAvailable() is the cheap precheck.
- No completion callback.
waitForFirstFrameis the only synchronization
knob. Tune it per backend, then screenshot.
- Restart drops queued pushes. The queue is in-memory by design - restart
= empty. If a dogfood loop relies on durability across restarts, you're using the wrong primitive.
- The bridge is loopback only. No external network exposure. The CORS
headers are wide so file:// origins (WKWebView) work; the listener is bound to 127.0.0.1.
Files
state/lib/chat-drive.ts- the API (importable + CLI).state/bin/head-screen/server.ts- owns the queue endpoints
(POST /chat-inject-push, POST /chat-inject-control, GET /chat-inject-pop).
~/projects/snappy-os-app/apps/snappy-os/web/src/App.tsx- the React polling effect and
composer-injection helper that drains the queue.
AGENTS.md- what the AI loads when this skill comes up
chat-drive - loader
Push text into the Snappy OS composer. Routes through same OpenUI path as human typing: processMessage -> /dispatch/chat -> AG-UI stream -> live cards. Closed-loop dogfood: agent pushes intent, auditor screenshots result. Actor != auditor by design.
Full reference: state/skills/chat-drive/SKILL.md. Lib: state/lib/chat-drive.ts. Server: state/bin/head-screen/server.ts. Consumer: ~/projects/snappy-os-app/apps/snappy-os/web/src/App.tsx (poll 1000ms).
Critical Rules
- Actor (push) ≠ auditor (screenshot).
dispatchInChatUI()queues text; lib never reports "did card render." Audit vianpx tsx state/lib/desktop.ts capture-screen /tmp/path.png+ Read. No completion callback. waitForFirstFrameis only sync knob. Default 8000ms. Budget: 8s dispatch + 1s React poll lag = 9s minimum. Tune per backend: Claude Code 12-15s, openrouter/gemini 6-10s. After push, screenshot.- Head-screen server MUST be alive. Pre-flight:
chatDriveAvailable()orbash state/bin/head-screen/launch.sh(idempotent). Verify:curl http://127.0.0.1:3147/healthzanswers 200. - Snappy OS must be running and visible. Push -> in-memory FIFO. No polling = silent eviction. Confirm:
pgrep -af "/Applications/SnappyChat.app"shows process. Build if missing:cd ~/projects/snappy-os-app/apps/snappy-os && bash scripts/build-app.sh --install. - Queue is in-memory FIFO: 50-item cap, 30s TTL per item, wiped on server restart. Never durability-dependent. Pre-drain before QA:
curl -XPOST http://127.0.0.1:3147/chat-inject-flush(returns{flushed:N}). - React polls /chat-inject-pop every 1000ms (App.tsx:337). Each push incurs up to 1000ms before composer sees it. Factor into
waitForFirstFramebudget. - tsx never hot-reloads server.ts. After any server.ts edit, kill + restart the server. Verify:
pgrep -af server.ts+git log --oneline -1match. Without restart, edits invisible. - Don't push faster than dispatcher streams. Wait for RUN_FINISHED before next text push. Use
resetChatUI()(not manual reset) between scenarios. resetChatUI()does NOT flush. It sends control items for nav reset (welcome). Pre-flush with/chat-inject-flushif stale pushes queued.- Activate app before screenshot on secondary Space. Snappy OS off-screen = screencapture captures wallpaper. Run
osascript -e 'tell application "Snappy Chat" to activate'with 1-2s wait. - React pre-fetches /chat-inject-pop items. Polling effect calls pop() before dispatching. Manual
curldrain competes with React. Use/chat-inject-flushendpoint only; never manual pop drain. - Concurrent
claude -p/claude --continuecompete for queue. Symptom: pushes queue but never dispatch (other session consumed them). Mitigation: serialize QA sessions or use/dispatch/chatPOST directly (isolated, independent thread). - Server crashes wipe in-memory queue. Watch for
FATAL evalLeaderboardRegex undefinedinstate/log/head-screen.log. Verify uptime:ps -p $(pgrep -f server.ts | head -1) -o etime. If elapsed time reset, queue is gone; re-push after restart. - Force fresh
crypto.randomUUID()for each messageId. Reusing any id (especially OpenUI's optimistic user-message id) collides in store reducer = duplicate-render bug. App.tsxprocessMessagemust callcrypto.randomUUID()per injected message. Do not remove. - Direct
/dispatch/chatPOST requiresintentfield.curl -XPOST http://127.0.0.1:3147/dispatch/chat -d '{"intent":"text","threadId":"<id>"}'(NOT flatmessages[]array). Returns400 intent requiredif missing. - TCC screencapture blocked in subagent context. If
capture-screenfails with exit:1, use the bundled Computer/helper path or a live appshot as the audit surface; do not route through external helper daemons. __snappyNavdoes NOT navigate to Live Apps.window.__snappyNav('#/chat/live-apps')navigates to the chat view, not the Live Apps surface. Do not use it for Live Apps navigation.- JS textarea injection does NOT submit messages. Injecting text into the OpenUI composer textarea via
document.querySelector(...).value = ...or.dispatchEvent(new Event(...))fills the field visually but does not trigger React's controlled-component submission handler. Use/chat-inject-pushinstead. - System Events keystrokes do NOT reach WKWebView. AppleScript
System Eventskeystroke commands target native AppKit views; WKWebView content lives in a separate web process and does not receive synthetic key events this way. - Live Apps navigation requires custom events via
/test-eval. The Live Apps surface listens forsnappy:open-artifactsandsnappy:live-apps-tabDOM events. Fire them via the/test-evalendpoint (see Commands)./chat-inject-controlview-artifactsnavigates to the artifacts sidebar tab, not the Live Apps surface.
Commands
| operation | command | |||||
|---|---|---|---|---|---|---|
| push (TS) | import { dispatchInChatUI, resetChatUI, chatDriveAvailable } from "./state/lib/chat-drive.ts"; await dispatchInChatUI("text", { waitForFirstFrame: 8000 }); | |||||
| push (CLI) | npx tsx state/lib/chat-drive.ts "intent text" | |||||
| push (direct) | curl -s -XPOST http://127.0.0.1:3147/chat-inject-push -H "Content-Type: application/json" -d '{"text":"your message here"}' | |||||
| preflight | chatDriveAvailable() (async, returns bool) | |||||
| server start | bash state/bin/head-screen/launch.sh (idempotent, :3147) | |||||
| server verify | pgrep -af server.ts (match git log --oneline -1) | |||||
| server log | `tail state/log/head-screen.log \ | grep FATAL` | ||||
| chat verify | pgrep -af "/Applications/SnappyChat.app" | |||||
| app activate | osascript -e 'tell application "Snappy Chat" to activate' (1-2s before screenshot) | |||||
| drain queue | curl -XPOST http://127.0.0.1:3147/chat-inject-flush (returns {flushed:N}) | |||||
| reset welcome | curl -XPOST http://127.0.0.1:3147/chat-inject-control -d '{"action":"reset"}' (does NOT flush) | |||||
| navigate view | `curl -XPOST http://127.0.0.1:3147/chat-inject-control -d '{"action":"view-artifacts\ | view-files\ | view-chat\ | view-scheduled\ | view-customize\ | view-projects"}'` (view-files = Skills tab) |
| open Live Apps | curl -s -XPOST http://127.0.0.1:3147/test-eval -H "Content-Type: application/json" -d '{"js":"window.dispatchEvent(new CustomEvent(\"snappy:open-artifacts\"))"}' | |||||
| Live Apps tab | curl -s -XPOST http://127.0.0.1:3147/test-eval -H "Content-Type: application/json" -d '{"js":"window.dispatchEvent(new CustomEvent(\"snappy:live-apps-tab\",{detail:{tab:\"apps\"}}))"}' (tab: "apps"\ | "components"\ | "themes") | |||
| select thread | curl -XPOST http://127.0.0.1:3147/chat-inject-control -H "Content-Type: application/json" -d '{"action":"select-thread","threadId":"<uuid>"}' | |||||
| list threads | curl -s http://127.0.0.1:3147/threads (returns array, copy threadId) | |||||
| direct dispatch | curl -XPOST http://127.0.0.1:3147/dispatch/chat -d '{"intent":"text","threadId":"<id>"}' (bypass queue) | |||||
| check contention | `pgrep -af "claude.*-p\ | claude.*continue"` (>1 = racing) | ||||
| screenshot | npx tsx state/lib/desktop.ts capture-screen /tmp/path.png (or use Computer/appshot when TCC blocks the shell) | |||||
| verify dispatched | `tail state/log/dispatch-chat.ndjson \ | grep intent_chars:<N>` | ||||
| server uptime | `ps -p $(pgrep -f server.ts \ | head -1) -o etime` (reset = queue wiped) | ||||
| env override | HEAD_SCREEN_URL=http://custom:port (default :3147) | |||||
| reference | state/skills/chat-drive/SKILL.md | |||||
| eval log | state/log/evals.ndjson (skill: chat-drive) |
WKWebView Hard Failures (do not retry these)
| approach | why it fails |
|---|---|
window.__snappyNav('#/chat/live-apps') | navigates to /chat, not Live Apps surface |
JS textarea.value = ... + synthetic events | fills visually, bypasses React controlled-component submit handler |
AppleScript System Events keystrokes | targets AppKit layer, WKWebView web process does not receive them |
desktop.ts capture-screen in subagent | TCC may block shell capture; use Computer/appshot for the audit instead |
/chat-inject-control view-artifacts for Live Apps | routes to artifacts sidebar tab, not the Live Apps surface |
Self-Test
An agent reading this should correctly:
- [ ] Pre-flight
chatDriveAvailable()before push? - [ ] Tune
waitForFirstFramefor backend + 1s React poll lag? - [ ] Audit by screenshot
Read, NOT lib return values? - [ ] Keep both snappy-os and head-screen alive?
- [ ] Use
resetChatUI()(not manual) between scenarios? - [ ] Restart server after any server.ts edit (tsx no hot-reload)?
- [ ] Serialize pushes; wait for RUN_FINISHED before next?
- [ ] Drain
/chat-inject-flushbefore QA tests? - [ ] Know React pre-fetches (no completion callback)?
- [ ] Activate app via osascript before screenshot on secondary Space?
- [ ] Know
resetChatUI()doesn't flush (pre-flush if stale)? - [ ] Check
pgrep claude.*-pfor competing consumers? - [ ] Know server crashes wipe queue; verify uptime via ps?
- [ ] Force fresh
crypto.randomUUID()per messageId? - [ ] Use bundled Computer/appshot or local desktop capture for screenshots when needed.
- [ ] Know TCC screencapture blocked in subagent context (use bridge)?
- [ ] Know
__snappyNavdoes NOT navigate to Live Apps? - [ ] Know JS textarea injection does NOT submit (use
/chat-inject-push)? - [ ] Know System Events keystrokes do NOT reach WKWebView content?
- [ ] Use
snappy:open-artifactsevent via/test-evalfor Live Apps navigation? - [ ] Use
snappy:live-apps-tabevent via/test-evalto switch Live Apps tabs?
Found a gap? Edit this file. <!-- footer-injection-point -->
api.ts- the code it can call
// snappy-chat-drive/api.ts
//
// Push text into the snappy-chat composer programmatically. The bridge is
// the head-screen server's chat-inject FIFO: this lib POSTs to /chat-inject-push,
// the snappy-chat WKWebView polls /chat-inject-pop on a 500ms interval and
// runs the text through the real OpenUI submit path (processMessage →
// /dispatch/chat). The result: dogfood loops, automated UX QA, and recursive
// subagent dispatch all flow through the actual chat surface — same React
// store, same generative-UI cards — instead of trying to drive WKWebView
// with synthetic clicks (peekaboo's clickAt does not fire React onClick on
// WKWebView).
//
// Sync contract: there is NO callback when the chat finishes streaming. The
// caller is the actor (push); the auditor is whatever reads a screenshot
// afterward. `waitForFirstFrame` is a coarse sleep so the dispatcher has
// time to start streaming before the auditor captures.
const HEAD_SCREEN_BASE = process.env.HEAD_SCREEN_URL ?? "http://127.0.0.1:3147";
const DEFAULT_FIRST_FRAME_MS = 8_000;
export interface DispatchInChatUIOpts {
/**
* Sleep duration after the push so the dispatcher has time to start
* streaming. Default 8000ms. Pass 0 to return immediately.
*/
waitForFirstFrame?: number;
/**
* Per-agent queue isolation key. The server keeps a Map<agentId, queue>
* so parallel QA subagents don't share a single FIFO. Default "ui" matches
* the snappy-chat React poll loop — so omitting this routes pushes to the
* actual cockpit. Pass a stable identifier (e.g. "qa-broad-smoke",
* "dogfood-loop2") to isolate from the cockpit and from each other.
*/
agentId?: string;
}
export interface DispatchInChatUIResult {
injectId: string | null;
queued: number | null;
agentId: string | null;
}
/**
* Push `text` onto the snappy-chat input bridge. Resolves once the queue
* has accepted the push and (optionally) `waitForFirstFrame` ms have passed.
*
* Throws if the head-screen server is unreachable or the push is rejected.
*/
export async function dispatchInChatUI(
text: string,
opts: DispatchInChatUIOpts = {},
): Promise<DispatchInChatUIResult> {
if (typeof text !== "string" || text.length === 0) {
throw new Error("dispatchInChatUI: text (non-empty string) required");
}
const wait = opts.waitForFirstFrame ?? DEFAULT_FIRST_FRAME_MS;
const body: { text: string; agentId?: string } = { text };
if (typeof opts.agentId === "string" && opts.agentId.length > 0) {
body.agentId = opts.agentId;
}
const res = await fetch(`${HEAD_SCREEN_BASE}/chat-inject-push`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
let detail = "";
try { detail = await res.text(); } catch {}
throw new Error(
`chat-inject-push ${res.status}: ${detail.slice(0, 240) || res.statusText}`,
);
}
let payload: unknown = null;
try { payload = await res.json(); } catch {}
if (wait > 0) {
await new Promise(r => setTimeout(r, wait));
}
const bodyOut = payload && typeof payload === "object" ? payload as Record<string, unknown> : {};
return {
injectId: typeof bodyOut.injectId === "string" ? bodyOut.injectId : null,
queued: typeof bodyOut.queued === "number" ? bodyOut.queued : null,
agentId: typeof bodyOut.agentId === "string" ? bodyOut.agentId : null,
};
}
export interface ResetChatUIOpts {
/**
* Sleep duration after the control push so the React app has time to
* pop the control message, unmount FullScreen, and remount the welcome
* surface. Default 1500ms — enough for the 500ms poll cadence + a remount.
*/
waitMs?: number;
/**
* Per-agent queue isolation key. See `DispatchInChatUIOpts.agentId`.
* Default "ui". Parallel QA agents pass their own ID so a reset on one
* thread doesn't drop the queue another agent is filling.
*/
agentId?: string;
}
/**
* Push a control message that resets the snappy-chat UI to the welcome
* surface. Equivalent to the user clicking "+ New chat" in the sidebar.
* Use between dogfood scenarios so a single subagent can run multiple
* intents end-to-end without thread state bleeding between them.
*
* Throws if the head-screen server is unreachable or the push is rejected.
*/
export async function resetChatUI(opts: ResetChatUIOpts = {}): Promise<void> {
const wait = opts.waitMs ?? 1500;
const body: { action: string; agentId?: string } = { action: "reset" };
if (typeof opts.agentId === "string" && opts.agentId.length > 0) {
body.agentId = opts.agentId;
}
const res = await fetch(`${HEAD_SCREEN_BASE}/chat-inject-control`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
let detail = "";
try { detail = await res.text(); } catch {}
throw new Error(
`chat-inject-control ${res.status}: ${detail.slice(0, 240) || res.statusText}`,
);
}
if (wait > 0) {
await new Promise(r => setTimeout(r, wait));
}
}
/**
* Cheap reachability check for the head-screen server. Returns true iff the
* server answers any 2xx-ish response on `/healthz`. Use to gate dogfood
* loops so they fail fast when the bridge is down rather than timing out
* mid-push.
*/
export async function chatDriveAvailable(): Promise<boolean> {
try {
const res = await fetch(`${HEAD_SCREEN_BASE}/healthz`, { method: "GET" });
return res.ok;
} catch {
return false;
}
}
// CLI smoke: `npx tsx state/lib/chat-drive.ts "say hello in three words"`
// Set CHAT_INJECT_AGENT_ID=<id> to isolate from the cockpit's "ui" queue
// (e.g. parallel QA subagents).
if (import.meta.url === `file://${process.argv[1]}`) {
const text = process.argv.slice(2).join(" ").trim();
if (!text) {
console.error('usage: tsx state/lib/chat-drive.ts "<intent>"');
process.exit(2);
}
const agentId = process.env.CHAT_INJECT_AGENT_ID;
(async () => {
const up = await chatDriveAvailable();
if (!up) {
console.error("head-screen server unreachable at", HEAD_SCREEN_BASE);
process.exit(1);
}
await dispatchInChatUI(text, { waitForFirstFrame: 0, agentId });
console.log("OK pushed:", text, agentId ? `(agentId=${agentId})` : "");
})().catch(e => { console.error("FAIL:", e?.message ?? e); process.exit(1); });
}
scripts- helper scripts it can run
prose-only skill - 2 inline code blocks live in SKILL.md above (no state/bin/ sidecar yet).
how we check it- the checks, plus the last 10 runs
no recent runs logged - the eval contract is declared but nothing has been graded yet