Gateway is Smithers’ headless control plane. Reach for it (instead of startServer()) when long-lived clients (bots, dashboards, schedulers, and custom UIs) need to authenticate once, stream events over WebSocket with resilient reconnection, decide approvals, inject signals, access metrics, and manage cron schedules across many registered workflows. Custom UIs, whether using the vanilla SDK or React hooks, rely on the Gateway to provide pushed updates and a stale-data-free model. For the single-workflow Hono-based HTTP surface, see Serve Mode (createServeApp() / bunx smithers-orchestrator up --serve).
API reference: Server & Gateway and Gateway Client list every gateway and client export, its options, and links to source and tests.
Quick start
Gateway client SDK
Programmatic clients (bots, schedulers, dashboards, third-party UIs) talk to the Gateway through the typed client SDK over the same RPC and WebSocket API. For the full custom-UI guide (declarative queries, pushed updates, stale guards, reconnect/resume, backpressure, optimistic mutations, auth, vanilla JS + React hooks) see Custom UIs.| Package | Exports |
|---|---|
smithers-orchestrator/gateway-client | SmithersGatewayClient, SmithersGatewayConnection, GatewayRpcError, gatewayBackoffDelay, RPC frame/type-map types, extension envelope helpers/types, GatewayUiBootConfig, SmithersGatewayClientOptions, createSmithersCollections, createSmithersDataClient, WorkspaceMode, SmithersCollections, SmithersDataClient, collection row types, gatewayKeys, smithersCollectionKeys, smithersLocalCollectionOptions, smithersElectricCollectionOptions, flattenGatewayRunNode, snapshotToGatewayRunNode, reconcileSnapshotNodes |
smithers-orchestrator/gateway-react | SmithersGatewayProvider, SmithersCollectionsProvider, createGatewayReactRoot, useSmithersCollections, useGatewayRun, useGatewayRuns, useGatewayWorkflows, useGatewayApprovals, useGatewayNodeOutput, useGatewayRunEvents, useGatewayActions, useGatewayRpc, useSmithersGateway, useGatewayExtensionResource, useGatewayExtensionAction, useGatewayExtensionStream, useGatewayRunTree, useGatewayConnectionStatus, useGatewayCrons, useGatewayMemoryFacts, useGatewayScores, useGatewayTickets, useGatewayPrompts |
Bun controllers and cron jobs
Run one Gateway singleton per workspace. Discover its verified URL with the publicgateway status command rather than reading the runtime state file or
assuming port 7331 (the preferred port may already be occupied):
bunx smithers-orchestrator gateway under your service manager when
status.running is false. A health cron should report the missing Gateway and
restart that singleton; it should not fall back to opening a store. Once it has
a run ID, use getRun for snapshots and streamRunEventsResilient for a live
cursor. Use launchRun, resumeRun, cancelRun, submitApproval, and
submitSignal for mutations so authentication, ownership, idempotency,
invalidation, and event emission stay intact.
Bundling in Next.js / webpack: in published versions up to and including
0.28.0, Releases after 0.28.0 ship the package as plain JavaScript with bundled type
declarations, so the workaround is not needed there.
gateway-client transitively imports
@smithers-orchestrator/electric-proxy, which shipped raw TypeScript
sources. Bun and Vite handle that natively, but webpack-based builds
(Next.js) fail hard unless you add it to
transpilePackages:REST domain API
The Gateway also exposes a typed REST domain surface under/v1/api/*. These
routes share the same auth modes, scope checks, validation, and backend code
paths as their /v1/rpc/<method> twins. Responses use { ok: true, data } for
reads and { ok: false, error } for failures.
Mutating routes include a write acknowledgement. SQLite and embedded PGlite
return { seq }, where seq is the invalidation frame emitted by
GET /v1/api/stream. Real Postgres returns { txid }, a numeric string from
pg_current_xact_id()::xid::text, for Electric transaction matching.
GET /v1/api/stream is a text/event-stream invalidation feed for local
TanStack Query collections. Change events are event: change frames with
data: {"seq": number, "collections": string[]}. Writes that happen inside a
short coalescing window emit one frame with the union of collection names.
Clients should send Last-Event-ID when reconnecting. The Gateway replays
missed frames from its bounded ring buffer when possible. If the requested seq
is too old, it sends event: reset with the current seq so the client can
invalidate all collection query keys. Each connection also has a bounded
outbound queue; a slow consumer is resynced through the same reset path, which
bounds memory per stream.
RPC methods (TOON)
health remains available as a utility RPC and GET /health is available without auth. The legacy method names are still accepted for compatibility (runs.create, runs.get, runs.list, runs.cancel, runs.rerun, runs.diff, frames.list, frames.get, attempts.list, attempts.get, workflows.list, approvals.list, approvals.decide, signals.send, cron.list, cron.add, cron.remove, cron.trigger, jumpToFrame, devtools.jumpToFrame, devtools.getNodeOutput, devtools.getNodeDiff), but new clients should use the v1 names above.
Scopes
* grants every scope. Pass a method name string in the scopes array (e.g. "launchRun") to grant access to exactly that RPC call. Legacy wildcard method grants such as cron.* continue to match legacy method names; typed scopes are the contract to use for new integrations. Legacy ranked grants (read, execute, approve, admin) are accepted so older tokens keep working.
rewindRun (destructive rewind)
Rewinds a run to a prior frame and makes it resumable from that point.
This is destructive: it truncates frames, attempts, output rows, and
diff-cache entries beyond the target; reverts JJ sandboxes; marks the
run running again; and emits a TimeTravelJumped event so
streamDevTools subscribers rebaseline.
Caller identity is authorized per-request: the connection must have
run:admin scope and must also be the run owner (userId matches
ownerId) or have role: "admin". Scope alone never grants access.
The legacy aliases jumpToFrame and devtools.jumpToFrame route to
rewindRun.
Request:
JumpResult):
run.time_travel_jumped with
{ runId, fromFrameNo, toFrameNo, timestampMs, caller }.
Quota: 10 rewinds per run per caller per hour (default window). Exceeded
→ RateLimited.
Failure modes and HTTP status:
| Code | Meaning | HTTP |
|---|---|---|
InvalidRunId | runId fails /^[a-z0-9_-]{1,64}$/. | 400 |
InvalidFrameNo | frameNo is not a non-negative i32 integer. | 400 |
ConfirmationRequired | Caller omitted confirm: true. | 400 |
FrameOutOfRange | frameNo > latest frame, or run has no frames. | 400 |
Unauthorized | Caller is neither the run owner nor an admin (audit row still written). | 401 |
RunNotFound | runId does not exist. | 404 |
Busy | Another rewind is in flight for this run. | 409 |
RateLimited | Caller exceeded rewind quota (default 10/hour). | 429 |
UnsupportedSandbox | A sandbox cannot be reverted (missing / untrackable jjPointer). | 501 |
VcsError | A JJ revert call failed; DB/reconciler rolled back. | 500 |
RewindFailed | Rewind failed and rollback was partial; run marked needs_attention. | 500 |
_smithers_time_travel_audit with result ∈ { success, failed, partial, in_progress }.
An in-progress row is inserted before any mutation and updated in place
on completion; startup recovery flips any leftover in_progress rows to
partial.
Node output
getNodeOutput returns the DevTools Output-tab payload for a single task iteration:
Error codes
Gateway v1 RPC errors use stable code strings and HTTP status mappings:Versioned wire shapes
All DevTools wire types carryversion: 1.
DevToolsSnapshot (v1):
DevToolsDelta (v1):
DevToolsEvent (v1), frames pushed over devtools.event:
snapshot event, then emits delta events
per frame. The server re-baselines (emits a full snapshot instead of a
delta) after 50 delta events, when a delta is larger than a fresh snapshot,
or when the gateway observes TimeTravelJumped for the run.
WebSocket protocol
Three frame types share the same socket:req:{ type: "req", id, method, params? }from client.res:{ type: "res", id, ok, payload?, error? }from server, correlated byid.event:{ type: "event", event, payload?, seq, stateVersion }server-pushed;seqis per connection,stateVersionis global.
connect.challenge ({ nonce, ts }). The client replies with a connect request carrying minProtocol, maxProtocol, client metadata, auth, and an optional subscribe: string[] to filter events by runId. The server returns a hello payload (protocol, features, policy.heartbeatMs, auth with sessionToken/role/scopes/userId, snapshot).
After connect, the gateway emits tick events every heartbeatMs. launchRun, submitApproval, submitSignal, and cronRun automatically subscribe the connection to the affected runId. Server-pushed event names:
| Event | Category |
|---|---|
connect.challenge | Connection |
tick | Connection |
run.event | Run lifecycle |
run.heartbeat | Run lifecycle |
run.gap_resync | Run lifecycle |
run.error | Run lifecycle |
run.completed | Run lifecycle |
run.time_travel_jumped | Run lifecycle |
node.started | Run lifecycle |
node.finished | Run lifecycle |
node.failed | Run lifecycle |
task.output | Run lifecycle |
task.heartbeat | Run lifecycle |
approval.requested | Approval |
approval.decided | Approval |
approval.auto_approved | Approval |
cron.triggered | Cron |
devtools.event | DevTools |
POST /rpc accepts the same body shape ({ id, method, params }) and returns the same ResponseFrame. Auth headers: Authorization: Bearer <token> or x-smithers-key: <token> (or trusted-proxy headers in trusted-proxy mode).
GatewayOptions
scope, role from role, and user id from sub unless the *Claim options override those claim names. Missing JWT role falls back to defaultRole and then operator; missing JWT scopes fall back to defaultScopes and then []. Trusted-proxy auth reads trustedHeaders as [user, scopes, role]; missing role falls back to defaultRole and then operator, and missing scopes fall back to defaultScopes, or the request is rejected when no defaultScopes is configured.
allowedOrigins is available in every mode (token, jwt, trusted-proxy) as defense-in-depth. It defaults to [], which enforces no Origin allowlist. When non-empty, the gateway rejects any HTTP RPC or WebSocket upgrade whose browser Origin header is not on the list; requests with no Origin header (server-to-server / CLI callers) are always allowed. Set it to your operator-UI origin(s) when exposing a token/jwt gateway to a browser.
Runs started through the gateway expose ctx.auth = { triggeredBy, role, scopes, createdAt }. <Approval> may further restrict decisions with allowedScopes and allowedUsers, which the gateway enforces before accepting submitApproval.
headersTimeout and requestTimeout are applied to the underlying Node HTTP server when gateway.listen() starts. Keep both below the corresponding reverse-proxy idle/read timeouts so slow clients are closed by Smithers first.
Notes
- Identity:
GET /health, thehealthRPC, and the WS hello carryidentity: { workspaceRoot, backend, version, pid, startedAtMs }. Clients (the CLI’sui/gateway statusamong them) verifyworkspaceRootagainst the workspace they resolved locally instead of trusting whichever process owns the port. - Bind failures reject:
gateway.listen()rejects onEADDRINUSE(and any other bind error) instead of crashing the process. Thesmithers gatewaycommand retries on an ephemeral port and records the real port in its runtime state file. - Singleton per workspace:
smithers gatewaywrites{pid, host, port, url, token, workspaceRoot, backend, version, protocol, startedAtMs}to<tmpdir>/smithers-gateway/<workspace-hash>.json(mode 0600) after listening, refuses to start when a healthy gateway for the same workspace is already running, and cleans the file up on shutdown.smithers gateway statusandsmithers gateway stopmanage it; stale files (dead pid, identity mismatch) are cleaned up on the next discovery. - Monitor mount: the
smithers gatewayCLI singleton also serves the Smithers Monitor, a live web UI over every run in the workspace, at/monitor(open it withbunx smithers-orchestrator monitor). The mount ships inside the CLI, so it needs no.smithers/pack and no registered workflow UI. Library embedders control the other UI mounts throughui(custom gateway UI, workflow UIs at/workflows/<key>) andoperatorUi(the embeddable operator console, default/console). --mint-token: mints a random bearer, requires it on every request, and records it only in the runtime state file (plus the daemon’s own stderr).bunx smithers-orchestrator uireads the token from the state file automatically for CLI RPC calls, but browser navigation to the printed workflow UI URL cannot send that bearer header until UI token injection ships; usesmithers gatewaywithout--mint-tokenwhen you need browser-served workflow UIs. Other clients can readSMITHERS_TOKEN/SMITHERS_API_KEY.- Cron:
gateway.register(name, wf, { schedule })writes a cron row keyedgateway:<name>; the gateway polls between 1 s and 15 s (clamped fromheartbeatMs). Cron-fired runs getctx.auth.role = "system",triggeredBy = "cron:gateway",scopes = ["*"]. - Host defense: an unauthenticated gateway (the autostart default) rejects any request whose
Hostheader is non-loopback, as a DNS-rebinding defense, so binding--host 0.0.0.0without a token returns 403Host is not allowed. This applies only when noauthis configured. To deliberately expose an unauthenticated remote bind, passsmithers gateway --insecure(orup --serve --insecure), which trusts any Host;SMITHERS_GATEWAY_TRUST_ANY_HOST=1(gateway) andSMITHERS_SERVE_TRUST_ANY_HOST=1(serve) do the same via the environment. - JWT mode currently validates
alg=HS256, HMAC,iss,aud,exp,nbf. Scope claims may be arrays or space/comma-separated strings. - Trusted-proxy mode is only safe behind something you control (Cloudflare Access, internal API gateway) that strips and rewrites identity headers.
- DevTools streams: see Versioned wire shapes for re-baseline triggers; over-capacity subscribers receive
BackpressureDisconnect.