Skip to main content
This example is a minimal first-class React UI for a workflow that ships with the Gateway. It demonstrates every piece a real UI exercises: the createGatewayReactRoot bootstrap, reading ?runId= and the boot config, live event subscription, node-output reads, a lifecycle action, and an approvals strip, all in one file. Drop it at .smithers/ui/<your-workflow>.tsx, register your workflow with ui: { entry: ".smithers/ui/<your-workflow>.tsx" }, and open it with bunx smithers-orchestrator ui. For the full conceptual walkthrough, see Custom Workflow UIs.

The bundle

/** @jsxImportSource react */
import { useMemo } from "react";
import {
  createGatewayReactRoot,
  useGatewayActions,
  useGatewayApprovals,
  useGatewayNodeOutput,
  useGatewayRun,
  useGatewayRunEvents,
  useGatewayRuns,
} from "smithers-orchestrator/gateway-react";

function runIdFromUrl(): string | undefined {
  if (typeof location === "undefined") return undefined;
  return new URLSearchParams(location.search).get("runId") ?? undefined;
}

function statusClass(status: string | undefined): string {
  if (status === "running" || status === "continued") return "running";
  if (status === "finished" || status === "succeeded") return "finished";
  if (status === "failed" || status === "cancelled") return "failed";
  if (status === "waiting" || status === "paused" || status?.startsWith("waiting-")) return "waiting";
  return "";
}

// The RPC payload types are intentionally loose `Record<string, unknown>` so
// the wire stays cheap to extend; narrow at the consumer with the structural
// shapes you actually rely on. `.smithers/ui/grill-me.tsx` uses the same pattern.
type RunSummary = { runId: string; status?: string; workflowKey?: string };
type RunStateView = { state?: string };
type RunRecord = { status?: string; workflowKey?: string; runState?: RunStateView };
type NodeOutputView =
  | { status: "produced"; row: Record<string, unknown> }
  | { status: "pending" | "failed" };

function App() {
  const initialRunId = useMemo(runIdFromUrl, []);
  // `useGatewayRuns` lets the picker work when no `?runId=` was passed.
  // `ListRunsRequest.filter` accepts `status` and `limit` only; the bundle is
  // already mounted for one workflow, so there is no workflow filter on the wire.
  const runs = useGatewayRuns({ filter: { limit: 20 } });
  const runSummaries = (runs.data ?? []) as RunSummary[];
  const runId = initialRunId ?? runSummaries[0]?.runId;

  // Live run + event stream. Both clear automatically when `runId` changes,
  // so no previous run's data ever leaks into the new one.
  const run = useGatewayRun(runId);
  const runRecord = run.data as RunRecord | undefined;
  const status = runRecord?.runState?.state ?? runRecord?.status;
  const events = useGatewayRunEvents(runId, { maxEvents: 200 });
  const approvals = useGatewayApprovals(runId ? { filter: { runId, limit: 20 } } : {});

  // Read the final task's output row. Pending runs return `status: "pending"`.
  const shipOutput = useGatewayNodeOutput({ runId, nodeId: "ship" });
  const shipView = shipOutput.data as NodeOutputView | undefined;

  const { submitApproval, cancelRun } = useGatewayActions();

  return (
    <main className="shell">
      <header className="topbar">
        <h1>Demo React Workflow UI</h1>
        <span className={`status ${statusClass(status)}`}>
          {status ?? "-"}
        </span>
        <code data-testid="demo-run-id">{runId ?? "(no run)"}</code>
        <button onClick={() => runId && cancelRun({ runId })} disabled={!runId}>
          Cancel
        </button>
      </header>

      <section className="row">
        <div className="card">
          <h2>Ship output</h2>
          {shipOutput.loading && <em>loading…</em>}
          {shipOutput.error && <pre className="err">{shipOutput.error.message}</pre>}
          {shipView?.status === "produced" && (
            <pre>{JSON.stringify(shipView.row, null, 2)}</pre>
          )}
          {shipView?.status === "pending" && <em>not produced yet</em>}
        </div>

        <div className="card">
          <h2>Pending approvals</h2>
          {(approvals.data ?? []).map((a) => (
            <div key={`${a.runId}:${a.nodeId}:${a.iteration ?? 0}`} className="row gap">
              <code>{a.nodeId}</code>
              <button
                onClick={() =>
                  submitApproval({
                    runId: a.runId,
                    nodeId: a.nodeId,
                    iteration: a.iteration,
                    decision: { approved: true },
                  })
                }
              >
                Approve
              </button>
              <button
                onClick={() =>
                  submitApproval({
                    runId: a.runId,
                    nodeId: a.nodeId,
                    iteration: a.iteration,
                    decision: { approved: false },
                  })
                }
              >
                Deny
              </button>
            </div>
          ))}
          {!approvals.data?.length && <em>none waiting</em>}
        </div>
      </section>

      <section className="card events">
        <h2>Live events {events.streaming ? "•" : "○"}</h2>
        <ul>
          {events.events.slice(-20).map((frame) => (
            <li key={frame.seq}>
              <code>{String(frame.seq).padStart(4, "0")}</code> {frame.event}
            </li>
          ))}
        </ul>
        {events.error && <pre className="err">{events.error.message}</pre>}
      </section>
    </main>
  );
}

// Minimal styles so the demo reads as a polished surface, not an unstyled tree.
const styles = `
:root { color-scheme: dark; }
body { margin: 0; background: #0b0b0e; color: #eaeaee; font: 13px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; }
.shell { display: flex; flex-direction: column; gap: 16px; padding: 20px; max-width: 960px; margin: 0 auto; }
.topbar { display: flex; gap: 12px; align-items: center; }
h1 { font-size: 16px; font-weight: 600; margin: 0; flex: 1; }
.status { font-size: 11px; padding: 4px 10px; border-radius: 999px; background: #1a1a1f; border: 1px solid #2a2a30; text-transform: uppercase; letter-spacing: 0.04em; }
.status.running { color: #5e8df0; border-color: #2a3f6a; }
.status.finished { color: #4ade80; border-color: #2a5a3a; }
.status.failed { color: #f87171; border-color: #6a2a2a; }
.status.waiting { color: #fbbf24; border-color: #6a4a1a; }
code { font-family: ui-monospace, monospace; background: #1a1a1f; padding: 2px 8px; border-radius: 4px; font-size: 12px; }
button { background: #1a1a1f; color: #eaeaee; border: 1px solid #2a2a30; padding: 6px 12px; border-radius: 6px; font: inherit; cursor: pointer; }
button:hover:not(:disabled) { border-color: #5e6ad2; color: #fff; }
button:disabled { opacity: 0.45; cursor: not-allowed; }
.row { display: flex; gap: 12px; flex-wrap: wrap; }
.row.gap { gap: 8px; align-items: center; }
.card { background: #15151a; border: 1px solid #26262d; border-radius: 8px; padding: 16px; flex: 1 1 360px; }
.card h2 { font-size: 12px; font-weight: 500; color: #8a8a92; text-transform: uppercase; letter-spacing: 0.06em; margin: 0 0 12px; }
.card pre { margin: 0; font: 12px/1.5 ui-monospace, monospace; white-space: pre-wrap; word-break: break-word; color: #c5c5cc; }
.card .err { color: #f87171; }
.card.events ul { list-style: none; padding: 0; margin: 0; max-height: 200px; overflow-y: auto; font: 12px/1.6 ui-monospace, monospace; }
.card.events li { display: flex; gap: 8px; color: #c5c5cc; }
.card.events li code { background: transparent; padding: 0; color: #8a8a92; }
`;

if (typeof document !== "undefined") {
  const sheet = document.createElement("style");
  sheet.textContent = styles;
  document.head.appendChild(sheet);
}

createGatewayReactRoot(<App />);

Registering the UI

In your workflow’s gateway setup (the bun .smithers/scripts/<workflow>.ts boot, or your hosted server):
import { Gateway } from "smithers-orchestrator";
import { demoReactUi } from "./.smithers/workflows/demo-react-ui";

const gateway = new Gateway({
  heartbeatMs: 15_000,
  auth: { mode: "token", tokens: { "dev-token": { role: "operator", scopes: ["*"] } } },
});

gateway.register("demo-react-ui", demoReactUi, {
  ui: {
    entry: ".smithers/ui/demo-react-ui.tsx",
    title: "Demo React Workflow",
    // Optional free-form props surfaced as `__SMITHERS_GATEWAY_UI__.props`.
    props: { brand: "demo" },
  },
});

await gateway.listen({ port: 7331 });
Then:
bunx smithers-orchestrator up demo-react-ui -d
bunx smithers-orchestrator ui                          # opens the bundle in your browser
The bundle is served at /workflows/demo-react-ui. The HTML shell injects __SMITHERS_GATEWAY_UI__ and ?runId= before your script runs; createGatewayReactRoot picks both up automatically.

Embedding it in apps/smithers

The PWA discovers workflows that advertise hasUi: true and renders this exact bundle in an iframe at /gw/demo-react-ui/<runId>. The same-origin Vite proxy (in dev) and Worker proxy (in Cloud) make the iframe’s RPC and WebSocket reach the real Gateway. No CORS, no token shuttling. See Same-origin proxy patterns.

What this example demonstrates

  • createGatewayReactRoot boot: one call, reads the boot config, mounts the provider, renders the tree.
  • ?runId= parsing with a safe fallback to the most recent run via useGatewayRuns.
  • Live subscription with useGatewayRunEvents: resilient reconnect, pushed updates, gap resync, metrics, and automatic teardown when runId changes.
  • Node output reads with useGatewayNodeOutput: handles produced / pending / failed.
  • Approvals strip with useGatewayApprovals + useGatewayActions().submitApproval.
  • Lifecycle action with useGatewayActions().cancelRun.
  • Stale-data-free transitions: every read and the event stream clears synchronously when runId changes, so a late response from the previous run can never repopulate state. See Stale-data-free update model.