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, the Gateway builds and serves it at /workflows/<key>, and every Smithers surface that knows about Gateway runs (the Smithers PWA, Studio 2’s Runs surface, the bunx smithers-orchestrator ui CLI, or your own embed) can render 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 with Smithers UI and the Gateway (such as in Smithers Cloud or behind Plue). For the underlying RPC and event protocol, see Gateway. For two compact end-to-end examples, see Workflow UI (React) and Workflow UI (Vanilla).

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. The host embeds the shell same-origin. apps/smithers mounts it as <iframe src="/workflows/<key>?runId=<id>">. Because the host and Gateway share an origin (via the Worker / Vite proxy described below), the iframe’s RPC calls and WebSocket all reach the real Gateway without CORS or token shuttling. Studio 2’s Runs surface and the bunx smithers-orchestrator ui command embed the same URL.

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 dev. Vite’s dev server proxies /v1/rpc, /health, and /workflows/* to the Gateway. The browser sees one origin; the Gateway is configured with mode: "token" and a single dev token Vite injects. See Local dev setup.
  • Smithers Cloud. A Cloudflare Worker terminates the user’s session (WorkOS / Auth0 / GitHub) and proxies /v1/rpc, /health, and /workflows/* to a private Gateway. It forwards x-user-id, x-user-scopes, x-user-role, and x-smithers-token-id to the Gateway, which trusts those headers because it is configured mode: "trusted-proxy". The browser never holds a Gateway credential. See Smithers Cloud / Plue 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 are embedded in an iframe and may be served from a different origin than the Gateway. The robust path is to put the Gateway behind a same-origin proxy on the host that embeds the iframe, so the iframe’s fetch("/v1/rpc/...") and new WebSocket("wss://<host>/") both hit the Gateway without CORS or token shuttling.

Local dev setup

The apps/smithers Vite config wires up the Gateway proxy when the SMITHERS_GATEWAY_PROXY_TARGET env var is set. When it is unset, the dev server stays gateway-less and those paths 404 (the app’s gateway store reads that as “offline”). The relevant excerpt:
// apps/smithers/vite.config.ts
const gatewayTarget = process.env.SMITHERS_GATEWAY_PROXY_TARGET;
if (gatewayTarget) {
  proxy["/v1/rpc"]    = { target: gatewayTarget, changeOrigin: true, ws: true };
  proxy["/health"]    = { target: gatewayTarget, changeOrigin: true };
  proxy["/workflows"] = { target: gatewayTarget, changeOrigin: true };
}
The outer apps/smithers Gateway client wrapper rewrites WebSocket URLs to /v1/rpc, which is why the Vite proxy enables ws: true there. Gateway-hosted iframe bundles that call new SmithersGatewayClient() directly use the boot wsPath (/ by default); proxy that path too or pass a WebSocket override if the iframe needs streams in local dev. /workflows only serves the HTML shell and the bundled JS, so it stays HTTP-only. Then:
export SMITHERS_GATEWAY_PROXY_TARGET=http://127.0.0.1:7331
bunx smithers-orchestrator up <workflow> -d            # or `bun .smithers/scripts/<workflow>.ts`
(cd apps/smithers && bun dev)
The browser sees a single origin. The iframe at /workflows/<key> is served by the Vite-proxied Gateway; its SmithersGatewayClient calls fetch("/v1/rpc/getRun", ...) on the same origin. Stream sockets depend on the client used: the outer app wrapper upgrades via /v1/rpc, while plain hosted bundles use the boot wsPath. No CORS, no token in the browser.

Smithers Cloud / Plue same-origin proxy

In Cloud (or any deployment behind Plue, our hosted code-platform that fronts custom workflow UIs), a single Cloudflare Worker terminates user sessions and forwards three disjoint route families:
/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)
/api/repos/*   →  Plue jjhub         (code hosting REST)
The Worker has two Gateway-auth branches:
  1. Service-token branch. If GATEWAY_AUTH_TOKEN is set, the Worker 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 Worker 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 Worker 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 Worker. The exact Worker is in apps/smithers/src/worker.ts; its isGatewayProxyRoute, isAuthProxyRoute, and isPlatformProxyRoute predicates split the route families. stripTrustedProxyHeaders and stripGatewayCredentialHeaders remove browser-supplied identity and Gateway credentials before either branch runs; gatewayAuthToken selects the service-token branch. Use it as the reference implementation when you stand up your own same-origin 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

# 1. Boot the Gateway with your workflow registered.
bunx smithers-orchestrator up <workflow> -d            # or `bun .smithers/scripts/<workflow>.ts`

# 2. Open the UI in your default browser at the most recent run.
bunx smithers-orchestrator ui                          # picks the most recent run
bunx smithers-orchestrator ui RUN_ID                  # specific run
bunx smithers-orchestrator ui RUN_ID --gateway http://127.0.0.1:7331

# 3. (Optional) Boot the apps/smithers PWA against the same Gateway.
cd apps/smithers && bun dev
# Visit http://localhost:5173/gw/<workflowKey>/<runId>
The bunx smithers-orchestrator ui command resolves the most recent run when invoked without an id, prints the URL it opened, and exits; it does not hold the run alive. Pair it with bunx smithers-orchestrator up <workflow> in a separate terminal.

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 host embeds 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 /gw/<key>/<runId>,
    • 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 canonical example is apps/smithers/tests/e2e/gatewayUi.spec.ts with the fixture at apps/smithers/tests/fixtures/gatewayFixture.tsx. Two demo bundles ship today:
  • apps/smithers/tests/fixtures/demoWorkflowUi.ts: 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.
  • apps/smithers/tests/fixtures/demoReactWorkflowUi.tsx: the same demo on gateway-react. Drives createGatewayReactRoot, useGatewayRun, and useGatewayActions against the real fixture Gateway; proves the React hooks survive an iframe boundary, a stale-data transition, and a button-driven action.
Add your own fixture under the same folder and register it with gateway.register("<key>", workflow, { ui: { entry: "…" } }) in gatewayFixture.tsx. The fixture executes the run to completion before binding the port so Playwright never races the run’s tree.

Reference