connect handshake, answers RPC calls (chat.send, sessions.list, agents.create and more), and broadcasts event frames (chat, agent, exec.approval.*, presence, heartbeat, session.message). Clawboo talks to it over two separate connections: one per browser tab through a same-origin proxy, and one long-lived connection held by the server.
Use this page when you work on the proxy, device pairing, or the server’s Gateway connection. For setting up OpenClaw as a user, see OpenClaw. For how browser frames become dashboard state, see The event pipeline.
Two connections
The browser never talks to the Gateway directly, and the server never sends the saved Gateway token to the browser. The server’s connection is the one that keeps working with every tab closed.
The same-origin proxy
createGatewayProxy (@clawboo/gateway-proxy) runs a ws WebSocketServer in noServer mode. The server’s upgrade handler routes /api/gateway/ws, or <base path>/api/gateway/ws when Clawboo is mounted under a subpath (CLAWBOO_BASE_PATH), into the proxy after the Origin guard passes; it answers a refused handshake with 403 Forbidden and destroys any other upgrade. The browser builds the URL from its own origin with resolveProxyGatewayUrl(), so it never hardcodes a port; the ws://localhost:18790/... fallback applies only where window is undefined (SSR and Node tests).
The proxy is eager: when a browser socket opens, it immediately loads the upstream settings and opens its own socket to the Gateway, before the first browser message. The Gateway sends a connect.challenge event right after the handshake. The proxy captures its nonce to sign the connect, and the browser’s client sends connect when the challenge arrives (or after 750 ms without one). Frames buffer in both directions until each side is ready, up to maxPendingFrames (512); an overflow closes the connection rather than growing without bound.
Every frame passes through untouched except a connect request, which the proxy rewrites:
- Token injection. If the browser sent no
auth.tokenand the server has an upstream token, the proxy adds it.GET /api/settingsexposes only ahasTokenflag, never the saved value. The browser sends a token only when the operator types a new one on the connect screen; the proxy keeps that one. - Device signing. The proxy signs the connect params with a persistent Ed25519 identity, loaded once and shared by all connections (
loadOrCreateProxyDeviceIdentity,signConnectParams, using the challenge nonce). It replaces any device fields the browser sent, and if signing fails it strips the device fields and forwards without them rather than failing the connect.
keepaliveIntervalMs (30 seconds) and terminates a connection whose pong has not arrived by the next ping, so a half-open socket is torn down within one or two intervals instead of stalling an RPC.
The connect handshake
The browser’sGatewayClient.connect() opens the socket, waits briefly for connect.challenge, then sends a connect request:
A rejected connect is parsed into a
GatewayResponseError carrying a code.
Device pairing
A new device must be approved once before the Gateway accepts it. An unapproved device’s connect fails with{ code: 'NOT_PAIRED', message: 'pairing required: device is not approved yet' }. The dashboard branches on that code (in GatewayConnectScreen, GatewayBootstrap and OpenClawInlineSetup) and shows a one-click approval card.
1
Preview the pending request
The card posts to
POST /api/system/approve-device, which runs openclaw devices approve --latest. On the CLI that is a preview: it prints the pending request id and exits non-zero by design.2
Extract the request id
The handler matches
/openclaw devices approve\s+([a-f0-9-]{36})/i against the captured stdout and stderr. No match answers 404 (no pending request found).3
Approve it
The handler runs
openclaw devices approve <UUID>. A non-zero exit here is a real failure and is reported with the CLI’s stderr.4
Reconnect
The dashboard connects again. On the connect screen that is one retry of the connect. In the OpenClaw setup flow, a failed reconnect (a second
NOT_PAIRED included) shows the error with a Retry button, and Retry brings back a fresh approval card when the device still needs one.The server connection
OpenClawAgentSource (apps/web/server/lib/agentSource/openClawAgentSource.ts) opens the server’s single Gateway connection and signs it with the same proxy device identity through the gateway-client signConnect hook. Its connect options (clientName: 'cli', an injected ws WebSocket with the Gateway host’s origin, signConnect, and caps: ['tool-events', 'exec-approvals']) and why each is required are listed on the AgentSource page. The connection has four jobs:
- Run OpenClaw agents for Clawboo. Every OpenClaw turn in team chat (
buildOpenClawServerAdapteroveroperatorClient(), used byserverDeliverand the peer exchange) and every OpenClaw Routine fire (routines/openclawDispatch.ts) runs over this connection, as do the read and write calls behind the Gateway cron list on the Routines view (theopenclaw-gateway-cronschedule source). None of this needs a browser tab. - Keep the registry in sync. It reconciles the Gateway’s
agents.listinto SQLite and resyncs onpresence,heartbeatandagentframes. Reads serve SQLite, so the fleet renders when the Gateway is down. Creating an agent and reading or writing its files need a live Gateway; a delete made while the Gateway is down is queued for the next sync, andupdateAgentwrites SQLite only. - Answer exec approvals with no tab open. Because it declares
exec-approvals, the Gateway routes a held shell command to it. The exec-approval surface (execApprovalSurface.ts) mirrors eachexec.approval.requestedinto Clawboo’stool_call_approvalsqueue, keyed by the Gateway’s own request id so a redelivery lands on the same row, and relays the answer back withexec.approval.resolve. It decides nothing: the command stays held by the Gateway, and the policy that decides which commands ask is OpenClaw’s own. A card expires at the Gateway’s deadline, or after 30 minutes, checked every 30 seconds; an expiry is never recorded as a deny. See Approvals. - Record activity nobody started from Clawboo. The session activity watcher (
sessionActivityWatcher.ts) callssessions.subscribeon every connect (a subscription dies with its socket) and readssession.message, which fires when a transcript row commits rather than on who is watching. A cron job the agent owns, an incoming channel message or someone typing at OpenClaw’s own terminal still produces activity rows. The same frames carry real token counts, so the watcher records OpenClaw spend incost_records, and it saves 1:1 transcripts so a conversation is kept even with no tab open.
Guards in front of the API and the proxy
Two independent checks sit in front of/api/* and the Gateway WebSocket upgrade. Nothing guards the server’s own outbound connection to the Gateway. Security is the canonical page; this is how they apply to the Gateway path.
The Origin and Host guard is always on (packages/gateway-proxy/src/origin-guard.ts). Every /api/* request and every WebSocket upgrade is checked against an allowlist of origins and hosts, which defends against cross-site WebSocket hijacking (a page riding the proxy’s injected token) and DNS rebinding on a default loopback install with no configuration. CLAWBOO_ALLOWED_ORIGINS and CLAWBOO_ALLOWED_HOSTS only widen the list; nothing disables it. A refused HTTP request gets 403 { "error": "Cross-origin request blocked." }, and a refused upgrade gets 403 Forbidden.
The access gate is optional (access-gate.ts), active only when STUDIO_ACCESS_TOKEN is set. Opening /?access_token=<token> once sets an HttpOnly cookie and redirects; after that every /api/* request and the WebSocket upgrade need the cookie. The token is compared after SHA-256 hashing both sides, with timingSafeEqual. A token containing a character outside [A-Za-z0-9._~-] stops the server at boot on every bind (accessTokenRefusal.ts), and createAccessGate throws on one. The server also refuses to start on a non-loopback bind with no token unless CLAWBOO_ALLOW_INSECURE=1.
The gate admits one other credential: the
attachToken that the server writes into the MCP attach URLs it gives to the runtimes it spawns (whose environment is scrubbed of the access token), and into the Memory, Tasks and Tools URLs it registers in a Gateway on the same machine. Only the four transport routes (/api/mcp/tasks, /memory, /tools, /teamchat) accept it; /api/mcp/config never does. It is accepted only on a direct loopback request with no forwarding headers, never from another host or through a proxy, because a same-host reverse proxy delivers every remote request from loopback.What the browser does with frames
Each tab’s frames run through the event pipeline: the Bridge classifies a frame, the Policy derives intents, and the Handler applies them to the stores. Two rules there depend on the split above:- Team chat is the server’s. For a team session key (
agent:<id>:team:<teamId>, with or without a room suffix) the browser skips anychatframe it receives rather than writing a second copy: no transcript commit and no live streaming-text writes. The team engine persists each turn and streams it back overGET /api/teams/:id/chat/stream. - OpenClaw spend is the server’s. OpenClaw turns are billed from the Gateway’s real token counts by the session activity watcher, so the browser’s cost path skips OpenClaw agents. A non-OpenClaw agent whose turn reaches the browser as a Gateway
chatframe is billed from the frame’s usage block when it has one, and otherwise estimated at about 4 characters per token.
Design rationale
A proxy rather than a direct browser connection. A direct connection would have to ship the upstream token to the browser and make every browser context register its own device key. The proxy keeps the credential on the server and reuses one device identity, at the cost of one hop and a rewrite of theconnect frame, paid once per connection.
One server connection for everything the server does. Funnelling registry sync, OpenClaw runs, approvals, cron and session activity through one OpenClawAgentSource connection gives exactly one place that owns the connect options, the reconnect rules and the pairing state on the server. The Gateway accepts many connections per device (every browser tab is one), so the server’s is one more.
Declare only what is answered. The server declares exec-approvals only because a handler mirrors every request it receives into the approvals queue and relays the answer a person gives. A declaring connection that never answered would turn the Gateway’s fast refusal into a held command waiting 30 minutes for nobody.
Boundaries
- OpenClaw only. The proxy, the handshake and the pairing flow exist for the OpenClaw Gateway. The other runtimes run on the server through their adapters (see the RuntimeAdapter trait).
- Pairing depends on the OpenClaw CLI.
approve-deviceis a convenience overopenclaw devices approve, not a reimplementation of OpenClaw’s pairing. - The server keeps the Gateway credentials. The saved token and the Ed25519 device identity stay on the server, and the identity signs the Gateway handshake only. Dashboard access is the separate, optional
STUDIO_ACCESS_TOKEN, held in the browser as anHttpOnlycookie while the gate is on.
Related
- OpenClaw: connecting and using the OpenClaw runtime
- The event pipeline: how browser frames become dashboard state
- AgentSource: the server connection’s sync, connect options and reconnect rules
- Architecture invariants: the same-origin rule and the browser pipeline rule
- Security: the Origin guard, the access gate and remote access
- Gateway client and proxy: the
@clawboo/gateway-clientand@clawboo/gateway-proxypackages