docs/web/control-ui.md
The Control UI is a small Vite + Lit single-page app served by the Gateway:
http://<host>:18789/gateway.controlUi.basePath (e.g. /openclaw)It speaks directly to the Gateway WebSocket on the same port.
If the Gateway is running on the same computer, open:
If the page fails to load, start the Gateway first: openclaw gateway.
Auth is supplied during the WebSocket handshake via:
connect.params.auth.tokenconnect.params.auth.passwordgateway.auth.allowTailscale: truegateway.auth.mode: "trusted-proxy"The dashboard settings panel keeps a token for the current browser tab session and selected gateway URL; passwords are not persisted. Onboarding usually generates a gateway token for shared-secret auth on first connect, but password auth works too when gateway.auth.mode is "password".
When you connect to the Control UI from a new browser or device, the Gateway usually requires a one-time pairing approval. This is a security measure to prevent unauthorized access.
What you'll see: "disconnected (1008): pairing required"
<Steps> <Step title="List pending requests"> ```bash openclaw devices list ``` </Step> <Step title="Approve by request ID"> ```bash openclaw devices approve <requestId> ``` </Step> </Steps>If the browser retries pairing with changed auth details (role/scopes/public key), the previous pending request is superseded and a new requestId is created. Re-run openclaw devices list before approval.
If the browser is already paired and you change it from read access to write/admin access, this is treated as an approval upgrade, not a silent reconnect. OpenClaw keeps the old approval active, blocks the broader reconnect, and asks you to approve the new scope set explicitly.
Once approved, the device is remembered and won't require re-approval unless you revoke it with openclaw devices revoke --device <id> --role <role>. See Devices CLI for token rotation and revocation.
The Control UI supports a per-browser personal identity (display name and avatar) attached to outgoing messages for attribution in shared sessions. It lives in browser storage, is scoped to the current browser profile, and is not synced to other devices or persisted server-side beyond the normal transcript authorship metadata on messages you actually send. Clearing site data or switching browsers resets it to empty.
The same browser-local pattern applies to the assistant avatar override. Uploaded assistant avatars overlay the gateway-resolved identity on the local browser only and never round-trip through config.patch. The shared ui.assistant.avatar config field is still available for non-UI clients writing the field directly (such as scripted gateways or custom dashboards).
The Control UI fetches its runtime settings from /__openclaw/control-ui-config.json. That endpoint is gated by the same gateway auth as the rest of the HTTP surface: unauthenticated browsers cannot fetch it, and a successful fetch requires either an already valid gateway token/password, Tailscale Serve identity, or a trusted-proxy identity.
The Control UI can localize itself on first load based on your browser locale. To override it later, open Overview -> Gateway Access -> Language. The locale picker lives in the Gateway Access card, not under Appearance.
en, zh-CN, zh-TW, pt-BR, de, es, ja-JP, ko, fr, ar, it, tr, uk, id, pl, th, vi, nl, faDocs translations are generated for the same non-English locale set, but the docs site's built-in Mintlify language picker is limited to the locale codes Mintlify accepts. Thai (th) and Persian (fa) docs are still generated in the publish repo; they may not appear in that picker until Mintlify supports those codes.
The Appearance panel keeps the built-in Claw, Knot, and Dash themes, plus one browser-local tweakcn import slot. To import a theme, open tweakcn editor, choose or create a theme, click Share, and paste the copied theme link into Appearance. The importer also accepts https://tweakcn.com/r/themes/<id> registry URLs, editor URLs like https://tweakcn.com/editor/theme?theme=amethyst-haze, relative /themes/<id> paths, raw theme IDs, and default theme names such as amethyst-haze.
Imported themes are stored only in the current browser profile. They are not written to gateway config and do not sync across devices. Replacing the imported theme updates the one local slot; clearing it switches the active theme back to Claw if the imported theme was selected.
In the Chat composer, the Talk control is the waves button next to the microphone dictation button. When Talk starts, the composer status row shows `Connecting Talk...`, then `Talk live` while audio is connected, or `Asking OpenClaw...` while a realtime tool call is consulting the configured larger model through `chat.send`.
Maintainer live smoke: `OPENAI_API_KEY=... GEMINI_API_KEY=... node --import tsx scripts/dev/realtime-talk-live-smoke.ts` verifies the OpenAI browser WebRTC SDP exchange, Google Live constrained-token browser WebSocket setup, and the Gateway relay browser adapter with fake microphone media. The command prints provider status only and does not log secrets.
The Control UI ships a manifest.webmanifest and a service worker, so modern browsers can install it as a standalone PWA. Web Push lets the Gateway wake the installed PWA with notifications even when the tab or browser window is not open.
| Surface | What it does |
|---|---|
ui/public/manifest.webmanifest | PWA manifest. Browsers offer "Install app" once it is reachable. |
ui/public/sw.js | Service worker that handles push events and notification clicks. |
push/vapid-keys.json (under the OpenClaw state dir) | Auto-generated VAPID keypair used to sign Web Push payloads. |
push/web-push-subscriptions.json | Persisted browser subscription endpoints. |
Override the VAPID keypair through env vars on the Gateway process when you want to pin keys (for multi-host deployments, secrets rotation, or tests):
OPENCLAW_VAPID_PUBLIC_KEYOPENCLAW_VAPID_PRIVATE_KEYOPENCLAW_VAPID_SUBJECT (defaults to mailto:openclaw@localhost)The Control UI uses these scope-gated Gateway methods to register and test browser subscriptions:
push.web.vapidPublicKey — fetches the active VAPID public key.push.web.subscribe — registers an endpoint plus keys.p256dh/keys.auth.push.web.unsubscribe — removes a registered endpoint.push.web.test — sends a test notification to the caller's subscription.Assistant messages can render hosted web content inline with the [embed ...] shortcode. The iframe sandbox policy is controlled by gateway.controlUi.embedSandbox:
Example:
{
gateway: {
controlUi: {
embedSandbox: "scripts",
},
},
}
Absolute external http(s) embed URLs stay blocked by default. If you intentionally want [embed url="https://..."] to load third-party pages, set gateway.controlUi.allowExternalEmbedUrls: true.
Grouped chat messages use a readable default max-width. Wide-monitor deployments can override it without patching bundled CSS by setting gateway.controlUi.chatMessageMaxWidth:
{
gateway: {
controlUi: {
chatMessageMaxWidth: "min(1280px, 82%)",
},
},
}
The value is validated before it reaches the browser. Supported values include plain lengths and percentages such as 960px or 82%, plus constrained min(...), max(...), clamp(...), calc(...), and fit-content(...) width expressions.
```bash
openclaw gateway --tailscale serve
```
Open:
- `https://<magicdns>/` (or your configured `gateway.controlUi.basePath`)
By default, Control UI/WebSocket Serve requests can authenticate via Tailscale identity headers (`tailscale-user-login`) when `gateway.auth.allowTailscale` is `true`. OpenClaw verifies the identity by resolving the `x-forwarded-for` address with `tailscale whois` and matching it to the header, and only accepts these when the request hits loopback with Tailscale's `x-forwarded-*` headers. For Control UI operator sessions with browser device identity, this verified Serve path also skips the device-pairing round trip; device-less browsers and node-role connections still follow the normal device checks. Set `gateway.auth.allowTailscale: false` if you want to require explicit shared-secret credentials even for Serve traffic. Then use `gateway.auth.mode: "token"` or `"password"`.
For that async Serve identity path, failed auth attempts for the same client IP and auth scope are serialized before rate-limit writes. Concurrent bad retries from the same browser can therefore show `retry later` on the second request instead of two plain mismatches racing in parallel.
<Warning>
Tokenless Serve auth assumes the gateway host is trusted. If untrusted local code may run on that host, require token/password auth.
</Warning>
Then open:
- `http://<tailscale-ip>:18789/` (or your configured `gateway.controlUi.basePath`)
Paste the matching shared secret into the UI settings (sent as `connect.params.auth.token` or `connect.params.auth.password`).
If you open the dashboard over plain HTTP (http://<lan-ip> or http://<tailscale-ip>), the browser runs in a non-secure context and blocks WebCrypto. By default, OpenClaw blocks Control UI connections without device identity.
Documented exceptions:
gateway.controlUi.allowInsecureAuth=truegateway.auth.mode: "trusted-proxy"gateway.controlUi.dangerouslyDisableDeviceAuth=trueRecommended fix: use HTTPS (Tailscale Serve) or open the UI locally:
https://<magicdns>/ (Serve)http://127.0.0.1:18789/ (on the gateway host)`allowInsecureAuth` is a local compatibility toggle only:
- It allows localhost Control UI sessions to proceed without device identity in non-secure HTTP contexts.
- It does not bypass pairing checks.
- It does not relax remote (non-localhost) device identity requirements.
<Warning>
`dangerouslyDisableDeviceAuth` disables Control UI device identity checks and is a severe security downgrade. Revert quickly after emergency use.
</Warning>
See Tailscale for HTTPS setup guidance.
The Control UI ships with a tight img-src policy: only same-origin assets, data: URLs, and locally generated blob: URLs are allowed. Remote http(s) and protocol-relative image URLs are rejected by the browser and do not issue network fetches.
What this means in practice:
/avatars/<id>) still render, including authenticated avatar routes that the UI fetches and converts into local blob: URLs.data:image/... URLs still render (useful for in-protocol payloads).blob: URLs created by the Control UI still render.You do not need to change anything to get this behavior — it is always on and not configurable.
When gateway auth is configured, the Control UI avatar endpoint requires the same gateway token as the rest of the API:
GET /avatar/<agentId> returns the avatar image only to authenticated callers. GET /avatar/<agentId>?meta=1 returns the avatar metadata under the same rule.If you disable gateway auth (not recommended on shared hosts), the avatar route also becomes unauthenticated, in line with the rest of the gateway.
When gateway auth is configured, assistant local-media previews use a two-step route:
GET /__openclaw__/assistant-media?meta=1&source=<path> requires the normal Control UI operator auth. The browser sends the gateway token as a bearer header when checking availability.mediaTicket scoped to that exact source path.mediaTicket=<ticket> instead of the active gateway token or password. The ticket expires quickly and cannot authorize a different source.This keeps normal media rendering compatible with browser-native media elements without putting reusable gateway credentials in visible media URLs.
The Gateway serves static files from dist/control-ui. Build them with:
pnpm ui:build
Optional absolute base (when you want fixed asset URLs):
OPENCLAW_CONTROL_UI_BASE_PATH=/openclaw/ pnpm ui:build
For local development (separate dev server):
pnpm ui:dev
Then point the UI at your Gateway WS URL (e.g. ws://127.0.0.1:18789).
The Control UI is static files; the WebSocket target is configurable and can be different from the HTTP origin. This is handy when you want the Vite dev server locally but the Gateway runs elsewhere.
<Steps> <Step title="Start the UI dev server"> ```bash pnpm ui:dev ``` </Step> <Step title="Open with gatewayUrl"> ```text http://localhost:5173/?gatewayUrl=ws%3A%2F%2F<gateway-host>%3A18789 ```Optional one-time auth (if needed):
```text
http://localhost:5173/?gatewayUrl=wss%3A%2F%2F<gateway-host>%3A18789#token=<gateway-token>
```
Example:
{
gateway: {
controlUi: {
allowedOrigins: ["http://localhost:5173"],
},
},
}
Remote access setup details: Remote access.