Skip to main content

Web client hosting

The WednesdayAI web client is a static bundle. It contains no gateway address, no credentials and no server code: everything environment-specific arrives at runtime from a small config.json served next to index.html. One build therefore serves every deployment - a CDN, a static host, a container, or the gateway itself. The client talks to the gateway over the WebSocket RPC protocol only. It authenticates with a bounded device credential it mints and redeems itself during onboarding, so the host that serves the files never handles a secret. See Protocol for the client identity rules.

The bundle ships with WednesdayAI

A published install already contains the built client at <install>/dist/web-client, packaged next to the Control UI. Nothing has to be built or downloaded first:
That is the default for both hosting shapes WednesdayAI runs itself: wednesdayai web serve without --root and the gateway route without gateway.webClient.root both serve the packaged bundle. Pointing either at your own directory always wins over the packaged one. From a source checkout the packaged bundle does not exist until you build it, and pnpm build empties dist/, so build it after:
Without either, web serve exits non-zero and the gateway route answers 503 - both naming pnpm web:build:packaged.

Choose a deployment

The first two use the packaged bundle by default; the last two host a copy you deploy yourself. Every shape needs the same two things: the browser origin in gateway.webClient.allowedOrigins, and a config.json naming the gateway.

Standalone server

This starts no gateway and keeps no state, so you can run as many instances behind a load balancer as you like. Flags win over environment variables, which win over defaults, and the packaged dist/web-client bundle is the default root. Add --root apps/web/dist (or any other directory) to serve a bundle of your own. http:// and https:// gateway URLs are accepted and upgraded to ws:// and wss://. Anything else - a relative URL, a query string, an unknown scheme, a missing bundle, a port outside 0-65535 - exits non-zero before the socket binds, with a message naming the flag and its environment variable. A source checkout with no packaged bundle and no --root is refused the same way, naming pnpm web:build:packaged. GET /healthz answers 200 {"status":"ok","service":"openclaw-user-client"} for load balancer probes.

Serving under a subpath

--base-path /client (any prefix works; this one is just an example) mounts everything under that prefix: /client/, /client/config.json, /client/assets/.... A request for the bare /client redirects to /client/, and paths outside the prefix are not handled at all. The bundle uses relative asset URLs, so no rebuild is needed.

Container

The image builds the bundle and runs the standalone server. Build from the repository root, not from apps/web: the build needs the shared packages/brand-tokens package (colours and fonts) in its context, and apps/web/Dockerfile.dockerignore already limits the context to the files the build uses.
The image contains only the static files and a dependency-free Node server. It holds no secrets and no state, runs as an unprivileged user, exposes 8787, and declares a HEALTHCHECK against /healthz. OPENCLAW_WEB_GATEWAY_URL is not baked in; supply it (and any other OPENCLAW_WEB_* variable from the table above) at run time.

Gateway route

For single-origin deployments the gateway can serve the bundle itself. This is off by default.
With this config the client is at https://gateway.example.com/v2/client/, beside the Control UI at the gateway root. Restart the gateway after changing gateway.webClient (Linux: systemctl --user restart openclaw-gateway; macOS: wednesdayai gateway restart --deep).
  • config.json is synthesized per request: gatewayUrl is the gateway’s own origin, so the client connects back to where it was loaded from.
  • basePath defaults to /v2/client (the default in the sample above) and may not collide with gateway.controlUi.basePath. A base path of / while the Control UI is enabled, or base paths that contain one another, are rejected when the config loads. With a distinct base path the Control UI is unaffected.
  • root is optional. Unset, the gateway serves the packaged dist/web-client bundle and logs which directory it picked. Set, it always wins - including when it is broken, which answers 503 with that reason rather than silently falling back. When neither resolves, the route answers 503 naming pnpm web:build:packaged and the gateway logs a warning at startup.
  • The origin allowlist still applies: add the gateway’s own origin to gateway.webClient.allowedOrigins, or the websocket connect is refused.
Upgrading from an earlier build. The default basePath used to be /client. On a deployment that never set basePath, the client now lives at /v2/client/ and a saved /client/ bookmark no longer reaches it. There is no redirect. With the Control UI at the gateway root (the default), /client/ is now answered by the Control UI; with the Control UI disabled it is a plain 404. Tell your users the new address, or pin the old one explicitly with "basePath": "/client". Deployments that already set basePath are unaffected, and so is wednesdayai web serve, whose default stays /.

Origins, HTTPS and secure context

  • gateway.webClient.allowedOrigins is mandatory. The gateway refuses an openclaw-user-client websocket from any origin not listed there, under every gateway.auth.mode and even behind a reverse proxy. gateway.controlUi.allowedOrigins never admits this client. List exact origins (scheme://host[:port]), for example https://chat.example.com. The default is empty, which admits nothing.
  • Use HTTPS and wss:// outside loopback. The client stores its device credential in browser storage and signs each connect with WebCrypto, which browsers expose only in a secure context: HTTPS, or http://localhost / http://127.0.0.1 for development. A plain http:// page on a LAN address cannot mint a device identity.
  • Do not mix schemes. An HTTPS page cannot open a ws:// socket. Serve the client over HTTPS and point gatewayUrl at wss://.
  • The client sends a browser Origin header on connect; a mismatch closes the socket with code 1008. Pair the client with wednesdayai qr --client openclaw-user-client, which prints the configuration key to fix if the origin is not yet allowed.

config.json

Fields, all public by construction - never put a token or any per-user value here: Leave features.voice unset. Voice and dictation are not available in this release: the client always renders the Dictate and Voice mode controls disabled, whatever the flag says, so setting it changes nothing users can see. The voice surface would need the talk.session.* methods, and those are deliberately not on the narrow operator.sessions.write allowlist the user client connects with (ADR 0077), so the gateway would refuse them. Voice is tracked in GitHub #650; a release note will say when it ships. The Runs panel likewise depends on gateway configuration outside config.json: its queued and task lists need SQLite or Postgres session storage (session.storage.backend). On the default JSONL backend those lists show an explanatory unavailable message. The session runs list still shows the chats that are streaming a reply right now (the gateway merges live runs into sessions.runs.list, see Protocol), but keeps no history - see Using the web client. Example:
Unknown keys are rejected, not ignored: the client shows a configuration error instead of starting half-configured.

What the client needs from the gateway

The client connects with exactly two scopes, operator.sessions.read and operator.sessions.write, which its setup code mints and its device token is bound to (see Protocol). It never holds operator.read, operator.write or operator.admin, so the gateway refuses every method outside the two allowlists below with missing scope. The sources of truth are NARROW_SESSION_READ_METHODS and NARROW_SESSION_WRITE_METHODS in src/gateway/method-scopes.ts; see Narrow scope method allowlist for the full lists. Methods the client calls with operator.sessions.read: Methods the client calls with operator.sessions.write: Gateway settings that decide what users can do with those methods:
  • Models. agents.defaults.models bounds the model menu and what sessions.patch and sessions.create accept. Set it before you expose the client: left empty, the menu lists the whole catalog and any provider/model reference that parses is accepted. See Configuration reference.
  • Assistants. The picker lists the agents the gateway’s agents.list method returns: every agents.list entry in config plus the default agent, or, with none configured, the default agent plus each agent directory under the gateway state directory. identity.name and identity.color set how each one looks; an assistant with no colour gets a stable one derived from its id.
  • Suggest a title. Each use sends the chat’s first message (up to 1000 characters) to the assistant’s default model - the agent’s configured model, not the model picked for the chat. The limit is 3 calls per 60 seconds per device and address, and it cannot be changed.
  • Reach. A paired client can read, send to and change the four sessions.patch settings of any session on the gateway, including channel and sub-agent sessions; per-session ownership does not exist yet (ADR 0077 section 5, ADR 0083). Pair only people who should have that reach.
Queueing, Send now, replies and the notice that a chat changed elsewhere need no gateway setting.

Themes and appearance

The client has four theme modes, chosen by each user from the account menu at the bottom of the sidebar. You do not configure them; there is no config.json or gateway setting for the theme.
  • The choice is stored per browser (localStorage, key openclaw.web.theme) and applies immediately, with no reload. The default is Auto.
  • A browser that stored the retired black or white mode is migrated on first load: black becomes dark, white becomes light, and the migrated value is written back.
  • The theme also sets the browser’s native colours for scroll bars and form controls (color-scheme), so they follow the chosen theme instead of the operating system. In high contrast, text on accent-coloured fills switches to dark for legibility.
  • Entrance animation is skipped when the browser’s prefers-reduced-motion is set.
  • Colours and fonts (Inter, Space Grotesk and JetBrains Mono) are bundled with the client and served from the same base path, so a deployment needs no font CDN. The host’s Content-Security-Policy still has to allow them: font-src 'self' is enough (see Rules the host must follow). They come from the same shared token package as the Control UI; developers see Brand tokens.
Navigation and transcript display settings are client-side, browser-local state. They are not stored in gateway configuration or config.json, and changing them does not require a gateway restart, page reload, or bundle rebuild. The browser stores navigation preferences separately for each gateway URL and client ID under openclaw.web.navigation:<encodedGatewayUrl>:<clientId>. Transcript display preferences use the openclaw.web.transcript-display.v1 key, which contains a record keyed by <gatewayUrl>|<clientId>. Missing, invalid, or unreadable saved preferences load defaults. If a storage write fails, the new choice still applies in memory while the page is open, but a reload can restore the older saved value or a default. Navigation and chat remain usable. The defaults and available choices are:
Navigation filters are not authorization boundaries. Hiding internal sessions or filtering the UI by recency or assistant changes only what the browser displays; it does not change RPC authorization. Enforce access through gateway authentication and permission policy, not these client-side preferences.
For sessions.list, the client sends includeInternal, sortBy, sortOrder, and includeAgentActivity: true. When includeInternal is false, the gateway applies the shared isUserFacingSessionKey rules: session keys whose root segment is acp, cron, heartbeat, or subagent are excluded, as are isolated heartbeat keys such as main:heartbeat and dashboard:*:heartbeat. Deploy matching web client and gateway versions together. A newer bundle can send includeInternal, sortBy, sortOrder, and includeAgentActivity to sessions.list; an older gateway with strict schema validation could reject fields it does not recognize. Older web client versions do not send these fields.

Diagnose missing chats

Check these display filters before investigating gateway storage:
  1. Time window: The assistant activity window defaults to 4 hours and narrows the assistant list, not the age of chats. Select All to include inactive assistants, or All assistants to search chats without an assistant filter.
  2. Internal sessions: Heartbeats, cron runs, subagents, and other background sessions are hidden by default. Enable Show internal (includeInternal: true) in the chats menu.
  3. Selected assistant: The chat list follows the active assistant. Check whether the chat was created under a different assistant ID.

Diagnose missing reasoning or thought transcripts

Reasoning is collapsed by default (showReasoning: false). Enable Show reasoning in the Display menu or chat display preferences to inspect thinking blocks. If the model or gateway did not produce reasoning blocks, enabling the preference shows nothing because the transcript has no reasoning content to display.

What users see that is not live yet

Some controls are drawn but switched off, with a tooltip naming the release phase (or a tracked finding) that enables them (for example “Canvas — coming in phase P4”). Selecting one sends nothing to the gateway. They are: Canvas in the top bar; Search, Dictate and Voice mode in the message box; the Read aloud, Feedback and Regenerate actions on replies; Settings and Keyboard shortcuts in the account menu. Calendar, Memory and Add extension cite the extension panel roadmap item (GitHub #634) instead of a phase; Feedback, Regenerate, Settings, Keyboard shortcuts and the message-box Search cite their own tracked finding (GitHub #696-#700) instead. The assistant picker, per-assistant colour, chat filter and search, paging past the first page, the model switcher and Think, queue/steer while a reply streams, rename and Suggest, and replying to a message are live (client-chat-v3 P2); see Using the web client for what users see. They are not gated by any setting, so there is nothing to enable. If users ask, point them at Using the web client.

Rules the host must follow

If you serve the bundle yourself (CDN, nginx, object storage), reproduce what wednesdayai web serve does: Send these headers on every response:
  • Content-Security-Policy with frame-ancestors 'none', script-src 'self', font-src 'self' (the bundled fonts load from the same origin), and connect-src listing 'self' plus the gateway origin with its ws:/wss: scheme
  • X-Frame-Options: DENY
  • X-Content-Type-Options: nosniff
  • Referrer-Policy: no-referrer
Caching config.json is the most common deployment mistake: a stale copy silently points the client at the previous gateway.