> ## Documentation Index
> Fetch the complete documentation index at: https://smithers-feat-claude-workflow-mirror.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Custom Workflow UIs

> Build a first-class browser UI for your workflow with smithers ui, the Gateway, gateway-client, and gateway-react.

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](/integrations/gateway). For two compact end-to-end examples, see [Workflow UI (React)](/examples/workflow-ui-react) and [Workflow UI (Vanilla)](/examples/workflow-ui-vanilla).

<Note>API reference: [Gateway React](/reference/gateway-react) lists every hook and component, its options, and links to source and tests.</Note>

Local launch is one command:

```sh theme={null}
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`](#the-boot-config)) 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-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 |

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:

```ts theme={null}
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:

```ts theme={null}
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.

```tsx theme={null}
/** @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](#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:

```ts theme={null}
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`](https://github.com/smithersai/smithers/blob/main/packages/gateway-client/src/SmithersGatewayClient.ts) 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](#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](#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.event`s.

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).

```tsx theme={null}
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:

```tsx theme={null}
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:

```bash theme={null}
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:

```bash theme={null}
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

```bash theme={null}
# 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

* **Package:** [`@smithers-orchestrator/gateway-react`](/reference/package-configuration#workspace-packages): React hooks and `createGatewayReactRoot`.
* **Package:** [`@smithers-orchestrator/gateway-client`](/reference/package-configuration#workspace-packages): vanilla `SmithersGatewayClient` and types.
* **Protocol:** [Gateway](/integrations/gateway): full RPC method list, WebSocket frame shapes, error codes, scopes.
* **CLI:** [`bunx smithers-orchestrator ui`](/cli/overview#smithers-ui): open a workflow's custom UI in your browser for a given run.
* **Examples:** [Workflow UI (React)](/examples/workflow-ui-react), [Workflow UI (Vanilla)](/examples/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`.
