Skip to main content
The Smithers Gateway is a versioned RPC + WebSocket contract: any UI (apps/smithers, an Electron shell, a Slack bot, your own dashboard) talks to it through the same typed surface. This page is the contract for third-party UIs. There are two SDKs:
  • @smithers-orchestrator/gateway-client: framework-neutral. One small class (SmithersGatewayClient) with typed RPC methods, a resilient event stream, and no React dependency. Use it from vanilla JS, Node, Bun, workers, Svelte, Solid, anywhere.
  • @smithers-orchestrator/gateway-react: declarative hooks (useGatewayRun, useGatewayRunEvents, useGatewayActions, …) over the same client. Use it from React 19. The hooks own the lifecycle: stale-result guards, abort on unmount, ring-buffered event feeds.
Pick the client for plumbing, the React layer for views.

Install

Re-export the SDKs through smithers-orchestrator so a UI bundle does not duplicate the runtime:
import { SmithersGatewayClient } from "smithers-orchestrator/gateway-client";
import {
  SmithersGatewayProvider,
  useGatewayRun,
  useGatewayRunEvents,
  useGatewayActions,
} from "smithers-orchestrator/gateway-react";
The scoped packages (@smithers-orchestrator/gateway-client, @smithers-orchestrator/gateway-react) work equivalently; use whichever your bundler / version policy prefers. Both ship typed RPC params and payloads keyed off the gateway’s GatewayRpcMethod union, so a typo at the call site is a build error.

The client (vanilla)

SmithersGatewayClient is the only thing you instantiate. It owns nothing but configuration.
import { SmithersGatewayClient } from "smithers-orchestrator/gateway-client";

const gateway = new SmithersGatewayClient({
  baseUrl: "https://gateway.example.com",
  token: "operator-jwt",
  client: { id: "my-ui", version: "1.0.0", platform: "browser" },
});

const workflows = await gateway.listWorkflows({ filter: { hasUi: true } });
baseUrl defaults to globalThis.location.origin so a UI hosted on the gateway needs no config. token is sent as Authorization: Bearer ... on HTTP RPC calls and as auth: { token } in the WebSocket connect request. Pass headers for extra HTTP RPC headers, or fetch / WebSocket to override the transport defaults (for tests, workers, or React Native).

Declarative RPC

Every gateway RPC is a typed instance method that returns the payload directly. Failures throw a GatewayRpcError with code, status, and (if relevant) requiredScope / refresh / details.
import { GatewayRpcError } from "smithers-orchestrator/gateway-client";

try {
  const run = await gateway.getRun({ runId: "run-1" });
  console.log(run.status);
} catch (error) {
  if (error instanceof GatewayRpcError && (error.code === "Forbidden" || error.code === "FORBIDDEN")) {
    promptReauth(error.requiredScope);
  } else {
    throw error;
  }
}
Generic HTTP RPC calls accept an AbortSignal through gateway.rpc, so a parent component or routing change can cancel a stalled call without leaking the promise:
const controller = new AbortController();
const list = await gateway.rpc(
  "listRuns",
  { filter: { status: "running", limit: 50 } },
  { signal: controller.signal },
);
controller.abort(); // cancels mid-flight
gateway.rpc(method, params, { signal }) is the generic escape hatch for caller-managed cancellation. Use the typed wrappers (gateway.listRuns, gateway.getRun, gateway.launchRun, …) when you do not need to pass per-call options and want autocomplete plus stronger inference.

Pushed updates over WebSocket

streamRunEvents is the production helper for one run. It opens a WebSocket, performs the handshake, subscribes to that run, and yields GatewayEventFrame values as they arrive. The connection is closed when the iterator returns.
const abort = new AbortController();

for await (const frame of gateway.streamRunEvents(
  { runId: "run-1" },
  { signal: abort.signal },
)) {
  if (frame.event === "run.completed") break;
  if (frame.event === "run.event") render(frame.payload);
}
Drop a stream by aborting the signal or break-ing the loop; the helper closes the underlying socket either way.

Reconnect + resume (no lost frames)

Real networks drop sockets. streamRunEventsResilient is the same iterator but with:
  • exponential backoff + jitter on reconnect,
  • resume via the last observed per-run seq (the gateway replays missed frames as run.gap_resync),
  • a healthy-after threshold so a server that flap-loops (accepts → replays one frame → closes) keeps escalating backoff instead of busy-looping at the base delay,
  • graceful stop on a run.completed terminal frame or when the caller aborts.
for await (const frame of gateway.streamRunEventsResilient(
  { runId: "run-1" },
  {
    signal: abort.signal,
    backoff: { baseMs: 250, maxMs: 10_000, factor: 2, jitter: 0.5 },
    healthyAfterMs: 1_000,
  },
)) {
  apply(frame);
}
That single helper covers the four things every UI needs but always re-implements badly: silent socket close (code 1006), gap replay, jittered backoff, and stop-on-completion. Use it.

Stale-response guards

Single-flight RPCs (getRun, getNodeOutput) are easy to race: a user clicks run-2 while the request for run-1 is still in flight. Always discard the stale resolution. The client gives you the signal, you discard:
let generation = 0;
async function refresh(runId: string) {
  const ours = ++generation;
  const run = await gateway.getRun({ runId });
  if (ours === generation) renderRun(run);
}
For React, useGatewayRpc does this internally; see below.

Backpressure

The gateway protects itself: over-capacity subscribers receive a BackpressureDisconnect close. Treat it like any other drop; streamRunEventsResilient already reconnects with backoff, so the UI naturally throttles to the rate the gateway can serve. For HTTP RPCs the gateway returns 429 / RateLimited; catch the error and retry with gatewayBackoffDelay(attempt, …):
import { gatewayBackoffDelay, GatewayRpcError } from "smithers-orchestrator/gateway-client";

async function withRetry<T>(call: () => Promise<T>): Promise<T> {
  let attempt = 0;
  while (true) {
    try {
      return await call();
    } catch (error) {
      const transient =
        error instanceof GatewayRpcError &&
        (error.code === "RateLimited" || error.code === "Busy" || error.code === "HTTP_ERROR");
      if (!transient || attempt >= 5) throw error;
      await new Promise((r) => setTimeout(r, gatewayBackoffDelay(attempt)));
      attempt += 1;
    }
  }
}

Optimistic mutations + rollback

Mutations (launchRun, submitApproval, cancelRun, submitSignal) are vanilla RPC calls. The pattern is local state first, RPC second, rollback on rejection:
type ApprovalState = "pending" | "approved" | "denied";
const local = new Map<string, ApprovalState>();

async function approve(nodeId: string, runId: string) {
  const prior = local.get(nodeId) ?? "pending";
  local.set(nodeId, "approved");
  render();
  try {
    await gateway.submitApproval({ runId, nodeId, decision: { approved: true } });
  } catch (error) {
    local.set(nodeId, prior);
    render();
    throw error;
  }
}
The pushed approval.decided event will arrive shortly after. When it does, reconcile against the server-truth value rather than your guess.

Auth handling

token is sent as a bearer header on HTTP RPC calls and in the WebSocket connect request body. When the gateway rejects auth or scope, the error carries:
  • code: UNAUTHORIZED / FORBIDDEN at the auth gate; some method-level errors use canonical Unauthorized / Forbidden.
  • requiredScope?: string: the scope the call needs (e.g. "run:write").
  • refresh?: string: a server hint string; current token and JWT expiry responses use “smithers token issue”.
A token refresh is just constructing a new client with the new bearer; SmithersGatewayProvider re-memoizes on token change, so React UIs see a clean cutover:
function App({ token }: { token: string }) {
  return (
    <SmithersGatewayProvider options={{ baseUrl: "/", token }}>
      <Dashboard />
    </SmithersGatewayProvider>
  );
}
For the vanilla client, throw the old instance away and instantiate with the new token. Pending HTTP RPCs made through gateway.rpc can be aborted by the caller’s AbortSignal; an open WebSocket connection is closed when the iterator returns.

The React hooks

Wrap the tree once in a provider, then read with hooks. The provider memoizes the client on baseUrl + token so an inline options literal does not trigger a reconnect storm on every render.
import { SmithersGatewayProvider } from "smithers-orchestrator/gateway-react";

export function App({ token }: { token: string }) {
  return (
    <SmithersGatewayProvider options={{ baseUrl: "/", token }}>
      <Dashboard />
    </SmithersGatewayProvider>
  );
}
createGatewayReactRoot(<App />, { baseUrl, token, rootId: "root" }) is the one-liner for top-level mounting; it wires the provider and createRoot for you and returns the client so you can call RPCs from event handlers outside React.

Declarative queries

useGatewayRpc(method, params, options?) is the primitive every other query hook is built from. It owns:
  • in-flight cancellation when params change or the component unmounts (a generation counter discards late resolutions),
  • clearing data when the query becomes disabled or the key changes (no stale runId leaking through),
  • a stable refetch that re-issues the call against the current params.
import { useGatewayRpc } from "smithers-orchestrator/gateway-react";

function Runs() {
  const { data, loading, error, refetch } = useGatewayRpc("listRuns", {
    filter: { status: "running", limit: 50 },
  });
  if (loading) return <Spinner />;
  if (error) return <Error error={error} onRetry={refetch} />;
  return <RunsTable runs={data ?? []} />;
}
Convenience wrappers cover the common reads:
useGatewayRun(runId);                                          // getRun, disabled when runId is undefined
useGatewayRuns({ filter: { status: "running" } });             // listRuns
useGatewayWorkflows({ filter: { hasUi: true } });              // listWorkflows
useGatewayApprovals({ filter: { runId: "run-1" } });           // listApprovals
useGatewayNodeOutput({ runId, nodeId, iteration: 3 });         // getNodeOutput
Pass undefined for the key (e.g. useGatewayRun(undefined)) and the hook reports loading: false and clears data, perfect for routes where the runId is not yet selected.

Pushed updates (with ring-buffer)

useGatewayRunEvents(runId, { afterSeq?, maxEvents? }) subscribes via streamRunEventsResilient and exposes:
  • events: a capped array of run-event frames. Defaults to 1000; setting maxEvents keeps the buffer bounded so a long-lived run does not balloon memory.
  • lastHeartbeat: surfaced separately so heartbeats never crowd out real events.
  • error: the last terminal error (only set when the subscription failed without the component aborting).
  • streaming: true while the iterator is live; flips to false on completion.
function RunFeed({ runId }: { runId: string }) {
  const { events, lastHeartbeat, streaming, error } = useGatewayRunEvents(runId, { maxEvents: 200 });
  return (
    <>
      <Header streaming={streaming} heartbeat={lastHeartbeat} />
      {error && <Banner kind="error">{error.message}</Banner>}
      <ol>
        {events.map((frame) => (
          <li key={`${frame.seq}-${frame.event}`}>{frame.event}</li>
        ))}
      </ol>
    </>
  );
}
Unmount or change runId and the hook aborts the underlying signal: no manual cleanup, no race against a now-stale runId.

Mutations + optimistic UI

useGatewayActions() returns a memoized bag of write helpers bound to the current client. Combine with local state for optimistic updates:
import { useState, useCallback } from "react";
import { useGatewayActions } from "smithers-orchestrator/gateway-react";

function Approve({ runId, nodeId }: { runId: string; nodeId: string }) {
  const actions = useGatewayActions();
  const [pending, setPending] = useState(false);
  const [optimistic, setOptimistic] = useState<"approved" | "denied" | null>(null);

  const decide = useCallback(async (approved: boolean) => {
    setOptimistic(approved ? "approved" : "denied");
    setPending(true);
    try {
      await actions.submitApproval({ runId, nodeId, decision: { approved } });
    } catch (error) {
      setOptimistic(null); // rollback
      throw error;
    } finally {
      setPending(false);
    }
  }, [actions, runId, nodeId]);

  return (
    <ApprovalButtons disabled={pending} optimistic={optimistic} onDecide={decide} />
  );
}
useGatewayRunEvents will push approval.decided shortly after; reconcile against it in a parent effect rather than holding the optimistic state forever.

One client per app

The provider is a singleton boundary. Mounting two providers with different tokens creates two clients with separate WebSocket connections; mounting one provider with an inline options literal does not; the client is memoized on baseUrl + token. Rotate auth by changing the token prop, not by recreating the provider.

A minimal third-party UI

End-to-end, vanilla + React mixed because real UIs are:
/// app.tsx
import { createGatewayReactRoot } from "smithers-orchestrator/gateway-react";
import { Dashboard } from "./Dashboard";

createGatewayReactRoot(<Dashboard />, {
  baseUrl: window.location.origin,
  token: bootToken(),
  rootId: "root",
});
/// Dashboard.tsx
import {
  useGatewayActions,
  useGatewayRun,
  useGatewayRunEvents,
  useGatewayWorkflows,
} from "smithers-orchestrator/gateway-react";
import { useState } from "react";

export function Dashboard() {
  const [runId, setRunId] = useState<string | undefined>();
  const workflows = useGatewayWorkflows({ filter: { hasUi: true } });
  const run = useGatewayRun(runId);
  const feed = useGatewayRunEvents(runId, { maxEvents: 500 });
  const actions = useGatewayActions();

  return (
    <main>
      <WorkflowPicker
        list={workflows.data ?? []}
        onLaunch={async (workflow) => {
          const { runId: created } = await actions.launchRun({ workflow });
          setRunId(created);
        }}
      />
      <RunPanel run={run.data} feed={feed.events} />
    </main>
  );
}
Every piece (pushed updates, reconnect, stale guards, abort on unmount, optimistic mutations) is already wired by the hooks.

Boot config (when the gateway hosts your UI)

When you serve a UI directly off gateway.register(name, workflow, { ui }), the gateway sets globalThis.__SMITHERS_GATEWAY_UI__ on the page before your bundle runs. SmithersGatewayClient reads it automatically. HTTP RPC calls go to /v1/rpc/<method> under baseUrl, and WebSocket streams use the boot wsPath (currently / under the Gateway origin, or that path after the socket separator for ws+unix: base URLs). Your UI code does not need to know; new SmithersGatewayClient() Just Works. For a page hosted elsewhere, there is normally no boot global; pass an explicit baseUrl and token.

Errors at a glance

CodeWhenWhat to do
UNAUTHORIZED / FORBIDDEN (or canonical Unauthorized / Forbidden)Token missing or lacking scope.Re-auth; new token; check requiredScope.
RateLimitedCaller exceeded a quota.Retry with gatewayBackoffDelay.
BusyConflicting operation in flight (e.g. rewind already running).Wait + retry.
BackpressureDisconnectServer shed the WebSocket due to subscriber backlog.streamRunEventsResilient reconnects automatically.
RunNotFound / NodeNotFound / NodeHasNoOutputThe id never existed or the iteration has no output yet.Surface to the user; do not retry.
INVALID_GATEWAY_RESPONSEThe gateway returned a frame the client could not validate.Treat as a bug or version skew; report and reconnect.
HTTP_ERRORNon-frame HTTP failure (502, 504, network).Retry with backoff.
Always branch on error.code. Inspecting error.message is fine for logs but is not part of the contract.

What’s next

  • The full RPC catalog and event union live in Gateway.
  • The event payload shape is documented in Event Types.
  • For a worked example of a UI registered against gateway.register(..., { ui }), see the workflow-UI starters under examples/.