Skip to main content
A custom workflow UI is a small browser bundle that lives next to your workflow and talks to the Smithers Gateway over the same RPC and WebSocket protocol the Gateway already serves. You write it once in .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.
Local launch is one command:
bunx smithers-orchestrator ui RUN_ID
If no Gateway is reachable on the local port, 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:
  1. 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 a GET /workflows/<key>/boot config that the rendered page hydrates from.
  2. The Gateway serves an HTML shell. Hitting /workflows/vcs returns a tiny HTML document with a <div id="root">, the bundled script, and a global __SMITHERS_GATEWAY_UI__ object (the GatewayUiBootConfig) 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.
  3. smithers ui opens the shell same-origin. bunx smithers-orchestrator ui RUN_ID resolves 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-clientsmithers-orchestrator/gateway-react
FootprintOne zero-dep class, no frameworkReact 19 + ReactDOM + hooks
Bootnew SmithersGatewayClient()createGatewayReactRoot(<App />)
Live eventsstreamRunEventsResilient async generatoruseGatewayRunEvents(runId)
Readsclient.getNodeOutput({...})useGatewayNodeOutput({...})
Writesclient.submitApproval(...)useGatewayActions().submitApproval(...)
Use whenYou 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
Both SDKs talk to the same Gateway and produce the same wire frames. Pick by appetite, not by capability. The vanilla SDK is published as @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:
declare global {
  var __SMITHERS_GATEWAY_UI__: GatewayUiBootConfig | undefined;
}

type GatewayUiBootConfig = {
  apiVersion: "v1";
  kind: "gateway" | "workflow";
  workflowKey: string | null;
  mountPath: string;     // where the Gateway mounted you, e.g. "/workflows/vcs"
  rpcPath: string;       // "/v1/rpc"
  wsPath: string;        // "/": the WebSocket path under the Gateway origin
  assetBasePath: string; // where to resolve relative bundle assets
  props: Record<string, unknown>; // free-form, set by `gateway.register({ ui: { props } })`
};
You do not normally touch this. 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:
const runId = new URLSearchParams(location.search).get("runId") ?? undefined;

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.
/** @jsxImportSource react */
import {
  createGatewayReactRoot,
  useGatewayRun,
  useGatewayRunEvents,
  useGatewayNodeOutput,
  useGatewayActions,
} from "smithers-orchestrator/gateway-react";

const runId = new URLSearchParams(location.search).get("runId") ?? undefined;

function App() {
  const run = useGatewayRun(runId);                   // run record + optional runState, refetches when runId changes
  const events = useGatewayRunEvents(runId);          // live stream, resilient reconnect, gap resync
  const output = useGatewayNodeOutput({               // last task output, refetches when the input key changes
    runId,
    nodeId: "ship",
  });
  const { submitApproval, cancelRun } = useGatewayActions();

  return (
    <main>
      <h1>{run.data?.workflowKey} · {run.data?.status}</h1>
      <button onClick={() => cancelRun({ runId: runId! })}>Cancel</button>
      <pre>{JSON.stringify(output.data?.row, null, 2)}</pre>
      <ul>{events.events.map((f) => <li key={f.seq}>{f.event}</li>)}</ul>
    </main>
  );
}

createGatewayReactRoot(<App />);
The React hooks all live in 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.
HookReturnsNotes
useSmithersGateway()SmithersGatewayClientBare 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 / useSyncSubscriptionSync cache hooksWrap a SyncClient for stale-while-revalidate reads, optimistic mutations, and shared stream subscriptions.
useGatewayQuery / useGatewayMutation / useGatewayRunStreamGateway sync shortcutsTyped helpers over gatewayKeys, Gateway RPC methods, and the shared subscription hub.

Vanilla SDK

The same primitives in zero-dep JS:
import { SmithersGatewayClient } from "smithers-orchestrator/gateway-client";

const runId = new URLSearchParams(location.search).get("runId");
const client = new SmithersGatewayClient();

const run = await client.getRun({ runId });
const out = await client.getNodeOutput({ runId, nodeId: "ship" });

// Live subscription with automatic reconnect + gap resync.
const abort = new AbortController();
(async () => {
  for await (const frame of client.streamRunEventsResilient({ runId }, { signal: abort.signal })) {
    if (frame.event === "run.completed") render(await client.getRun({ runId }));
  }
})();

// Approvals / signals / cancellation are one-shot RPC.
await client.submitApproval({ runId, nodeId: "review", decision: { approved: true } });
await client.cancelRun({ runId });
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.
The hooks read no auth state of their own. They call 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).
const ship = useGatewayNodeOutput({ runId, nodeId: "ship" });
// ship.data: { status: "produced", row: { ok: true, sha: "…" }, schema: {...} }

const diff = useGatewayRpc("getNodeDiff", { runId, nodeId: "ship" });
// diff.data: { status, files: [{ path, status, hunks?: [...] }], … }
A row arrives either inline (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:
const { submitApproval, submitSignal, cancelRun } = useGatewayActions();

<button onClick={() => submitApproval({ runId, nodeId: "review", decision: { approved: true } })}>
  Approve
</button>

<button onClick={() => submitSignal({ runId, correlationKey: "utterance", payload: { text } })}>
  Send
</button>

<button onClick={() => cancelRun({ runId })}>Cancel</button>
The 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 from runId=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:
  1. Clearing on input change. When runId, nodeId, or any custom dep changes, the effect synchronously clears data and error, then fires a fresh fetch. Consumers see an empty state while the new fetch is in flight.
  2. Generation-tagged requests. Each refetch reads a per-hook generation counter; only the latest generation can repopulate state. A late response from a previous inputs version is dropped on arrival.
  3. Disabling clears too. enabled: false (e.g. when runId becomes undefined) 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’s fetch("/v1/rpc/...") and new WebSocket("wss://<host>/") both hit the Gateway without CORS or token shuttling.

Local dev setup

For local work, use bunx 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:
bunx smithers-orchestrator up WORKFLOW -d
bunx smithers-orchestrator ui RUN_ID
If you want the Gateway to stay alive between browser launches, run it yourself and point the UI command at it:
bunx smithers-orchestrator gateway --port 7331
bunx smithers-orchestrator ui RUN_ID --gateway http://127.0.0.1:7331
The browser sees a single origin. The page at /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:
/api/auth/*    →  WorkOS / Auth0 / GitHub (user session terminator)
/v1/rpc/*      →  Smithers Gateway   (RPC, trusted-proxy headers or service token attached)
/workflows/*   →  Smithers Gateway   (HTML shell + bundle)
/health        →  Smithers Gateway   (liveness)
The proxy usually has two Gateway-auth branches:
  1. Service-token branch. If GATEWAY_AUTH_TOKEN is set, the proxy strips browser-supplied Gateway credentials and trusted-proxy headers, adds Authorization: Bearer <service-token>, and forwards the request without minting user identity headers.
  2. 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).
The Gateway auth mode determines which branch preserves per-user identity. In 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

# Just open the UI. If no Gateway is reachable on the local port, `ui` starts
# one for you (it auto-mounts each workflow's UI from .smithers/ui/<key>.tsx).
bunx smithers-orchestrator ui                          # picks the most recent run
bunx smithers-orchestrator ui RUN_ID                   # specific run

# Or run the Gateway yourself (to keep it alive / choose the port), then open:
bunx smithers-orchestrator gateway --port 7331         # serves every workspace workflow + its .smithers/ui/*.tsx
bunx smithers-orchestrator ui RUN_ID --gateway http://127.0.0.1:7331   # point ui at it (--no-autostart never spawns one)

The 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:
  1. 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.
  2. 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.
The server package’s Gateway UI tests are the closest in-repo reference for the serving contract. Two useful fixture styles are:
  • A dependency-free vanilla bundle that reads ?runId= from location.search and renders a heading. Use it to assert the bundle path without coupling to a UI library.
  • The same demo on gateway-react. Drive createGatewayReactRoot, useGatewayRun, and useGatewayActions against the real fixture Gateway to prove the React hooks survive an iframe boundary, a stale-data transition, and a button-driven action.
Register your fixture with gateway.register("<key>", workflow, { ui: { entry: "..." } }). Execute the run to completion before binding the port so Playwright never races the run’s tree.

Reference