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.
Install
Re-export the SDKs throughsmithers-orchestrator so a UI bundle does not duplicate the runtime:
@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.
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 aGatewayRpcError with code, status, and (if relevant) requiredScope / refresh / details.
AbortSignal through gateway.rpc, so a parent component or routing change can cancel a stalled call without leaking the promise:
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.
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 asrun.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.completedterminal frame or when the caller aborts.
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:
useGatewayRpc does this internally; see below.
Backpressure
The gateway protects itself: over-capacity subscribers receive aBackpressureDisconnect 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, …):
Optimistic mutations + rollback
Mutations (launchRun, submitApproval, cancelRun, submitSignal) are vanilla RPC calls. The pattern is local state first, RPC second, rollback on rejection:
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/FORBIDDENat the auth gate; some method-level errors use canonicalUnauthorized/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”.
SmithersGatewayProvider re-memoizes on token change, so React UIs see a clean cutover:
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 onbaseUrl + token so an inline options literal does not trigger a reconnect storm on every render.
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
paramschange or the component unmounts (a generation counter discards late resolutions), - clearing
datawhen the query becomes disabled or the key changes (no stale runId leaking through), - a stable
refetchthat re-issues the call against the current params.
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; settingmaxEventskeeps 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:truewhile the iterator is live; flips tofalseon completion.
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:
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 onbaseUrl + 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:Boot config (when the gateway hosts your UI)
When you serve a UI directly offgateway.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
| Code | When | What to do |
|---|---|---|
UNAUTHORIZED / FORBIDDEN (or canonical Unauthorized / Forbidden) | Token missing or lacking scope. | Re-auth; new token; check requiredScope. |
RateLimited | Caller exceeded a quota. | Retry with gatewayBackoffDelay. |
Busy | Conflicting operation in flight (e.g. rewind already running). | Wait + retry. |
BackpressureDisconnect | Server shed the WebSocket due to subscriber backlog. | streamRunEventsResilient reconnects automatically. |
RunNotFound / NodeNotFound / NodeHasNoOutput | The id never existed or the iteration has no output yet. | Surface to the user; do not retry. |
INVALID_GATEWAY_RESPONSE | The gateway returned a frame the client could not validate. | Treat as a bug or version skew; report and reconnect. |
HTTP_ERROR | Non-frame HTTP failure (502, 504, network). | Retry with backoff. |
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 underexamples/.