Skip to main content
This is the same workflow UI as the React example, 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.

The bundle

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 — 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 is a 60-line reference if you want a minimal recipe.

Registering the UI

gateway.register("demo-vanilla-ui", workflow, {
  ui: { entry: ".smithers/ui/demo-vanilla-ui.ts", title: "Demo Vanilla Workflow" },
});
The Gateway 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").
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 bootnew 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.