<UI entry="../ui/<workflow>.tsx" />, keep the browser code in .smithers/ui/<workflow>.tsx, and the Gateway
builds and serves it at /workflows/<key>. bunx smithers-orchestrator ui
opens it for a run with a stable ?runId= deep link.
Compose the UI from the shared component library rather than hand-rolling
markup: UI Docs by Type maps every layer, from the
shadcn-based base library and agent-native components to the run-aware
gateway-ui widgets and the gateway-react hooks this guide builds on.
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 bunx smithers-orchestrator ui and the Gateway.
For the underlying RPC and event protocol, see Gateway. For the TanStack DB collection layer that backs the React hooks, see Sync. For two compact end-to-end examples, see Workflow UI (React) and Workflow UI (Vanilla). For the visual language every workflow UI shares (tokens, type, depth, motion) so they look like one product, see Workflow UI Design Principles.
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. A hand-written .smithers/gateway.ts is not required for local UI launch as long as the workflow declares <UI>. Use --no-autostart to require an already-running Gateway, or --gateway <url> to point at a remote one.
Moving an older
.smithers/ui/<workflow>.tsx auto-mount or gateway.register(..., { ui }) setup? See Migrate to Workflow-Owned UIs.How a custom UI is wired
Three pieces collaborate:-
The workflow declares its UI. Add
<UI entry="../ui/vcs.tsx" title="VCS" />inside.smithers/workflows/vcs.tsx. When the launcher callsgateway.register("vcs", vcs, { entryFile }), the Gateway discovers that declaration, 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. The shell is also theme-aware: it inlines the workflow UI style-guide tokens (light values, dark overrides, andcolor-scheme) so the document follows the OSprefers-color-schemebefore your bundle loads, and it honors an explicit?theme=dark/?theme=lightquery param by stampingdata-themeon<html>pre-paint. That query param is the channel an embedding host (for example an app rendering the page in an iframe) uses to push its own light/dark toggle into the UI. -
bunx smithers-orchestrator 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
Both SDKs talk to the same Gateway. The vanilla SDK is published as
@smithers-orchestrator/gateway-client; the React layer is @smithers-orchestrator/gateway-react and adds providers plus hooks over the TanStack DB collections.
The shared component library
Before styling anything by hand, reach forsmithers-orchestrator/ui (the published @smithers-orchestrator/ui package; full catalog in the UI Component Library reference). Prefer the smithers-orchestrator/ui subpath, which resolves through the smithers-orchestrator install you already have; importing the scoped @smithers-orchestrator/ui name directly requires declaring it as a direct dependency under pnpm-style isolated layouts. It ships shadcn-anatomy components (variant props, asChild slots, Radix behavior for dialogs, tabs, tooltips, and selects) that are styled entirely through the workflow UI theme tokens, so every component is correct in light and dark automatically: the OS prefers-color-scheme is respected, and an explicit ?theme=dark|light on the host page always wins.
.smithers/ui/triage.tsx
- Styles travel as a JS string, never a
.cssimport. The Gateway bundler keeps only the JS output, soimport "./x.css"is silently dropped. Render<SmithersUiStyles/>once at the root; every component also self-injects the sheet in the browser as a fallback, so a missing style tag degrades gracefully instead of unstyled. - All classes are namespaced
sui-*, so they can never collide with the styleguide page vocabulary (.button,.badge,.card) or your own CSS. Your escape hatch for custom rules is<SmithersUiStyles extra=".mine { ... }" />or a plain<style>string of your own. - Standalone hosts (pages the Gateway shell does not serve) pass
withThemeto prepend the token block:<SmithersUiStyles withTheme />. - The status vocabulary is shared.
normalizeStatus,statusClass,formatStatus, andisTerminalRunStatusare exported fromsmithers-orchestrator/ui(and re-exported bygateway-ui), so stop re-implementing them per UI.
Button (the default variant is the house tinted-brand primary; solid is the filled look), Card family, Badge and StatusPill, Tabs (with a count slot per trigger), Dialog (backdrop, Esc, focus trap for free), Tooltip, Select, Input/Textarea/Label/Field, Alert (the honest-degradation surface), Table, Progress, Skeleton, Spinner, Separator, EmptyState, SectionHeader/Eyebrow, RowButton, and KpiStat.
For run-aware building blocks that already talk to the Gateway (RunList, RunTree, RunEventLog, ApprovalPanel, SimpleWorkflowDashboard), use @smithers-orchestrator/gateway-ui; it is built on the same tokens and re-exports the status helpers.
The boot config
Every page the Gateway serves at/workflows/<key> (and /inspector/RUN_ID 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. The provider also mounts SmithersCollectionsProvider, which creates the TanStack DB collection registry for the current WorkspaceMode. Local mode reads through /v1/api/* and refreshes from /v1/api/stream; multiplayer mode reads through Electric shapes served by @smithers-orchestrator/electric-proxy.
packages/gateway-react/src and follow one rule: they never surface a previous run’s data after the inputs change. Collection hooks key their TanStack DB query to the current input, and RPC escape hatches still clear data and error synchronously before issuing 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.
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 bunx smithers-orchestrator 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.
Each entry in events is a GatewayEventFrame: { event, payload, seq }. The
event NAME is frame.event ("NodeStarted", "NodeFinished", "NodeFailed",
"RunStatusChanged", …) and everything else is under frame.payload. To build a
live per-task view, including surfacing a task that FAILED, key off
frame.payload.nodeId and read frame.payload.error on NodeFailed:
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.
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. Its data IS the array of gates (no data.approvals wrapper); each
gate is { runId, nodeId, iteration, requestTitle?, requestSummary?, workflowKey? }
the title is requestTitle, not title. Feed nodeId + iteration straight
into submitApproval, and remember decision is required:
.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 bunx smithers-orchestrator 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 and discovers the run’s workflow-owned <UI> declaration; 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 whose JSX graph declares
<UI entry="../ui/<key>.tsx" />, 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, { entryFile: "/path/to/.smithers/workflows/<key>.tsx" }) and put <UI entry="../ui/<key>.tsx" /> inside that workflow. 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.