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 smallconfig.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:
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:
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
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 fromapps/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.
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.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.jsonis synthesized per request:gatewayUrlis the gateway’s own origin, so the client connects back to where it was loaded from.basePathdefaults to/v2/client(the default in the sample above) and may not collide withgateway.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.rootis optional. Unset, the gateway serves the packageddist/web-clientbundle and logs which directory it picked. Set, it always wins - including when it is broken, which answers503with that reason rather than silently falling back. When neither resolves, the route answers503namingpnpm web:build:packagedand 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.allowedOriginsis mandatory. The gateway refuses anopenclaw-user-clientwebsocket from any origin not listed there, under everygateway.auth.modeand even behind a reverse proxy.gateway.controlUi.allowedOriginsnever admits this client. List exact origins (scheme://host[:port]), for examplehttps://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, orhttp://localhost/http://127.0.0.1for development. A plainhttp://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 pointgatewayUrlatwss://. - The client sends a browser
Originheader on connect; a mismatch closes the socket with code1008. Pair the client withwednesdayai 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:
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.modelsbounds the model menu and whatsessions.patchandsessions.createaccept. Set it before you expose the client: left empty, the menu lists the whole catalog and anyprovider/modelreference that parses is accepted. See Configuration reference. - Assistants. The picker lists the agents the gateway’s
agents.listmethod returns: everyagents.listentry in config plus the default agent, or, with none configured, the default agent plus each agent directory under the gateway state directory.identity.nameandidentity.colorset 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.patchsettings 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.
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 noconfig.json or gateway setting for the theme.
- The choice is stored per browser (
localStorage, keyopenclaw.web.theme) and applies immediately, with no reload. The default is Auto. - A browser that stored the retired
blackorwhitemode is migrated on first load:blackbecomesdark,whitebecomeslight, 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-motionis 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-Policystill 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 preferences
Navigation and transcript display settings are client-side, browser-local state. They are not stored in gateway configuration orconfig.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:
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:- 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.
- Internal sessions: Heartbeats, cron runs, subagents, and other background sessions are hidden
by default. Enable Show internal (
includeInternal: true) in the chats menu. - 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 whatwednesdayai web serve does:
Send these headers on every response:
Content-Security-Policywithframe-ancestors 'none',script-src 'self',font-src 'self'(the bundled fonts load from the same origin), andconnect-srclisting'self'plus the gateway origin with itsws:/wss:schemeX-Frame-Options: DENYX-Content-Type-Options: nosniffReferrer-Policy: no-referrer
config.json is the most common deployment mistake: a stale copy silently points the
client at the previous gateway.
Related
- Using the web client - what your users see: pairing, chat, and runs
- Control UI - the operator console, a separate app with its own route
- Brand tokens - the shared colour and font package (developers)
- Web client components - how the client UI is built (developers)
- Gateway protocol - client identity, scopes and the connect handshake
- Configuration reference - every
gateway.webClientkey - Pairing QR and setup codes - onboarding a browser with
--client openclaw-user-client