aio-html-interactive — bridge Claude to a browser via Monitor + WebSocket
The problem this solves
Claude runs in a turn-by-turn CLI loop. It has no event loop, can't
addEventListener on a browser, can't block on await userClick(). So
how does an interactive UI — one the user is actually clicking on right
now — drive Claude's behavior while a task is in progress?
The answer two channels, both fronted by a single Bun HTTP + WebSocket
server:
- Browser → Claude (input channel).
RT.send(type, payload) in the
browser POSTs /api/event; the server writes one line
MSG::{instance,type,payload} to stdout. The Monitor tool is
configured to pattern-match MSG:: lines on that server's stdout and
surface each one as a notification. Notifications are how a turn-based
agent gets a "user-clicked-X" event without an event loop.
- Claude → browser (output channel). Claude POSTs
/api/push {type,payload} from any shell tool call; the server
broadcasts that JSON verbatim over WebSocket to every connected tab.
The runtime processes a small built-in vocabulary (state merge,
toast, html, js, reload) before dispatching to
app-registered handlers.
UI is vendored Vue 3 + Tailwind, no build step. Claude only writes the
APP REGION of app.html; the runtime, server, and vendor blocks
are frozen so the protocol stays intact across edits.
Workflow
Copy scaffold —
cp -r ${CLAUDE_PLUGIN_ROOT}/skills/aio-html-interactive/scaffold /tmp/aio-html-interactive-<slug>
via Bash. Do not Read+Write to copy — that round-trips through the
model and risks editing the runtime by accident. For a session that may
span a reboot, copy into a stable project dir instead of /tmp (which is
cleared on restart) — e.g. ./.aio-html-interactive-<slug>; the slug,
launch, and cleanup steps are otherwise identical.
Build app — Read /tmp/aio-html-interactive-<slug>/app.html
once before editing. The Edit tool requires a prior in-conversation
Read; the cp above does NOT count, so skipping Read makes the
first Edit fail with File has not been read yet. Read with
offset/limit around the two markers (~25 lines at end of file) is
enough. Edit ONLY the text between
<!-- ===== APP REGION START ... --> and
<!-- ===== APP REGION END ===== -->, using both marker lines as
Edit anchors. Never rewrite the whole file. Never touch the runtime
block, server.js, or vendor/ — and do not Read them either; the
full API is documented below.
Launch — run bun /tmp/aio-html-interactive-<slug>/server.js
through the Monitor tool. The startup line prints URL + instance
id; the browser opens automatically.
Interact — browser events arrive as Monitor notifications:
MSG::{instance,type,payload}. Claude pushes back with
curl -s -X POST http://localhost:<PORT>/api/push -d '{"type":"...","payload":{...}}'.
Cleanup (required) — when done: TaskStop the Monitor task AND
rm -rf /tmp/aio-html-interactive-<slug>. Leaving the task running
holds the port; leaving the directory is litter.
Server API
POST /api/push {type,payload} — Claude → browser. Server
broadcasts the JSON verbatim over WebSocket to every connected tab.
POST /api/event {type,payload} — browser → Claude. RT.send()
calls this; server writes MSG::{instance,type,payload} to stdout.
Monitor surfaces each line as a notification.
MSG:: stdout line — JSON after the prefix; instance disambiguates
when multiple aio-html-interactive apps run concurrently. The
prefix is the Monitor-side contract — do not change it.
Runtime API (browser-side)
RT.start(appDef) — the only entry point the APP REGION calls (last,
exactly once). appDef is a Vue component options object: template,
setup, etc. The runtime injects state / send / on into the
render scope; values returned from setup() merge on top.
RT.state — reactive global state (Vue.reactive). Template reads
state.*.
RT.send(type, payload) — emit a browser → Claude event
(POSTs /api/event).
RT.on(type, fn) — register a handler for a custom push type
(non-built-in).
- Template reads
state / send / on directly (runtime returns
them into the render scope). Inside setup() they are NOT visible
— there, address them as RT.state / RT.send / RT.on (they are
runtime closures, not globals; bare send(...) inside setup()
throws ReferenceError).
Built-in push types
POST /api/push with these type values is handled by the runtime
directly (before app-registered handlers; an app handler cannot shadow
these):
type |
payload |
Effect |
state |
object |
Shallow-merge into RT.state → Vue re-renders. Primary UI-update mechanism. |
state-set |
object |
Full replace — clear RT.state then assign payload. |
toast |
{kind,text} |
Toast notification. kind: ok (green, auto-dismiss ~4s) / err (red) / held (amber) / info (gray). |
html |
{target,mode,html} |
querySelector(target); mode:"append" appends, anything else replaces innerHTML. Missing target → toast err. |
js |
{code} |
Eval the code string (escape hatch). Errors → toast err (never silent). |
reload |
— |
location.reload(). |
Any other type → invokes the handler registered via RT.on(); no
handler → silently ignored.
Design principles
- Drive UI through
state. Push a state patch; let Vue
reactivity re-render. Prefer this over html / js, which are
escape hatches.
- Bake initial state in
setup() when Claude already has it. If
Claude holds the initial dataset at the time of writing the app
(list, table, config…), seed it directly into RT.state inside
setup() so first paint is complete. Use push state for
subsequent updates only. Launching the server and then pushing
initial state is a wasted round-trip with a blank-flash for the user.
- Single writer per
state key. Either the app writes locally
(optimistic) OR Claude push state writes — never both for the same
key. Both writing → last-writer-wins race, value flickers. Pick a
model: Claude-authoritative (browser clicks only send(), only
Claude pushes — no race but adds latency) or browser-authoritative
(app writes local, Claude reads events but does not push that key).
- Define a small, explicit message vocabulary. A handful of
type
values for browser → Claude, a handful for Claude → browser. Spell
them out at the top of the APP REGION.
- Explicit submission + busy flag. Claude turn-takes asynchronously
and can take seconds;
send() is fire-and-forget. Do NOT fire
send() on every micro-interaction (every keystroke) and leave the
user blind — they cannot tell whether Claude received the event, is
processing, or whether further input is allowed. Collect input into
local state, give the user an explicit "Send to AI" button, set
a pending flag (e.g. state.busy = true) on submit so the UI shows
"waiting for AI…" and/or disables input, and have Claude push state
to clear the flag when done. The feedback loop must stay closed — the
user always knows whose turn it is.
Starter — APP REGION skeleton (optional)
The skeleton below follows the design principles above: header,
centered container, explicit "Send to AI" button, state.busy flag,
initial state baked in setup(). Copy over the placeholder content and
replace the body. Head-start only — different layouts are fine, this is
not required.
RT.start({
template: `
<div class="min-h-screen pb-24">
<!-- header — app title + one-line description -->
<div class="bg-slate-900 text-white">
<div class="max-w-3xl mx-auto px-6 py-5">
<h1 class="text-lg font-semibold">{{ state.title }}</h1>
<p class="text-slate-400 text-sm mt-0.5">{{ state.subtitle }}</p>
</div>
</div>
<!-- main content — replace this block with the real app -->
<div class="max-w-3xl mx-auto px-6 py-6">
<div class="bg-white rounded-xl border border-slate-200 p-6 text-sm text-slate-600">
App content here.
</div>
</div>
<!-- action bar — explicit Send button, locked while waiting on AI -->
<div class="fixed bottom-0 inset-x-0 bg-white border-t border-slate-200">
<div class="max-w-3xl mx-auto px-6 py-3 flex items-center gap-4">
<div class="flex-1 text-sm text-slate-500">
{{ state.busy ? '⏳ Waiting for AI…' : 'Ready.' }}
</div>
<button @click="submit" :disabled="state.busy"
class="px-5 py-2 rounded-lg text-sm font-semibold text-white
bg-slate-900 hover:bg-slate-700 disabled:opacity-40">
Send to AI →
</button>
</div>
</div>
</div>
`,
setup() {
// Bake initial state — first paint is complete, no blank-flash.
RT.state.title = "App title";
RT.state.subtitle = "One-line description";
RT.state.busy = false;
// Browser → AI: commit the submission and raise the busy flag so the
// UI locks itself.
function submit() {
if (RT.state.busy) return;
RT.state.busy = true;
RT.send("submit", {});
}
// AI → browser: on completion, AI pushes {"type":"done"} to release
// the busy flag.
RT.on("done", function () {
RT.state.busy = false;
});
return { submit: submit };
},
});
Lifecycle
Server lifetime is tied to the Monitor task. TaskStop terminates the
Bun process, which closes every WebSocket; subsequent browser actions
silently fail (no handler reachable). The /tmp/aio-html-interactive-<slug>/
directory is Claude's responsibility to remove after TaskStop —
see step 5 of the workflow.
1---2name: aio-html-interactive3description: Bridge Claude to a browser UI in real time via a frozen Bun + Vue3 + Tailwind scaffold — browser events become Monitor-tool notifications, AI pushes become WebSocket broadcasts. Use when Claude needs to drive an interactive UI mid-task: form capture, multi-step decision flow, live preview, approval queue, or side-by-side review. See body for the "why" (Claude has no event loop) and architecture details.4---56# aio-html-interactive — bridge Claude to a browser via Monitor + WebSocket78## The problem this solves910Claude runs in a turn-by-turn CLI loop. It has no event loop, can't11`addEventListener` on a browser, can't block on `await userClick()`. So12how does an interactive UI — one the user is actually clicking on right13now — drive Claude's behavior while a task is in progress?1415The answer two channels, both fronted by a single Bun HTTP + WebSocket16server:1718- **Browser → Claude (input channel).** `RT.send(type, payload)` in the19 browser POSTs `/api/event`; the server writes one line20 `MSG::{instance,type,payload}` to stdout. The **Monitor tool** is21 configured to pattern-match `MSG::` lines on that server's stdout and22 surface each one as a notification. Notifications are how a turn-based23 agent gets a "user-clicked-X" event without an event loop.24- **Claude → browser (output channel).** Claude POSTs25 `/api/push {type,payload}` from any shell tool call; the server26 broadcasts that JSON verbatim over WebSocket to every connected tab.27 The runtime processes a small built-in vocabulary (`state` merge,28 `toast`, `html`, `js`, `reload`) before dispatching to29 app-registered handlers.3031UI is vendored Vue 3 + Tailwind, no build step. Claude only writes the32**APP REGION** of `app.html`; the runtime, server, and vendor blocks33are frozen so the protocol stays intact across edits.3435## Workflow36371. **Copy scaffold** —38 `cp -r ${CLAUDE_PLUGIN_ROOT}/skills/aio-html-interactive/scaffold /tmp/aio-html-interactive-<slug>`39 via Bash. Do not Read+Write to copy — that round-trips through the40 model and risks editing the runtime by accident. For a session that may41 span a reboot, copy into a stable project dir instead of `/tmp` (which is42 cleared on restart) — e.g. `./.aio-html-interactive-<slug>`; the slug,43 launch, and cleanup steps are otherwise identical.44452. **Build app** — `Read` `/tmp/aio-html-interactive-<slug>/app.html`46 once before editing. The Edit tool requires a prior in-conversation47 `Read`; the `cp` above does NOT count, so skipping `Read` makes the48 first `Edit` fail with `File has not been read yet`. Read with49 `offset`/`limit` around the two markers (~25 lines at end of file) is50 enough. Edit ONLY the text between51 `<!-- ===== APP REGION START ... -->` and52 `<!-- ===== APP REGION END ===== -->`, using both marker lines as53 `Edit` anchors. Never rewrite the whole file. Never touch the runtime54 block, `server.js`, or `vendor/` — and do not Read them either; the55 full API is documented below.56573. **Launch** — run `bun /tmp/aio-html-interactive-<slug>/server.js`58 through the Monitor tool. The startup line prints URL + `instance`59 id; the browser opens automatically.60614. **Interact** — browser events arrive as Monitor notifications:62 `MSG::{instance,type,payload}`. Claude pushes back with63 `curl -s -X POST http://localhost:<PORT>/api/push -d '{"type":"...","payload":{...}}'`.64655. **Cleanup (required)** — when done: `TaskStop` the Monitor task AND66 `rm -rf /tmp/aio-html-interactive-<slug>`. Leaving the task running67 holds the port; leaving the directory is litter.6869## Server API7071- `POST /api/push` `{type,payload}` — Claude → browser. Server72 broadcasts the JSON verbatim over WebSocket to every connected tab.73- `POST /api/event` `{type,payload}` — browser → Claude. `RT.send()`74 calls this; server writes `MSG::{instance,type,payload}` to stdout.75 Monitor surfaces each line as a notification.76- `MSG::` stdout line — JSON after the prefix; `instance` disambiguates77 when multiple `aio-html-interactive` apps run concurrently. The78 prefix is the Monitor-side contract — do not change it.7980## Runtime API (browser-side)8182- `RT.start(appDef)` — the only entry point the APP REGION calls (last,83 exactly once). `appDef` is a Vue component options object: `template`,84 `setup`, etc. The runtime injects `state` / `send` / `on` into the85 render scope; values returned from `setup()` merge on top.86- `RT.state` — reactive global state (`Vue.reactive`). Template reads87 `state.*`.88- `RT.send(type, payload)` — emit a browser → Claude event89 (POSTs `/api/event`).90- `RT.on(type, fn)` — register a handler for a custom push `type`91 (non-built-in).92- **Template** reads `state` / `send` / `on` directly (runtime returns93 them into the render scope). **Inside `setup()`** they are NOT visible94 — there, address them as `RT.state` / `RT.send` / `RT.on` (they are95 runtime closures, not globals; bare `send(...)` inside `setup()`96 throws `ReferenceError`).9798## Built-in push types99100`POST /api/push` with these `type` values is handled by the runtime101directly (before app-registered handlers; an app handler cannot shadow102these):103104| `type` | `payload` | Effect |105|---|---|---|106| `state` | object | **Shallow-merge** into `RT.state` → Vue re-renders. Primary UI-update mechanism. |107| `state-set` | object | Full replace — clear `RT.state` then assign payload. |108| `toast` | `{kind,text}` | Toast notification. `kind`: `ok` (green, auto-dismiss ~4s) / `err` (red) / `held` (amber) / `info` (gray). |109| `html` | `{target,mode,html}` | `querySelector(target)`; `mode:"append"` appends, anything else replaces `innerHTML`. Missing target → `toast err`. |110| `js` | `{code}` | Eval the `code` string (escape hatch). Errors → `toast err` (never silent). |111| `reload` | — | `location.reload()`. |112113Any other `type` → invokes the handler registered via `RT.on()`; no114handler → silently ignored.115116## Design principles117118- **Drive UI through `state`.** Push a `state` patch; let Vue119 reactivity re-render. Prefer this over `html` / `js`, which are120 escape hatches.121- **Bake initial state in `setup()` when Claude already has it.** If122 Claude holds the initial dataset at the time of writing the app123 (list, table, config…), seed it directly into `RT.state` inside124 `setup()` so first paint is complete. Use `push state` for125 *subsequent updates only*. Launching the server and then pushing126 initial state is a wasted round-trip with a blank-flash for the user.127- **Single writer per `state` key.** Either the app writes locally128 (optimistic) OR Claude `push state` writes — never both for the same129 key. Both writing → last-writer-wins race, value flickers. Pick a130 model: *Claude-authoritative* (browser clicks only `send()`, only131 Claude pushes — no race but adds latency) or *browser-authoritative*132 (app writes local, Claude reads events but does not push that key).133- **Define a small, explicit message vocabulary.** A handful of `type`134 values for browser → Claude, a handful for Claude → browser. Spell135 them out at the top of the APP REGION.136- **Explicit submission + busy flag.** Claude turn-takes asynchronously137 and can take seconds; `send()` is fire-and-forget. Do NOT fire138 `send()` on every micro-interaction (every keystroke) and leave the139 user blind — they cannot tell whether Claude received the event, is140 processing, or whether further input is allowed. Collect input into141 local `state`, give the user an explicit **"Send to AI"** button, set142 a pending flag (e.g. `state.busy = true`) on submit so the UI shows143 "waiting for AI…" and/or disables input, and have Claude push `state`144 to clear the flag when done. The feedback loop must stay closed — the145 user always knows whose turn it is.146147## Starter — APP REGION skeleton (optional)148149The skeleton below follows the design principles above: header,150centered container, explicit "Send to AI" button, `state.busy` flag,151initial state baked in `setup()`. Copy over the placeholder content and152replace the body. Head-start only — different layouts are fine, this is153not required.154155```html156RT.start({157 template: `158 <div class="min-h-screen pb-24">159160 <!-- header — app title + one-line description -->161 <div class="bg-slate-900 text-white">162 <div class="max-w-3xl mx-auto px-6 py-5">163 <h1 class="text-lg font-semibold">{{ state.title }}</h1>164 <p class="text-slate-400 text-sm mt-0.5">{{ state.subtitle }}</p>165 </div>166 </div>167168 <!-- main content — replace this block with the real app -->169 <div class="max-w-3xl mx-auto px-6 py-6">170 <div class="bg-white rounded-xl border border-slate-200 p-6 text-sm text-slate-600">171 App content here.172 </div>173 </div>174175 <!-- action bar — explicit Send button, locked while waiting on AI -->176 <div class="fixed bottom-0 inset-x-0 bg-white border-t border-slate-200">177 <div class="max-w-3xl mx-auto px-6 py-3 flex items-center gap-4">178 <div class="flex-1 text-sm text-slate-500">179 {{ state.busy ? '⏳ Waiting for AI…' : 'Ready.' }}180 </div>181 <button @click="submit" :disabled="state.busy"182 class="px-5 py-2 rounded-lg text-sm font-semibold text-white183 bg-slate-900 hover:bg-slate-700 disabled:opacity-40">184 Send to AI →185 </button>186 </div>187 </div>188189 </div>190 `,191 setup() {192 // Bake initial state — first paint is complete, no blank-flash.193 RT.state.title = "App title";194 RT.state.subtitle = "One-line description";195 RT.state.busy = false;196197 // Browser → AI: commit the submission and raise the busy flag so the198 // UI locks itself.199 function submit() {200 if (RT.state.busy) return;201 RT.state.busy = true;202 RT.send("submit", {});203 }204205 // AI → browser: on completion, AI pushes {"type":"done"} to release206 // the busy flag.207 RT.on("done", function () {208 RT.state.busy = false;209 });210211 return { submit: submit };212 },213});214```215216## Lifecycle217218Server lifetime is tied to the Monitor task. `TaskStop` terminates the219Bun process, which closes every WebSocket; subsequent browser actions220silently fail (no handler reachable). The `/tmp/aio-html-interactive-<slug>/`221directory is Claude's responsibility to remove after `TaskStop` —222see step 5 of the workflow.