Building a Gateway Client: Authentication, Reconnect, and More

Learn to build operator dashboards, WebChat clients, and third-party apps using the Gateway WebSocket protocol. Covers client lifecycle: authentication, capabilities, reconnect, history, subscriptions, and upgrades.

Read this when

  • Building an operator, dashboard, or WebChat client outside the OpenClaw repository
  • Implementing Gateway reconnect, history, approvals, or device pairing
  • Updating a third-party client for a new Gateway wire version

Use the published Gateway packages to build operator dashboards, WebChat clients, and other third-party applications. This guide covers the client lifecycle around the wire contract: authentication, capabilities, reconnect recovery, history, subscriptions, and version upgrades.

For frame shapes, the handshake, errors, and the complete method surface, read the Gateway protocol specification.

Install the packages

npm install @openclaw/gateway-client @openclaw/gateway-protocol

Note

These packages ship with OpenClaw release trains. During the initial rollout, npm may return E404 until the first package-bearing OpenClaw release is published; install them only after the registry pages below resolve.

  • @openclaw/gateway-protocol provides schemas, runtime validators, TypeScript types, client identity and capability registries, structured error readers, and protocol version constants. Its npm tarball also includes the generated protocol.schema.json machine-readable contract.
  • @openclaw/gateway-client is the reference connection implementation. Import the package root for the Node client and @openclaw/gateway-client/browser for the browser-safe protocol, device-auth, and reconnect helpers.

The Node entry owns its WebSocket transport. A browser host supplies a WebSocket adapter plus persistent storage and signing callbacks for the device identity and device token.

Choose scopes and pair the device

A full interactive chat client that also renders approval prompts should request role: "operator" with these scopes:

ScopeUse it for
operator.readchat.history, sessions.list, sessions.subscribe, model status, and read-only events
operator.writechat.send and ordinary session mutations
operator.approvalsListing, displaying, and resolving exec or plugin approvals

Add operator.questions only if the client handles interactive questions, operator.pairing only if it manages paired devices or nodes, and operator.admin only for administrative operations such as config.patch. The operator scopes reference defines the complete method and approval-time rules.

Do not create a per-client bearer token by hand-editing openclaw.json. Configure the Gateway's shared bootstrap authentication with openclaw configure --section gateway or the openclaw onboard --gateway-auth ... options, then let device pairing mint the client token:

  1. Persist an Ed25519 device identity in the client.
  2. Wait for connect.challenge, use its ts as the device proof's signedAt, sign the challenge-bound device payload, and send connect with the requested operator role, scopes, and the shared Gateway token or password for bootstrap authentication. A received WebSocket challenge without a non-negative integer ts is invalid. Clients that explicitly support Gateways from before connect.challenge existed may use local time only on their no-challenge path.
  3. If the Gateway returns structured PAIRING_REQUIRED details, show the request ID and pause or retry according to error.details.recommendedNextStep.
  4. On the Gateway host, review the request with openclaw devices list, then approve that exact current request with openclaw devices approve <requestId>.
  5. Reconnect and persist hello-ok.auth.deviceToken with the negotiated role and scopes. Use that device token for later connections.

Scope or role upgrades create a new pending pairing request. Token rotation cannot expand the approved pairing contract. See the Devices CLI for approval, rotation, and revocation commands.

connect.params.caps describes optional behavior the client can consume. It does not grant authorization. Import names from GATEWAY_CLIENT_CAPS instead of duplicating string literals:

import { GATEWAY_CLIENT_CAPS } from "@openclaw/gateway-protocol/client-info";

const caps = [GATEWAY_CLIENT_CAPS.TOOL_EVENTS];

The current registry contains approvals, exec-approvals, inline-widgets, run-tool-bindings, session-scoped-events, plugin-approvals, task-suggestions, terminal-offset-seq, tool-events, and ui-commands. Advertise only capabilities the client actually implements.

Warning

tool-events gates live tool-execution streaming. The Gateway registers only connections that advertise this capability as recipients for a run's structured tool events. Without it, the connection receives no live tool events and the handshake does not report an error.

Capability-gated agent tools are a separate use of the same declaration. If an agent tool requires a client capability, the Gateway omits that tool unless the originating client advertised every required capability.

Validate attachments before sending

Attachment limits are operator-tunable, so do not hardcode them. Read hello-ok.policy.attachments and validate locally before uploading:

const attachments = hello.policy.attachments;
if (attachments) {
  const ceiling = isImage ? attachments.maxImageBytes : attachments.maxBytes;
  if (file.byteLength > ceiling) rejectLocally();
}

Both values are decoded per-attachment ceilings. Even so, check the serialized request against policy.maxPayload: attachments travel as base64, so a file near maxBytes can exceed the frame limit on its own. Older gateways omit policy.attachments; when it is absent, send and handle the server outcome. Accepted MIME types and per-message handling are not advertised because they depend on the entrypoint and the resolved model. The gateway can return a typed rejection, while text-only model runs can omit additional images after their offload cap and still complete the request. The values are a connection-time snapshot, so re-read them on every reconnect.

Recover state after reconnect

Treat every successful reconnect as a new projection over durable history and current in-memory run state:

  1. Re-establish sessions.subscribe and the selected session's sessions.messages.subscribe subscription.
  2. Call chat.history for the selected sessionKey and replace local persisted rows with the returned messages projection.
  3. If inFlightRun is present, adopt its runId, buffered text, and optional plan. Adopt the run even when text is empty.
  4. Treat sessionInfo.hasActiveRun as aggregate direct-session activity. activeRunIds, when present, is the complete exact active set; an empty array therefore proves the session is idle. When hasActiveRun is true and activeRunIds is omitted, another runtime owner is active but its exact run identities are unavailable. In incremental merge events, omission means no change, null is the event-only tombstone that clears cached exact IDs to unavailable, and an array replaces the cache (including [] for proven idle). Correlate only a run ID the client owns locally or received from a request, history response, or event, and never select the first list entry as an owner.
  5. Show an observer headline or run-inspector link only when the observer digest's exact runId is present in activeRunIds. Aggregate activity alone does not make a retained digest current.
  6. Reconcile subsequent agent events by payload.runId and payload.seq. Maintain the highest accepted sequence independently for each run, ignore an already-seen or lower sequence, and treat a forward gap as a reason to reload authoritative history.

Active-run cache matrix

Classify the source before applying activeRunIds; the same omission has different meaning in a full snapshot and an incremental delta.

Client cacheRead pathClassRequired behavior
Web session rostersessions.list, reconnect hydrationSnapshotReplace the row; omission clears cached exact IDs to unavailable.
Web selected sessionchat.history.sessionInfoSnapshotReplace the row projection; omission clears cached exact IDs.
Web session eventssessions.changed, session.message, lifecycle snapshotsDeltaOmission is inert; null clears; an array replaces.
Android session rostersessions.list, reconnect hydrationSnapshotReplace the list rows; omission clears cached exact IDs.
Android selected sessionchat.history.sessionInfo, reconnect recoverySnapshotReplace activeRunIds even while other partial history fields merge.
Android session eventssessions.changed, session.message, lifecycle snapshotsDeltaField presence controls replacement; null clears and omission is inert.
Apple session rostersessions.list, reconnect hydrationSnapshotReplace live rows; the offline cache strips transient active-run facts.
Apple selected sessionchat.history.sessionInfo, reconnect recoverySnapshotReplace both the current row and its run-ID projection; omission clears both.
Apple session eventssessions.changed, session.message, lifecycle snapshotsDeltaPreserve field presence through decoding; omission is inert, null clears, and an array replaces.

The outer event frame also has an optional seq, which orders events on the current WebSocket connection. It resets with a new connection. The seq inside an agent event payload is assigned per run and orders that run's lifecycle, assistant, plan, tool, and other stream events.

Render generated image artifacts

Assistant-generated images arrive as canonical type: "image" content blocks. Managed blocks include a stable artifactId, a Gateway-relative url, MIME type, dimensions, size, and accessible alt text. Keep that reference in the transcript cache; do not persist downloaded bytes or temporary download URLs.

Resolve the image through the authenticated WebSocket connection:

  1. Call artifacts.download with the current sessionKey, optional agentId, and the block's artifactId.
  2. Use the returned short-lived url before expiresAt. The URL is scoped to that exact transcript-backed artifact and does not contain a reusable Gateway or device credential.
  3. Fetch it from the Gateway origin using the same TLS pin and reverse-proxy headers as the active connection. Validate the response as an image and enforce a 12 MiB source limit plus a bounded decoded thumbnail.
  4. If the URL expires, repeat artifacts.download once. Reconnect or route changes cancel the old load rather than retargeting it to another Gateway.

Older image blocks without artifactId remain displayable by existing Control UI clients, but native clients should show a readable attachment fallback rather than forward a shared owner credential.

Use history metadata and stable anchors

Rows coming back from chat.history may include an __openclaw metadata wrapper:

  • id serves as the identity of a transcript entry. Rely on it for anchored history lookups, but avoid treating it as a unique key for display rows.

  • seq represents the positive sequence of transcript records. A single stored record can map to multiple display rows, so group rows sharing the same id and sequence together.

  • kind marks synthetic rows. A compaction boundary relies on kind: "compaction" and can incorporate tokensBefore and tokensAfter when an associated checkpoint recorded those metrics.

    A session reset boundary relies on kind: "reset". No checkpoint token metrics accompany it.

Use the response's hasMore and nextOffset values to page backward. Numeric offsets reflect the current transcript projection, so avoid storing them as durable bookmarks across reset or compaction events. Persist __openclaw.id instead. To navigate back to a specific row, invoke chat.history with messageId and the sessionId that produced it. The Gateway can locate that anchor from reset archive history; anchored responses deliberately exclude numeric paging metadata.

Subscribe instead of polling usage

Fetch the initial catalog via sessions.list, then invoke sessions.subscribe once for each connection. Combine sessions.changed events using sessionKey. Session change payloads may include live inputTokens, outputTokens, totalTokens, totalTokensFresh, contextTokens, estimatedCostUsd, response-usage settings, and active-run state.

Certain change notifications act only as invalidation signals. When an event lacks the row fields your view depends on, reload sessions.list. Avoid polling usage.cost or sessions.usage to maintain an up-to-date live session list; save those calls for on-demand aggregate or detailed reports.

Backfill exec approvals

A client with operator.approvals should register its event listener right after hello-ok finishes, then call exec.approval.list to fill in requests that happened before the connection. Match the list against live exec.approval.requested / exec.approval.resolved events by approval ID so a transition racing the list request is neither dropped nor revived.

Track protocol versions

The current wire version is 4. General operator and WebChat clients must agree on the exact current version using minProtocol: 4 and maxProtocol: 4. Only authenticated node clients and lightweight probes get the N-1 acceptance window, which currently spans protocol 3 through 4.

Protocol changes start as additive. protocol.schema.json includes since release-vintage metadata and required scope metadata for core methods, yet a wire version bump remains an explicit breaking change for third-party clients. Lock down the package versions you test, update the client and Gateway together whenever the wire version shifts, and check the OpenClaw changelog before each upgrade.

1,915 words · updated Aug 24, 2026