---
title: Workflow UI (Vanilla)
description: A complete zero-dep workflow UI on `@smithers-orchestrator/gateway-client`: boot, live events, node output, approvals, all in one file.
---

This is the same workflow UI as the [React example](/examples/workflow-ui-react), built directly on `@smithers-orchestrator/gateway-client` with no framework. Pick this shape when you want a tiny bundle, when you already own your render layer (Solid, Lit, Mithril, plain DOM), or when you need the UI to render before any framework has booted.

For the full conceptual walkthrough, see [Custom Workflow UIs](/guides/custom-workflow-ui).

## The bundle

```ts
import { SmithersGatewayClient } from "smithers-orchestrator/gateway-client";

const WORKFLOW_KEY = "demo-vanilla-ui";

const runId = new URLSearchParams(location.search).get("runId") ?? undefined;
const client = new SmithersGatewayClient();  // reads __SMITHERS_GATEWAY_UI__ + same-origin baseUrl

const root = document.getElementById("root")!;

function render(state: {
  status?: string;
  shipRow?: Record<string, unknown> | null;
  eventCount: number;
  approvals: Array<{ runId: string; nodeId: string; iteration?: number }>;
  error?: string;
}) {
  root.innerHTML = "";
  const main = document.createElement("main");
  main.dataset.testid = "demo-vanilla-ui";
  main.style.cssText = "font: 13px/1.5 system-ui; padding: 24px; max-width: 720px; margin: 0 auto; color: #eaeaee; background: #0b0b0e; min-height: 100vh;";

  main.innerHTML = `
    <header style="display:flex;gap:12px;align-items:center;margin-bottom:16px">
      <h1 style="flex:1;font-size:16px;margin:0">Demo Vanilla Workflow UI</h1>
      <span style="font-size:11px;padding:4px 10px;border-radius:999px;background:#1a1a1f;border:1px solid #2a2a30;text-transform:uppercase">${state.status ?? "-"}</span>
      <code data-testid="demo-run-id" style="font:12px ui-monospace;background:#1a1a1f;padding:2px 8px;border-radius:4px">${runId ?? "(none)"}</code>
    </header>
    <section style="background:#15151a;border:1px solid #26262d;border-radius:8px;padding:16px;margin-bottom:12px">
      <h2 style="font-size:12px;color:#8a8a92;text-transform:uppercase;margin:0 0 8px">Ship output</h2>
      <pre data-testid="ship-output" style="margin:0;font:12px/1.5 ui-monospace;white-space:pre-wrap;color:#c5c5cc">${state.shipRow ? escapeHtml(JSON.stringify(state.shipRow, null, 2)) : "-"}</pre>
    </section>
    <section style="background:#15151a;border:1px solid #26262d;border-radius:8px;padding:16px;margin-bottom:12px">
      <h2 style="font-size:12px;color:#8a8a92;text-transform:uppercase;margin:0 0 8px">Live events <span data-testid="event-count">${state.eventCount}</span></h2>
    </section>
    <section style="background:#15151a;border:1px solid #26262d;border-radius:8px;padding:16px">
      <h2 style="font-size:12px;color:#8a8a92;text-transform:uppercase;margin:0 0 8px">Pending approvals (${state.approvals.length})</h2>
      ${state.approvals
        .map(
          (a) => `
        <div style="display:flex;gap:8px;align-items:center;margin-bottom:6px">
          <code style="font:12px ui-monospace;background:#1a1a1f;padding:2px 8px;border-radius:4px">${escapeHtml(a.nodeId)}</code>
          <button data-action="approve" data-node="${escapeHtml(a.nodeId)}" data-iter="${a.iteration ?? 0}">Approve</button>
          <button data-action="deny"    data-node="${escapeHtml(a.nodeId)}" data-iter="${a.iteration ?? 0}">Deny</button>
        </div>`
        )
        .join("") || "<em>none waiting</em>"}
    </section>
    ${state.error ? `<pre style="color:#f87171;margin-top:12px">${escapeHtml(state.error)}</pre>` : ""}
  `;

  main.querySelectorAll<HTMLButtonElement>("button[data-action]").forEach((btn) => {
    btn.addEventListener("click", async () => {
      if (!runId) return;
      const approved = btn.dataset.action === "approve";
      const nodeId = btn.dataset.node!;
      const iteration = Number(btn.dataset.iter ?? 0);
      try {
        await client.submitApproval({ runId, nodeId, iteration, decision: { approved } });
        void refreshApprovals();
      } catch (err) {
        state.error = err instanceof Error ? err.message : String(err);
        render(state);
      }
    });
  });

  root.replaceChildren(main);
}

function escapeHtml(s: string): string {
  return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;" })[c]!);
}

const state = {
  status: undefined as string | undefined,
  shipRow: null as Record<string, unknown> | null,
  eventCount: 0,
  approvals: [] as Array<{ runId: string; nodeId: string; iteration?: number }>,
  error: undefined as string | undefined,
};

async function refreshRun(): Promise<void> {
  if (!runId) return;
  try {
    // `getRun` and `getNodeOutput` return `Record<string, unknown>`; narrow at the
    // call site to the structural shapes you actually consume.
    const run = (await client.getRun({ runId })) as { status?: string };
    state.status = run.status;
    const output = (await client.getNodeOutput({ runId, nodeId: "ship" })) as
      | { status: "produced"; row: Record<string, unknown> }
      | { status: "pending" | "failed" };
    state.shipRow = output.status === "produced" ? output.row : null;
    render(state);
  } catch (err) {
    state.error = err instanceof Error ? err.message : String(err);
    render(state);
  }
}

async function refreshApprovals(): Promise<void> {
  if (!runId) return;
  try {
    // `listApprovals` returns `GatewayApprovalSummary[]` directly, with no wrapper.
    const res = await client.listApprovals({ filter: { runId, limit: 20 } });
    state.approvals = res.map((a) => ({ runId: a.runId, nodeId: a.nodeId, iteration: a.iteration }));
    render(state);
  } catch {
    /* leave previous list; listApprovals is non-critical */
  }
}

async function watchRun(): Promise<void> {
  if (!runId) return;
  const abort = new AbortController();
  window.addEventListener("beforeunload", () => abort.abort());
  try {
    for await (const frame of client.streamRunEventsResilient({ runId }, { signal: abort.signal })) {
      state.eventCount += 1;
      // Refetch the heavy state only when something materially changed; the
      // event stream itself is the lightweight tick for the counter.
      if (
        frame.event === "node.finished" ||
        frame.event === "approval.requested" ||
        frame.event === "approval.decided" ||
        frame.event === "run.completed"
      ) {
        void refreshRun();
        void refreshApprovals();
      } else {
        render(state);
      }
    }
  } catch (err) {
    state.error = err instanceof Error ? err.message : String(err);
    render(state);
  }
}

render(state);
void refreshRun();
void refreshApprovals();
void watchRun();
```

## Stale-data-free in vanilla

The React layer's stale-data-free model is built into the hooks. In vanilla code you wear that hat yourself. Three rules:

1. **Re-key on `runId` change.** If your UI lets the user switch between runs without a full page reload, abort the live stream, clear `state`, and re-issue `refreshRun()` / `refreshApprovals()` before painting.
2. **Generation-tag in-flight reads.** Increment a counter on every input change; drop responses whose generation no longer matches.
3. **Render after every fetch resolves, never before.** A render that prepaints the previous state and then updates is the most confusing failure mode at the iframe boundary; render only after the new fetch lands or after an explicit "loading" tick.

The React `useGatewayRpc` source in [`packages/gateway-react/src/useGatewayRpc.ts`](/reference/package-configuration) is a 60-line reference if you want a minimal recipe.

## Declaring the UI

```tsx
/** @jsxImportSource smithers-orchestrator */
import { createSmithers, UI } from "smithers-orchestrator";
import { z } from "zod";

const { Workflow, Task, smithers, outputs } = createSmithers({
  result: z.object({ ok: z.boolean() }),
});

export default smithers(() => (
  <Workflow name="demo-vanilla-ui">
    <UI entry="../ui/demo-vanilla-ui.ts" title="Demo Vanilla Workflow" />
    <Task id="ship" output={outputs.result}>{{ ok: true }}</Task>
  </Workflow>
));
```

The Gateway discovers the `<UI>` declaration when it registers the workflow with its `entryFile`, bundles the entry with esbuild (no React injected, no framework assumed), and serves it at `/workflows/demo-vanilla-ui`.

## Test pattern (no UI library coupling)

A vanilla bundle pairs nicely with a Playwright test that asserts the HTML the bundle painted, since there is no virtual DOM to traverse. The fixture in `apps/smithers/tests/fixtures/demoWorkflowUi.ts` is exactly this pattern: render a `<main data-testid="demo-vanilla-ui">` with a `data-testid="demo-run-id"` child, then assert against the iframe with `customUiFrame(page).getByTestId("demo-run-id")`.

```ts
import { expect, test } from "@playwright/test";
test("vanilla custom UI renders and reads the run id from ?runId=", async ({ page }) => {
  await page.goto(`/gw/demo-vanilla-ui/demo-vanilla-run-1`);
  const frame = page.frameLocator('[data-testid="gateway-workflow-ui-frame"]');
  await expect(frame.getByTestId("demo-vanilla-ui")).toBeVisible();
  await expect(frame.getByTestId("demo-run-id")).toHaveText("demo-vanilla-run-1");
});
```

This is the *integration* test (bundle + serve + embed + deep-link) that catches the regressions vanilla bundles are most likely to hit.

## What this example demonstrates

- **Vanilla SDK boot**: `new SmithersGatewayClient()` reads the boot config; no framework required.
- **`?runId=` parsing** + same-origin RPC against the Gateway.
- **`streamRunEventsResilient`** as an async generator for pushed updates and metrics, managed with an `AbortController` lifecycle.
- **Node output reads**, **approvals**, and **submitApproval** with explicit error handling.
- **A render model you author yourself** that mirrors the stale-data-free contract the React hooks provide.
