---
title: Custom Workflow UIs
description: 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 *belongs to your workflow*
and talks to the Smithers Gateway through its domain API, RPC API, and live
event streams. Declare it in the workflow with `<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.

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 the TanStack DB collection layer that backs the React hooks, see [Sync](/guides/sync). For two compact end-to-end examples, see [Workflow UI (React)](/examples/workflow-ui-react) and [Workflow UI (Vanilla)](/examples/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](/guides/workflow-ui-design).

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

<Tip>Want a dashboard without wiring hooks by hand? [`@smithers-orchestrator/gateway-ui`](/reference/gateway-ui) ships prebuilt components (`RunList`, `RunTree`, `RunEventLog`, `ApprovalPanel`, `LaunchButton`, and more) that each connect to the Gateway by themselves. Compose a full UI in a dozen lines, then drop to the hooks below only where you need something custom.</Tip>

Local launch is one command:

```sh
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. 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.

<Note>Moving an older `.smithers/ui/<workflow>.tsx` auto-mount or `gateway.register(..., { ui })` setup? See [Migrate to Workflow-Owned UIs](/guides/workflow-owned-ui-migration).</Note>

## How a custom UI is wired

Three pieces collaborate:

1. **The workflow declares its UI.** Add `<UI entry="../ui/vcs.tsx" title="VCS" />` inside `.smithers/workflows/vcs.tsx`. When the launcher calls `gateway.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 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. The shell is also **theme-aware**: it inlines the [workflow UI style-guide tokens](/guides/workflow-ui-design) (light values, dark overrides, and `color-scheme`) so the document follows the OS `prefers-color-scheme` before your bundle loads, and it honors an explicit `?theme=dark` / `?theme=light` query param by stamping `data-theme` on `<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.

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)` over TanStack DB |
| **Reads** | `client.getNodeOutput({...})` and REST domain calls | `useGateway*` hooks or `useLiveQuery` over collections |
| **Writes** | `client.submitApproval(...)` and REST domain writes | `useGatewayActions()` or collection mutation handlers |
| **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. 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 for `smithers-orchestrator/ui` (the published `@smithers-orchestrator/ui` package). 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](/guides/workflow-ui-design), 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.

```tsx .smithers/ui/triage.tsx
/** @jsxImportSource react */
import { useState } from "react";
import { createGatewayReactRoot, useGatewayActions, useGatewayRuns } from "smithers-orchestrator/gateway-react";
import { WorkflowUiShell } from "smithers-orchestrator/gateway-ui";
import {
  Button,
  Card,
  CardHeader,
  CardTitle,
  EmptyState,
  Input,
  RowButton,
  SmithersUiStyles,
  StatusPill,
} from "smithers-orchestrator/ui";

function App() {
  const [prompt, setPrompt] = useState("");
  const runs = useGatewayRuns({ filter: { limit: 20 } });
  const actions = useGatewayActions();
  return (
    <WorkflowUiShell title="Triage">
      <SmithersUiStyles />
      <Card>
        <CardHeader>
          <CardTitle>Launch</CardTitle>
        </CardHeader>
        <div style={{ display: "flex", gap: 8 }}>
          <Input style={{ flex: 1 }} value={prompt} onChange={(e) => setPrompt(e.currentTarget.value)} placeholder="Optional prompt..." />
          <Button onClick={() => void actions.launchRun({ workflow: "triage", input: { prompt } })}>Start</Button>
        </div>
      </Card>
      {(runs.data ?? []).length === 0 ? (
        <EmptyState title="No runs yet" description="Launch one to see it here." />
      ) : (
        (runs.data ?? []).map((r) => (
          <RowButton key={r.runId}>
            <span className="mono">{r.runId.slice(0, 8)}</span>
            <StatusPill status={r.status} />
          </RowButton>
        ))
      )}
    </WorkflowUiShell>
  );
}

createGatewayReactRoot(<App />);
```

The important mechanics:

- **Styles travel as a JS string, never a `.css` import.** The Gateway bundler keeps only the JS output, so `import "./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 `withTheme` to prepend the token block: `<SmithersUiStyles withTheme />`.
- **The status vocabulary is shared.** `normalizeStatus`, `statusClass`, `formatStatus`, and `isTerminalRunStatus` are exported from `smithers-orchestrator/ui` (and re-exported by `gateway-ui`), so stop re-implementing them per UI.

The component set: `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`](/reference/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:

```ts
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 `<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
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. 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`.

```tsx
/** @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.** 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](#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. |
| `useSmithersCollections()` | `{ client, collections, queryClient }` | Direct access to the TanStack DB collection registry for custom `useLiveQuery` reads. |
| `SmithersCollectionsProvider` | React provider | Advanced provider for supplying a custom `WorkspaceMode`, `SmithersDataClient`, or `QueryClient`. |

## Vanilla SDK

The same primitives in zero-dep JS:

```ts
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 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](#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.

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`:

```tsx
const { events } = useGatewayRunEvents(runId, { afterSeq: 0 });
const byNode = new Map<string, { state: string; error?: string }>();
for (const frame of events) {
  const nodeId = (frame.payload as { nodeId?: string })?.nodeId;
  if (!nodeId) continue;
  if (frame.event === "NodeFailed") {
    const err = (frame.payload as { error?: { message?: string } }).error;
    byNode.set(nodeId, { state: "failed", error: err?.message });
  } else if (frame.event === "NodeFinished") byNode.set(nodeId, { state: "done" });
  else if (frame.event === "NodeStarted") byNode.set(nodeId, { state: "running" });
}
// render every node in byNode, showing its state + error; a failed task must be visible, not swallowed.
```

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

## 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
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. 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:

```tsx
const approvals = useGatewayApprovals({ filter: { runId } });
const { submitApproval } = useGatewayActions();
return (approvals.data ?? []).map((gate) => (
  <div key={gate.nodeId}>
    <span>{gate.requestTitle ?? gate.nodeId}</span>
    <button onClick={() => submitApproval({ runId, nodeId: gate.nodeId, iteration: gate.iteration, decision: { approved: true } })}>
      Approve
    </button>
  </div>
));
```

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 bunx smithers-orchestrator 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
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
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/*    →  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
# Just open the UI. If no Gateway is reachable on the local port, `ui` starts
# one for you and serves workflow-owned <UI> declarations.
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 + declared <UI> entries
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** 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:

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

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, { 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`](/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`.
