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 smthrs 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.Launch attribution
Pass caller-known provenance underlaunchRun(...).options.startedBy, for
example { harness: "openclaw", sessionId: "session-key" }. It is durable and
projects as startedBy on run list/get rows. It never changes Gateway auth,
scopes, auth.triggeredBy, or cron/webhook source classification.
Run ownership and tenant isolation
Run ownership is the indexed, persisted pair(owner, app). Both values must
be present or both absent. Authenticated Gateway launches derive the pair from
the verified userId and appId; callers cannot supply different ownership in
launch options. Child runs, continue-as-new runs, retries, rewinds, and forks
inherit the source run’s pair.
For owned runs, the Gateway provides isolation, not merely list scoping.
It applies the existing method scope check first, then requires an exact
(owner, app) match for non-admin requests. A foreign run id is answered as
RunNotFound, including when guessed directly. role: "admin" (and internal system
execution) is the explicit cross-owner bypass. listRuns accepts an exact
owner/app filter for admin and unauthenticated trusted-local callers; tenant
callers are automatically limited to their own pair. The composite
(owner, app, created_at_ms) index backs this filter.
Ownership guards cover Gateway reads for run list/get, descendants, events,
frames, attempts, node states and outputs, diffs, run trees/devtools streams,
token usage, approvals, scores, and run summaries. They also cover resume,
retry, pause, cancel, approval/deny, signal, hijack, rewind, and rerun controls.
Artifacts, durable workspace snapshots, human-request administration, local
fork commands, CLI commands, and MCP store tools are trusted operator surfaces:
they access the store directly and intentionally have the same cross-owner
authority as role: "admin". Do not expose those local surfaces to tenants;
tenant-facing clients must use the Gateway.
Migration leaves existing runs with owner = NULL, app = NULL. These legacy
unowned runs keep their previous shared read/control behavior, including for
authenticated callers, so upgrading a single-tenant deployment is invisible.
This means a database containing unowned runs is not retroactively isolated.
One launch behavior does change for everyone, owned or not. launchRun with an
options.runId that an existing run already holds is now rejected with
CONFLICT instead of re-driving that run, because re-driving someone else’s id
is exactly how a tenant would otherwise take over a foreign run. Use
resumeRun to continue an existing run, and a fresh id to start a new one.
Unowned runs are not only a migration artifact. Cron- and webhook-triggered
launches execute as the Gateway’s own system identity, which carries no
(owner, app) pair, so the runs they create are unowned and therefore readable
and controllable by every tenant. Isolate those triggers per tenant by running a
Gateway per tenant, or launch from a tenant-authenticated client instead.
Before relying on tenant isolation, configure both identity dimensions, ensure
new launches go through the Gateway, restrict local store/CLI/MCP access to
operators, keep cron/webhook triggers out of tenant-visible deployments, and
either retain legacy runs as intentionally shared or migrate them to an explicit
pair.
Live browser viewer
Browser sessions expose a stationary viewer at/browser/<sessionId>/viewer.
It paints ephemeral browser.frame screencast events onto a canvas and sends
pointer, keyboard, and toolbar input back through browserAct. The page has no
credentials, and keystrokes aimed at sensitive fields (password and other
masked inputs) are redacted before they reach the settled action journal or the
browser.activity broadcast: full type actions and single press keypresses
alike. Embed it through the authenticated proxy that serves the Gateway,
and use theme, hostOrigin, and embed query parameters when the embedding
consumer needs to control presentation or receive viewer activity and revision
messages through postMessage.
Browser RPC requests are strictly closed shapes: unknown fields, empty required
strings, invalid nested sources/viewports/locators/actions, and unknown context
slices are rejected before dispatch. A click action must contain exactly one
target, either a locator (testId, css, or role with an optional name)
or a point; locator and point cannot be combined. browserAct returns a typed
successful outcome (ok, with optional redirect or redaction metadata), while
browser.activity carries the typed action and result journal entry. Browser
activity attributes the authenticated caller as user or agent; raw
undeclared internal error codes are normalized to Internal. Context slices
are bounded and may include inline JPEG screenshots and redacted selection
history.
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 smthrs 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
@smthrs/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)
Stateless RPC POSTs go toPOST /v1/rpc/<method> with the method’s params as the JSON request body. Over WebSocket the same methods use { id, method, params } frames. The legacy POST /rpc route takes the full { id, method, params } envelope in the body.
cancelRun is recursive: it cancels the requested run and every transitive child-workflow descendant as one idempotent operation (see Durability). descendants lists each run the cascade reached with its depth and action (cancel-requested, cancelled, already-terminal, missing), cancelledAttempts totals the attempts closed across the subtree, and terminatedOwners/terminatedAgents list the owner and agent process trees terminated so no work outlives the cancelled subtree. Runs outside the subtree are never touched; time-travel forks are spared. A run whose whole subtree is already terminal returns RUN_NOT_ACTIVE, and an unknown run returns RunNotFound.
getRun, listRuns, and cancelRun expose the durable first-caller attribution as optional cancellationSource. Its kind is signal, rpc, cli, or engine; detail, signal, client PID, request ID, and authenticated client identity are included when persisted. Historical or unattributed cancellations omit the field.
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.
Liveness and workflow readiness
The Gateway binds and publishes its health endpoint before importing the workspace workflow pack. During this periodGET /health and the health RPC
remain live and include additive workflowsLoaded and workflowsTotal counts.
Workflow-specific requests, including launchRun, wait for the requested
workflow’s registration; a definitive NOT_FOUND is returned only after the
load/refresh completes. smithers gateway status --format json keeps its
existing result fields and does not require workflow readiness. This startup
behavior addresses GitHub issue #1362.
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 }.
Side-effect boundary events are broadcast as
run.side_effect_boundary_crossed. Late detached-tool completions include
lateCompletion: true and archivedByOp.
Quota: 10 rewinds per run per caller per hour (default window). Exceeded
→ RateLimited.
Failure modes and HTTP status:
Every call, whether success, failure, or unauthorized, writes one row to
_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; use browser:<sessionId> to subscribe to that session’s frames. 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:
Browser viewer
The gateway serves a credential-free stationary viewer at/browser/<sessionId>/viewer. It is protected by the gateway’s normal UI
authentication and connects back to the same gateway WebSocket. Embed it with
theme=light or theme=dark; hostOrigin accepts a validated origin and
embed=1 hides the toolbar for host-controlled embedding. Subscribe the socket
to browser:<sessionId> to receive live screencast frames.
For stateless callers, 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).
Browser session handoff
Browsers cannot send anAuthorization header on top-level navigations or
WebSocket upgrades, so a token-protected gateway would 401 the very UI URLs
bunx smthrs monitor / bunx smthrs ui print.
The gateway closes that gap with a session handoff:
HttpOnly; SameSite=Lax; Path=/
smithers_session cookie, and the response lands the browser on next via
location.replace, so the token never stays in the address bar or browser
history. next must be a same-origin absolute path; anything else falls back
to / (no open redirects). Every authenticated surface (UI pages
(/monitor, /console, /workflows/*, workflow <UI> mounts), the HTTP
RPC/API, and the WebSocket connect frame) accepts the cookie as an
alternative to the Bearer header, so once the handoff runs the UI just works.
The cookie also gets Secure when the request arrives over TLS (directly or
through a proxy that sets X-Forwarded-Proto: https). With no ?token=, the
endpoint falls back to the Authorization header or an existing session
cookie, so a re-navigation refreshes the session instead of returning 401.
bunx smthrs monitor / bunx smthrs ui /
bunx smthrs gui build this handoff URL automatically
whenever the resolved gateway has a token.
Remote access (Tailscale / SSH / LAN)
A gateway bound to127.0.0.1 is unreachable from another machine. To drive
the monitor or a workflow UI over Tailscale, SSH, or the LAN:
--mint-token
(or --auth-token / SMITHERS_API_KEY) is required; bunx smthrs monitor / bunx smthrs ui / bunx smthrs gui add --mint-token automatically when they
autostart on a non-loopback --host (also settable via
SMITHERS_GATEWAY_HOST). A wildcard (0.0.0.0) bind is not dialable, so the
CLI prints one URL per candidate interface address (Tailscale 100.x first,
then LAN), each already routed through the session handoff, so the printed
--no-open URL is copy-pasteable to another machine. When a loopback-only
URL is printed inside an SSH/mosh session, the CLI warns that it is usable
only on the gateway host itself.
GatewayOptions
scope, role from role, and user id from sub
unless the *Claim options override those claim names. The app half of the
tenant key comes from the app claim (appClaim overrides that name). 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. The app header defaults to x-app-id and is honored only when
trustedHeaders is omitted entirely (all four defaults apply) or explicitly
names a fourth entry: an existing three-entry allow-list never starts trusting
x-app-id, so a proxy that does not strip that header cannot be used to claim
a different app half of the tenant key. Ownership requires both halves of the
(user, app) pair, so a caller whose identity supplies only one half is not
partially trusted and sees only unowned runs. Those headers are only read at
all when the request’s transport peer matches trustedProxies (see below).
Trusted-proxy trust boundary
trustedProxies is required in mode: "trusted-proxy" and must be non-empty. The identity headers above are ordinary request headers, so any client that can reach the listener can set them. The Gateway therefore honors them only when the request’s immediate transport peer is on the trustedProxies list.
Each entry is one of:
The peer is
req.socket.remoteAddress: the far end of the TCP (or Unix) connection actually attached to this process. It is never read from X-Forwarded-For or any other header, because those are supplied by the caller whose provenance is being checked, which would make the check circular. In a chain such as client -> edge proxy -> internal proxy -> Gateway, list only the last hop, the internal proxy that opens the connection to the Gateway. Earlier hops in X-Forwarded-For are data your trusted proxy vouched for, not identity the Gateway verifies.
Failure modes, all fail-closed:
An untrusted peer is rejected rather than downgraded to an anonymous session, so a forged-credential attempt is visible in logs and metrics and the supplied headers never enter the authenticated context. WebSocket upgrades are refused at the handshake, before the socket opens. There is no configuration flag that restores the previous behavior of accepting identity headers from any peer.
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.
PostgreSQL pool sharing
A Gateway process that registers several workflows against one normalized PostgreSQL URL shares one process-localpg.Pool; registrations do not retain one PostgreSQL client each. The pool admits at most 16 connections by default. Set SMITHERS_POSTGRES_POOL_MAX to a positive integer before Gateway startup, or pass postgresPoolMax to openSmithersBackend / createSmithersPostgres for embedded hosts. All registrations using one normalized URL must use the same bound; a conflicting bound rejects instead of allocating another pool.
Each backend opening holds one reference. Closing a workflow backend releases its reference, and the final release closes the shared pool. Transactions pin one pool client from BEGIN through COMMIT or ROLLBACK; ordinary queries return to the bounded pool. SQLite, PGlite, and custom caller-provided PostgreSQL connections retain their existing connection behavior. Smithers scopes BIGINT parsing to its own pool/client configuration and does not mutate node-postgres’ process-global type parsers.
An acquire waits a bounded 10 seconds for a free connection; set SMITHERS_POSTGRES_ACQUIRE_TIMEOUT_MS to change it. Waiting past that bound means every pooled connection stayed busy, so Smithers raises PG_POOL_SATURATED instead of queueing forever. The error names the pool (with URL credentials redacted), the cap and whether it is the default 16, the live open/idle/waiting counts, and the exact knob and value that raise the cap.
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 smthrs 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 smthrs 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.