name: aio-html-interactive description: | 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. when_to_use: bridge Claude to browser, AI event loop substitute, Monitor stdout to notification, WebSocket push from AI, AI-driven UI, interactive UI for AI, realtime browser AI, browser to AI bidirectional, browser ↔ AI realtime, Monitor + WebSocket bridge, dựng UI tương tác AI, làm UI tương tác AI argument-hint: "[slug for /tmp dir, e.g. 'picker' or 'review-queue']" effort: medium
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 lineMSG::{instance,type,payload}to stdout. The Monitor tool is configured to pattern-matchMSG::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 (statemerge,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.htmlonce before editing. The Edit tool requires a prior in-conversationRead; thecpabove does NOT count, so skippingReadmakes the firstEditfail withFile has not been read yet. Read withoffset/limitaround 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 asEditanchors. Never rewrite the whole file. Never touch the runtime block,server.js, orvendor/— and do not Read them either; the full API is documented below.Launch — run
bun /tmp/aio-html-interactive-<slug>/server.jsthrough the Monitor tool. The startup line prints URL +instanceid; the browser opens automatically.Interact — browser events arrive as Monitor notifications:
MSG::{instance,type,payload}. Claude pushes back withcurl -s -X POST http://localhost:<PORT>/api/push -d '{"type":"...","payload":{...}}'.Cleanup (required) — when done:
TaskStopthe Monitor task ANDrm -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 writesMSG::{instance,type,payload}to stdout. Monitor surfaces each line as a notification.MSG::stdout line — JSON after the prefix;instancedisambiguates when multipleaio-html-interactiveapps 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).appDefis a Vue component options object:template,setup, etc. The runtime injectsstate/send/oninto the render scope; values returned fromsetup()merge on top.RT.state— reactive global state (Vue.reactive). Template readsstate.*.RT.send(type, payload)— emit a browser → Claude event (POSTs/api/event).RT.on(type, fn)— register a handler for a custom pushtype(non-built-in).- Template reads
state/send/ondirectly (runtime returns them into the render scope). Insidesetup()they are NOT visible — there, address them asRT.state/RT.send/RT.on(they are runtime closures, not globals; baresend(...)insidesetup()throwsReferenceError).
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 astatepatch; let Vue reactivity re-render. Prefer this overhtml/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 intoRT.stateinsidesetup()so first paint is complete. Usepush statefor 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
statekey. Either the app writes locally (optimistic) OR Claudepush statewrites — never both for the same key. Both writing → last-writer-wins race, value flickers. Pick a model: Claude-authoritative (browser clicks onlysend(), 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
typevalues 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 firesend()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 localstate, 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 pushstateto 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.