.smithers/ui/<workflow>.tsx, the Gateway builds and serves it at /workflows/<key>, and bunx smithers-orchestrator ui opens it for a run with a stable ?runId= deep link.
This guide covers the whole shape: vanilla SDK boot, React hooks, the boot config the iframe receives, live event subscriptions (with automatic pushed updates and resilient reconnection), node-output and diff reads, approvals and signals, the crucial stale-data-free update model to avoid data bleeding across runs, DevTools observability streams, sample tests, and the same-origin proxy patterns you reach for when custom UIs run through smithers ui and the Gateway.
For the underlying RPC and event protocol, see Gateway. For two compact end-to-end examples, see Workflow UI (React) and Workflow UI (Vanilla).
API reference: Gateway React lists every hook and component, its options, and links to source and tests.
bunx smithers-orchestrator ui starts bunx smithers-orchestrator gateway for the workspace and waits for it before opening the browser. The Gateway automatically mounts any .smithers/ui/<workflow>.tsx file whose name matches a discovered workflow id, so a hand-written .smithers/gateway.ts is not required for local UI launch. Use --no-autostart to require an already-running Gateway, or --gateway <url> to point at a remote one.
How a custom UI is wired
Three pieces collaborate:-
The workflow registers its UI. When you call
gateway.register("vcs", vcs, { ui: { entry: ".smithers/ui/vcs.tsx", title: "VCS" } }), the Gateway bundles the entry file into a single browser script and serves it at/workflows/vcs. It also exposes aGET /workflows/<key>/bootconfig that the rendered page hydrates from. -
The Gateway serves an HTML shell. Hitting
/workflows/vcsreturns a tiny HTML document with a<div id="root">, the bundled script, and a global__SMITHERS_GATEWAY_UI__object (theGatewayUiBootConfig) set before your script runs. The shell uses?runId=<id>from the query string to scope a session to one run; if it is omitted, your UI is responsible for showing a picker or empty state. -
smithers ui opens the shell same-origin.
bunx smithers-orchestrator ui RUN_IDresolves the run’s workflow, ensures a Gateway is reachable, and opens/workflows/<key>?runId=<id>in the browser. Because the page is served by the Gateway origin, its RPC calls and WebSocket streams reach the real Gateway without CORS or token shuttling.
Two SDKs: pick by appetite
smithers-orchestrator/gateway-client | smithers-orchestrator/gateway-react | |
|---|---|---|
| Footprint | One zero-dep class, no framework | React 19 + ReactDOM + hooks |
| Boot | new SmithersGatewayClient() | createGatewayReactRoot(<App />) |
| Live events | streamRunEventsResilient async generator | useGatewayRunEvents(runId) |
| Reads | client.getNodeOutput({...}) | useGatewayNodeOutput({...}) |
| Writes | client.submitApproval(...) | useGatewayActions().submitApproval(...) |
| Use when | You want one tiny bundle, or you already own your render layer (Solid, Lit, vanilla DOM) | You are writing a React UI and want the stale-data-free model baked in |
@smithers-orchestrator/gateway-client; the React layer is @smithers-orchestrator/gateway-react and adds context/hooks on top of the same client instead of a separate wire protocol.
The boot config
Every page the Gateway serves at/workflows/<key> (and /inspector/<runId> for the built-in inspector) sets a global before your bundle parses:
new SmithersGatewayClient() reads it automatically. Its HTTP RPC wrapper calls /v1/rpc/<method> under baseUrl, while WebSocket streams use the boot wsPath. Reach for the boot config directly when you need a UI-specific prop (__SMITHERS_GATEWAY_UI__?.props.brand), a direct fetch target (rpcPath), a CDN asset (assetBasePath), or to react to whether the Gateway mounted you for one workflow or for the global inspector (kind === "workflow").
The ?runId= query parameter is not in the boot config; it is in location.search, because a single UI bundle may be invoked across many runs. Read it once at startup and pass it through your store:
React: createGatewayReactRoot and hooks
createGatewayReactRoot is the one-line bootstrap: it reads the boot config, constructs a SmithersGatewayClient, mounts the SmithersGatewayProvider, and renders your tree.
packages/gateway-react/src and follow one rule: they never surface a previous run’s data after the inputs change. When runId changes (or any other dep), the underlying useGatewayRpc clears data and error synchronously, bumps a generation counter that invalidates any in-flight request, then issues a fresh fetch. A late response from the previous runId cannot repopulate the cleared state, so the UI never blinks the wrong run between transitions. See Stale-data-free update model for why this matters at the iframe boundary.
| Hook | Returns | Notes |
|---|---|---|
useSmithersGateway() | SmithersGatewayClient | Bare client; fall back to it for one-off RPC. |
useGatewayRun(runId) | { data: Record<string, unknown>, loading, error, refetch } | Reads the getRun payload: a run record with summary and optional runState: RunStateView. Refetches when runId changes. Disabled when runId is undefined. |
useGatewayRuns({ filter? }) | GatewayAsyncState<Record<string, unknown>[]> | List recent runs; filter accepts status, limit. |
useGatewayWorkflows() | GatewayAsyncState<ListWorkflowsResponse> | Powers picker UIs. |
useGatewayRunEvents(runId, opts?) | { events, lastHeartbeat, streaming, error } | Live WebSocket subscription. Drops the oldest events past maxEvents (default 1000). |
useGatewayNodeOutput({ runId, nodeId, iteration? }) | GatewayAsyncState<Record<string, unknown>> | Reads a finished task’s row + schema. |
useGatewayApprovals({ filter? }) | GatewayAsyncState<ListApprovalsResponse> | All gates waiting; filter accepts runId, workflow, limit. |
useGatewayActions() | Bound submitApproval, submitSignal, cancelRun, resumeRun, rewindRun, cronCreate, … | Memoized so dependent effects do not retrigger. |
useGatewayRpc(method, params, opts?) | GatewayAsyncState<Payload> | Escape hatch for any v1 RPC. |
useGatewayExtensionResource(namespace, key, params?, opts?) | GatewayAsyncState<T> | Declarative extension read/action call over SmithersGatewayClient.extensionRpc with the same stale-response fence as useGatewayRpc. |
useGatewayExtensionAction(namespace, key) | { call, pending, error, data } | Imperative extension action helper backed by extensionRpc; generation-fenced so rapid calls cannot leave stale state. |
useGatewayExtensionStream(namespace, key, params?, opts?) | { frames, latest, error, streaming } | Extension stream subscription with bounded frames, stale-frame fencing, and backoff reconnect. |
SyncProvider + useSyncQuery / useSyncMutation / useSyncSubscription | Sync cache hooks | Wrap a SyncClient for stale-while-revalidate reads, optimistic mutations, and shared stream subscriptions. |
useGatewayQuery / useGatewayMutation / useGatewayRunStream | Gateway sync shortcuts | Typed helpers over gatewayKeys, Gateway RPC methods, and the shared subscription hub. |
Vanilla SDK
The same primitives in zero-dep JS:streamRunEventsResilient is the option you almost always want over streamRunEvents: it reconnects with backoff + jitter on dropped sockets, resumes from the last per-run seq, and refuses to reset the backoff counter until a connection has proved itself with a live (non-replay) frame or by surviving a settle window. See the JSDoc on SmithersGatewayClient.streamRunEventsResilient for the exact semantics.
Auth
A custom UI almost never holds a token. The Gateway accepts auth via three mechanisms (Authorization: Bearer <token>, an x-smithers-key header, or a trusted-proxy mode that reads identity headers off an upstream proxy), and the right answer for an embedded UI is trusted-proxy via same-origin.
In practice this means one of:
- Local smithers ui. The CLI opens the Gateway-served
/workflows/<key>?runId=<id>page directly. The browser sees one origin and the Gateway can use the local token or key configured for that process. See Local dev setup. - Your own host. Put the Gateway behind a reverse proxy that serves
/v1/rpc,/health, and/workflows/*on the same origin as the page embedding the UI. A trusted proxy can terminate the user’s session, strip browser-supplied identity headers, and forward only the identity headers it validated. See Trusted same-origin proxy. - Bring-your-own token. Pass
new SmithersGatewayClient({ token, baseUrl })if you really do hold a token at the UI layer. Useful for tooling and one-off bots; avoid it in user-facing surfaces.
client.rpc(...), and the client carries whatever token / headers you set when you constructed it (or that the trusted-proxy stripped and rewrote on the way in).
Live subscriptions
useGatewayRunEvents(runId) opens one streamRunEventsResilient socket per runId and surfaces every non-heartbeat frame in the events array. A heartbeat updates lastHeartbeat instead. Heartbeats are how you detect a still-alive but quiet run, so they belong on their own pin to avoid bloating the events array. When runId changes the prior connection is aborted and the buffer resets, so the UI never shows the wrong run’s events.
The vanilla equivalent (for await (const frame of client.streamRunEventsResilient(...))) gives you the same loop without React state. Either way the Gateway picks up where the last seq left off after a reconnect, replaying anything you missed as a run.gap_resync frame before resuming live run.events.
When the run finishes, the server emits one run.completed frame and closes the stream cleanly; both layers stop reconnecting on that signal.
Node output and diff reads
Every finished task has a structured output row (Zod-validated, JSON-serializable) and an optional diff payload (the snapshot of files the task changed).status: "produced") or as a typed pending / failed state with an optional partial payload on failure. Node-output hooks default to a row-shaped value that you should destructure as either the row directly or { row, schema, status }; .smithers/ui/vcs.tsx shows the canonical normalizer (rowOf(value)).
DevTools observability streams
Beyond standard node output and run events,streamDevTools provides the live DevTools tree: an initial snapshot plus devtools.event delta frames such as replaceRoot, addNode, removeNode, updateProps, and updateTask. Use it for inspectors that need node props, task metadata, and rebaselining after rewind. It is a raw WebSocket iterator on SmithersGatewayClient; if you need reconnect behavior, re-subscribe with the last afterSeq or use the sync subscription layer on top of gatewayKeys.devtools(runId).
Approvals, signals, and lifecycle actions
useGatewayActions() returns a stable object of bound mutators. Use it for human-in-the-loop gates and for any lifecycle write:
correlationKey passed to submitSignal must match the workflow wait’s correlationId, for example <WaitForEvent event="utterance" correlationId="utterance">. The Gateway enforces approval scopes (allowedScopes, allowedUsers) per the workflow’s <Approval> declaration, so a UI that surfaces “Approve” for an unauthorized user will still fail at the RPC boundary. Render the gate optimistically and let the error surface through useGatewayActions’s return value.
useGatewayApprovals({ filter: { runId } }) lists every pending gate for the run; pair it with useGatewayActions().submitApproval to drive a “pending approvals” surface. The reference UIs in .smithers/ui/grill-me.tsx and .smithers/ui/ultragrill.tsx show this pattern at scale.
Stale-data-free update model
The fundamental rule across both SDKs is: a hook or read whose inputs have changed must never surface the previous inputs’ result. Without this, a UI that switches fromrunId=A to runId=B momentarily shows A’s data before B’s fetch lands. At the iframe boundary that looks like the embed belongs to the wrong run, which is the most confusing failure mode possible.
useGatewayRpc enforces this by:
- Clearing on input change. When
runId,nodeId, or any custom dep changes, the effect synchronously clearsdataanderror, then fires a fresh fetch. Consumers see an empty state while the new fetch is in flight. - Generation-tagged requests. Each
refetchreads a per-hook generation counter; only the latest generation can repopulate state. A late response from a previous inputs version is dropped on arrival. - Disabling clears too.
enabled: false(e.g. whenrunIdbecomesundefined) clears state to empty rather than freezing the last known value.
useGatewayRunEvents does the same for the WebSocket: it aborts the prior stream and resets events/lastHeartbeat when runId changes, so no frame from the previous run can leak across the boundary.
If you build your own read on top of useGatewayRpc (via the deps option) or hand-roll one with the vanilla client, mirror the same pattern: clear the state when inputs change, and tag in-flight work so late responses are dropped.
Same-origin proxy patterns
Custom UIs may be embedded in an iframe, or opened directly by smithers ui. The robust path is to serve the UI shell and Gateway RPC from the same origin, so the page’sfetch("/v1/rpc/...") and new WebSocket("wss://<host>/") both hit the Gateway without CORS or token shuttling.
Local dev setup
For local work, usebunx smithers-orchestrator ui first. It starts or reuses a Gateway, resolves the run’s workflow, and opens the Gateway-hosted /workflows/<key>?runId=<id> page:
/workflows/<key> is served by the Gateway, so new SmithersGatewayClient() calls fetch("/v1/rpc/getRun", ...) on the same origin and streams use the boot wsPath. No CORS, no token shuttling.
Trusted same-origin proxy
If you embed a custom workflow UI in your own host, put the Gateway behind a reverse proxy that forwards the Gateway route families on the host’s origin:- Service-token branch. If
GATEWAY_AUTH_TOKENis set, the proxy strips browser-supplied Gateway credentials and trusted-proxy headers, addsAuthorization: Bearer <service-token>, and forwards the request without minting user identity headers. - Trusted-proxy branch. If no service token is configured, the proxy validates the user’s session, strips client-supplied trusted-proxy headers (
x-user-id,x-user-scopes,x-user-role,x-smithers-token-id), and re-injects them from the validated session plus a small allowlist of scopes (run:read,run:write,approval:submit,signal:submit,cron:read,cron:write,observability:read).
mode: "trusted-proxy", the Gateway reads role, scopes, user id, and token id from the trusted headers the proxy set. In mode: "token" or mode: "jwt", the Gateway reads the bearer credential and ignores trusted-proxy identity headers; use the service-token branch only when service identity is acceptable. The browser never sees a Gateway credential; the iframe’s RPC calls and WebSocket upgrades flow through the proxy.
The same shape works behind any reverse proxy (Cloudflare, Cloudflare Access, Caddy, nginx, an internal API gateway): terminate session, strip identity headers off the request, set them from the validated session, forward to the Gateway. Trusted-proxy mode is only safe behind something you control that strips and rewrites identity headers; the Gateway docs call this out explicitly.
Local dev quick reference
bunx smithers-orchestrator ui command resolves the most recent run when invoked without an id, prints the URL it opened, and exits. When no Gateway answers on the local port it auto-starts one (auto-mounting .smithers/ui/<workflowKey>.tsx); pass --no-autostart to disable that, or --gateway <url> to point at an existing one. Note: up --serve is a per-run HTTP server, not a full Gateway, so ui needs a Gateway.
Sample tests
A custom UI has two layers worth testing:-
The bundle parses and boots without a network. A Bun test that imports the module under a happy-dom registrator and asserts the React tree renders covers a regression-class of “the bundle broke because of a Vite/tsup mishap” failures cheaply. The reference is
packages/gateway-react/tests/gatewayReactBehavior.test.ts; it constructs spy clients, drives every hook, and asserts they never surface a previous input’s result after a re-render. -
The Gateway serves it and the run flows through. A real-backend Playwright test that boots a Gateway, registers a workflow with a
ui:entry, executes a run to completion, and drives a real browser that:- deep-links to
/workflows/<key>?runId=<id>, - asserts the iframe rendered the custom UI,
- asserts
data-testid="demo-run-id"matches?runId=(proof the boot path works), - flips to the native inspector and back.
- deep-links to
- A dependency-free vanilla bundle that reads
?runId=fromlocation.searchand renders a heading. Use it to assert the bundle path without coupling to a UI library. - The same demo on
gateway-react. DrivecreateGatewayReactRoot,useGatewayRun, anduseGatewayActionsagainst the real fixture Gateway to prove the React hooks survive an iframe boundary, a stale-data transition, and a button-driven action.
gateway.register("<key>", workflow, { ui: { entry: "..." } }). Execute the run to completion before binding the port so Playwright never races the run’s tree.
Reference
- Package:
@smithers-orchestrator/gateway-react: React hooks andcreateGatewayReactRoot. - Package:
@smithers-orchestrator/gateway-client: vanillaSmithersGatewayClientand types. - Protocol: Gateway: full RPC method list, WebSocket frame shapes, error codes, scopes.
- CLI:
bunx smithers-orchestrator ui: open a workflow’s custom UI in your browser for a given run. - Examples: Workflow UI (React), Workflow UI (Vanilla).
- Reference bundles in this repo:
.smithers/ui/vcs.tsx,.smithers/ui/grill-me.tsx,.smithers/ui/ultragrill.tsx,.smithers/ui/workflow-skill.tsx.