Anything-can-drive-the-UI command bus. Single dispatch point for LLM tool
calls, scripts, and (optional) WebSocket remote control. JSON canonical
form plus a plain-text DSL sugar layer. Includes a visible virtual cursor
that animates to elements before commands act on them.
Modules:
- bus.ts — CommandBus class, dispatch, handlers, vars, history,
listActions, readState. Built-in handlers: navigate, click,
fill, submit, select, wait, wait_for, scroll, read, expect,
set
- parser.ts — DSL ↔ JSON, with quoted values, # comments, # speed:
directive, $name = <cmd> assignment, $var interpolation,
expect assertions
- script.ts — script runner; loads /scripts/<name>.script, supports
sub-scripts via `run` command
- cursor.ts — virtual cursor + ripple, speed-controlled, aria-hidden
- provider.tsx — React glue: registers `navigate` via react-router, mounts
cursor, exposes window.commandBus / runScript /
runScriptText for console use
- ws.ts — optional WebSocket producer (connectCommandSocket)
- llm-bridge.ts — buildSystemPrompt (DSL ref + live action snapshot),
extractActionBlocks, runActionBlocks, estimateTokens,
trimMessages
- index.tsx — barrel re-exports
Peer deps: react, react-router (consuming app provides).
Originally inline in `comfy-cloud/app/lib/`. Extracted as part of the
crema-app-aifirst-template scaffolding.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
4.9 KiB
@crema/action-bus
The "anything can drive the UI" command bus. One dispatch point for LLM tool calls, scripts, and (optional) remote control over WebSocket — all funnel through the same handlers, run against the same DOM contract.
Pairs with lib-aifirst-shell-ui
which provides a [data-action]-tagged shell. Non-AI apps that want
scriptable / e2e-testable UIs can use this lib alone.
Public API
import {
commandBus, // singleton
CommandBusProvider, // mounts cursor + registers `navigate`
parseScript, // DSL ↔ JSON
runScript, runScriptText,
buildSystemPrompt, // LLM bridge
extractActionBlocks, runActionBlocks,
estimateTokens, trimMessages,
connectCommandSocket, // optional WebSocket producer
} from "@crema/action-bus"
Usage
Wrap your app:
// app/root.tsx
import { CommandBusProvider } from "@crema/action-bus"
export default function App() {
return (
<CommandBusProvider>
<Outlet />
</CommandBusProvider>
)
}
Tag interactive elements:
<button data-action="save-button">Save</button>
<input data-action="search-input" />
Drive the UI:
commandBus.dispatch({ type: "click", target: "save-button" })
commandBus.dispatch({ type: "fill", target: "search-input", value: "acme" })
// Or as text DSL:
import { runScriptText } from "@crema/action-bus"
runScriptText(`
navigate /resources
wait_for resources-table
fill search-input "acme"
`)
Built-in commands
| Command | Args | Purpose |
|---|---|---|
navigate |
path |
React Router navigation |
click |
target |
Click [data-action=target] |
fill |
target, value |
Set input value, fire input + change events |
submit |
target |
Submit the form containing target |
select |
target, value |
Set <select> value |
wait |
ms |
Sleep |
wait_for |
target, timeout? |
Poll until element exists |
scroll |
target? |
Scroll element into view |
read |
target? |
Return innerText |
expect |
target, op, value? |
to_contain / to_be_visible / to_have_value |
set |
name, value |
Set a variable |
Register your own:
commandBus.register("highlight", async (cmd) => {
document.querySelector(`[data-action="${cmd.target}"]`)?.classList.add("hot")
})
DSL syntax
Plain text, one command per line. Quote values with spaces. # starts a
comment. # speed: <n> at the top adjusts cursor animation. $name = <cmd>
captures a result; $name interpolates.
# speed: 0.7
click sidebar-toggle
wait 500
$id = read row-1
click $id
expect detail-panel to_contain "Acme"
Round-trips: parseScript(dsl) → { options, commands }, stringifyScript(commands, options) → text.
LLM integration
import { buildSystemPrompt, runActionBlocks } from "@crema/action-bus"
// 1. Inject into the model's system prompt — includes a tight DSL ref +
// every visible [data-action] on screen right now.
const system = buildSystemPrompt({ path: window.location.pathname })
// 2. Extract and run any ```action ... ``` blocks the model emits.
const { ran, errors } = await runActionBlocks(message.content)
Plus token-budgeting helpers (estimateTokens, trimMessages) for keeping
chat history within a model's context window.
See comfy-cloud's docs/AI_FIRST.md for the full system tour.
Visible cursor
By default, CommandBusProvider hooks a virtual cursor into the bus —
animates to the target element before each command, ripples on click. Makes
LLM-driven sessions legible to a watching human and gives demo recordings
for free. Speed is controlled by the # speed: directive in DSL scripts;
the setCursorSpeed(n) API sets it programmatically.
To run silently (e.g. tests), pass { silent: true } to dispatch() /
run().
Optional WebSocket producer
import { connectCommandSocket } from "@crema/action-bus"
const sock = connectCommandSocket("ws://localhost:9229/ui")
// sock.close() to disconnect
Server messages: { id?, command } | { id?, script } | { id?, dsl }.
Replies with { id, ok: true } or { id, ok: false, error }.
Not auto-connected — call when ready. Add origin checks and an opt-in toggle in production; the lib provides the transport, not the policy.
Conventions
[data-action]is the contract. Every interactive element opts in by declaring an id. The bus introspects the DOM at dispatch time — no central registry to maintain.- No global state besides the singleton. The bus is a class; you can instantiate your own if you want isolation.
- Theme-agnostic. Cursor and ripple read CSS variables (
--primary), fall back to neutral colors.
Peer dependencies
reactreact-router(forCommandBusProvider'snavigatehandler)
Both come from the consuming app.