Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,203 @@
---
summary: "Model authentication: OAuth, API keys, Claude CLI reuse, and Anthropic setup-token"
read_when:
- Debugging model auth or OAuth expiry
- Documenting authentication or credential storage
title: "Authentication"
---
<Note>
This page covers **model provider** authentication (API keys, OAuth, Claude CLI reuse, Anthropic setup-token). For **gateway connection** authentication (token, password, trusted-proxy), see [Configuration](/gateway/configuration) and [Trusted Proxy Auth](/gateway/trusted-proxy-auth).
</Note>
OpenClaw supports OAuth and API keys for model providers. For an always-on gateway host, an API key is the most predictable option; subscription/OAuth flows work too when they match your provider account model.
- Full OAuth flow and storage layout: [/concepts/oauth](/concepts/oauth)
- SecretRef-based auth (`env`/`file`/`exec` providers): [Secrets Management](/gateway/secrets)
- Credential eligibility/reason codes used by `models status --probe`: [Auth Credential Semantics](/auth-credential-semantics)
## Recommended setup: API key (any provider)
1. Create an API key in your provider console.
2. Put it on the **gateway host** (the machine running `openclaw gateway`):
```bash
export <PROVIDER>_API_KEY="..."
openclaw models status
```
3. If the gateway runs under systemd/launchd, put the key in `~/.openclaw/.env` so the daemon can read it:
```bash
cat >> ~/.openclaw/.env <<'EOF'
<PROVIDER>_API_KEY=...
EOF
```
4. Restart the gateway process (or the daemon), then re-check:
```bash
openclaw models status
openclaw doctor
```
`openclaw onboard` can also store API keys for daemon use if you don't want to manage env vars yourself. See [Environment variables](/help/environment) for the full env-loading precedence (`env.shellEnv`, `~/.openclaw/.env`, systemd/launchd).
## Anthropic: Claude CLI reuse
Anthropic setup-token auth remains a supported path. Claude CLI reuse (`claude -p`-style usage) is also sanctioned for this integration; when a Claude CLI login is available on the host, that's the preferred path for local/desktop use. For long-lived gateway hosts, an Anthropic API key is still the most predictable choice, with explicit server-side billing control.
Host setup for Claude CLI reuse:
```bash
# Run on the gateway host
claude auth login
claude auth status --text
openclaw models auth login --provider anthropic --method cli --set-default
```
This is two steps: log Claude Code into Anthropic on the host, then tell OpenClaw to route Anthropic model selection through the local `claude-cli` backend and store the matching OpenClaw auth profile.
If `claude` isn't on `PATH`, install Claude Code or set `agents.defaults.cliBackends.claude-cli.command` to the binary path.
## Manual token entry
Works for any provider; writes the per-agent SQLite auth store and updates config:
```bash
openclaw models auth paste-token --provider openrouter
```
OpenClaw reads auth profiles from each agent's `openclaw-agent.sqlite`. Endpoint details (`baseUrl`, `api`, model ids, headers, timeouts) belong under `models.providers.<id>` in `openclaw.json` or `models.json`, not in auth profiles.
If an older install still has `auth-profiles.json`, `auth-state.json`, or a flat shape like `{ "openrouter": { "apiKey": "..." } }`, run `openclaw doctor --fix` to import it into SQLite; doctor keeps timestamped backups beside the original JSON files.
External auth routes such as Bedrock `auth: "aws-sdk"` aren't credentials. For a named Bedrock route, set `auth.profiles.<id>.mode: "aws-sdk"` in `openclaw.json` — don't write `type: "aws-sdk"` into the auth profile store. `openclaw doctor --fix` migrates legacy AWS SDK markers from the credential store into config metadata.
### SecretRef-backed credentials
- `api_key` credentials can use `keyRef: { source, provider, id }`
- `token` credentials can use `tokenRef: { source, provider, id }`
- OAuth-mode profiles reject SecretRef credentials: if `auth.profiles.<id>.mode` is `"oauth"`, a SecretRef-backed `keyRef`/`tokenRef` for that profile is rejected.
## Checking model auth status
```bash
openclaw models status
openclaw doctor
```
Automation-friendly check, exit `1` when expired/missing, `2` when expiring:
```bash
openclaw models status --check
```
Live auth probes (add `--probe-provider`, `--probe-profile`, `--probe-timeout`, `--probe-concurrency`, or `--probe-max-tokens` to narrow scope):
```bash
openclaw models status --probe
```
Notes:
- Probe rows can come from auth profiles, env credentials, or `models.json`.
- If `auth.order.<provider>` omits a stored profile, probe reports `excluded_by_auth_order` for that profile instead of trying it.
- If auth exists but OpenClaw can't resolve a probeable model for that provider, probe reports `status: no_model`.
- Rate-limit cooldowns can be model-scoped: a profile cooling down for one model can still serve a sibling model on the same provider.
Optional ops scripts (systemd/Termux): [Auth monitoring scripts](/help/scripts#auth-monitoring-scripts).
## API key rotation (gateway)
Some providers retry a request with an alternate configured key when a call hits a provider rate limit.
Key priority order per provider:
1. `OPENCLAW_LIVE_<PROVIDER>_KEY` (single override, pins one key)
2. `<PROVIDER>_API_KEYS` (comma/space/semicolon-separated list)
3. `<PROVIDER>_API_KEY`
4. `<PROVIDER>_API_KEY_*` (any env var with this prefix)
Google providers (`google`, `google-vertex`) additionally fall back to `GOOGLE_API_KEY`. The combined list is deduplicated before use.
OpenClaw rotates to the next key only when the error message matches: `rate_limit`, `rate limit`, `429`, `quota exceeded`/`quota_exceeded`, `resource exhausted`/`resource_exhausted`, or `too many requests`. Other errors are not retried with alternate keys. If all keys fail, the final error from the last attempt is returned.
<Note>
Provider-specific phrases like `ThrottlingException`, `concurrency limit reached`, or `workers_ai ... quota limit exceeded` drive **failover/retry classification** (switching models or providers on repeated failure), a separate mechanism from API-key rotation above.
</Note>
Removing saved auth does not revoke the key at the provider — rotate or revoke it in the provider dashboard when you need provider-side invalidation.
## Removing provider auth while the gateway is running
When you remove provider auth through the gateway control plane, OpenClaw deletes the saved auth profiles for that provider and aborts active chat/agent runs whose selected model provider matches the removed one. Aborted runs emit the normal cancellation/lifecycle events with `stopReason: "auth-revoked"`, so connected clients can show the run stopped because credentials were removed.
## Controlling which credential is used
### OpenAI and legacy `openai-codex` ids
OpenAI API-key profiles and ChatGPT/Codex OAuth profiles both use the canonical provider id `openai`. Use `openai:*` profile ids and `auth.order.openai` for new config.
If you see `openai-codex` in older config, auth profile ids, or `auth.order.openai-codex`, treat it as legacy migration input — don't create new `openai-codex` profiles. Run:
```bash
openclaw doctor --fix
openclaw models auth list --provider openai
```
Doctor rewrites legacy `openai-codex:*` profile ids and `auth.order.openai-codex` entries to the canonical `openai` route. For OpenAI-specific model/runtime routing, see [OpenAI](/providers/openai).
### During login (CLI)
```bash
openclaw models auth login --provider openai --profile-id openai:ritsuko
openclaw models auth login --provider openai --profile-id openai:lain
```
`--profile-id` keeps multiple OAuth logins for the same provider separate inside one agent.
`--force` deletes the saved auth profiles for that provider in the selected agent directory, then reruns the same auth flow. Use it when a saved profile is stuck, expired, or tied to the wrong account. It doesn't revoke credentials at the provider.
```bash
openclaw models auth login --provider anthropic --force
```
### Per-session (chat command)
- `/model <alias-or-id>@<profileId>` pins a specific provider credential for the current session (example profile ids: `anthropic:default`, `anthropic:work`).
- `/model` (or `/model list`) shows a compact picker; `/model status` shows the full view (candidates + next auth profile, plus provider endpoint details when configured).
If you change auth order or profile pinning for a chat that's already running, send `/new` or `/reset` to start a fresh session — existing sessions keep their current model/profile selection until reset.
### Per-agent (CLI override)
Auth order overrides are stored in that agent's SQLite auth state:
```bash
openclaw models auth order get --provider anthropic
openclaw models auth order set --provider anthropic anthropic:default
openclaw models auth order clear --provider anthropic
```
Use `--agent <id>` to target a specific agent; omit it to use the configured default agent. `openclaw models status --probe` shows omitted stored profiles as `excluded_by_auth_order` rather than silently skipping them.
## Troubleshooting
### "No credentials found"
Configure an Anthropic API key on the **gateway host**, or set up the Anthropic setup-token path, then re-check:
```bash
openclaw models status
```
### Token expiring/expired
Run `openclaw models status` to see which profile is expiring. If an Anthropic token profile is missing or expired, refresh it via setup-token or migrate to an Anthropic API key.
## Related
- [Secrets management](/gateway/secrets)
- [Remote access](/gateway/remote)
- [Auth storage](/concepts/oauth)

View File

@@ -0,0 +1,146 @@
---
summary: "Background exec execution and process management"
read_when:
- Adding or modifying background exec behavior
- Debugging long-running exec tasks
title: "Background exec and process tool"
---
OpenClaw runs shell commands through the `exec` tool and keeps long-running tasks in memory. The `process` tool manages those background sessions.
## exec tool
Parameters:
| Parameter | Description |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `command` | Required. Shell command to run. |
| `workdir` | Working directory; omit to use the default cwd. |
| `env` | Extra environment variables for the command. |
| `yieldMs` | Milliseconds to wait before backgrounding (default 10000). |
| `background` | Run in background immediately. |
| `timeout` | Timeout in seconds (default `tools.exec.timeoutSec`); kills the process on expiry. Set `timeout: 0` to disable the exec process timeout for that call. |
| `pty` | Run in a pseudo-terminal when available (TTY-required CLIs, coding agents). |
| `elevated` | Run outside the sandbox if elevated mode is enabled/allowed (`gateway` by default, or `node` when the exec target is `node`). |
| `host` | Exec target: `auto`, `sandbox`, `gateway`, or `node`. |
| `node` | Node id/name, used with `host: "node"`. |
Behavior:
- Foreground runs return output directly.
- When backgrounded (explicit or via `yieldMs` timeout), the tool returns `status: "running"` + `sessionId` and a short output tail.
- Backgrounded and `yieldMs` runs inherit `tools.exec.timeoutSec` unless the call passes an explicit `timeout`.
- Output stays in memory until the session is polled or cleared.
- If the `process` tool is disallowed, `exec` runs synchronously and ignores `yieldMs`/`background`.
- Spawned exec commands receive `OPENCLAW_SHELL=exec` for context-aware shell/profile rules.
- For long-running work that starts now: start it once and rely on automatic completion wake (when enabled) once the command emits output or fails.
- If automatic completion wake is unavailable, or you need quiet-success confirmation for a command that exits cleanly with no output, poll with `process`.
- Don't emulate reminders or delayed follow-ups with `sleep` loops or repeated polling — use cron for future work.
### Env overrides
| Variable | Effect |
| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `OPENCLAW_BASH_YIELD_MS` | Default yield before backgrounding (ms). Default 10000, clamped 10-120000. |
| `OPENCLAW_BASH_MAX_OUTPUT_CHARS` | In-memory output cap (chars). |
| `OPENCLAW_BASH_PENDING_MAX_OUTPUT_CHARS` | Pending stdout/stderr cap per stream (chars). |
| `OPENCLAW_BASH_JOB_TTL_MS` | TTL for finished sessions (ms), bounded to 1m-3h. |
| `OPENCLAW_PROCESS_INPUT_WAIT_IDLE_MS` | Idle-output threshold before writable background sessions are marked as likely waiting for input. Default 15000. |
### Config (preferred over env overrides)
| Key | Default | Effect |
| ------------------------------------- | ------- | ------------------------------------------------------------------------------- |
| `tools.exec.backgroundMs` | 10000 | Same as `OPENCLAW_BASH_YIELD_MS`. |
| `tools.exec.timeoutSec` | 1800 | Default per-call timeout. |
| `tools.exec.cleanupMs` | 1800000 | Same as `OPENCLAW_BASH_JOB_TTL_MS`. |
| `tools.exec.notifyOnExit` | true | Enqueue a system event + request heartbeat when a backgrounded exec exits. |
| `tools.exec.notifyOnExitEmptySuccess` | false | Also enqueue completion events for successful backgrounded runs with no output. |
## Child process bridging
When spawning long-running child processes outside the exec/process tools (CLI respawns, gateway helpers), attach the child-process bridge helper so termination signals forward and listeners detach on exit/error. This avoids orphaned processes on systemd and keeps shutdown consistent across platforms.
## process tool
Actions:
| Action | Effect |
| ----------- | ----------------------------------------------------------------------------- |
| `list` | Running + finished sessions. |
| `poll` | Drain new output for a session (also reports exit status). |
| `log` | Read aggregated output and input-recovery hints. Supports `offset` + `limit`. |
| `write` | Send stdin (`data`, optional `eof`). |
| `send-keys` | Send explicit key tokens or bytes to a PTY-backed session. |
| `submit` | Send Enter/carriage return to a PTY-backed session. |
| `paste` | Send literal text, optionally wrapped in bracketed paste mode. |
| `kill` | Terminate a background session. |
| `clear` | Remove a finished session from memory. |
| `remove` | Kill if running, otherwise clear if finished. |
Notes:
- Only backgrounded sessions are listed/persisted — in memory only, not on disk. Sessions are lost on process restart.
- Session logs are only saved to chat history if you run `process poll`/`log` and the tool result is recorded.
- `process` is scoped per agent; it only sees sessions started by that agent.
- Use `poll`/`log` for status, logs, or completion confirmation when automatic completion wake is unavailable.
- Use `log` before recovering an interactive CLI, so the current transcript, stdin state, and input-wait hint are visible together.
- Use `write`/`send-keys`/`submit`/`paste`/`kill` when you need input or intervention.
- `process list` includes a derived `name` (command verb + target) for quick scans.
- `process list`, `poll`, and `log` report `waitingForInput` only when the session still has writable stdin and has been idle longer than the input-wait threshold (default 15000 ms, `OPENCLAW_PROCESS_INPUT_WAIT_IDLE_MS`).
- `process log` uses line-based `offset`/`limit`. When both are omitted, it returns the last 200 lines with a paging hint. When `offset` is set and `limit` isn't, it returns from `offset` to the end (not capped to 200).
- `poll`'s `timeout` waits up to that many milliseconds before returning; values above 30000 are clamped to 30000.
- Polling is for on-demand status, not wait-loop scheduling. If the work should happen later, use cron.
## Examples
Run a long task and poll later:
```json
{ "tool": "exec", "command": "sleep 5 && echo done", "yieldMs": 1000 }
```
```json
{ "tool": "process", "action": "poll", "sessionId": "<id>" }
```
Inspect an interactive session before sending input:
```json
{ "tool": "process", "action": "log", "sessionId": "<id>" }
```
Start immediately in background:
```json
{ "tool": "exec", "command": "npm run build", "background": true }
```
Send stdin:
```json
{ "tool": "process", "action": "write", "sessionId": "<id>", "data": "y\n" }
```
Send PTY keys:
```json
{ "tool": "process", "action": "send-keys", "sessionId": "<id>", "keys": ["C-c"] }
```
Submit current line:
```json
{ "tool": "process", "action": "submit", "sessionId": "<id>" }
```
Paste literal text:
```json
{ "tool": "process", "action": "paste", "sessionId": "<id>", "text": "line1\nline2\n" }
```
## Related
- [Exec tool](/tools/exec)
- [Exec approvals](/tools/exec-approvals)

239
docs/gateway/bonjour.md Normal file
View File

@@ -0,0 +1,239 @@
---
summary: "Bonjour/mDNS discovery + debugging (Gateway beacons, clients, and common failure modes)"
read_when:
- Debugging Bonjour discovery issues on macOS/iOS
- Changing mDNS service types, TXT records, or discovery UX
title: "Bonjour discovery"
---
OpenClaw can use Bonjour (mDNS/DNS-SD) to discover an active gateway (WebSocket endpoint). Multicast `local.` browsing is a **LAN-only convenience**: the bundled `bonjour` plugin owns LAN advertising, auto-starting on macOS hosts and opt-in on Linux, Windows, and containerized gateway deployments. The same beacon can also publish through a configured wide-area DNS-SD domain for cross-network discovery. Discovery is best-effort and does **not** replace SSH or Tailnet-based connectivity.
## Wide-area Bonjour (Unicast DNS-SD) over Tailscale
If the node and gateway are on different networks, multicast mDNS can't cross the boundary. Keep the same discovery UX by switching to **unicast DNS-SD** ("Wide-Area Bonjour") over Tailscale:
1. Run a DNS server on the gateway host, reachable over the Tailnet.
2. Publish DNS-SD records for `_openclaw-gw._tcp` under a dedicated zone (example: `openclaw.internal.`).
3. Configure Tailscale **split DNS** so your chosen domain resolves via that DNS server for clients, including iOS.
`openclaw.internal.` above is just an example — OpenClaw supports any discovery domain. iOS/Android nodes browse both `local.` and your configured wide-area domain.
### Gateway config
```json5
{
gateway: { bind: "tailnet" }, // tailnet-only (recommended)
discovery: { wideArea: { enabled: true, domain: "openclaw.internal" } },
}
```
`discovery.wideArea.domain` also accepts the `OPENCLAW_WIDE_AREA_DOMAIN` env var as a fallback when unset.
### One-time DNS server setup (gateway host, macOS only)
```bash
openclaw dns setup --apply
```
This command is macOS-only and requires Homebrew and a running Tailscale connection. It installs CoreDNS (`brew install coredns`) and configures it to:
- listen on port 53 only on the gateway's Tailscale interfaces
- serve your chosen domain (example: `openclaw.internal.`) from `~/.openclaw/dns/<domain>.db`
Run without `--apply` first to preview the plan (domain, zone file path, detected Tailnet IP, recommended config) without installing anything.
Validate from a Tailnet-connected machine:
```bash
dns-sd -B _openclaw-gw._tcp openclaw.internal.
dig @<TAILNET_IPV4> -p 53 _openclaw-gw._tcp.openclaw.internal PTR +short
```
### Tailscale DNS settings
In the Tailscale admin console:
- Add a nameserver pointing at the gateway's Tailnet IP (UDP/TCP 53).
- Add split DNS so your discovery domain uses that nameserver.
Once clients accept Tailnet DNS, iOS nodes and CLI discovery can browse `_openclaw-gw._tcp` in your discovery domain without multicast.
### Gateway listener security
The gateway WS port (default `18789`) binds to loopback by default. For LAN/Tailnet access, bind explicitly and keep auth enabled. For Tailnet-only setups, set `gateway.bind: "tailnet"` in `~/.openclaw/openclaw.json` and restart the gateway (or the macOS menubar app).
## What advertises
Only the gateway advertises `_openclaw-gw._tcp`. LAN multicast advertising comes from the bundled `bonjour` plugin when enabled; wide-area DNS-SD publishing stays gateway-owned.
## Service types
- `_openclaw-gw._tcp` - gateway transport beacon, used by macOS/iOS/Android nodes.
## TXT keys (non-secret hints)
| Key | When present |
| ----------------------------- | ------------------------------------------------------------------------------ |
| `role=gateway` | Always. |
| `displayName=<friendly name>` | Always. |
| `lanHost=<hostname>.local` | Always. |
| `gatewayPort=<port>` | Always (gateway WS + HTTP). |
| `transport=gateway` | Always. |
| `gatewayTls=1` | Only when TLS is enabled. |
| `gatewayTlsSha256=<sha256>` | Only when TLS is enabled and a fingerprint is available. |
| `gatewayDirectReachable=1` | Only when the gateway is directly reachable (not only via a relay/proxy path). |
| `canvasPort=<port>` | Only when the canvas host is enabled; currently the same as `gatewayPort`. |
| `tailnetDns=<magicdns>` | mDNS full mode only; optional hint when Tailnet is available. |
| `sshPort=<port>` | Full mode only; omitted in minimal and off modes. |
| `cliPath=<path>` | Full mode only; omitted in minimal and off modes. |
Security notes:
- Bonjour/mDNS TXT records are **unauthenticated**. Clients must not treat TXT as authoritative routing.
- Clients should route using the resolved service endpoint (SRV + A/AAAA). Treat `lanHost`, `tailnetDns`, `gatewayPort`, and `gatewayTlsSha256` as hints only.
- SSH auto-targeting should likewise use the resolved service host, not TXT-only hints.
- TLS pinning must never let an advertised `gatewayTlsSha256` override a previously stored pin.
- iOS/Android nodes should treat discovery-based direct connects as **TLS-only** and require explicit user confirmation before trusting a first-time fingerprint.
## Debugging on macOS
Built-in tools:
```bash
# Browse instances
dns-sd -B _openclaw-gw._tcp local.
# Resolve one instance (replace <instance>)
dns-sd -L "<instance>" _openclaw-gw._tcp local.
```
If browsing works but resolving fails, you're usually hitting a LAN policy or mDNS resolver issue.
## Debugging in Gateway logs
The gateway writes a rolling log file (printed on startup as `gateway log file: ...`). Look for `bonjour:` lines, especially:
- `bonjour: advertise failed ...`
- `bonjour: suppressing ciao cancellation ...`
- `bonjour: ... name conflict resolved` / `hostname conflict resolved`
- `bonjour: watchdog detected non-announced service ...`
- `bonjour: disabling advertiser after ... failed restarts ...`
The watchdog treats active `probing`, `announcing`, and fresh conflict-renames as in-progress states. If the service never reaches `announced`, OpenClaw recreates the advertiser and, after repeated failures, disables Bonjour for that gateway process instead of re-advertising forever.
Bonjour uses the system hostname for the advertised `.local` host when it's a valid DNS label. If the system hostname contains spaces, underscores, or another invalid DNS-label character, OpenClaw falls back to `openclaw.local`. Set `OPENCLAW_MDNS_HOSTNAME=<name>` before starting the gateway when you need an explicit host label.
## Debugging on iOS node
The iOS node uses `NWBrowser` to discover `_openclaw-gw._tcp`.
To capture logs: Settings -> Gateway -> Advanced -> **Discovery Debug Logs**, then Settings -> Gateway -> Advanced -> **Discovery Logs** -> reproduce -> **Copy**. The log includes browser state transitions and result-set changes.
## When to enable Bonjour
Bonjour auto-starts for empty-config gateway startup on macOS hosts, since the local app and nearby iOS/Android nodes commonly rely on same-LAN discovery.
Enable it explicitly when same-LAN auto-discovery is useful on Linux, Windows, or another non-macOS host:
```bash
openclaw plugins enable bonjour
```
When enabled, Bonjour uses `discovery.mdns.mode` to decide how much TXT metadata to publish; the same mode controls optional TXT hints in wide-area DNS-SD records. Modes:
| Mode | Behavior |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `minimal` (default) | Core TXT keys only; omits `sshPort`, `cliPath`, `tailnetDns`. |
| `full` | Adds `sshPort`, `cliPath`, `tailnetDns` — use when clients need those hints. |
| `off` | Suppresses LAN multicast without changing plugin enablement; wide-area DNS-SD can still publish the minimal beacon when `discovery.wideArea.enabled` is true. |
## When to disable Bonjour
Leave Bonjour disabled when LAN multicast advertising is unnecessary, unavailable, or harmful — common cases are non-macOS servers, Docker bridge networking, WSL, or a network policy that drops mDNS multicast. The gateway stays reachable through its published URL, SSH, Tailnet, or wide-area DNS-SD; only LAN auto-discovery is unreliable.
Use the env override for deployment-scoped problems (safe for Docker images, service files, launch scripts, one-off debugging — it disappears when the environment does):
```bash
OPENCLAW_DISABLE_BONJOUR=1
```
Use plugin configuration when you intentionally want to turn off the bundled LAN discovery plugin for that OpenClaw config:
```bash
openclaw plugins disable bonjour
```
## Docker gotchas
The bundled Bonjour plugin auto-disables LAN multicast advertising in detected containers when `OPENCLAW_DISABLE_BONJOUR` is unset. Docker bridge networks usually don't forward mDNS multicast (`224.0.0.251:5353`) between the container and the LAN, so advertising from the container rarely makes discovery work.
Gotchas:
- Bonjour auto-starts on macOS hosts and is opt-in elsewhere. Leaving it disabled doesn't stop the gateway — it only skips LAN multicast advertising.
- Disabling Bonjour doesn't change `gateway.bind`; Docker still defaults to `OPENCLAW_GATEWAY_BIND=lan` so the published host port works.
- Disabling Bonjour doesn't disable wide-area DNS-SD. Use wide-area discovery or Tailnet when the gateway and node aren't on the same LAN.
- Reusing the same `OPENCLAW_CONFIG_DIR` outside Docker doesn't persist the container auto-disable policy.
- Set `OPENCLAW_DISABLE_BONJOUR=0` only for host networking, macvlan, or another network where mDNS multicast is known to pass; set it to `1` to force-disable.
## Troubleshooting disabled Bonjour
If a node no longer auto-discovers the gateway after Docker setup:
1. Confirm whether the gateway is running in auto, forced-on, or forced-off mode:
```bash
docker compose config | grep OPENCLAW_DISABLE_BONJOUR
```
2. Confirm the gateway itself is reachable through the published port:
```bash
curl -fsS http://127.0.0.1:18789/healthz
```
3. Use a direct target when Bonjour is disabled:
- Control UI or local tools: `http://127.0.0.1:18789`
- LAN clients: `http://<gateway-host>:18789`
- Cross-network clients: Tailnet MagicDNS, Tailnet IP, SSH tunnel, or wide-area DNS-SD
4. If you deliberately enabled the Bonjour plugin in Docker and forced advertising with `OPENCLAW_DISABLE_BONJOUR=0`, test multicast from the host:
```bash
dns-sd -B _openclaw-gw._tcp local.
```
If browsing is empty, or Gateway logs show repeated ciao watchdog cancellations, restore `OPENCLAW_DISABLE_BONJOUR=1` and use a direct or Tailnet route.
## Common failure modes
- **Bonjour doesn't cross networks**: use Tailnet or SSH.
- **Multicast blocked**: some Wi-Fi networks disable mDNS.
- **Advertiser stuck in probing/announcing**: hosts with blocked multicast, container bridges, WSL, or interface churn can leave the ciao advertiser in a non-announced state. OpenClaw retries a few times, then disables Bonjour for the current gateway process instead of restarting the advertiser forever.
- **Docker bridge networking**: Bonjour auto-disables in detected containers. Set `OPENCLAW_DISABLE_BONJOUR=0` only for host, macvlan, or another mDNS-capable network.
- **Sleep/interface churn**: macOS may temporarily drop mDNS results; retry.
- **Browse works but resolve fails**: keep machine names simple (avoid emojis or punctuation), then restart the gateway. The service instance name derives from the host name, so overly complex names can confuse some resolvers.
## Escaped instance names (`\032`)
Bonjour/DNS-SD often escapes bytes in service instance names as decimal `\DDD` sequences (spaces become `\032`). This is normal at the protocol level; UIs should decode for display (iOS uses `BonjourEscapes.decode`).
## Enabling / disabling / configuration
| Setting | Effect |
| ---------------------------------------------------- | --------------------------------------------------------------------------------- |
| `openclaw plugins enable bonjour` | Enables the bundled LAN discovery plugin on hosts where it isn't default-enabled. |
| `openclaw plugins disable bonjour` | Disables LAN multicast advertising by disabling the bundled plugin. |
| `OPENCLAW_DISABLE_BONJOUR=1` (or `true`/`yes`/`on`) | Disables LAN multicast advertising without changing plugin config. |
| `OPENCLAW_DISABLE_BONJOUR=0` (or `false`/`no`/`off`) | Forces LAN multicast advertising on, including inside detected containers. |
| `discovery.mdns.mode` | `off` \| `minimal` (default) \| `full` — see modes above. |
| `gateway.bind` | Controls the gateway bind mode in `~/.openclaw/openclaw.json`. |
| `OPENCLAW_SSH_PORT` | Overrides the SSH port when `sshPort` is advertised (full mode). |
| `OPENCLAW_TAILNET_DNS` | Publishes a MagicDNS hint in TXT when mDNS full mode is enabled. |
| `OPENCLAW_CLI_PATH` | Overrides the advertised CLI path (full mode). |
macOS hosts auto-start the bundled LAN discovery plugin by default. When the Bonjour plugin is enabled and `OPENCLAW_DISABLE_BONJOUR` is unset, Bonjour advertises on normal hosts and auto-disables inside detected containers (Docker, Fly.io machines, and common container runtimes).
## Related docs
- Discovery policy and transport selection: [Discovery](/gateway/discovery)
- Node pairing + approvals: [Gateway pairing](/gateway/pairing)

View File

@@ -0,0 +1,79 @@
---
summary: "Historical bridge protocol (legacy nodes): TCP JSONL, pairing, scoped RPC"
read_when:
- Investigating old node client code or archived pairing logs
- Auditing what the legacy node surface used to expose
title: "Bridge protocol"
---
<Warning>
The TCP bridge has been **removed**. Current OpenClaw builds do not ship the bridge listener, and `bridge.*` config keys are no longer in the schema. This page is historical reference only. Use the [Gateway protocol](/gateway/protocol) for all node/operator clients.
</Warning>
## Why it existed
- **Security boundary**: exposed a small allowlist instead of the full gateway API surface.
- **Pairing + node identity**: node admission was owned by the gateway and tied to a per-node token.
- **Discovery UX**: nodes could discover gateways via Bonjour on LAN, or connect directly over a tailnet.
- **Loopback WS**: the full WS control plane stayed local unless tunneled via SSH.
## Transport
- TCP, one JSON object per line (JSONL).
- Optional TLS (`bridge.tls.enabled: true`).
- Default listener port was `18790`.
When TLS was enabled, discovery TXT records included `bridgeTls=1` plus `bridgeTlsSha256` as a non-secret hint. Bonjour/mDNS TXT records are unauthenticated; clients could not treat the advertised fingerprint as an authoritative pin without other out-of-band verification.
## Handshake and pairing
1. Client sends `hello` with node metadata plus token (if already paired).
2. If not paired, gateway replies `error` (`NOT_PAIRED` / `UNAUTHORIZED`).
3. Client sends `pair-request`.
4. Gateway waits for approval, then sends `pair-ok` and `hello-ok`.
`hello-ok` used to return `serverName`; hosted plugin surfaces are now advertised through `pluginSurfaceUrls` on the current Gateway protocol (Canvas/A2UI uses `pluginSurfaceUrls.canvas`).
## Frames
Client to gateway:
- `req` / `res`: scoped gateway RPC (chat, sessions, config, health, voicewake, skills.bins).
- `event`: node signals (voice transcript, agent request, chat subscribe, exec lifecycle).
Gateway to client:
- `invoke` / `invoke-res`: node commands (`canvas.*`, `camera.*`, `screen.record`, `location.get`, `sms.send`).
- `event`: chat updates for subscribed sessions.
- `ping` / `pong`: keepalive.
Allowlist enforcement lived in `src/gateway/server-bridge.ts` (removed).
## Exec lifecycle events
Nodes emitted `exec.finished` to surface completed `system.run` activity, mapped to system events by the gateway (legacy nodes could also emit `exec.started`). `exec.denied` marked a denied `system.run` attempt as a terminal denial without enqueuing a system event or waking agent work.
Payload fields (all optional unless noted):
| Field | Notes |
| -------------------------------- | ---------------------------------------------------------------------------------------------- |
| `sessionKey` | Required. Agent session for event correlation and, for `exec.finished`, system event delivery. |
| `runId` | Unique exec id for grouping. |
| `command` | Raw or formatted command string. |
| `exitCode`, `timedOut`, `output` | Completion details (finished only). |
| `reason` | Denial reason (denied only). |
## Historical tailnet usage
- Bind the bridge to a tailnet IP: `bridge.bind: "tailnet"` in `~/.openclaw/openclaw.json` (historical only; `bridge.*` is no longer valid config).
- Clients connected via MagicDNS name or tailnet IP.
- Bonjour does not cross networks; wide-area DNS-SD or a manual host/port was required otherwise.
## Versioning
The bridge was implicit v1, with no min/max negotiation. Current node/operator clients use the WebSocket [Gateway protocol](/gateway/protocol), which does negotiate a protocol version range.
## Related
- [Gateway protocol](/gateway/protocol)
- [Nodes](/nodes)

View File

@@ -0,0 +1,313 @@
---
summary: "CLI backends: local AI CLI fallback with optional MCP tool bridge"
read_when:
- You want a reliable fallback when API providers fail
- You are running local AI CLIs and want to reuse them
- You want to understand the MCP loopback bridge for CLI backend tool access
title: "CLI backends"
---
OpenClaw can run a local AI CLI as a text-only fallback when API providers are down, rate-limited, or misbehaving. It is intentionally conservative:
- OpenClaw tools are not injected directly, but a backend with `bundleMcp: true` can receive gateway tools through a loopback MCP bridge.
- JSONL streaming for CLIs that support it.
- Sessions are supported, so follow-up turns stay coherent.
- Images pass through if the CLI accepts image paths.
Use it as a safety net for "always works" text responses, not a primary path. For a full harness runtime with ACP session controls, background tasks, thread/conversation binding, and persistent external coding sessions, use [ACP Agents](/tools/acp-agents) instead; CLI backends are not ACP.
<Tip>
Building a new backend plugin? See [CLI backend plugins](/plugins/cli-backend-plugins). This page covers configuring and operating an already-registered backend.
</Tip>
## Quick start
The bundled Anthropic plugin registers a default `claude-cli` backend, so it works with no config beyond having Claude Code installed and logged in:
```bash
openclaw agent --agent main --message "hi" --model claude-cli/claude-sonnet-4-6
```
`main` is the default agent id when no explicit agent list is configured; swap in your own agent id otherwise.
If the gateway runs under launchd/systemd with a minimal `PATH`, point at the binary explicitly:
```json5
{
agents: {
defaults: {
cliBackends: {
"claude-cli": {
command: "/opt/homebrew/bin/claude",
},
},
},
},
}
```
If you use a bundled CLI backend as the primary message provider on a gateway host, OpenClaw auto-loads the owning bundled plugin when your config references that backend in a model ref or under `agents.defaults.cliBackends`.
## Using it as a fallback
Add the CLI backend to your fallback list so it only runs when primary models fail:
```json5
{
agents: {
defaults: {
model: {
primary: "anthropic/claude-opus-4-6",
fallbacks: ["claude-cli/claude-sonnet-4-6"],
},
models: {
"anthropic/claude-opus-4-6": { alias: "Opus" },
"claude-cli/claude-sonnet-4-6": {},
},
},
},
}
```
If you use `agents.defaults.models` as an allowlist, include your CLI backend models there too. When the primary provider fails (auth, rate limits, timeouts), OpenClaw tries the CLI backend next.
## Configuration
All CLI backends live under `agents.defaults.cliBackends`, keyed by provider id (e.g. `claude-cli`, `my-cli`). The provider id becomes the left side of the model ref: `<provider>/<model>`.
```json5
{
agents: {
defaults: {
cliBackends: {
"my-cli": {
command: "my-cli",
args: ["--json"],
output: "json",
input: "arg",
modelArg: "--model",
modelAliases: {
"claude-opus-4-6": "opus",
"claude-sonnet-4-6": "sonnet",
},
sessionArg: "--session",
sessionMode: "existing",
sessionIdFields: ["session_id", "conversation_id"],
systemPromptArg: "--system",
// Dedicated prompt-file flag:
// systemPromptFileArg: "--system-file",
// Codex-style config-override flag instead:
// systemPromptFileConfigArg: "-c",
// systemPromptFileConfigKey: "model_instructions_file",
systemPromptWhen: "first",
imageArg: "--image",
imageMode: "repeat",
// Opt in only if this backend may reseed invalidated sessions from
// bounded raw OpenClaw transcript history before compaction.
reseedFromRawTranscriptWhenUncompacted: true,
serialize: true,
},
},
},
},
}
```
## How it works
1. Selects a backend by provider prefix (`claude-cli/...`).
2. Builds a system prompt using the same OpenClaw prompt and workspace context.
3. Executes the CLI with a session id (if supported) so history stays consistent. The bundled `claude-cli` backend keeps a Claude stdio process alive per OpenClaw session and sends follow-up turns over stream-json stdin.
4. Parses output (JSON or plain text) and returns the final text.
5. Persists session ids per backend so follow-ups reuse the same CLI session.
### Claude CLI specifics
The bundled `claude-cli` backend prefers Claude Code's native skill resolver. When the current skills snapshot has at least one selected skill with a materialized path, OpenClaw passes a temporary Claude Code plugin via `--plugin-dir` and omits the duplicate OpenClaw skills catalog from the appended system prompt. Without a materialized plugin skill, OpenClaw keeps the prompt catalog as a fallback. Skill env/API key overrides still apply to the child process environment for the run.
Claude CLI has its own noninteractive permission mode; OpenClaw maps that to the existing exec policy instead of adding Claude-specific config. For OpenClaw-managed Claude live sessions, the effective exec policy is authoritative: YOLO (`tools.exec.security: "full"` and `tools.exec.ask: "off"`) launches Claude with `--permission-mode bypassPermissions`, while a restrictive policy launches it with `--permission-mode default`. Per-agent `agents.list[].tools.exec` settings override the global `tools.exec` for that agent. Raw backend args may still include `--permission-mode`, but live Claude launches normalize that flag to match the effective policy.
The backend also maps OpenClaw `/think` levels to Claude Code's native `--effort` flag: `minimal`/`low` -> `low`, `adaptive`/`medium` -> `medium`, and `high`/`xhigh`/`max` pass through directly. Other CLI backends need their owning plugin to declare an equivalent argv mapper before `/think` affects the spawned CLI.
Before OpenClaw can use `claude-cli`, Claude Code itself must be logged in on the same host:
```bash
claude auth login
claude auth status --text
openclaw models auth login --provider anthropic --method cli --set-default
```
Docker installs need Claude Code installed and logged in inside the persisted container home, not only on the host; see [Claude CLI backend in Docker](/install/docker#claude-cli-backend-in-docker).
Set `agents.defaults.cliBackends.claude-cli.command` only when the `claude` binary is not already on `PATH`.
## Sessions
- If the CLI supports sessions, set `sessionArg` (e.g. `--session-id`), or `sessionArgs` (placeholder `{sessionId}`) when the id needs to land in multiple flags.
- If the CLI uses a resume subcommand with different flags, set `resumeArgs` (replaces `args` when resuming) and optionally `resumeOutput` for non-JSON resumes.
- `sessionMode`:
- `always`: always send a session id (new UUID if none stored).
- `existing`: only send a session id if one was stored before.
- `none`: never send a session id.
- `claude-cli` defaults to `liveSession: "claude-stdio"`, `output: "jsonl"`, and `input: "stdin"`, so follow-up turns reuse the live Claude process while it is active, including for custom configs that omit transport fields. If the gateway restarts or the idle process exits, OpenClaw resumes from the stored Claude session id. Stored session ids are verified against a readable project transcript before resume; a missing transcript clears the binding (logged as `reason=transcript-missing`) instead of silently starting a fresh session under `--resume`.
- Claude live sessions keep bounded JSONL output guards: 8 MiB and 20,000 raw JSONL lines per turn by default. Raise them per backend with `agents.defaults.cliBackends.claude-cli.reliability.outputLimits.maxTurnRawChars` and `maxTurnLines`; OpenClaw clamps those settings to 64 MiB and 100,000 lines.
- Stored CLI sessions are provider-owned continuity. The implicit daily session reset does not cut them; `/reset` and explicit `session.reset` policies still do.
- Fresh CLI sessions normally reseed only from OpenClaw's compaction summary plus the post-compaction tail. To recover short sessions invalidated before compaction, a backend can opt in with `reseedFromRawTranscriptWhenUncompacted: true`. Raw transcript reseed stays bounded and limited to safe invalidations, such as a missing CLI transcript, an orphaned tool-use tail, message-policy/system-prompt/cwd/MCP changes, or a session-expired retry; auth profile or credential-epoch changes never reseed raw transcript history.
Serialization: `serialize: true` keeps same-lane runs ordered (most CLIs serialize on one provider lane). OpenClaw also drops stored CLI session reuse when the selected auth identity changes, including a changed auth profile id, static API key, static token, or OAuth account identity when the CLI exposes one; OAuth access/refresh token rotation alone does not cut the session. If a CLI has no stable OAuth account id, OpenClaw lets that CLI enforce its own resume permissions.
## Fallback prelude from claude-cli sessions
When a `claude-cli` attempt fails over to a non-CLI candidate in [`agents.defaults.model.fallbacks`](/concepts/model-failover), OpenClaw seeds the next attempt with a context prelude harvested from Claude Code's local JSONL transcript (under `~/.claude/projects/`, keyed per workspace). Without this seed the fallback provider starts cold, since OpenClaw's own session transcript is empty for `claude-cli` runs.
- The prelude prefers the latest `/compact` summary or `compact_boundary` marker, then appends the most recent post-boundary turns up to a char budget. Pre-boundary turns are dropped because the summary already represents them.
- Tool blocks are coalesced to compact `(tool call: name)` and `(tool result: …)` hints to keep the prompt budget honest; an oversized summary is truncated and labeled `(truncated)`.
- Same-provider `claude-cli` to `claude-cli` fallbacks rely on Claude's own `--resume` and skip the prelude.
- The seed reuses the existing Claude session-file path validation, so arbitrary paths cannot be read.
## Images
If your CLI accepts image paths, set `imageArg`:
```json5
imageArg: "--image",
imageMode: "repeat"
```
OpenClaw writes base64 images to temp files. If `imageArg` is set, those paths are passed as CLI args; if not, OpenClaw appends the file paths to the prompt (path injection), which works for CLIs that auto-load local files from plain paths.
## Inputs and outputs
- `output: "text"` (default) treats stdout as the final response.
- `output: "json"` tries to parse JSON and extract text plus a session id.
- `output: "jsonl"` parses a JSONL stream and extracts the final agent message plus session identifiers when present.
- For Gemini CLI JSON output, OpenClaw reads reply text from `response` and usage from `stats` when `usage` is missing or empty. The bundled Gemini CLI default uses `stream-json`; old `--output-format json` overrides still use the JSON parser.
Input modes:
- `input: "arg"` (default) passes the prompt as the last CLI arg.
- `input: "stdin"` sends the prompt via stdin.
- If the prompt is very long and `maxPromptArgChars` is set, stdin is used instead.
## Plugin-owned defaults
CLI backend defaults are part of the plugin surface:
- Plugins register them with `api.registerCliBackend(...)`.
- The backend `id` becomes the provider prefix in model refs.
- User config in `agents.defaults.cliBackends.<id>` still overrides the plugin default.
- Backend-specific config cleanup stays plugin-owned through the optional `normalizeConfig` hook.
Anthropic owns `claude-cli` and Google owns `google-gemini-cli`. OpenAI Codex agent runs use the Codex app-server harness through `openai/*`; OpenClaw no longer registers a bundled `codex-cli` backend.
The bundled Anthropic plugin registers for `claude-cli`:
| Key | Value |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `command` | `claude` |
| `args` | `-p --output-format stream-json --include-partial-messages --verbose --setting-sources user --allowedTools mcp__openclaw__* --disallowedTools ScheduleWakeup,CronCreate,Bash(run_in_background:true),Monitor` |
| `output` | `jsonl` |
| `input` | `stdin` |
| `modelArg` | `--model` |
| `sessionArg` | `--session-id` |
| `sessionMode` | `always` |
| `imageArg` | `@` |
| `imagePathScope` | `workspace` |
| `systemPromptFileArg` | `--append-system-prompt-file` |
| `systemPromptMode` | `append` |
The bundled Google plugin registers for `google-gemini-cli`:
| Key | Value |
| ------------------------- | -------------------------------------------------------------------------------------- |
| `command` | `gemini` |
| `args` | `--skip-trust --approval-mode auto_edit --output-format stream-json --prompt {prompt}` |
| `resumeArgs` | same, with `--resume {sessionId}` |
| `output` / `resumeOutput` | `jsonl` |
| `jsonlDialect` | `gemini-stream-json` |
| `imageArg` | `@` |
| `imagePathScope` | `workspace` |
| `modelArg` | `--model` |
| `sessionMode` | `existing` |
| `sessionIdFields` | `["session_id", "sessionId"]` |
Prerequisite: the local Gemini CLI must be installed and on `PATH` as `gemini` (`brew install gemini-cli` or `npm install -g @google/gemini-cli`).
Gemini CLI output notes:
- The default `stream-json` parser reads assistant `message` events, tool events, final `result` usage, and fatal Gemini error events.
- If you override Gemini args to `--output-format json`, OpenClaw normalizes that backend back to `output: "json"` and reads reply text from the JSON `response` field.
- Usage falls back to `stats` when `usage` is absent or empty; `stats.cached` normalizes into OpenClaw `cacheRead`, and if `stats.input` is missing, input tokens derive from `stats.input_tokens - stats.cached`.
Override defaults only if needed (most commonly an absolute `command` path).
## Text transform overlays
Plugins that need small prompt/message compatibility shims can declare bidirectional text transforms without replacing a provider or CLI backend:
```typescript
api.registerTextTransforms({
input: [{ from: /red basket/g, to: "blue basket" }],
output: [{ from: /blue basket/g, to: "red basket" }],
});
```
`input` rewrites the system prompt and user prompt passed to the CLI. `output` rewrites streamed assistant text and parsed final text before OpenClaw handles its own control markers and channel delivery; for provider-backed model calls it also restores string values inside structured tool-call arguments after stream repair and before tool execution. Raw provider JSON fragments are left unchanged; consumers should use the structured partial, end, or result payload.
For CLIs that emit provider-specific JSONL events, set `jsonlDialect` on that backend's config: `claude-stream-json` for Claude Code-compatible streams, `gemini-stream-json` for Gemini CLI `stream-json` events.
## Native compaction ownership
Some CLI backends run an agent that compacts its own transcript, so OpenClaw must not run its safeguard summarizer against them — doing so fights the backend's own compaction and can hard-fail the turn.
`claude-cli` has no harness endpoint (Claude Code compacts internally), so it declares `ownsNativeCompaction: true` and OpenClaw's compaction path returns the session entry unchanged. Native-harness sessions such as Codex keep routing to their harness compaction endpoint instead.
```typescript
api.registerCliBackend({ id: "my-cli", ownsNativeCompaction: true /* ... */ });
```
Only declare `ownsNativeCompaction` for a backend that genuinely owns compaction: it must reliably bound its own transcript near the context window and persist a resumable session (e.g. `--resume` / `--session-id`), or a deferred session can stay over budget.
## Bundle MCP overlays
CLI backends do not receive OpenClaw tool calls directly, but a backend can opt into a generated MCP config overlay with `bundleMcp: true`. Current bundled behavior:
- `claude-cli`: generated strict MCP config file.
- `google-gemini-cli`: generated Gemini system settings file.
When bundle MCP is enabled, OpenClaw:
- spawns a loopback HTTP MCP server that exposes gateway tools to the CLI process, authenticated with a per-session token (`OPENCLAW_MCP_TOKEN`);
- scopes tool access to the current session, account, and channel context;
- loads enabled bundle-MCP servers for the current workspace and merges them with any existing backend MCP config/settings shape;
- rewrites the launch config using the backend-owned integration mode from the owning plugin.
If no MCP servers are enabled, OpenClaw still injects a strict config when a backend opts into bundle MCP, so background runs stay isolated.
Session-scoped bundled MCP runtimes are cached for reuse within a session, then reaped after `mcp.sessionIdleTtlMs` milliseconds of idle time (default 10 minutes; set `0` to disable). One-shot embedded runs such as auth probes, slug generation, and active-memory recall request cleanup at run end so stdio children and Streamable HTTP/SSE streams do not outlive the run.
## Reseed history cap
When a fresh CLI session is seeded from a prior OpenClaw transcript (for example after a `session_expired` retry), the rendered `<conversation_history>` block is capped to keep reseed prompts from exploding. The default is 12,288 characters (about 3,000 tokens).
Claude CLI backends scale this cap with the resolved Claude context window instead: larger context windows get a larger prior-history slice, up to a fixed ceiling; other CLI backends keep the conservative default. This cap only governs the reseed prompt's prior-history block — live-session output limits are tuned separately under `reliability.outputLimits` (see [Sessions](#sessions)).
## Limitations
- No direct OpenClaw tool calls: OpenClaw does not inject tool calls into the CLI backend protocol. Backends only see gateway tools when they opt into `bundleMcp: true`.
- Streaming is backend-specific: some backends stream JSONL, others buffer until exit.
- Structured outputs depend on the CLI's own JSON format.
## Troubleshooting
| Symptom | Fix |
| --------------------- | ----------------------------------------------------------------- |
| CLI not found | Set `command` to a full path. |
| Wrong model name | Use `modelAliases` to map `provider/model` to the CLI's model id. |
| No session continuity | Ensure `sessionArg` is set and `sessionMode` is not `none`. |
| Images ignored | Set `imageArg` and verify the CLI supports file paths. |
## Related
- [Gateway runbook](/gateway)
- [Local models](/gateway/local-models)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,963 @@
---
summary: "Channel configuration: access control, pairing, per-channel keys across Slack, Discord, Telegram, WhatsApp, Matrix, iMessage, and more"
read_when:
- Configuring a channel plugin (auth, access control, multi-account)
- Troubleshooting per-channel config keys
- Auditing DM policy, group policy, or mention gating
title: "Configuration — channels"
---
Per-channel configuration keys under `channels.*`: DM and group access, multi-account setups, mention gating, and per-channel keys for Slack, Discord, Telegram, WhatsApp, Matrix, iMessage, and other channel plugins.
For agents, tools, gateway runtime, and other top-level keys, see [Configuration reference](/gateway/configuration-reference).
## Channels
Each channel starts automatically when its config section exists (unless `enabled: false`). Telegram and iMessage ship inside the core `openclaw` package. Other official channels (Discord, Slack, WhatsApp, Matrix, Microsoft Teams, IRC, Google Chat, Signal, Mattermost, and more) install as separate plugins with `openclaw plugins install <spec>`; see [Channels](/channels) for the full list and install specs.
### DM and group access
All channels support DM policies and group policies:
| DM policy | Behavior |
| ------------------- | --------------------------------------------------------------- |
| `pairing` (default) | Unknown senders get a one-time pairing code; owner must approve |
| `allowlist` | Only senders in `allowFrom` (or paired allow store) |
| `open` | Allow all inbound DMs (requires `allowFrom: ["*"]`) |
| `disabled` | Ignore all inbound DMs |
| Group policy | Behavior |
| --------------------- | ------------------------------------------------------ |
| `allowlist` (default) | Only groups matching the configured allowlist |
| `open` | Bypass group allowlists (mention-gating still applies) |
| `disabled` | Block all group/room messages |
<Note>
`channels.defaults.groupPolicy` sets the default when a provider's `groupPolicy` is unset.
Pairing codes expire after 1 hour. Pending pairing requests are capped at **3 per account** (scoped by channel and account id).
If a provider block is missing entirely (`channels.<provider>` absent), runtime group policy falls back to `allowlist` (fail-closed) with a startup warning.
</Note>
### Channel model overrides
Use `channels.modelByChannel` to pin specific channel IDs or direct-message peers to a model. Values accept `provider/model` or configured model aliases. The channel mapping only applies when a session does not already have an active model override (for example, one set via `/model`).
For group/thread conversations, keys are channel-specific group IDs, topic IDs, or channel names. For direct-message (DM) conversations, keys are peer identifiers derived from the channel's sender identity (`nativeDirectUserId`, `origin.from`, `origin.to`, `OriginatingTo`, `From`, or `SenderId`). The exact key form depends on the channel:
| Channel | DM key form | Example |
| -------- | ------------------- | -------------------------------------------- |
| Discord | raw user ID | `987654321` |
| Feishu | `feishu:ou_...` | `feishu:ou_a8b6cab7e945387de5f253775d9b4d85` |
| Matrix | Matrix user ID | `@user:matrix.org` |
| Slack | `user:U...` | `user:U12345` |
| Telegram | raw user ID | `123456789` |
| WhatsApp | phone number or JID | `15551234567` |
```json5
{
channels: {
modelByChannel: {
discord: {
"123456789012345678": "anthropic/claude-opus-4-6",
},
slack: {
C1234567890: "openai/gpt-5.5",
"user:U12345": "openai/gpt-5.4-mini",
},
telegram: {
"-1001234567890": "openai/gpt-5.4-mini",
"-1001234567890:topic:99": "anthropic/claude-sonnet-4-6",
"123456789": "openai/gpt-4.1",
},
},
},
}
```
DM-specific keys only match in direct-message conversations; they do not affect group/thread routing.
### Channel defaults and heartbeat
Use `channels.defaults` for shared group-policy and heartbeat behavior across providers:
```json5
{
channels: {
defaults: {
groupPolicy: "allowlist", // open | allowlist | disabled
contextVisibility: "all", // all | allowlist | allowlist_quote
heartbeat: {
showOk: false,
showAlerts: true,
useIndicator: true,
},
},
},
}
```
- `channels.defaults.groupPolicy`: fallback group policy when a provider-level `groupPolicy` is unset.
- `channels.defaults.contextVisibility`: default supplemental context visibility mode for all channels. Values: `all` (default, include all quoted/thread/history context), `allowlist` (only include context from allowlisted senders), `allowlist_quote` (same as allowlist but keep explicit quote/reply context). Per-channel override: `channels.<channel>.contextVisibility`.
- `channels.defaults.heartbeat.showOk`: include healthy channel statuses in heartbeat output (default `false`).
- `channels.defaults.heartbeat.showAlerts`: include degraded/error statuses in heartbeat output (default `true`).
- `channels.defaults.heartbeat.useIndicator`: render compact indicator-style heartbeat output (default `true`).
### WhatsApp
WhatsApp runs through the gateway's web channel (Baileys Web). It starts automatically when a linked session exists.
```json5
{
web: {
enabled: true,
heartbeatSeconds: 60,
whatsapp: {
keepAliveIntervalMs: 25000,
connectTimeoutMs: 60000,
defaultQueryTimeoutMs: 60000,
},
reconnect: {
initialMs: 2000,
maxMs: 30000,
factor: 1.8,
jitter: 0.25,
maxAttempts: 12, // 0 = retry forever
},
},
channels: {
whatsapp: {
dmPolicy: "pairing", // pairing | allowlist | open | disabled
allowFrom: ["+15555550123", "+447700900123"],
textChunkLimit: 4000,
chunkMode: "length", // length | newline
mediaMaxMb: 50,
sendReadReceipts: true, // blue ticks (false in self-chat mode)
groups: {
"*": { requireMention: true },
},
groupPolicy: "allowlist",
groupAllowFrom: ["+15551234567"],
},
},
}
```
- `web.whatsapp.keepAliveIntervalMs` (default `25000`), `connectTimeoutMs` (default `60000`), and `defaultQueryTimeoutMs` (default `60000`) tune the Baileys socket.
- `web.reconnect` defaults: `initialMs: 2000`, `maxMs: 30000`, `factor: 1.8`, `jitter: 0.25`, `maxAttempts: 12`. `maxAttempts: 0` retries forever instead of giving up.
- Top-level `bindings[]` entries with `type: "acp"` configure persistent ACP bindings for WhatsApp DMs and groups. Use an E.164 direct number or WhatsApp group JID in `match.peer.id`. Field semantics are shared in [ACP Agents](/tools/acp-agents#persistent-channel-bindings).
<Accordion title="Multi-account WhatsApp">
```json5
{
channels: {
whatsapp: {
accounts: {
default: {},
personal: {},
biz: {
// authDir: "~/.openclaw/credentials/whatsapp/biz",
},
},
},
},
}
```
- Outbound commands default to account `default` if present; otherwise the first configured account id (sorted).
- Optional `channels.whatsapp.defaultAccount` overrides that fallback default account selection when it matches a configured account id.
- Legacy single-account Baileys auth dir is migrated by `openclaw doctor` into `whatsapp/default`.
- Per-account overrides: `channels.whatsapp.accounts.<id>.sendReadReceipts`, `channels.whatsapp.accounts.<id>.dmPolicy`, `channels.whatsapp.accounts.<id>.allowFrom`.
</Accordion>
### Telegram
```json5
{
channels: {
telegram: {
enabled: true,
botToken: "your-bot-token",
dmPolicy: "pairing",
allowFrom: ["tg:123456789"],
groups: {
"*": { requireMention: true },
"-1001234567890": {
allowFrom: ["@admin"],
systemPrompt: "Keep answers brief.",
topics: {
"99": {
requireMention: false,
skills: ["search"],
systemPrompt: "Stay on topic.",
},
},
},
},
customCommands: [
{ command: "backup", description: "Git backup" },
{ command: "generate", description: "Create an image" },
],
historyLimit: 50,
replyToMode: "first", // off | first | all | batched
linkPreview: true,
streaming: "partial", // off | partial | block | progress (default: partial)
actions: { reactions: true, sendMessage: true },
reactionNotifications: "own", // off | own | all
mediaMaxMb: 100,
retry: {
attempts: 3,
minDelayMs: 400,
maxDelayMs: 30000,
jitter: 0.1,
},
network: {
autoSelectFamily: true,
dnsResultOrder: "ipv4first",
},
apiRoot: "https://api.telegram.org",
trustedLocalFileRoots: ["/srv/telegram-bot-api-data"],
proxy: "socks5://localhost:9050",
webhookUrl: "https://example.com/telegram-webhook",
webhookSecret: "secret",
webhookPath: "/telegram-webhook",
},
},
}
```
- Bot token: `channels.telegram.botToken` or `channels.telegram.tokenFile` (regular file only; symlinks rejected), with `TELEGRAM_BOT_TOKEN` as fallback for the default account.
- `apiRoot` is the Telegram Bot API root only. Use `https://api.telegram.org` or your self-hosted/proxy root, not `https://api.telegram.org/bot<TOKEN>`; `openclaw doctor --fix` removes an accidental trailing `/bot<TOKEN>` suffix.
- For a self-hosted Bot API server in `--local` mode, `trustedLocalFileRoots` lists host paths OpenClaw may read. Mount the server data volume on the OpenClaw host and configure either its data root or per-token directory; container paths under `/var/lib/telegram-bot-api` are mapped into those roots. Other absolute paths remain rejected.
- Optional `channels.telegram.defaultAccount` overrides default account selection when it matches a configured account id.
- In multi-account setups (2+ account ids), set an explicit default (`channels.telegram.defaultAccount` or `channels.telegram.accounts.default`) to avoid fallback routing; `openclaw doctor` warns when this is missing or invalid.
- `configWrites: false` blocks Telegram-initiated config writes (supergroup ID migrations, `/config set|unset`).
- Top-level `bindings[]` entries with `type: "acp"` configure persistent ACP bindings for forum topics (use canonical `chatId:topic:topicId` in `match.peer.id`). Field semantics are shared in [ACP Agents](/tools/acp-agents#persistent-channel-bindings).
- Telegram stream previews use `sendMessage` + `editMessageText` (works in direct and group chats).
- `network.dnsResultOrder` defaults to `"ipv4first"` to avoid common IPv6 fetch failures.
- Retry policy: see [Retry policy](/concepts/retry).
### Discord
```json5
{
channels: {
discord: {
enabled: true,
token: "your-bot-token",
mediaMaxMb: 100,
allowBots: false,
actions: {
reactions: true,
stickers: true,
polls: true,
permissions: true,
messages: true,
threads: true,
pins: true,
search: true,
memberInfo: true,
roleInfo: true,
roles: false,
channelInfo: true,
voiceStatus: true,
events: true,
moderation: false,
},
replyToMode: "off", // off | first | all | batched
dmPolicy: "pairing",
allowFrom: ["1234567890", "123456789012345678"],
dm: { enabled: true, groupEnabled: false, groupChannels: ["openclaw-dm"] },
guilds: {
"123456789012345678": {
slug: "friends-of-openclaw",
requireMention: false,
ignoreOtherMentions: true,
reactionNotifications: "own",
users: ["987654321098765432"],
channels: {
general: { allow: true },
help: {
allow: true,
requireMention: true,
users: ["987654321098765432"],
skills: ["docs"],
systemPrompt: "Short answers only.",
},
},
},
},
historyLimit: 20,
textChunkLimit: 2000,
suppressEmbeds: true,
chunkMode: "length", // length | newline
streaming: {
mode: "progress", // off | partial | block | progress (Discord default: progress)
progress: {
label: "auto",
maxLines: 8,
maxLineChars: 120,
toolProgress: true,
},
},
maxLinesPerMessage: 17,
ui: {
components: {
accentColor: "#5865F2",
},
},
threadBindings: {
enabled: true,
idleHours: 24,
maxAgeHours: 0,
spawnSessions: true,
defaultSpawnContext: "fork",
},
voice: {
enabled: true,
autoJoin: [
{
guildId: "123456789012345678",
channelId: "234567890123456789",
},
],
daveEncryption: true,
decryptionFailureTolerance: 24,
connectTimeoutMs: 30000,
reconnectGraceMs: 15000,
tts: {
provider: "openai",
openai: { voice: "alloy" },
},
},
execApprovals: {
enabled: "auto", // true | false | "auto"
approvers: ["987654321098765432"],
agentFilter: ["default"],
sessionFilter: ["discord:"],
target: "dm", // dm | channel | both
cleanupAfterResolve: false,
},
retry: {
attempts: 3,
minDelayMs: 500,
maxDelayMs: 30000,
jitter: 0.1,
},
},
},
}
```
- Token: `channels.discord.token`, with `DISCORD_BOT_TOKEN` as fallback for the default account.
- Direct outbound calls that provide an explicit Discord `token` use that token for the call; account retry/policy settings still come from the selected account in the active runtime snapshot.
- Optional `channels.discord.defaultAccount` overrides default account selection when it matches a configured account id.
- Use `user:<id>` (DM) or `channel:<id>` (guild channel) for delivery targets; bare numeric IDs are rejected.
- Guild slugs are lowercase with spaces replaced by `-`; channel keys use the slugged name (no `#`). Prefer guild IDs.
- Bot-authored messages are ignored by default. `allowBots: true` enables them; use `allowBots: "mentions"` to only accept bot messages that mention the bot (own messages still filtered).
- Channels that support bot-authored inbound messages can use shared [bot loop protection](/channels/bot-loop-protection). Set `channels.defaults.botLoopProtection` for baseline pair budgets, then override the channel or account only when one surface needs different limits.
- `channels.discord.guilds.<id>.ignoreOtherMentions` (and channel overrides) drops messages that mention another user or role but not the bot (excluding @everyone/@here).
- `channels.discord.mentionAliases` maps stable outbound `@handle` text to Discord user IDs before sending, so known teammates can be mentioned deterministically even when the transient directory cache is empty. Per-account overrides live under `channels.discord.accounts.<accountId>.mentionAliases`.
- `maxLinesPerMessage` (default `17`) splits tall messages even when under 2000 chars.
- `channels.discord.suppressEmbeds` defaults to `true`, so outbound URLs do not expand into Discord link previews unless disabled. Explicit `embeds` payloads still send normally; per-message tool calls can override with `suppressEmbeds`.
- `channels.discord.threadBindings` controls Discord thread-bound routing:
- `enabled`: Discord override for thread-bound session features (`/focus`, `/unfocus`, `/agents`, `/session idle`, `/session max-age`, and bound delivery/routing)
- `idleHours`: Discord override for inactivity auto-unfocus in hours (`0` disables)
- `maxAgeHours`: Discord override for hard max age in hours (`0` disables)
- `spawnSessions`: switch for `sessions_spawn({ thread: true })` and ACP thread-spawn auto thread creation/binding (default: `true`)
- `defaultSpawnContext`: native subagent context for thread-bound spawns (`"fork"` by default)
- Top-level `bindings[]` entries with `type: "acp"` configure persistent ACP bindings for channels and threads (use channel/thread id in `match.peer.id`). Field semantics are shared in [ACP Agents](/tools/acp-agents#persistent-channel-bindings).
- `channels.discord.ui.components.accentColor` sets the accent color for Discord components v2 containers.
- `channels.discord.agentComponents.ttlMs` controls how long sent Discord component callbacks remain registered. Default `1800000` (30 minutes), maximum `86400000` (24 hours). Per-account overrides live under `channels.discord.accounts.<accountId>.agentComponents.ttlMs`. Prefer the shortest TTL that fits the workflow.
- `channels.discord.voice` enables Discord voice channel conversations and optional auto-join + LLM + TTS overrides. Text-only Discord configs leave voice off by default; set `channels.discord.voice.enabled=true` to opt in.
- `channels.discord.voice.model` optionally overrides the LLM model used for Discord voice channel responses.
- `channels.discord.voice.daveEncryption` (default `true`) and `channels.discord.voice.decryptionFailureTolerance` (default `24`) pass through to `@discordjs/voice` DAVE options.
- `channels.discord.voice.connectTimeoutMs` controls the initial `@discordjs/voice` Ready wait for `/vc join` and auto-join attempts (default `30000`).
- `channels.discord.voice.reconnectGraceMs` controls how long a disconnected voice session may take to enter reconnect signalling before OpenClaw destroys it (default `15000`).
- Discord voice playback is not interrupted by another user's speaking-start event. To avoid feedback loops, OpenClaw ignores new voice capture while TTS is playing.
- OpenClaw additionally attempts voice receive recovery by leaving/rejoining a voice session after repeated decrypt failures.
- `channels.discord.streaming` is the canonical stream mode key. Discord defaults to `streaming.mode: "progress"` so tool/work progress appears in one edited preview message; set `streaming.mode: "off"` to disable it. Legacy `streamMode` and boolean `streaming` values remain runtime aliases; run `openclaw doctor --fix` to rewrite persisted config.
- `channels.discord.autoPresence` maps runtime availability to bot presence (healthy => online, degraded => idle, exhausted => dnd) and allows optional status text overrides.
- `channels.discord.dangerouslyAllowNameMatching` re-enables mutable name/tag matching (break-glass compatibility mode).
- `channels.discord.execApprovals`: Discord-native exec approval delivery and approver authorization.
- `enabled`: `true`, `false`, or `"auto"` (default). In auto mode, exec approvals activate when approvers can be resolved from `approvers` or `commands.ownerAllowFrom`.
- `approvers`: Discord user IDs allowed to approve exec requests. Falls back to `commands.ownerAllowFrom` when omitted.
- `agentFilter`: optional agent ID allowlist. Omit to forward approvals for all agents.
- `sessionFilter`: optional session key patterns (substring or regex).
- `target`: where to send approval prompts. `"dm"` (default) sends to approver DMs, `"channel"` sends to the originating channel, `"both"` sends to both. When target includes `"channel"`, buttons are only usable by resolved approvers.
- `cleanupAfterResolve`: when `true`, deletes approval DMs after approval, denial, or timeout.
**Reaction notification modes:** `off` (none), `own` (bot's messages, default), `all` (all messages), `allowlist` (from `guilds.<id>.users` on all messages).
### Google Chat
```json5
{
channels: {
googlechat: {
enabled: true,
serviceAccountFile: "/path/to/service-account.json",
audienceType: "app-url", // app-url | project-number
audience: "https://gateway.example.com/googlechat",
webhookPath: "/googlechat",
botUser: "users/1234567890",
dm: {
enabled: true,
policy: "pairing",
allowFrom: ["users/1234567890"],
},
groupPolicy: "allowlist",
groups: {
"spaces/AAAA": { allow: true, requireMention: true },
},
actions: { reactions: true },
typingIndicator: "message",
mediaMaxMb: 20,
},
},
}
```
- Service account JSON: inline (`serviceAccount`) or file-based (`serviceAccountFile`).
- Service account SecretRef is also supported (`serviceAccountRef`).
- Env fallbacks: `GOOGLE_CHAT_SERVICE_ACCOUNT` or `GOOGLE_CHAT_SERVICE_ACCOUNT_FILE` (default account only).
- Use `spaces/<spaceId>` or `users/<userId>` for delivery targets.
- `channels.googlechat.dangerouslyAllowNameMatching` re-enables mutable email principal matching (break-glass compatibility mode).
### Slack
```json5
{
channels: {
slack: {
enabled: true,
botToken: "xoxb-...",
appToken: "xapp-...",
socketMode: {
clientPingTimeout: 15000,
serverPingTimeout: 30000,
pingPongLoggingEnabled: false,
},
dmPolicy: "pairing",
allowFrom: ["U123", "U456", "*"],
dm: { enabled: true, groupEnabled: false, groupChannels: ["G123"] },
channels: {
C123: { allow: true, requireMention: true, allowBots: false },
"#general": {
allow: true,
requireMention: true,
allowBots: false,
users: ["U123"],
skills: ["docs"],
systemPrompt: "Short answers only.",
},
},
historyLimit: 50,
allowBots: false,
reactionNotifications: "own",
reactionAllowlist: ["U123"],
replyToMode: "off", // off | first | all | batched
thread: {
historyScope: "thread", // thread | channel
inheritParent: false,
initialHistoryLimit: 20,
},
actions: {
reactions: true,
messages: true,
pins: true,
memberInfo: true,
emojiList: true,
},
slashCommand: {
enabled: true,
name: "openclaw",
sessionPrefix: "slack:slash",
ephemeral: true,
},
typingReaction: "hourglass_flowing_sand",
unfurlLinks: false,
unfurlMedia: false,
textChunkLimit: 4000,
chunkMode: "length",
streaming: {
mode: "partial", // off | partial | block | progress
nativeTransport: true, // use Slack native streaming API when mode=partial
},
mediaMaxMb: 20,
execApprovals: {
enabled: "auto", // true | false | "auto"
approvers: ["U123"],
agentFilter: ["default"],
sessionFilter: ["slack:"],
target: "dm", // dm | channel | both
},
},
},
}
```
- **Socket mode** requires both `botToken` and `appToken` (`SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` for default account env fallback).
- **HTTP mode** requires `botToken` plus `signingSecret` (at root or per-account).
- `socketMode` passes Slack SDK Socket Mode transport tuning through to the public Bolt receiver API. Use it only when investigating ping/pong timeout or stale websocket behavior. `clientPingTimeout` defaults to `15000`; `serverPingTimeout` and `pingPongLoggingEnabled` are passed only when configured.
- `botToken`, `appToken`, `signingSecret`, and `userToken` accept plaintext
strings or SecretRef objects.
- Slack account snapshots expose per-credential source/status fields such as
`botTokenSource`, `botTokenStatus`, `appTokenStatus`, and, in HTTP mode,
`signingSecretStatus`. `configured_unavailable` means the account is
configured through SecretRef but the current command/runtime path could not
resolve the secret value.
- `configWrites: false` blocks Slack-initiated config writes.
- Optional `channels.slack.defaultAccount` overrides default account selection when it matches a configured account id.
- `channels.slack.streaming.mode` is the canonical Slack stream mode key (default `"partial"`). `channels.slack.streaming.nativeTransport` controls Slack's native streaming transport (default `true`). Legacy `streamMode`, boolean `streaming`, `chunkMode`, `blockStreaming`, `blockStreamingCoalesce`, and `nativeStreaming` values remain runtime aliases; run `openclaw doctor --fix` to rewrite persisted config to `streaming.{mode,chunkMode,block.enabled,block.coalesce,nativeTransport}`.
- `unfurlLinks` and `unfurlMedia` pass Slack's `chat.postMessage` link and media unfurl booleans through for bot replies. `unfurlLinks` defaults to `false` so outbound bot links do not expand inline unless enabled; `unfurlMedia` is omitted unless configured. Set either value at `channels.slack.accounts.<accountId>` to override the top-level value for one account.
- Use `user:<id>` (DM) or `channel:<id>` for delivery targets.
**Reaction notification modes:** `off`, `own` (default), `all`, `allowlist` (from `reactionAllowlist`).
**Thread session isolation:** `thread.historyScope` is per-thread (default) or shared across channel. `thread.inheritParent` copies parent channel transcript to new threads. `thread.initialHistoryLimit` (default `20`) caps how many existing thread messages are fetched when a new thread session starts; `0` disables thread history fetching.
- Slack native streaming plus the Slack assistant-style "is typing..." thread status require a reply thread target. Top-level DMs stay off-thread by default, so they can still stream through Slack draft post-and-edit previews instead of showing the thread-style native stream/status preview.
- `typingReaction` adds a temporary reaction to the inbound Slack message while a reply is running, then removes it on completion. Use a Slack emoji shortcode such as `"hourglass_flowing_sand"`.
- `channels.slack.execApprovals`: Slack-native approval-client delivery and exec approver authorization. Same schema as Discord: `enabled` (`true`/`false`/`"auto"`), `approvers` (Slack user IDs), `agentFilter`, `sessionFilter`, and `target` (`"dm"`, `"channel"`, or `"both"`). Plugin approvals can use this native-client path for Slack-origin requests when Slack plugin approvers resolve; Slack-native plugin approval delivery can also be enabled through `approvals.plugin` for Slack-origin sessions or Slack targets. Plugin approvals use Slack plugin approvers from `allowFrom` and default routing, not exec approvers.
| Action group | Default | Notes |
| ------------ | ------- | ---------------------- |
| reactions | enabled | React + list reactions |
| messages | enabled | Read/send/edit/delete |
| pins | enabled | Pin/unpin/list |
| memberInfo | enabled | Member info |
| emojiList | enabled | Custom emoji list |
### Mattermost
Mattermost installs as a separate plugin, the same way Discord, Slack, and WhatsApp do:
```bash
openclaw plugins install @openclaw/mattermost
```
Check [npmjs.com/package/@openclaw/mattermost](https://www.npmjs.com/package/@openclaw/mattermost) for the current dist-tags before pinning a version.
```json5
{
channels: {
mattermost: {
enabled: true,
botToken: "mm-token",
baseUrl: "https://chat.example.com",
dmPolicy: "pairing",
chatmode: "oncall", // oncall | onmessage | onchar
oncharPrefixes: [">", "!"],
groups: {
"*": { requireMention: true },
"team-channel-id": { requireMention: false },
},
commands: {
native: true, // opt-in
nativeSkills: true,
callbackPath: "/api/channels/mattermost/command",
// Optional explicit URL for reverse-proxy/public deployments
callbackUrl: "https://gateway.example.com/api/channels/mattermost/command",
},
textChunkLimit: 4000,
chunkMode: "length",
},
},
}
```
Chat modes: `oncall` (respond on @-mention, default), `onmessage` (every message), `onchar` (messages starting with trigger prefix).
When Mattermost native commands are enabled:
- `commands.callbackPath` must be a path (for example `/api/channels/mattermost/command`), not a full URL.
- `commands.callbackUrl` must resolve to the OpenClaw gateway endpoint and be reachable from the Mattermost server.
- Native slash callbacks are authenticated with the per-command tokens returned
by Mattermost during slash command registration. If registration fails or no
commands are activated, OpenClaw rejects callbacks with
`Unauthorized: invalid command token.`
- For private/tailnet/internal callback hosts, Mattermost may require
`ServiceSettings.AllowedUntrustedInternalConnections` to include the callback host/domain.
Use host/domain values, not full URLs.
- `channels.mattermost.configWrites`: allow or deny Mattermost-initiated config writes.
- `channels.mattermost.requireMention`: require `@mention` before replying in channels.
- `channels.mattermost.groups.<channelId>.requireMention`: per-channel mention-gating override (`"*"` for default).
- Optional `channels.mattermost.defaultAccount` overrides default account selection when it matches a configured account id.
### Signal
```json5
{
channels: {
signal: {
enabled: true,
account: "+15555550123", // optional account binding
dmPolicy: "pairing",
allowFrom: ["+15551234567", "uuid:123e4567-e89b-12d3-a456-426614174000"],
configWrites: true,
reactionNotifications: "own", // off | own | all | allowlist
reactionAllowlist: ["+15551234567", "uuid:123e4567-e89b-12d3-a456-426614174000"],
historyLimit: 50,
},
},
}
```
**Reaction notification modes:** `off`, `own` (default), `all`, `allowlist` (from `reactionAllowlist`).
- `channels.signal.account`: pin channel startup to a specific Signal account identity.
- `channels.signal.configWrites`: allow or deny Signal-initiated config writes.
- Optional `channels.signal.defaultAccount` overrides default account selection when it matches a configured account id.
### iMessage
OpenClaw spawns `imsg rpc` (JSON-RPC over stdio). No daemon or port required. This is the preferred path for new OpenClaw iMessage setups when the host can grant Messages database and Automation permissions.
BlueBubbles support was removed. `channels.bluebubbles` is not a supported runtime config surface on current OpenClaw. Migrate old configs to `channels.imessage`; use [BlueBubbles removal and the imsg iMessage path](/announcements/bluebubbles-imessage) for the short version and [Coming from BlueBubbles](/channels/imessage-from-bluebubbles) for the full translation table.
If the Gateway is not running on the signed-in Messages Mac, keep `channels.imessage.enabled=true` and set `channels.imessage.cliPath` to an SSH wrapper that runs `imsg "$@"` on that Mac. The default local `imsg` path is macOS-only.
Before relying on an SSH wrapper for production sends, verify an outbound `imsg send` through that exact wrapper. Some macOS TCC states assign Messages Automation to `/usr/libexec/sshd-keygen-wrapper`, which can make reads and probes work while sends fail with AppleEvents `-1743`; see the SSH wrapper troubleshooting section on [iMessage](/channels/imessage).
```json5
{
channels: {
imessage: {
enabled: true,
cliPath: "imsg",
dbPath: "~/Library/Messages/chat.db",
remoteHost: "user@gateway-host",
dmPolicy: "pairing",
allowFrom: ["+15555550123", "user@example.com", "chat_id:123"],
historyLimit: 50,
includeAttachments: false,
attachmentRoots: ["/Users/*/Library/Messages/Attachments"],
remoteAttachmentRoots: ["/Users/*/Library/Messages/Attachments"],
mediaMaxMb: 16,
service: "auto",
sendTransport: "auto",
region: "US",
actions: {
reactions: true,
edit: true,
unsend: true,
reply: true,
sendWithEffect: true,
sendAttachment: true,
},
},
},
}
```
- Optional `channels.imessage.defaultAccount` overrides default account selection when it matches a configured account id.
- Requires Full Disk Access to the Messages DB.
- Prefer `chat_id:<id>` targets. Use `imsg chats --limit 20` to list chats.
- `cliPath` can point to an SSH wrapper; set `remoteHost` (`host` or `user@host`) for SCP attachment fetching.
- `attachmentRoots` and `remoteAttachmentRoots` restrict inbound attachment paths (default: `/Users/*/Library/Messages/Attachments`).
- SCP uses strict host-key checking, so ensure the relay host key already exists in `~/.ssh/known_hosts`.
- `channels.imessage.configWrites`: allow or deny iMessage-initiated config writes.
- `channels.imessage.sendTransport`: preferred `imsg` RPC send transport for normal outbound replies. `auto` (default) uses the IMCore bridge for existing chats when it is running, then falls back to AppleScript; `bridge` requires private-API delivery; `applescript` forces the public Messages automation path.
- `channels.imessage.actions.*`: enable private API actions that are also gated by `imsg status` / `openclaw channels status --probe`.
- `channels.imessage.includeAttachments` is off by default; set it to `true` before expecting inbound media in agent turns.
- Inbound recovery after a bridge/gateway restart is automatic (GUID dedupe plus a stale-backlog age fence). Existing `channels.imessage.catchup.enabled: true` configs are still honored as a deprecated compatibility profile; `catchup` is disabled by default.
- `channels.imessage.groups`: group registry and per-group settings. With `groupPolicy: "allowlist"`, configure either explicit `chat_id` keys or a `"*"` wildcard entry so group messages can pass the registry gate.
- Top-level `bindings[]` entries with `type: "acp"` can bind iMessage conversations to persistent ACP sessions. Use a normalized handle or explicit chat target (`chat_id:*`, `chat_guid:*`, `chat_identifier:*`) in `match.peer.id`. Shared field semantics: [ACP Agents](/tools/acp-agents#persistent-channel-bindings).
<Accordion title="iMessage SSH wrapper example">
```bash
#!/usr/bin/env bash
exec ssh -T gateway-host imsg "$@"
```
</Accordion>
### Matrix
Matrix is plugin-backed and configured under `channels.matrix`.
```json5
{
channels: {
matrix: {
enabled: true,
homeserver: "https://matrix.example.org",
accessToken: "syt_bot_xxx",
proxy: "http://127.0.0.1:7890",
encryption: true,
initialSyncLimit: 20,
defaultAccount: "ops",
accounts: {
ops: {
name: "Ops",
userId: "@ops:example.org",
accessToken: "syt_ops_xxx",
},
alerts: {
userId: "@alerts:example.org",
password: "secret",
proxy: "http://127.0.0.1:7891",
},
},
},
},
}
```
- Token auth uses `accessToken`; password auth uses `userId` + `password`.
- `channels.matrix.proxy` routes Matrix HTTP traffic through an explicit HTTP(S) proxy. Named accounts can override it with `channels.matrix.accounts.<id>.proxy`.
- `channels.matrix.network.dangerouslyAllowPrivateNetwork` allows private/internal homeservers. `proxy` and this network opt-in are independent controls.
- `channels.matrix.defaultAccount` selects the preferred account in multi-account setups.
- `channels.matrix.autoJoin` defaults to `"off"`, so invited rooms and fresh DM-style invites are ignored until you set `autoJoin: "allowlist"` with `autoJoinAllowlist` or `autoJoin: "always"`.
- `channels.matrix.execApprovals`: Matrix-native exec approval delivery and approver authorization.
- `enabled`: `true`, `false`, or `"auto"` (default). In auto mode, exec approvals activate when approvers can be resolved from `approvers` or `commands.ownerAllowFrom`.
- `approvers`: Matrix user IDs (e.g. `@owner:example.org`) allowed to approve exec requests.
- `agentFilter`: optional agent ID allowlist. Omit to forward approvals for all agents.
- `sessionFilter`: optional session key patterns (substring or regex).
- `target`: where to send approval prompts. `"dm"` (default), `"channel"` (originating room), or `"both"`.
- Per-account overrides: `channels.matrix.accounts.<id>.execApprovals`.
- `channels.matrix.dm.sessionScope` controls how Matrix DMs group into sessions: `per-user` (default) shares by routed peer, while `per-room` isolates each DM room.
- Matrix status probes and live directory lookups use the same proxy policy as runtime traffic.
- Full Matrix configuration, targeting rules, and setup examples are documented in [Matrix](/channels/matrix).
### Microsoft Teams
Microsoft Teams is plugin-backed and configured under `channels.msteams`.
```json5
{
channels: {
msteams: {
enabled: true,
configWrites: true,
// appId, appPassword, tenantId, webhook, team/channel policies:
// see /channels/msteams
},
},
}
```
- Core key paths covered here: `channels.msteams`, `channels.msteams.configWrites`.
- Full Teams config (credentials, webhook, DM/group policy, per-team/per-channel overrides) is documented in [Microsoft Teams](/channels/msteams).
### IRC
IRC is plugin-backed and configured under `channels.irc`.
```json5
{
channels: {
irc: {
enabled: true,
dmPolicy: "pairing",
configWrites: true,
nickserv: {
enabled: true,
service: "NickServ",
password: "${IRC_NICKSERV_PASSWORD}",
register: false,
registerEmail: "bot@example.com",
},
},
},
}
```
- Core key paths covered here: `channels.irc`, `channels.irc.dmPolicy`, `channels.irc.configWrites`, `channels.irc.nickserv.*`.
- Optional `channels.irc.defaultAccount` overrides default account selection when it matches a configured account id.
- Full IRC channel configuration (host/port/TLS/channels/allowlists/mention gating) is documented in [IRC](/channels/irc).
### Multi-account (all channels)
Run multiple accounts per channel (each with its own `accountId`):
```json5
{
channels: {
telegram: {
accounts: {
default: {
name: "Primary bot",
botToken: "123456:ABC...",
},
alerts: {
name: "Alerts bot",
botToken: "987654:XYZ...",
},
},
},
},
}
```
- `default` is used when `accountId` is omitted (CLI + routing).
- Env tokens only apply to the **default** account.
- Base channel settings apply to all accounts unless overridden per account.
- Use `bindings[].match.accountId` to route each account to a different agent.
- If you add a non-default account via `openclaw channels add` (or channel onboarding) while still on a single-account top-level channel config, OpenClaw promotes account-scoped top-level single-account values into the channel account map first so the original account keeps working. Most channels move them into `channels.<channel>.accounts.default`; Matrix can preserve an existing matching named/default target instead.
- Existing channel-only bindings (no `accountId`) keep matching the default account; account-scoped bindings remain optional.
- `openclaw doctor --fix` also repairs mixed shapes by moving account-scoped top-level single-account values into the promoted account chosen for that channel. Most channels use `accounts.default`; Matrix can preserve an existing matching named/default target instead.
### Other plugin channels
Many plugin channels are configured as `channels.<id>` and documented in their dedicated channel pages (for example Feishu, LINE, Nextcloud Talk, Nostr, QQ Bot, Synology Chat, Twitch, and Zalo).
See the full channel index: [Channels](/channels).
### Group chat mention gating
Group messages default to **require mention** (metadata mention or safe regex patterns). Applies to WhatsApp, Telegram, Discord, Google Chat, and iMessage group chats.
Visible replies are controlled separately. Normal group, channel, and internal WebChat direct requests default to automatic final delivery: final assistant text posts through the legacy visible reply path. Opt into `messages.visibleReplies: "message_tool"` or `messages.groupChat.visibleReplies: "message_tool"` when visible output should only post after the agent calls `message(action=send)`. If the model returns final text without calling the message tool in an opted-in tool-only mode, that final text stays private and the gateway verbose log records suppressed payload metadata.
Tool-only visible replies require a model/runtime that reliably calls tools, and are recommended for shared ambient rooms on latest-generation models such as GPT 5.5. Some weaker models can answer final text but fail to understand that source-visible output must be sent with `message(action=send)`. For those models, use `"automatic"` so the final assistant turn is the visible reply path. If the session log shows assistant text with `didSendViaMessagingTool: false`, the model produced private final text instead of calling the message tool. Switch to a stronger tool-calling model for that channel, inspect the gateway verbose log for the suppressed payload summary, or set `messages.groupChat.visibleReplies: "automatic"` to use visible final replies for every group/channel request.
If the message tool is unavailable under the active tool policy, OpenClaw falls back to automatic visible replies instead of silently suppressing the response. `openclaw doctor` warns about this mismatch.
This rule applies to normal agent final text. Plugin-owned conversation bindings use the owning plugin's returned reply as the visible response for claimed bound-thread turns; the plugin does not need to call `message(action=send)` for those binding replies.
**Troubleshooting: group @mention triggers typing then silence (no error)**
Symptom: a group/channel @mention shows the typing indicator and the gateway log reports `dispatch complete (queuedFinal=false, replies=0)`, but no message lands in the room. DMs to the same agent reply normally.
Cause: the group/channel visible-reply mode resolves to `"message_tool"`, so OpenClaw runs the turn but suppresses the final assistant text unless the agent calls `message(action=send)`. There is no `NO_REPLY` contract in this mode; no message-tool call means no source reply. There is no error because suppression is the configured behavior. Normal group and channel turns default to `"automatic"`, so this symptom only appears when `messages.groupChat.visibleReplies` (or global `messages.visibleReplies`) is explicitly set to `"message_tool"`. Harness `defaultVisibleReplies` does not apply here — the group/channel resolver ignores it; it only affects direct/source chats (the Codex harness suppresses direct-chat finals that way).
Fix: either pick a stronger tool-calling model, remove the explicit `"message_tool"` override to fall back to the `"automatic"` default, or set `messages.groupChat.visibleReplies: "automatic"` to force visible replies for every group/channel request. The gateway hot-reloads `messages` config after the file is saved; only restart the gateway when file watching or config reload is disabled in the deployment.
**Mention types:**
- **Metadata mentions**: Native platform @-mentions. Ignored in WhatsApp self-chat mode.
- **Text patterns**: Safe regex patterns in `agents.list[].groupChat.mentionPatterns`. Invalid patterns and unsafe nested repetition are ignored.
- Mention gating is enforced only when detection is possible (native mentions or at least one pattern).
```json5
{
messages: {
visibleReplies: "automatic", // force old automatic final replies for direct/source chats
groupChat: {
historyLimit: 50,
unmentionedInbound: "room_event", // always-on unmentioned room chatter becomes quiet context
visibleReplies: "message_tool", // opt-in; require message(action=send) for visible room replies
},
},
agents: {
list: [{ id: "main", groupChat: { mentionPatterns: ["@openclaw", "openclaw"] } }],
},
}
```
`messages.groupChat.historyLimit` sets the global default. Channels can override with `channels.<channel>.historyLimit` (or per-account). Set `0` to disable.
`messages.groupChat.unmentionedInbound: "room_event"` submits unmentioned always-on group/channel messages as quiet room context on supported channels. Mentioned messages, commands, and direct messages remain user requests. See [Ambient room events](/channels/ambient-room-events) for complete Discord, Slack, and Telegram examples.
`messages.visibleReplies` is the global source-event default; `messages.groupChat.visibleReplies` overrides it for group/channel source events. When `messages.visibleReplies` is unset, direct/source chats use the selected runtime or harness default, but internal WebChat direct turns use automatic final delivery for Pi/Codex prompt parity. Set `messages.visibleReplies: "message_tool"` to intentionally require `message(action=send)` for visible output. Channel allowlists and mention gating still decide whether an event is processed.
#### DM history limits
```json5
{
channels: {
telegram: {
dmHistoryLimit: 30,
dms: {
"123456789": { historyLimit: 50 },
},
},
},
}
```
Resolution: per-DM override → provider default → no limit (all retained).
This resolver reads `channels.<provider>.dmHistoryLimit` and `channels.<provider>.dms.<id>.historyLimit` for any channel whose session key follows the standard `provider:direct:<id>` (or legacy `provider:dm:<id>`) shape, so it works across bundled and plugin channels alike, not just a fixed list.
#### Self-chat mode
Include your own number in `allowFrom` to enable self-chat mode (ignores native @-mentions, only responds to text patterns):
```json5
{
channels: {
whatsapp: {
allowFrom: ["+15555550123"],
groups: { "*": { requireMention: true } },
},
},
agents: {
list: [
{
id: "main",
groupChat: { mentionPatterns: ["reisponde", "@openclaw"] },
},
],
},
}
```
### Commands (chat command handling)
```json5
{
commands: {
native: "auto", // register native commands when supported
nativeSkills: "auto", // register native skill commands when supported
text: true, // parse /commands in chat messages
bash: false, // allow ! (alias: /bash)
bashForegroundMs: 2000,
config: false, // allow /config
mcp: false, // allow /mcp
plugins: false, // allow /plugins
debug: false, // allow /debug
restart: true, // allow /restart + gateway restart tool
ownerAllowFrom: ["discord:123456789012345678"],
ownerDisplay: "raw", // raw | hash
ownerDisplaySecret: "${OWNER_ID_HASH_SECRET}",
allowFrom: {
"*": ["user1"],
discord: ["user:123"],
},
useAccessGroups: true,
},
}
```
<Accordion title="Command details">
- This block configures command surfaces. For the current built-in + bundled command catalog, see [Slash Commands](/tools/slash-commands).
- This page is a **config-key reference**, not the full command catalog. Channel/plugin-owned commands such as QQ Bot `/bot-ping` `/bot-help` `/bot-logs`, LINE `/card`, device-pair `/pair`, memory `/dreaming`, phone-control `/phone`, and Talk `/voice` are documented in their channel/plugin pages plus [Slash Commands](/tools/slash-commands).
- Text commands must be **standalone** messages with leading `/`.
- `native: "auto"` turns on native commands for Discord/Telegram, leaves Slack off.
- `nativeSkills: "auto"` turns on native skill commands for Discord/Telegram, leaves Slack off.
- Override per channel: `channels.discord.commands.native` (bool or `"auto"`). For Discord, `false` skips native command registration and cleanup during startup.
- Override native skill registration per channel with `channels.<provider>.commands.nativeSkills`.
- `channels.telegram.customCommands` adds extra Telegram bot menu entries.
- `bash: true` enables `! <cmd>` for host shell. Requires `tools.elevated.enabled` and sender in `tools.elevated.allowFrom.<channel>`.
- `config: true` enables `/config` (reads/writes `openclaw.json`). For gateway `chat.send` clients, persistent `/config set|unset` writes also require `operator.admin`; read-only `/config show` stays available to normal write-scoped operator clients.
- `mcp: true` enables `/mcp` for OpenClaw-managed MCP server config under `mcp.servers`.
- `plugins: true` enables `/plugins` for plugin discovery, install, and enable/disable controls.
- `channels.<provider>.configWrites` gates config mutations per channel (default: true).
- For multi-account channels, `channels.<provider>.accounts.<id>.configWrites` also gates writes that target that account (for example `/allowlist --config --account <id>` or `/config set channels.<provider>.accounts.<id>...`).
- `restart: false` disables `/restart` and gateway restart tool actions. Default: `true`.
- `ownerAllowFrom` is the explicit owner allowlist for owner-only commands and owner-gated channel actions. It is separate from `allowFrom`.
- `ownerDisplay: "hash"` hashes owner ids in the system prompt. Set `ownerDisplaySecret` to control hashing.
- `allowFrom` is per-provider. When set, it is the **only** authorization source (channel allowlists/pairing and `useAccessGroups` are ignored).
- `useAccessGroups: false` allows commands to bypass access-group policies when `allowFrom` is not set.
- Command docs map:
- built-in + bundled catalog: [Slash Commands](/tools/slash-commands)
- channel-specific command surfaces: [Channels](/channels)
- QQ Bot commands: [QQ Bot](/channels/qqbot)
- pairing commands: [Pairing](/channels/pairing)
- LINE card command: [LINE](/channels/line)
- memory dreaming: [Dreaming](/concepts/dreaming)
</Accordion>
---
## Related
- [Configuration reference](/gateway/configuration-reference) — top-level keys
- [Configuration — agents](/gateway/config-agents)
- [Channels overview](/channels)

View File

@@ -0,0 +1,832 @@
---
summary: "Tools config (policy, experimental toggles, provider-backed tools) and custom provider/base-URL setup"
read_when:
- Configuring `tools.*` policy, allowlists, or experimental features
- Registering custom providers or overriding base URLs
- Setting up OpenAI-compatible self-hosted endpoints
title: "Configuration — tools and custom providers"
sidebarTitle: "Tools and custom providers"
---
`tools.*` config keys and custom provider / base-URL setup. For agents, channels, and other top-level config keys, see [Configuration reference](/gateway/configuration-reference).
## Tools
### Tool profiles
`tools.profile` sets a base allowlist before `tools.allow`/`tools.deny`:
<Note>
Local onboarding defaults new local configs to `tools.profile: "coding"` when unset (existing explicit profiles are preserved).
</Note>
| Profile | Includes |
| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `minimal` | `session_status` only |
| `coding` | `group:fs`, `group:runtime`, `group:web`, `group:sessions`, `group:memory`, `cron`, `get_goal`, `create_goal`, `update_goal`, `update_plan`, `skill_workshop`, `image`, `image_generate`, `music_generate`, `video_generate` |
| `messaging` | `group:messaging`, `sessions_list`, `sessions_history`, `sessions_send`, `session_status` |
| `full` | No restriction (same as unset) |
`coding` and `messaging` also implicitly allow `bundle-mcp` (configured MCP servers).
### Tool groups
| Group | Tools |
| ------------------ | ----------------------------------------------------------------------------------------------------------------------- |
| `group:runtime` | `exec`, `process`, `code_execution` (`bash` is accepted as an alias for `exec`) |
| `group:fs` | `read`, `write`, `edit`, `apply_patch` |
| `group:sessions` | `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `sessions_yield`, `subagents`, `session_status` |
| `group:memory` | `memory_search`, `memory_get` |
| `group:web` | `web_search`, `x_search`, `web_fetch` |
| `group:ui` | `browser`, `canvas` |
| `group:automation` | `heartbeat_respond`, `cron`, `gateway` |
| `group:messaging` | `message` |
| `group:nodes` | `nodes` |
| `group:agents` | `agents_list`, `get_goal`, `create_goal`, `update_goal`, `update_plan`, `skill_workshop` |
| `group:media` | `image`, `image_generate`, `music_generate`, `video_generate`, `tts` |
| `group:openclaw` | All built-in tools above except `read`/`write`/`edit`/`apply_patch`/`exec`/`process`/`canvas` (excludes plugin tools) |
| `group:plugins` | Tools owned by loaded plugins, including configured MCP servers exposed through `bundle-mcp` |
### MCP and plugin tools inside sandbox tool policy
Configured MCP servers are exposed as plugin-owned tools under the `bundle-mcp` plugin id. Normal tool profiles can allow them, but `tools.sandbox.tools` is an additional gate for sandboxed sessions. If sandbox mode is `"all"` or `"non-main"`, include one of these entries in the sandbox tool allowlist when MCP/plugin tools should be visible:
- `bundle-mcp` for OpenClaw-managed MCP servers from `mcp.servers`
- the plugin id for a specific native plugin
- `group:plugins` for all loaded plugin-owned tools
- exact MCP server tool names or server globs such as `outlook__send_mail` or `outlook__*` when you only want one server
Server globs use the provider-safe MCP server prefix, not necessarily the raw `mcp.servers` key. Non-`[A-Za-z0-9_-]` characters become `-`, names that do not start with a letter get an `mcp-` prefix, and long or duplicate prefixes may be truncated or suffixed; for example, `mcp.servers["Outlook Graph"]` uses a glob like `outlook-graph__*`.
```json5
{
agents: { defaults: { sandbox: { mode: "all" } } },
mcp: {
servers: {
outlook: { command: "node", args: ["./outlook-mcp.js"] },
},
},
tools: {
sandbox: {
tools: {
alsoAllow: ["web_search", "web_fetch", "memory_search", "memory_get", "bundle-mcp"],
},
},
},
}
```
Without that sandbox-layer entry, the MCP server can still load successfully while its tools are filtered before the provider request. Use `openclaw doctor` to catch this shape for OpenClaw-managed servers in `mcp.servers`. MCP servers loaded from bundled plugin manifests or Claude `.mcp.json` use the same sandbox gate, but this diagnostic does not enumerate those sources yet; use the same allowlist entries if their tools disappear in sandboxed turns.
### `tools.codeMode`
`tools.codeMode` enables the generic OpenClaw code-mode surface. When enabled
for a run with tools, the model sees only `exec` and `wait`; normal OpenClaw
tools move behind the in-sandbox `tools.*` catalog bridge, and MCP tools are
available through the generated `MCP` namespace.
```json5
{
tools: {
codeMode: {
enabled: true,
},
},
}
```
The shorthand is also accepted:
```json5
{
tools: { codeMode: true },
}
```
MCP declarations are exposed through the read-only virtual API file surface in
code mode. Guest code can call `API.list("mcp")` and
`API.read("mcp/<server>.d.ts")` to inspect TypeScript-style signatures before
calling `MCP.<server>.<tool>()`. See [Code mode](/reference/code-mode) for the
runtime contract, limits, and debugging steps.
### `tools.allow` / `tools.deny`
Global tool allow/deny policy (deny wins). Case-insensitive, supports `*` wildcards. Applied even when Docker sandbox is off.
```json5
{
tools: { deny: ["browser", "canvas"] },
}
```
`write` and `apply_patch` are separate tool ids. `allow: ["write"]` also enables `apply_patch` for compatible models, but `deny: ["write"]` does not deny `apply_patch`. To block all file mutation, deny `group:fs` or list each mutating tool explicitly:
```json5
{
tools: { deny: ["write", "edit", "apply_patch"] },
}
```
<Note>
`allow` and `alsoAllow` cannot both be set in the same scope (`tools`, `tools.byProvider.<id>`, `agents.list[].tools`) — config validation rejects it. Merge `alsoAllow` entries into `allow`, or drop `allow` and use `profile` + `alsoAllow` instead.
</Note>
### `tools.byProvider`
Further restrict tools for specific providers or models. Order: base profile → provider profile → allow/deny.
```json5
{
tools: {
profile: "coding",
byProvider: {
"google-antigravity": { profile: "minimal" },
"openai/gpt-5.4": { allow: ["group:fs", "sessions_list"] },
},
},
}
```
### `tools.toolsBySender`
Restricts tools for a specific requester identity. This is defense-in-depth on top of channel access control; sender values must come from the channel adapter, not message text.
```json5
{
tools: {
toolsBySender: {
"channel:discord:1234567890123": { alsoAllow: ["group:fs"] },
"id:guest-user-id": { deny: ["group:runtime", "group:fs"] },
"*": { deny: ["exec", "process", "write", "edit", "apply_patch"] },
},
},
}
```
Keys use explicit prefixes: `channel:<channelId>:<senderId>`, `id:<senderId>`, `e164:<phone>`, `username:<handle>`, `name:<displayName>`, or `"*"`. Channel ids are canonical OpenClaw ids; aliases such as `teams` normalize to `msteams`. Legacy unprefixed keys are accepted as `id:` only. Matching order is channel+id, id, e164, username, name, then wildcard.
Per-agent `agents.list[].tools.toolsBySender` overrides the global sender match when it matches, even with an empty `{}` policy.
### `tools.elevated`
Controls elevated exec access outside the sandbox:
```json5
{
tools: {
elevated: {
enabled: true,
allowFrom: {
whatsapp: ["+15555550123"],
discord: ["1234567890123", "987654321098765432"],
},
},
},
}
```
- Per-agent override (`agents.list[].tools.elevated`) can only further restrict.
- `/elevated on|off|ask|full` stores state per session; inline directives apply to single message.
- Elevated `exec` bypasses sandboxing and uses the configured escape path (`gateway` by default, or `node` when the exec target is `node`).
### `tools.exec`
```json5
{
tools: {
exec: {
backgroundMs: 10000,
timeoutSec: 1800,
cleanupMs: 1800000,
approvalRunningNoticeMs: 10000,
notifyOnExit: true,
notifyOnExitEmptySuccess: false,
commandHighlighting: false,
applyPatch: {
enabled: true,
allowModels: ["gpt-5.5"],
},
},
},
}
```
Values shown are defaults except `applyPatch.allowModels` (empty/unset by default, meaning any compatible model may use `apply_patch`). `approvalRunningNoticeMs` emits a running notice when approval-backed exec runs long; `0` disables it.
### `tools.loopDetection`
Tool-loop safety checks are **disabled by default**. Set `enabled: true` to activate detection. Settings can be defined globally in `tools.loopDetection` and overridden per-agent at `agents.list[].tools.loopDetection`.
```json5
{
tools: {
loopDetection: {
enabled: true,
historySize: 30,
warningThreshold: 10,
unknownToolThreshold: 10,
criticalThreshold: 20,
globalCircuitBreakerThreshold: 30,
detectors: {
genericRepeat: true,
knownPollNoProgress: true,
pingPong: true,
},
postCompactionGuard: {
windowSize: 3,
},
},
},
}
```
<ParamField path="historySize" type="number">
Max tool-call history retained for loop analysis.
</ParamField>
<ParamField path="warningThreshold" type="number">
Repeating no-progress pattern threshold for warnings.
</ParamField>
<ParamField path="unknownToolThreshold" type="number">
Blocks repeated calls to the same unavailable/unknown tool name after this many misses.
</ParamField>
<ParamField path="criticalThreshold" type="number">
Higher repeating threshold for blocking critical loops.
</ParamField>
<ParamField path="globalCircuitBreakerThreshold" type="number">
Hard stop threshold for any no-progress run.
</ParamField>
<ParamField path="detectors.genericRepeat" type="boolean">
Warn on repeated same-tool/same-args calls.
</ParamField>
<ParamField path="detectors.knownPollNoProgress" type="boolean">
Warn/block on known poll tools (`process.poll`, `command_status`, etc.).
</ParamField>
<ParamField path="detectors.pingPong" type="boolean">
Warn/block on alternating no-progress pair patterns.
</ParamField>
<ParamField path="postCompactionGuard.windowSize" type="number">
Number of attempts after auto-compaction the guard stays armed for; aborts if the agent repeats the same (tool, args, result) inside that window.
</ParamField>
<Warning>
If `warningThreshold >= criticalThreshold` or `criticalThreshold >= globalCircuitBreakerThreshold`, validation fails.
</Warning>
### `tools.web`
```json5
{
tools: {
web: {
search: {
enabled: true,
apiKey: "brave_api_key", // or BRAVE_API_KEY env (Brave provider)
maxResults: 5,
timeoutSeconds: 30,
cacheTtlMinutes: 15,
},
fetch: {
enabled: true,
provider: "firecrawl", // optional; omit for auto-detect
maxChars: 20000,
maxCharsCap: 20000,
maxResponseBytes: 750000,
timeoutSeconds: 30,
cacheTtlMinutes: 15,
maxRedirects: 3,
readability: true,
userAgent: "custom-ua",
},
},
},
}
```
Values shown are defaults except `provider` and `userAgent`. `maxResponseBytes` clamps to 3200010000000; `maxChars` clamps to `maxCharsCap` (raise `maxCharsCap` to allow larger responses).
### `tools.media`
Configures inbound media understanding (image/audio/video):
```json5
{
tools: {
media: {
concurrency: 2,
asyncCompletion: {
directSend: false, // deprecated: completions stay agent-mediated
},
audio: {
enabled: true,
maxBytes: 20971520,
scope: {
default: "deny",
rules: [{ action: "allow", match: { chatType: "direct" } }],
},
models: [
{ provider: "openai", model: "gpt-4o-mini-transcribe" },
{ type: "cli", command: "whisper", args: ["--model", "base", "{{MediaPath}}"] },
],
},
image: {
enabled: true,
timeoutSeconds: 180,
models: [{ provider: "ollama", model: "gemma4:26b", timeoutSeconds: 300 }],
},
video: {
enabled: true,
maxBytes: 52428800,
models: [{ provider: "google", model: "gemini-3-flash-preview" }],
},
},
},
}
```
`concurrency` (default `2`), `audio.maxBytes` (default 20 MB), and `video.maxBytes` (default 50 MB) are shown at their defaults; `image.maxBytes` defaults to 10 MB. Per-capability request timeout defaults: image/audio `60`s, video `120`s.
<AccordionGroup>
<Accordion title="Media model entry fields">
**Provider entry** (`type: "provider"` or omitted):
- `provider`: API provider id (`openai`, `anthropic`, `google`/`gemini`, `groq`, etc.)
- `model`: model id override
- `profile` / `preferredProfile`: `auth-profiles.json` profile selection
**CLI entry** (`type: "cli"`):
- `command`: executable to run
- `args`: templated args (supports `{{MediaPath}}`, `{{Prompt}}`, `{{MaxChars}}`, etc.; `openclaw doctor --fix` migrates deprecated `{input}` placeholders to `{{MediaPath}}`)
**Common fields:**
- `capabilities`: optional list (`image`, `audio`, `video`). Each provider plugin declares its own default capability set; for example the bundled `openai` provider defaults to image+audio, `anthropic`/`minimax` to image, `google` to image+audio+video, and `groq` to audio.
- `prompt`, `maxChars`, `maxBytes`, `timeoutSeconds`, `language`: per-entry overrides.
- `tools.media.image.timeoutSeconds` and matching image model `timeoutSeconds` entries also apply when the agent calls the explicit `image` tool. For image understanding, this timeout applies to the request itself and is not reduced by earlier preparation work.
- Failures fall back to the next entry.
Provider auth follows standard order: `auth-profiles.json` → env vars → `models.providers.*.apiKey`.
**Async completion fields:**
- `asyncCompletion.directSend`: deprecated compatibility flag. Completed async media tasks stay requester-session mediated so the agent receives the result, decides how to tell the user, and uses the message tool when source delivery requires it.
</Accordion>
</AccordionGroup>
### `tools.agentToAgent`
```json5
{
tools: {
agentToAgent: {
enabled: false,
allow: ["home", "work"],
},
},
}
```
### `tools.sessions`
Controls which sessions can be targeted by the session tools (`sessions_list`, `sessions_history`, `sessions_send`).
Default: `tree` (current session + sessions spawned by it, such as subagents).
```json5
{
tools: {
sessions: {
// "self" | "tree" | "agent" | "all"
visibility: "tree",
},
},
}
```
<AccordionGroup>
<Accordion title="Visibility scopes">
- `self`: only the current session key.
- `tree`: current session + sessions spawned by the current session (subagents).
- `agent`: any session belonging to the current agent id (can include other users if you run per-sender sessions under the same agent id).
- `all`: any session. Cross-agent targeting still requires `tools.agentToAgent`.
- Sandbox clamp: when the current session is sandboxed and `agents.defaults.sandbox.sessionToolsVisibility="spawned"` (the default), visibility is forced to `tree` even if `tools.sessions.visibility="all"`.
- When not `all`, `sessions_list` includes a compact `visibility` field
describing the effective mode and a warning that some sessions may be
omitted outside the current scope.
</Accordion>
</AccordionGroup>
### `tools.sessions_spawn`
Controls inline attachment support for `sessions_spawn`.
```json5
{
tools: {
sessions_spawn: {
attachments: {
enabled: false, // opt-in: set true to allow inline file attachments
maxTotalBytes: 5242880, // 5 MB total across all files
maxFiles: 50,
maxFileBytes: 1048576, // 1 MB per file
retainOnSessionKeep: false, // keep attachments when cleanup="keep"
},
},
},
}
```
<AccordionGroup>
<Accordion title="Attachment notes">
- Attachments require `enabled: true`.
- Subagent attachments are materialized into the child workspace at `.openclaw/attachments/<uuid>/` with a `.manifest.json`.
- ACP attachments are image-only and forwarded inline to the ACP runtime after the same file count, per-file byte, and total byte limits pass.
- Attachment content is automatically redacted from transcript persistence.
- Base64 inputs are validated with strict alphabet/padding checks and a pre-decode size guard.
- Subagent attachment file permissions are `0700` for directories and `0600` for files.
- Subagent cleanup follows the `cleanup` policy: `delete` always removes attachments; `keep` retains them only when `retainOnSessionKeep: true`.
</Accordion>
</AccordionGroup>
<a id="toolsexperimental"></a>
### `tools.experimental`
Experimental built-in tool flags. Default off unless a strict-agentic GPT-5 auto-enable rule applies.
```json5
{
tools: {
experimental: {
planTool: true, // enable experimental update_plan
},
},
}
```
- `planTool`: enables the structured `update_plan` tool for non-trivial multi-step work tracking.
- Default: `false` unless `agents.defaults.embeddedAgent.executionContract` (or a per-agent override) is set to `"strict-agentic"` for an `openai` provider run against a GPT-5-family model id (this covers OpenAI Codex CLI runs too, since Codex auth/model routing lives under the `openai` provider). Set `true` to force the tool on outside that scope, or `false` to keep it off even for strict-agentic GPT-5 runs.
- When enabled, the system prompt also adds usage guidance so the model only uses it for substantial work and keeps at most one step `in_progress`.
### `agents.defaults.subagents`
```json5
{
agents: {
defaults: {
subagents: {
allowAgents: ["research"],
model: "minimax/MiniMax-M2.7",
maxConcurrent: 8,
runTimeoutSeconds: 900,
announceTimeoutMs: 120000,
archiveAfterMinutes: 60,
},
},
},
}
```
- `model`: default model for spawned sub-agents. If omitted, sub-agents inherit the caller's model.
- `allowAgents`: default allowlist of configured target agent ids for `sessions_spawn` when the requester agent does not set its own `subagents.allowAgents` (`["*"]` = any configured target; default: same agent only). Stale entries whose agent config was deleted are rejected by `sessions_spawn` and omitted from `agents_list`; run `openclaw doctor --fix` to clean them up.
- `maxConcurrent`: max concurrent sub-agent runs. Default: `8`.
- `runTimeoutSeconds`: timeout (seconds) for `sessions_spawn` when the caller does not pass its own override. Default: `0` (no timeout); the `900` shown above is a common opt-in value, not the built-in default.
- `announceTimeoutMs`: per-call timeout (milliseconds) for gateway `agent` announce delivery attempts. Default: `120000`. Transient retries can make the total announce wait longer than one configured timeout.
- `archiveAfterMinutes`: minutes after a sub-agent session completes before it is auto-archived. Default: `60`; `0` disables auto-archive.
- Per-subagent tool policy: `tools.subagents.tools.allow` / `tools.subagents.tools.deny`.
---
## Custom providers and base URLs
Provider plugins publish their own model catalog rows. Add custom providers via `models.providers` in config or `~/.openclaw/agents/<agentId>/agent/models.json`.
Configuring a custom/local provider `baseUrl` is also the narrow network trust decision for model HTTP requests: OpenClaw allows that exact `scheme://host:port` origin through the guarded fetch path, without adding a separate config option or trusting other private origins.
```json5
{
models: {
mode: "merge", // merge (default) | replace
providers: {
"custom-proxy": {
baseUrl: "http://localhost:4000/v1",
apiKey: "LITELLM_KEY",
api: "openai-completions", // openai-completions | openai-responses | anthropic-messages | google-generative-ai | etc.
models: [
{
id: "llama-3.1-8b",
name: "Llama 3.1 8B",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
contextTokens: 96000,
maxTokens: 32000,
},
],
},
},
},
}
```
<AccordionGroup>
<Accordion title="Auth and merge precedence">
- Use `authHeader: true` + `headers` for custom auth needs.
- Override agent config root with `OPENCLAW_AGENT_DIR`.
- Merge precedence for matching provider IDs:
- Non-empty agent `models.json` `baseUrl` values win.
- Non-empty agent `apiKey` values win only when that provider is not SecretRef-managed in current config/auth-profile context.
- SecretRef-managed provider `apiKey` values are refreshed from source markers (`ENV_VAR_NAME` for env refs, `secretref-managed` for file/exec refs) instead of persisting resolved secrets.
- SecretRef-managed provider header values are refreshed from source markers (`secretref-env:ENV_VAR_NAME` for env refs, `secretref-managed` for file/exec refs).
- Empty or missing agent `apiKey`/`baseUrl` fall back to `models.providers` in config.
- Matching model `contextWindow`/`maxTokens`: the explicit config value wins when present and valid (a positive finite number); otherwise the implicit/generated catalog value is used.
- Matching model `contextTokens` follows the same explicit-wins-else-implicit rule; use it to limit effective context without changing native model metadata.
- Provider-plugin catalogs are stored as generated plugin-owned catalog shards under the agent's plugin state.
- Use `models.mode: "replace"` when you want config to fully rewrite `models.json` and skip merging in plugin-owned catalog shards.
- Marker persistence is source-authoritative: markers are written from the active source config snapshot (pre-resolution), not from resolved runtime secret values.
</Accordion>
</AccordionGroup>
### Provider field details
<AccordionGroup>
<Accordion title="Top-level catalog">
- `models.mode`: provider catalog behavior (`merge` or `replace`).
- `models.providers`: custom provider map keyed by provider id.
- Safe edits: use `openclaw config set models.providers.<id> '<json>' --strict-json --merge` or `openclaw config set models.providers.<id>.models '<json-array>' --strict-json --merge` for additive updates. `config set` refuses destructive replacements unless you pass `--replace`.
</Accordion>
<Accordion title="Provider connection and auth">
- `models.providers.*.api`: request adapter (`openai-completions`, `openai-responses`, `openai-chatgpt-responses`, `anthropic-messages`, `google-generative-ai`, `google-vertex`, `github-copilot`, `bedrock-converse-stream`, `ollama`, `azure-openai-responses`). For self-hosted `/v1/chat/completions` backends such as MLX, vLLM, SGLang, and most OpenAI-compatible local servers, use `openai-completions`. A custom provider with `baseUrl` but no `api` defaults to `openai-completions`; set `openai-responses` only when the backend supports `/v1/responses`.
- `models.providers.*.apiKey`: provider credential (prefer SecretRef/env substitution).
- `models.providers.*.auth`: auth strategy (`api-key`, `token`, `oauth`, `aws-sdk`).
- `models.providers.*.contextWindow`: default native context window for models under this provider when the model entry does not set `contextWindow`.
- `models.providers.*.contextTokens`: default effective runtime context cap for models under this provider when the model entry does not set `contextTokens`.
- `models.providers.*.maxTokens`: default output-token cap for models under this provider when the model entry does not set `maxTokens`.
- `models.providers.*.timeoutSeconds`: optional per-provider model HTTP request timeout in seconds, including connect, headers, body, and total request abort handling.
- `models.providers.*.injectNumCtxForOpenAICompat`: for Ollama + `openai-completions`, inject `options.num_ctx` into requests (default: `true`).
- `models.providers.*.authHeader`: force credential transport in the `Authorization` header when required.
- `models.providers.*.baseUrl`: upstream API base URL.
- `models.providers.*.headers`: extra static headers for proxy/tenant routing.
</Accordion>
<Accordion title="Request transport overrides">
`models.providers.*.request`: transport overrides for model-provider HTTP requests.
- `request.headers`: extra headers (merged with provider defaults). Values accept SecretRef.
- `request.auth`: auth strategy override. Modes: `"provider-default"` (use provider's built-in auth), `"authorization-bearer"` (with `token`), `"header"` (with `headerName`, `value`, optional `prefix`).
- `request.proxy`: HTTP proxy override. Modes: `"env-proxy"` (use `HTTP_PROXY`/`HTTPS_PROXY` env vars), `"explicit-proxy"` (with `url`). Both modes accept an optional `tls` sub-object.
- `request.tls`: TLS override for direct connections. Fields: `ca`, `cert`, `key`, `passphrase` (all accept SecretRef), `serverName`, `insecureSkipVerify`.
- `request.allowPrivateNetwork`: when `true`, allow model-provider HTTP requests to private, CGNAT, or similar ranges through the provider HTTP fetch guard. Custom/local provider base URLs already trust the exact configured origin, except metadata/link-local origins, which remain blocked without explicit opt-in. Set this to `false` to opt out of exact-origin trust. WebSocket uses the same `request` for headers/TLS but not that fetch SSRF gate. Default `false`.
</Accordion>
<Accordion title="Model catalog entries">
- `models.providers.*.models`: explicit provider model catalog entries.
- `models.providers.*.models.*.input`: model input modalities. Use `["text"]` for text-only models and `["text", "image"]` for native image/vision models. Image attachments are only injected into agent turns when the selected model is marked image-capable.
- `models.providers.*.models.*.contextWindow`: native model context window metadata. This overrides provider-level `contextWindow` for that model.
- `models.providers.*.models.*.contextTokens`: optional runtime context cap. This overrides provider-level `contextTokens`; use it when you want a smaller effective context budget than the model's native `contextWindow`; `openclaw models list` shows both values when they differ.
- `models.providers.*.models.*.compat.supportsDeveloperRole`: optional compatibility hint. For `api: "openai-completions"` with a non-empty non-native `baseUrl` (host not `api.openai.com`), OpenClaw forces this to `false` at runtime. Empty/omitted `baseUrl` keeps default OpenAI behavior.
- `models.providers.*.models.*.compat.requiresStringContent`: optional compatibility hint for string-only OpenAI-compatible chat endpoints. When `true`, OpenClaw flattens pure text `messages[].content` arrays into plain strings before sending the request.
- `models.providers.*.models.*.compat.strictMessageKeys`: optional compatibility hint for strict OpenAI-compatible chat endpoints. When `true`, OpenClaw strips outgoing Chat Completions message objects to `role` and `content` before sending the request.
- `models.providers.*.models.*.compat.thinkingFormat`: optional thinking payload hint. Use `"together"` for Together-style `reasoning.enabled`, `"qwen"` for top-level `enable_thinking`, or `"qwen-chat-template"` for `chat_template_kwargs.enable_thinking` on Qwen-family OpenAI-compatible servers that support request-level chat-template kwargs, such as vLLM. Configured vLLM Qwen models expose binary `/think` choices (`off`, `on`) for these formats.
- `models.providers.*.models.*.compat.requiresReasoningContentOnAssistantMessages`: optional compatibility hint for DeepSeek-style Chat Completions backends that require prior assistant messages to keep `reasoning_content` on replay. When `true`, OpenClaw preserves that field on outgoing assistant messages. Use this when wiring a custom DeepSeek-compatible proxy that rejects requests after stripped reasoning. Default `false`.
</Accordion>
<Accordion title="Amazon Bedrock discovery">
- `plugins.entries.amazon-bedrock.config.discovery`: Bedrock auto-discovery settings root.
- `plugins.entries.amazon-bedrock.config.discovery.enabled`: turn implicit discovery on/off.
- `plugins.entries.amazon-bedrock.config.discovery.region`: AWS region for discovery.
- `plugins.entries.amazon-bedrock.config.discovery.providerFilter`: optional provider-id filter for targeted discovery.
- `plugins.entries.amazon-bedrock.config.discovery.refreshInterval`: polling interval for discovery refresh.
- `plugins.entries.amazon-bedrock.config.discovery.defaultContextWindow`: fallback context window for discovered models.
- `plugins.entries.amazon-bedrock.config.discovery.defaultMaxTokens`: fallback max output tokens for discovered models.
</Accordion>
</AccordionGroup>
Interactive custom-provider onboarding infers image input for known vision-model-id patterns, including GPT-4o/GPT-4.1/GPT-5+, the `o1`/`o3`/`o4` reasoning families, Claude, Gemini, any `-vl`-suffixed id (Qwen-VL and similar), and named families such as LLaVA, Pixtral, InternVL, Mllama, MiniCPM-V, and GLM-4V; it skips the extra question for known text-only families (Llama, DeepSeek, Mistral/Mixtral, Kimi/Moonshot, Codestral, Devstral, Phi, QwQ, CodeLlama, and bare Qwen ids without a vl/vision suffix). Unknown model IDs still prompt for image support. Non-interactive onboarding uses the same inference; pass `--custom-image-input` to force image-capable metadata or `--custom-text-input` to force text-only metadata.
### Provider examples
<AccordionGroup>
<Accordion title="Cerebras (GLM 4.7 / GPT OSS)">
The official external `cerebras` provider plugin can configure this via `openclaw onboard --auth-choice cerebras-api-key`. Use explicit provider config only when overriding defaults.
```json5
{
env: { CEREBRAS_API_KEY: "sk-..." },
agents: {
defaults: {
model: {
primary: "cerebras/zai-glm-4.7",
fallbacks: ["cerebras/gpt-oss-120b"],
},
models: {
"cerebras/zai-glm-4.7": { alias: "GLM 4.7 (Cerebras)" },
"cerebras/gpt-oss-120b": { alias: "GPT OSS 120B (Cerebras)" },
},
},
},
models: {
mode: "merge",
providers: {
cerebras: {
baseUrl: "https://api.cerebras.ai/v1",
apiKey: "${CEREBRAS_API_KEY}",
api: "openai-completions",
models: [
{ id: "zai-glm-4.7", name: "GLM 4.7 (Cerebras)" },
{ id: "gpt-oss-120b", name: "GPT OSS 120B (Cerebras)" },
],
},
},
},
}
```
Use `cerebras/zai-glm-4.7` for Cerebras; `zai/glm-4.7` for Z.AI direct.
</Accordion>
<Accordion title="Kimi Coding">
```json5
{
env: { KIMI_API_KEY: "sk-..." },
agents: {
defaults: {
model: { primary: "kimi/kimi-for-coding" },
models: { "kimi/kimi-for-coding": { alias: "Kimi Code" } },
},
},
}
```
Anthropic-compatible, built-in provider. Shortcut: `openclaw onboard --auth-choice kimi-code-api-key`.
</Accordion>
<Accordion title="Local models (LM Studio)">
See [Local Models](/gateway/local-models). TL;DR: run a large local model via LM Studio Responses API on serious hardware; keep hosted models merged for fallback.
</Accordion>
<Accordion title="MiniMax M3 (direct)">
```json5
{
agents: {
defaults: {
model: { primary: "minimax/MiniMax-M3" },
models: {
"minimax/MiniMax-M3": { alias: "Minimax" },
},
},
},
models: {
mode: "merge",
providers: {
minimax: {
baseUrl: "https://api.minimax.io/anthropic",
apiKey: "${MINIMAX_API_KEY}",
api: "anthropic-messages",
models: [
{
id: "MiniMax-M3",
name: "MiniMax M3",
reasoning: true,
input: ["text", "image"],
cost: { input: 0.6, output: 2.4, cacheRead: 0.12, cacheWrite: 0 },
contextWindow: 1000000,
maxTokens: 131072,
},
],
},
},
},
}
```
Set `MINIMAX_API_KEY`. Shortcuts: `openclaw onboard --auth-choice minimax-global-api` or `openclaw onboard --auth-choice minimax-cn-api`. The model catalog defaults to M3 and also includes the M2.7 variants. On the Anthropic-compatible streaming path, OpenClaw disables MiniMax M2.x thinking by default unless you explicitly set `thinking` yourself; MiniMax-M3 (and M3.x) stays on the provider's omitted/adaptive thinking path by default. `/fast on` or `params.fastMode: true` rewrites `MiniMax-M2.7` to `MiniMax-M2.7-highspeed`.
</Accordion>
<Accordion title="Moonshot AI (Kimi)">
```json5
{
env: { MOONSHOT_API_KEY: "sk-..." },
agents: {
defaults: {
model: { primary: "moonshot/kimi-k2.6" },
models: { "moonshot/kimi-k2.6": { alias: "Kimi K2.6" } },
},
},
models: {
mode: "merge",
providers: {
moonshot: {
baseUrl: "https://api.moonshot.ai/v1",
apiKey: "${MOONSHOT_API_KEY}",
api: "openai-completions",
models: [
{
id: "kimi-k2.6",
name: "Kimi K2.6",
reasoning: false,
input: ["text", "image"],
cost: { input: 0.95, output: 4, cacheRead: 0.16, cacheWrite: 0 },
contextWindow: 262144,
maxTokens: 262144,
},
],
},
},
},
}
```
For the China endpoint: `baseUrl: "https://api.moonshot.cn/v1"` or `openclaw onboard --auth-choice moonshot-api-key-cn`.
Native Moonshot endpoints advertise streaming usage compatibility on the shared `openai-completions` transport, and OpenClaw keys that off endpoint capabilities rather than the built-in provider id alone.
</Accordion>
<Accordion title="OpenCode">
```json5
{
agents: {
defaults: {
model: { primary: "opencode/claude-opus-4-6" },
models: { "opencode/claude-opus-4-6": { alias: "Opus" } },
},
},
}
```
Set `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`). Use `opencode/...` refs for the Zen catalog or `opencode-go/...` refs for the Go catalog. Shortcut: `openclaw onboard --auth-choice opencode-zen` or `openclaw onboard --auth-choice opencode-go`.
</Accordion>
<Accordion title="Synthetic (Anthropic-compatible)">
```json5
{
env: { SYNTHETIC_API_KEY: "sk-..." },
agents: {
defaults: {
model: { primary: "synthetic/hf:MiniMaxAI/MiniMax-M2.5" },
models: { "synthetic/hf:MiniMaxAI/MiniMax-M2.5": { alias: "MiniMax M2.5" } },
},
},
models: {
mode: "merge",
providers: {
synthetic: {
baseUrl: "https://api.synthetic.new/anthropic",
apiKey: "${SYNTHETIC_API_KEY}",
api: "anthropic-messages",
models: [
{
id: "hf:MiniMaxAI/MiniMax-M2.5",
name: "MiniMax M2.5",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 192000,
maxTokens: 65536,
},
],
},
},
},
}
```
Base URL should omit `/v1` (Anthropic client appends it). Shortcut: `openclaw onboard --auth-choice synthetic-api-key`.
</Accordion>
<Accordion title="Z.AI (GLM-4.7)">
```json5
{
agents: {
defaults: {
model: { primary: "zai/glm-4.7" },
models: { "zai/glm-4.7": {} },
},
},
}
```
Set `ZAI_API_KEY`. Model refs use the canonical `zai/*` provider ID. Shortcut: `openclaw onboard --auth-choice zai-api-key`.
- General endpoint: `https://api.z.ai/api/paas/v4`
- Coding endpoint: `https://api.z.ai/api/coding/paas/v4`
- The default `zai-api-key` auth choice probes your key and auto-detects which endpoint it belongs to (falling back to a prompt, defaulting to Global, if detection is inconclusive). Dedicated CN and Coding-Plan auth choices are also available for explicit selection.
- For the general endpoint, define a custom provider with the base URL override.
</Accordion>
</AccordionGroup>
---
## Related
- [Configuration — agents](/gateway/config-agents)
- [Configuration — channels](/gateway/config-channels)
- [Configuration reference](/gateway/configuration-reference) — other top-level keys
- [Tools and plugins](/tools)

View File

@@ -0,0 +1,706 @@
---
summary: "Schema-accurate configuration examples for common OpenClaw setups"
read_when:
- Learning how to configure OpenClaw
- Looking for configuration examples
- Setting up OpenClaw for the first time
title: "Configuration examples"
---
Examples below are aligned with the current config schema. For the exhaustive reference and per-field notes, see [Configuration](/gateway/configuration).
## Quick start
### Absolute minimum
```json5
{
agents: { defaults: { workspace: "~/.openclaw/workspace" } },
channels: { whatsapp: { allowFrom: ["+15555550123"] } },
}
```
Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number.
### Recommended starter
```json5
{
agents: {
defaults: {
workspace: "~/.openclaw/workspace",
model: { primary: "anthropic/claude-sonnet-4-6" },
},
list: [
{
id: "main",
identity: {
name: "Clawd",
theme: "helpful assistant",
emoji: "🦞",
},
},
],
},
channels: {
whatsapp: {
allowFrom: ["+15555550123"],
groups: { "*": { requireMention: true } },
},
},
messages: {
visibleReplies: "automatic",
groupChat: {
visibleReplies: "message_tool", // opt-in; visible output requires message(action=send)
unmentionedInbound: "room_event",
},
},
}
```
## Expanded example (major options)
> JSON5 lets you use comments and trailing commas. Regular JSON works too.
```json5
{
// Environment + shell
env: {
OPENROUTER_API_KEY: "sk-or-...",
vars: {
GROQ_API_KEY: "gsk-...",
},
shellEnv: {
enabled: true,
timeoutMs: 15000,
},
},
// Auth profile metadata (secrets live in auth-profiles.json)
auth: {
profiles: {
"anthropic:default": { provider: "anthropic", mode: "api_key" },
"anthropic:work": { provider: "anthropic", mode: "api_key" },
"openai:default": { provider: "openai", mode: "api_key" },
"openai:personal": { provider: "openai", mode: "oauth" },
},
order: {
anthropic: ["anthropic:default", "anthropic:work"],
openai: ["openai:personal", "openai:default"],
},
},
// Identity is per agent — set it on agents.list[].identity below.
// Logging
logging: {
level: "info",
file: "/tmp/openclaw/openclaw.log",
consoleLevel: "info",
consoleStyle: "pretty",
redactSensitive: "tools",
},
// Message formatting
messages: {
messagePrefix: "[openclaw]",
visibleReplies: "automatic",
responsePrefix: ">",
ackReaction: "👀",
ackReactionScope: "group-mentions",
groupChat: {
historyLimit: 50,
visibleReplies: "message_tool", // opt in for shared rooms with tool-reliable models
unmentionedInbound: "room_event",
},
queue: {
mode: "followup",
debounceMs: 500,
cap: 20,
drop: "summarize",
byChannel: {
whatsapp: "followup",
telegram: "followup",
discord: "collect",
slack: "collect",
signal: "followup",
imessage: "followup",
webchat: "followup",
},
},
},
// Tooling
tools: {
media: {
audio: {
enabled: true,
maxBytes: 20971520,
models: [
{ provider: "openai", model: "gpt-4o-transcribe" },
// Optional CLI fallback (Whisper binary):
// { type: "cli", command: "whisper", args: ["--model", "base", "{{MediaPath}}"] }
],
timeoutSeconds: 120,
},
video: {
enabled: true,
maxBytes: 52428800,
models: [{ provider: "google", model: "gemini-3-flash-preview" }],
},
},
},
// Session behavior
session: {
scope: "per-sender",
dmScope: "per-channel-peer", // recommended for multi-user inboxes
reset: {
mode: "daily",
atHour: 4,
idleMinutes: 60,
},
resetByChannel: {
discord: { mode: "idle", idleMinutes: 10080 },
},
resetTriggers: ["/new", "/reset"],
store: "~/.openclaw/agents/main/sessions/sessions.json",
maintenance: {
mode: "warn",
pruneAfter: "30d",
maxEntries: 500,
resetArchiveRetention: "30d", // duration or false
maxDiskBytes: "500mb", // optional
highWaterBytes: "400mb", // optional (defaults to 80% of maxDiskBytes)
},
typingIntervalSeconds: 5,
sendPolicy: {
default: "allow",
rules: [{ action: "deny", match: { channel: "discord", chatType: "group" } }],
},
},
// Channels
channels: {
whatsapp: {
dmPolicy: "pairing",
allowFrom: ["+15555550123"],
groupPolicy: "allowlist",
groupAllowFrom: ["+15555550123"],
groups: { "*": { requireMention: true } },
},
telegram: {
enabled: true,
botToken: "YOUR_TELEGRAM_BOT_TOKEN",
allowFrom: ["123456789"],
groupPolicy: "allowlist",
groupAllowFrom: ["123456789"],
groups: { "*": { requireMention: true } },
},
discord: {
enabled: true,
token: "YOUR_DISCORD_BOT_TOKEN",
dm: { enabled: true, allowFrom: ["123456789012345678"] },
guilds: {
"123456789012345678": {
slug: "friends-of-openclaw",
requireMention: false,
channels: {
general: { enabled: true },
help: { enabled: true, requireMention: true },
},
},
},
},
slack: {
enabled: true,
botToken: "xoxb-REPLACE_ME",
appToken: "xapp-REPLACE_ME",
channels: {
"#general": { enabled: true, requireMention: true },
},
dm: { enabled: true, allowFrom: ["U123"] },
slashCommand: {
enabled: true,
name: "openclaw",
sessionPrefix: "slack:slash",
ephemeral: true,
},
},
},
// Agent runtime
agents: {
defaults: {
workspace: "~/.openclaw/workspace",
userTimezone: "America/Chicago",
model: {
primary: "anthropic/claude-sonnet-4-6",
fallbacks: ["anthropic/claude-opus-4-6", "openai/gpt-5.4"],
},
imageModel: {
primary: "openrouter/anthropic/claude-sonnet-4-6",
},
models: {
"anthropic/claude-opus-4-6": { alias: "opus" },
"anthropic/claude-sonnet-4-6": { alias: "sonnet" },
"openai/gpt-5.4": { alias: "gpt" },
},
skills: ["github", "weather"], // inherited by agents that omit list[].skills
thinkingDefault: "low",
verboseDefault: "off",
toolProgressDetail: "explain",
reasoningDefault: "off",
elevatedDefault: "on",
blockStreamingDefault: "off",
blockStreamingBreak: "text_end",
blockStreamingChunk: {
minChars: 800,
maxChars: 1200,
breakPreference: "paragraph",
},
blockStreamingCoalesce: {
idleMs: 1000,
},
humanDelay: {
mode: "natural",
},
timeoutSeconds: 600,
mediaMaxMb: 5,
typingIntervalSeconds: 5,
maxConcurrent: 3,
heartbeat: {
every: "30m",
model: "anthropic/claude-sonnet-4-6",
target: "last",
directPolicy: "allow", // allow (default) | block
to: "+15555550123",
prompt: "HEARTBEAT",
ackMaxChars: 300,
},
memorySearch: {
provider: "gemini",
model: "gemini-embedding-001",
remote: {
apiKey: "${GEMINI_API_KEY}",
},
extraPaths: ["../team-docs", "/srv/shared-notes"],
},
sandbox: {
mode: "non-main",
scope: "session", // preferred over legacy perSession: true
workspaceRoot: "~/.openclaw/sandboxes",
docker: {
image: "openclaw-sandbox:bookworm-slim",
workdir: "/workspace",
readOnlyRoot: true,
tmpfs: ["/tmp", "/var/tmp", "/run"],
network: "none",
user: "1000:1000",
},
browser: {
enabled: false,
},
},
},
list: [
{
id: "main",
default: true,
identity: {
name: "Samantha",
theme: "helpful sloth",
emoji: "🦥",
},
// inherits defaults.skills -> github, weather
groupChat: {
mentionPatterns: ["@openclaw", "openclaw"],
},
thinkingDefault: "high", // per-agent thinking override
reasoningDefault: "on", // per-agent reasoning visibility
fastModeDefault: false, // per-agent fast mode
},
{
id: "quick",
skills: [], // no skills for this agent
fastModeDefault: true, // this agent always runs fast
thinkingDefault: "off",
},
],
},
tools: {
allow: ["exec", "process", "read", "write", "edit", "apply_patch"],
deny: ["browser", "canvas"],
exec: {
backgroundMs: 10000,
timeoutSec: 1800,
cleanupMs: 1800000,
},
elevated: {
enabled: true,
allowFrom: {
whatsapp: ["+15555550123"],
telegram: ["123456789"],
discord: ["123456789012345678"],
slack: ["U123"],
signal: ["+15555550123"],
imessage: ["user@example.com"],
webchat: ["session:demo"],
},
},
},
// Custom model providers
models: {
mode: "merge",
providers: {
"custom-proxy": {
baseUrl: "http://localhost:4000/v1",
apiKey: "LITELLM_KEY",
api: "openai-responses",
authHeader: true,
headers: { "X-Proxy-Region": "us-west" },
models: [
{
id: "llama-3.1-8b",
name: "Llama 3.1 8B",
api: "openai-responses",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 32000,
},
],
},
},
},
// Cron jobs
cron: {
enabled: true,
store: "~/.openclaw/cron/jobs.json",
maxConcurrentRuns: 8, // default; cron dispatch + isolated cron agent-turn execution
sessionRetention: "24h",
runLog: {
maxBytes: "2mb",
keepLines: 2000,
},
},
// Webhooks
hooks: {
enabled: true,
path: "/hooks",
token: "shared-secret",
presets: ["gmail"],
transformsDir: "~/.openclaw/hooks/transforms",
mappings: [
{
id: "gmail-hook",
match: { path: "gmail" },
action: "agent",
wakeMode: "now",
name: "Gmail",
sessionKey: "hook:gmail:{{messages[0].id}}",
messageTemplate: "From: {{messages[0].from}}\nSubject: {{messages[0].subject}}",
textTemplate: "{{messages[0].snippet}}",
deliver: true,
channel: "last",
to: "+15555550123",
thinking: "low",
timeoutSeconds: 300,
transform: {
module: "gmail.js",
export: "transformGmail",
},
},
],
gmail: {
account: "openclaw@gmail.com",
label: "INBOX",
topic: "projects/<project-id>/topics/gog-gmail-watch",
subscription: "gog-gmail-watch-push",
pushToken: "shared-push-token",
hookUrl: "http://127.0.0.1:18789/hooks/gmail",
includeBody: true,
maxBytes: 20000,
renewEveryMinutes: 720,
serve: { bind: "127.0.0.1", port: 8788, path: "/" },
tailscale: { mode: "funnel", path: "/gmail-pubsub" },
},
},
// Gateway + networking
gateway: {
mode: "local",
port: 18789,
bind: "loopback",
controlUi: { enabled: true, basePath: "/openclaw" },
auth: {
mode: "token",
token: "gateway-token",
allowTailscale: true,
},
tailscale: { mode: "serve", resetOnExit: false },
remote: { url: "ws://gateway-host.ts.net:18789", token: "remote-token" },
reload: { mode: "hybrid", debounceMs: 300 },
},
skills: {
allowBundled: ["gemini", "peekaboo"],
load: {
extraDirs: ["~/Projects/agent-scripts/skills"],
allowSymlinkTargets: ["~/Projects/agent-scripts/skills"],
},
install: {
preferBrew: true,
nodeManager: "npm", // npm | pnpm | yarn | bun
allowUploadedArchives: false,
},
entries: {
"image-lab": {
enabled: true,
apiKey: "GEMINI_KEY_HERE",
env: { GEMINI_API_KEY: "GEMINI_KEY_HERE" },
},
peekaboo: { enabled: true },
},
},
}
```
### Symlinked sibling skill repo
Use this when a built-in skill root contains a symlink into a sibling repo, for
example `~/.agents/skills/manager -> ~/Projects/manager/skills`.
```json5
{
skills: {
load: {
extraDirs: ["~/Projects/manager/skills"],
allowSymlinkTargets: ["~/Projects/manager/skills"],
},
},
}
```
- `extraDirs` scans the sibling repo as an explicit skill root.
- `allowSymlinkTargets` lets symlinked skill folders resolve into that trusted
real target root without allowing arbitrary symlink escapes.
- To let Skill Workshop apply write through the same trusted symlink target,
set `skills.workshop.allowSymlinkTargetWrites: true`.
## Common patterns
### Shared skill baseline with one override
```json5
{
agents: {
defaults: {
workspace: "~/.openclaw/workspace",
skills: ["github", "weather"],
},
list: [
{ id: "main", default: true },
{ id: "docs", workspace: "~/.openclaw/workspace-docs", skills: ["docs-search"] },
],
},
}
```
- `agents.defaults.skills` is the shared baseline.
- `agents.list[].skills` replaces that baseline for one agent.
- Use `skills: []` when an agent should see no skills.
### Multi-platform setup
```json5
{
agents: { defaults: { workspace: "~/.openclaw/workspace" } },
channels: {
whatsapp: { allowFrom: ["+15555550123"] },
telegram: {
enabled: true,
botToken: "YOUR_TOKEN",
allowFrom: ["123456789"],
},
discord: {
enabled: true,
token: "YOUR_TOKEN",
dm: { allowFrom: ["123456789012345678"] },
},
},
}
```
### Trusted node network auto-approval
Keep device pairing manual unless you control the network path. For a dedicated
lab or tailnet subnet, you can opt in to first-time node device auto-approval
with exact CIDRs or IPs:
```json5
{
gateway: {
nodes: {
pairing: {
autoApproveCidrs: ["192.168.1.0/24", "fd00:1234:5678::/64"],
},
},
},
}
```
This remains off when unset. It only applies to fresh `role: node` pairing with
no requested scopes. Operator/browser clients and role, scope, metadata, or
public-key upgrades still require manual approval.
### Secure DM mode (shared inbox / multi-user DMs)
If more than one person can DM your bot (multiple entries in `allowFrom`, pairing approvals for multiple people, or `dmPolicy: "open"`), enable **secure DM mode** so DMs from different senders don't share one context by default:
```json5
{
// Secure DM mode (recommended for multi-user or sensitive DM agents)
session: { dmScope: "per-channel-peer" },
channels: {
// Example: WhatsApp multi-user inbox
whatsapp: {
dmPolicy: "allowlist",
allowFrom: ["+15555550123", "+15555550124"],
},
// Example: Discord multi-user inbox
discord: {
enabled: true,
token: "YOUR_DISCORD_BOT_TOKEN",
dm: { enabled: true, allowFrom: ["123456789012345678", "987654321098765432"] },
},
},
}
```
For Discord/Google Chat/IRC/Mattermost/Microsoft Teams/Slack, sender authorization is ID-first by default.
Only enable direct mutable name/email/nick matching with each channel's `dangerouslyAllowNameMatching: true` if you explicitly accept that risk.
### Anthropic API key + MiniMax fallback
```json5
{
auth: {
profiles: {
"anthropic:api": {
provider: "anthropic",
mode: "api_key",
},
},
order: {
anthropic: ["anthropic:api"],
},
},
models: {
providers: {
minimax: {
baseUrl: "https://api.minimax.io/anthropic",
api: "anthropic-messages",
apiKey: "${MINIMAX_API_KEY}",
},
},
},
agents: {
defaults: {
workspace: "~/.openclaw/workspace",
model: {
primary: "anthropic/claude-opus-4-6",
fallbacks: ["minimax/MiniMax-M2.7"],
},
},
},
}
```
### Work bot (restricted access)
```json5
{
agents: {
defaults: {
workspace: "~/work-openclaw",
elevatedDefault: "off",
},
list: [
{
id: "main",
identity: {
name: "WorkBot",
theme: "professional assistant",
},
},
],
},
channels: {
slack: {
enabled: true,
botToken: "xoxb-...",
channels: {
"#engineering": { enabled: true, requireMention: true },
"#general": { enabled: true, requireMention: true },
},
},
},
}
```
### Local models only
```json5
{
agents: {
defaults: {
workspace: "~/.openclaw/workspace",
model: { primary: "lmstudio/my-local-model" },
},
},
models: {
mode: "merge",
providers: {
lmstudio: {
baseUrl: "http://127.0.0.1:1234/v1",
apiKey: "lmstudio",
api: "openai-responses",
models: [
{
id: "my-local-model",
name: "Local Model",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 196608,
maxTokens: 8192,
},
],
},
},
},
}
```
## Tips
- If you set `dmPolicy: "open"`, the matching `allowFrom` list must include `"*"`.
- Provider IDs differ (phone numbers, user IDs, channel IDs). Use the provider docs to confirm the format.
- Optional sections to add later: `web`, `browser`, `ui`, `discovery`, `plugins`, `talk`, `signal`, `imessage`.
- See [Providers](/providers) and [Troubleshooting](/gateway/troubleshooting) for deeper setup notes.
## Related
- [Configuration reference](/gateway/configuration-reference)
- [Configuration](/gateway/configuration)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,749 @@
---
summary: "Configuration overview: common tasks, quick setup, and links to the full reference"
read_when:
- Setting up OpenClaw for the first time
- Looking for common configuration patterns
- Navigating to specific config sections
title: "Configuration"
---
OpenClaw reads an optional <Tooltip tip="JSON5 supports comments and trailing commas">**JSON5**</Tooltip> config from `~/.openclaw/openclaw.json`. If the file is missing, OpenClaw uses safe defaults.
The active config path must be a regular file. OpenClaw-owned writes replace it atomically (rename onto the path), so a symlinked `openclaw.json` gets its target replaced rather than written through - avoid symlinked config layouts. If you keep config outside the default state directory, point `OPENCLAW_CONFIG_PATH` directly at the real file.
Common reasons to add a config:
- Connect channels and control who can message the bot
- Set models, tools, sandboxing, or automation (cron, hooks)
- Tune sessions, media, networking, or UI
See the [full reference](/gateway/configuration-reference) for every available field.
Agents and automation should use `config.schema.lookup` for exact field-level
docs before editing config. Use this page for task-oriented guidance and
[Configuration reference](/gateway/configuration-reference) for the broader
field map and defaults.
<Tip>
**New to configuration?** Start with `openclaw onboard` for interactive setup, or check out the [Configuration Examples](/gateway/configuration-examples) guide for complete copy-paste configs.
</Tip>
## Minimal config
```json5
// ~/.openclaw/openclaw.json
{
agents: { defaults: { workspace: "~/.openclaw/workspace" } },
channels: { whatsapp: { allowFrom: ["+15555550123"] } },
}
```
## Editing config
<Tabs>
<Tab title="Interactive wizard">
```bash
openclaw onboard # full onboarding flow
openclaw configure # config wizard
```
</Tab>
<Tab title="CLI (one-liners)">
```bash
openclaw config get agents.defaults.workspace
openclaw config set agents.defaults.heartbeat.every "2h"
openclaw config unset plugins.entries.brave.config.webSearch.apiKey
```
</Tab>
<Tab title="Control UI">
Open [http://127.0.0.1:18789](http://127.0.0.1:18789) and use the **Config** tab.
The Control UI renders a form from the live config schema, including field
`title` / `description` docs metadata plus plugin and channel schemas when
available, with a **Raw JSON** editor as an escape hatch. For drill-down
UIs and other tooling, the gateway also exposes `config.schema.lookup` to
fetch one path-scoped schema node plus immediate child summaries.
</Tab>
<Tab title="Direct edit">
Edit `~/.openclaw/openclaw.json` directly. The Gateway watches the file and applies changes automatically (see [hot reload](#config-hot-reload)).
</Tab>
</Tabs>
## Strict validation
<Warning>
OpenClaw only accepts configurations that fully match the schema. Unknown keys, malformed types, or invalid values cause the Gateway to **refuse to start**. The only root-level exception is `$schema` (string), so editors can attach JSON Schema metadata.
</Warning>
`openclaw config schema` prints the canonical JSON Schema used by Control UI
and validation. `config.schema.lookup` fetches a single path-scoped node plus
child summaries for drill-down tooling. Field `title`/`description` docs metadata
carries through nested objects, wildcard (`*`), array-item (`[]`), and `anyOf`/
`oneOf`/`allOf` branches. Runtime plugin and channel schemas merge in when the
manifest registry is loaded.
When validation fails:
- The Gateway does not boot
- Only diagnostic commands work (`openclaw doctor`, `openclaw logs`, `openclaw health`, `openclaw status`)
- Run `openclaw doctor` to see exact issues
- Run `openclaw doctor --fix` (`--repair` is the same flag; `--yes` skips prompts) to apply repairs
The Gateway keeps a trusted last-known-good copy after each successful startup,
but startup and hot reload do not restore it automatically - only `openclaw doctor --fix`
does. If `openclaw.json` fails validation (including plugin-local validation), Gateway
startup fails or the reload is skipped and the current runtime keeps the last accepted
config. A rejected write is also saved as `<path>.rejected.<timestamp>` for inspection.
The Gateway blocks writes that look like accidental clobbers - dropping `gateway.mode`,
losing the `meta` block, or shrinking the file by more than half - unless the write
explicitly allows destructive changes. Promotion to last-known-good is skipped when a
candidate contains a redacted secret placeholder such as `***` or `[redacted]`.
## Common tasks
<AccordionGroup>
<Accordion title="Set up a channel (WhatsApp, Telegram, Discord, etc.)">
Each channel has its own config section under `channels.<provider>`. See the dedicated channel page for setup steps:
- [Discord](/channels/discord) - `channels.discord`
- [Feishu](/channels/feishu) - `channels.feishu`
- [Google Chat](/channels/googlechat) - `channels.googlechat`
- [iMessage](/channels/imessage) - `channels.imessage`
- [Mattermost](/channels/mattermost) - `channels.mattermost`
- [Microsoft Teams](/channels/msteams) - `channels.msteams`
- [Signal](/channels/signal) - `channels.signal`
- [Slack](/channels/slack) - `channels.slack`
- [Telegram](/channels/telegram) - `channels.telegram`
- [WhatsApp](/channels/whatsapp) - `channels.whatsapp`
All channels share the same DM policy pattern:
```json5
{
channels: {
telegram: {
enabled: true,
botToken: "123:abc",
dmPolicy: "pairing", // pairing | allowlist | open | disabled
allowFrom: ["tg:123"], // only for allowlist/open
},
},
}
```
</Accordion>
<Accordion title="Choose and configure models">
Set the primary model and optional fallbacks:
```json5
{
agents: {
defaults: {
model: {
primary: "anthropic/claude-sonnet-4-6",
fallbacks: ["openai/gpt-5.4"],
},
models: {
"anthropic/claude-sonnet-4-6": { alias: "Sonnet" },
"openai/gpt-5.4": { alias: "GPT" },
},
},
},
}
```
- `agents.defaults.models` defines the model catalog and acts as the allowlist for `/model`; `provider/*` entries filter `/model`, `/models`, and model pickers to selected providers while still using dynamic model discovery.
- Use `openclaw config set agents.defaults.models '<json>' --strict-json --merge` to add allowlist entries without removing existing models. Plain replacements that would remove entries are rejected unless you pass `--replace`.
- Model refs use `provider/model` format (e.g. `anthropic/claude-opus-4-6`).
- `agents.defaults.imageMaxDimensionPx` controls transcript/tool image downscaling (default `1200`); lower values usually reduce vision-token usage on screenshot-heavy runs.
- See [Models CLI](/concepts/models) for switching models in chat and [Model Failover](/concepts/model-failover) for auth rotation and fallback behavior.
- For custom/self-hosted providers, see [Custom providers](/gateway/config-tools#custom-providers-and-base-urls) in the reference.
</Accordion>
<Accordion title="Control who can message the bot">
DM access is controlled per channel via `dmPolicy` (default `"pairing"`):
- `"pairing"`: unknown senders get a one-time pairing code to approve
- `"allowlist"`: only senders in `allowFrom` (or the paired allow store)
- `"open"`: allow all inbound DMs (requires `allowFrom: ["*"]`)
- `"disabled"`: ignore all DMs
For groups, use `groupPolicy` (`"allowlist" | "open" | "disabled"`) plus `groupAllowFrom` or channel-specific allowlists.
See the [full reference](/gateway/config-channels#dm-and-group-access) for per-channel details.
</Accordion>
<Accordion title="Set up group chat mention gating">
Group messages default to **require mention**. Configure trigger patterns per agent. Normal group/channel replies post automatically; opt into the message-tool path for shared rooms where the agent should decide when to speak:
```json5
{
messages: {
visibleReplies: "automatic", // set "message_tool" to require message-tool sends everywhere
groupChat: {
visibleReplies: "message_tool", // opt-in; visible output requires message(action=send)
unmentionedInbound: "room_event", // unmentioned always-on group chatter is quiet context
},
},
agents: {
list: [
{
id: "main",
groupChat: {
mentionPatterns: ["@openclaw", "openclaw"],
},
},
],
},
channels: {
whatsapp: {
groups: { "*": { requireMention: true } },
},
},
}
```
- **Metadata mentions**: native @-mentions (WhatsApp tap-to-mention, Telegram @bot, etc.)
- **Text patterns**: safe regex patterns in `mentionPatterns`
- **Visible replies**: `messages.visibleReplies` can require message-tool sends globally; `messages.groupChat.visibleReplies` overrides that for groups/channels.
- See [full reference](/gateway/config-channels#group-chat-mention-gating) for visible reply modes, per-channel overrides, and self-chat mode.
</Accordion>
<Accordion title="Restrict skills per agent">
Use `agents.defaults.skills` for a shared baseline, then override specific
agents with `agents.list[].skills`:
```json5
{
agents: {
defaults: {
skills: ["github", "weather"],
},
list: [
{ id: "writer" }, // inherits github, weather
{ id: "docs", skills: ["docs-search"] }, // replaces defaults
{ id: "locked-down", skills: [] }, // no skills
],
},
}
```
- Omit `agents.defaults.skills` for unrestricted skills by default.
- Omit `agents.list[].skills` to inherit the defaults.
- Set `agents.list[].skills: []` for no skills.
- See [Skills](/tools/skills), [Skills config](/tools/skills-config), and
the [Configuration Reference](/gateway/config-agents#agents-defaults-skills).
</Accordion>
<Accordion title="Tune gateway channel health monitoring">
Control how aggressively the gateway restarts channels that look stale:
```json5
{
gateway: {
channelHealthCheckMinutes: 5,
channelStaleEventThresholdMinutes: 30,
channelMaxRestartsPerHour: 10,
},
channels: {
telegram: {
healthMonitor: { enabled: false },
accounts: {
alerts: {
healthMonitor: { enabled: true },
},
},
},
},
}
```
- Values shown are the defaults. Set `gateway.channelHealthCheckMinutes: 0` to disable health-monitor restarts globally.
- `channelStaleEventThresholdMinutes` should be greater than or equal to the check interval.
- Use `channels.<provider>.healthMonitor.enabled` or `channels.<provider>.accounts.<id>.healthMonitor.enabled` to disable auto-restarts for one channel or account without disabling the global monitor.
- See [Health Checks](/gateway/health) for operational debugging and the [full reference](/gateway/configuration-reference#gateway) for all fields.
</Accordion>
<Accordion title="Tune gateway WebSocket handshake timeout">
Give local clients more time to complete the pre-auth WebSocket handshake on
loaded or low-powered hosts:
```json5
{
gateway: {
handshakeTimeoutMs: 30000,
},
}
```
- Default is `15000` milliseconds.
- `OPENCLAW_HANDSHAKE_TIMEOUT_MS` still takes precedence for one-off service or shell overrides.
- Prefer fixing startup/event-loop stalls first; this knob is for hosts that are healthy but slow during warmup.
</Accordion>
<Accordion title="Configure sessions and resets">
Sessions control conversation continuity and isolation:
```json5
{
session: {
dmScope: "per-channel-peer", // recommended for multi-user
threadBindings: {
enabled: true,
idleHours: 24,
maxAgeHours: 0,
},
reset: {
mode: "daily",
atHour: 4,
idleMinutes: 120,
},
},
}
```
- `dmScope`: `main` (shared) | `per-peer` | `per-channel-peer` | `per-account-channel-peer`
- `threadBindings`: global defaults for thread-bound session routing. `/focus`, `/unfocus`, `/agents`, `/session idle`, and `/session max-age` bind, unbind, list, and tune this per session (Discord binds threads, Telegram binds topics/conversations).
- See [Session Management](/concepts/session) for scoping, identity links, and send policy.
- See [full reference](/gateway/config-agents#session) for all fields.
</Accordion>
<Accordion title="Enable sandboxing">
Run agent sessions in isolated sandbox runtimes:
```json5
{
agents: {
defaults: {
sandbox: {
mode: "non-main", // off | non-main | all
scope: "agent", // session | agent | shared
},
},
},
}
```
Build the image first - from a source checkout run `scripts/sandbox-setup.sh`, or from an npm install see the inline `docker build` command in [Sandboxing § Images and setup](/gateway/sandboxing#images-and-setup).
See [Sandboxing](/gateway/sandboxing) for the full guide and [full reference](/gateway/config-agents#agentsdefaultssandbox) for all options.
</Accordion>
<Accordion title="Enable relay-backed push for official iOS builds">
Relay-backed push for public App Store builds uses the hosted OpenClaw relay: `https://ios-push-relay.openclaw.ai`.
Custom relay deployments require a deliberately separate iOS build/deployment path whose relay URL matches the gateway relay URL. If you are using a custom relay build, set this in gateway config:
```json5
{
gateway: {
push: {
apns: {
relay: {
baseUrl: "https://relay.example.com",
// Optional. Default: 10000
timeoutMs: 10000,
},
},
},
},
}
```
CLI equivalent:
```bash
openclaw config set gateway.push.apns.relay.baseUrl https://relay.example.com
```
What this does:
- Lets the gateway send `push.test`, wake nudges, and reconnect wakes through the external relay.
- Uses a registration-scoped send grant forwarded by the paired iOS app. The gateway does not need a deployment-wide relay token.
- Binds each relay-backed registration to the gateway identity that the iOS app paired with, so another gateway cannot reuse the stored registration.
- Keeps local/manual iOS builds on direct APNs. Relay-backed sends apply only to official distributed builds that registered through the relay.
- Must match the relay base URL baked into the iOS build, so registration and send traffic reach the same relay deployment.
End-to-end flow:
1. Install the official iOS app.
2. Optional: configure `gateway.push.apns.relay.baseUrl` on the gateway only when using a deliberately separate custom relay build.
3. Pair the iOS app to the gateway and let both node and operator sessions connect.
4. The iOS app fetches the gateway identity, registers with the relay using App Attest plus the app receipt, and then publishes the relay-backed `push.apns.register` payload to the paired gateway.
5. The gateway stores the relay handle and send grant, then uses them for `push.test`, wake nudges, and reconnect wakes.
Operational notes:
- If you switch the iOS app to a different gateway, reconnect the app so it can publish a new relay registration bound to that gateway.
- If you ship a new iOS build that points at a different relay deployment, the app refreshes its cached relay registration instead of reusing the old relay origin.
Compatibility note:
- `OPENCLAW_APNS_RELAY_BASE_URL` and `OPENCLAW_APNS_RELAY_TIMEOUT_MS` still work as temporary env overrides.
- Custom gateway relay URLs must match the relay base URL baked into the iOS build; the public App Store release lane rejects custom iOS relay URL overrides.
- `OPENCLAW_APNS_RELAY_ALLOW_HTTP=true` remains a loopback-only development escape hatch; do not persist HTTP relay URLs in config.
See [iOS App](/platforms/ios#relay-backed-push-for-official-builds) for the end-to-end flow and [Authentication and trust flow](/platforms/ios#authentication-and-trust-flow) for the relay security model.
</Accordion>
<Accordion title="Set up heartbeat (periodic check-ins)">
```json5
{
agents: {
defaults: {
heartbeat: {
every: "30m",
target: "last",
},
},
},
}
```
- `every`: duration string (`30m`, `2h`). Set `0m` to disable. Default: `30m`.
- `target`: `last` | `none` | `<channel-id>` (for example `discord`, `matrix`, `telegram`, or `whatsapp`)
- `directPolicy`: `allow` (default) or `block` for DM-style heartbeat targets
- See [Heartbeat](/gateway/heartbeat) for the full guide.
</Accordion>
<Accordion title="Configure cron jobs">
```json5
{
cron: {
enabled: true,
maxConcurrentRuns: 8, // default; cron dispatch + isolated cron agent-turn execution
sessionRetention: "24h",
runLog: {
maxBytes: "2mb",
keepLines: 2000,
},
},
}
```
- `sessionRetention`: prune completed isolated run sessions from `sessions.json` (default `24h`; set `false` to disable).
- `runLog`: prune retained cron run-history rows per job. History is stored in SQLite; `maxBytes` (default `2_000_000`) is retained for compatibility with older file-backed run logs, `keepLines` defaults to `2000`.
- See [Cron jobs](/automation/cron-jobs) for feature overview and CLI examples.
</Accordion>
<Accordion title="Set up webhooks (hooks)">
Enable HTTP webhook endpoints on the Gateway:
```json5
{
hooks: {
enabled: true,
token: "shared-secret",
path: "/hooks",
defaultSessionKey: "hook:ingress",
allowRequestSessionKey: false,
allowedSessionKeyPrefixes: ["hook:"],
mappings: [
{
match: { path: "gmail" },
action: "agent",
agentId: "main",
deliver: true,
},
],
},
}
```
Security note:
- Treat all hook/webhook payload content as untrusted input.
- Use a dedicated `hooks.token`; do not reuse active Gateway auth secrets (`gateway.auth.token` / `OPENCLAW_GATEWAY_TOKEN` or `gateway.auth.password` / `OPENCLAW_GATEWAY_PASSWORD`).
- Hook auth is header-only (`Authorization: Bearer ...` or `x-openclaw-token`); query-string tokens are rejected.
- `hooks.path` cannot be `/`; keep webhook ingress on a dedicated subpath such as `/hooks`.
- Keep unsafe-content bypass flags disabled (`hooks.gmail.allowUnsafeExternalContent`, `hooks.mappings[].allowUnsafeExternalContent`) unless doing tightly scoped debugging.
- If you enable `hooks.allowRequestSessionKey`, also set `hooks.allowedSessionKeyPrefixes` to bound caller-selected session keys.
- For hook-driven agents, prefer strong modern model tiers and strict tool policy (for example messaging-only plus sandboxing where possible).
See [full reference](/gateway/configuration-reference#hooks) for all mapping options and Gmail integration.
</Accordion>
<Accordion title="Configure multi-agent routing">
Run multiple isolated agents with separate workspaces and sessions:
```json5
{
agents: {
list: [
{ id: "home", default: true, workspace: "~/.openclaw/workspace-home" },
{ id: "work", workspace: "~/.openclaw/workspace-work" },
],
},
bindings: [
{ agentId: "home", match: { channel: "whatsapp", accountId: "personal" } },
{ agentId: "work", match: { channel: "whatsapp", accountId: "biz" } },
],
}
```
See [Multi-Agent](/concepts/multi-agent) and [full reference](/gateway/config-agents#multi-agent-routing) for binding rules and per-agent access profiles.
</Accordion>
<Accordion title="Split config into multiple files ($include)">
Use `$include` to organize large configs:
```json5
// ~/.openclaw/openclaw.json
{
gateway: { port: 18789 },
agents: { $include: "./agents.json5" },
broadcast: {
$include: ["./clients/a.json5", "./clients/b.json5"],
},
}
```
- **Single file**: replaces the containing object
- **Array of files**: deep-merged in order (later wins), up to 10 nested levels deep
- **Sibling keys**: merged after includes (override included values)
- **Relative paths**: resolved relative to the including file
- **Path format**: include paths must not contain null bytes and must be strictly shorter than 4096 characters before and after resolution
- **OpenClaw-owned writes**: when a write changes only one top-level section
backed by a single-file include such as `plugins: { $include: "./plugins.json5" }`,
OpenClaw updates that included file and leaves `openclaw.json` intact
- **Unsupported write-through**: root includes, include arrays, and includes
with sibling overrides fail closed for OpenClaw-owned writes instead of
flattening the config
- **Confinement**: `$include` paths must resolve under the directory holding
`openclaw.json`. To share a tree across machines or users, set
`OPENCLAW_INCLUDE_ROOTS` to a path-list (`:` on POSIX, `;` on Windows) of
additional directories that includes may reference. Symlinks are resolved
and re-checked, so a path that lexically lives in a config dir but whose
real target escapes every allowed root is still rejected.
- **Error handling**: clear errors for missing files, parse errors, circular includes, invalid path format, and excessive length
</Accordion>
</AccordionGroup>
## Config hot reload
The Gateway watches `~/.openclaw/openclaw.json` and applies changes automatically - no manual restart needed for most settings.
Direct file edits are treated as untrusted until they validate. The watcher waits
for editor temp-write/rename churn to settle, reads the final file, and rejects
invalid external edits without rewriting `openclaw.json`. OpenClaw-owned config
writes use the same schema gate before writing (see [Strict validation](#strict-validation)
for the clobber/rollback rules that apply to every write).
If you see `config reload skipped (invalid config)` or startup reports `Invalid
config`, inspect the config, run `openclaw config validate`, then run `openclaw
doctor --fix` for repair. See [Gateway troubleshooting](/gateway/troubleshooting#gateway-rejected-invalid-config)
for the checklist.
### Reload modes
| Mode | Behavior |
| ---------------------- | --------------------------------------------------------------------------------------- |
| **`hybrid`** (default) | Hot-applies safe changes instantly. Automatically restarts for critical ones. |
| **`hot`** | Hot-applies safe changes only. Logs a warning when a restart is needed - you handle it. |
| **`restart`** | Restarts the Gateway on any config change, safe or not. |
| **`off`** | Disables file watching. Changes take effect on the next manual restart. |
```json5
{
gateway: {
reload: { mode: "hybrid", debounceMs: 300 },
},
}
```
### What hot-applies vs what needs a restart
Most fields hot-apply without downtime; some hot-applied sections restart just that
subsystem (channel, cron, heartbeat, health monitor) rather than the whole Gateway. In
`hybrid` mode, Gateway-restart-required changes are handled automatically.
| Category | Fields | Gateway restart needed? |
| ------------------- | ----------------------------------------------------------------------- | ---------------------------- |
| Channels | `channels.*`, `web` (WhatsApp) - all built-in and plugin channels | No (restarts that channel) |
| Agent & models | `agent`, `agents`, `models`, `routing` | No |
| Automation | `hooks`, `cron`, `agent.heartbeat` | No (restarts that subsystem) |
| Sessions & messages | `session`, `messages` | No |
| Tools & media | `tools`, `skills`, `mcp`, `audio`, `talk` | No |
| Plugin config | `plugins.entries.*`, `plugins.allow`, `plugins.deny`, `plugins.enabled` | No (reloads plugin runtime) |
| UI & misc | `ui`, `logging`, `identity`, `bindings` | No |
| Gateway server | `gateway.*` (port, bind, auth, tailscale, TLS, HTTP, push) | **Yes** |
| Infrastructure | `discovery`, `browser`, `plugins.load`, `plugins.installs` | **Yes** |
<Note>
`gateway.reload` and `gateway.remote` are exceptions under `gateway.*` - changing them does **not** trigger a restart. Individual plugins can also override this table: a loaded plugin may declare its own restart-triggering config prefixes (for example the bundled Canvas plugin restarts the Gateway for `plugins.enabled`, `plugins.allow`, and `plugins.deny`, not just its own `plugins.entries.canvas`), so the actual behavior depends on which plugins are active.
</Note>
### Reload planning
When you edit a source file that is referenced through `$include`, OpenClaw plans
the reload from the source-authored layout, not the flattened in-memory view.
That keeps hot-reload decisions (hot-apply vs restart) predictable even when a
single top-level section lives in its own included file such as
`plugins: { $include: "./plugins.json5" }`. Reload planning fails closed if the
source layout is ambiguous.
## Config RPC (programmatic updates)
For tooling that writes config over the gateway API, prefer this flow:
- `config.schema.lookup` to inspect one subtree (shallow schema node + child
summaries)
- `config.get` to fetch the current snapshot plus `hash`
- `config.patch` for partial updates (JSON merge patch: objects merge, `null`
deletes, arrays replace when explicitly confirmed with `replacePaths` if
entries would be removed)
- `config.apply` only when you intend to replace the entire config
- `update.run` for explicit self-update plus restart; include `continuationMessage` when the post-restart session should run one follow-up turn
- `update.status` to inspect the latest update restart sentinel and verify the running version after a restart
Agents should treat `config.schema.lookup` as the first stop for exact
field-level docs and constraints. Use [Configuration reference](/gateway/configuration-reference)
when they need the broader config map, defaults, or links to dedicated
subsystem references.
<Note>
Control-plane writes (`config.apply`, `config.patch`, `update.run`) are
rate-limited to 3 requests per 60 seconds per `deviceId+clientIp`. Restart
requests coalesce and then enforce a 30-second cooldown between restart cycles.
`update.status` is read-only but admin-scoped because the restart sentinel can
include update step summaries and command output tails.
</Note>
Example partial patch:
```bash
openclaw gateway call config.get --params '{}' # capture payload.hash
openclaw gateway call config.patch --params '{
"raw": "{ channels: { telegram: { groups: { \"*\": { requireMention: false } } } } }",
"baseHash": "<hash>"
}'
```
Both `config.apply` and `config.patch` accept `raw`, `baseHash`, `sessionKey`,
`note`, and `restartDelayMs`. `baseHash` is required for both methods once a
config file already exists (a first write with no existing config skips the check).
`config.patch` also accepts `replacePaths`, an array of config paths whose array
replacement is intentional. If a patch would replace or delete an existing array
with fewer entries, the Gateway rejects the write unless that exact path appears
in `replacePaths`; nested arrays under array entries use `[]`, such as
`agents.list[].skills`. This prevents truncated `config.get` snapshots from
silently clobbering routing or allowlist arrays. Use `config.apply` when you
intend to replace the full config.
## Environment variables
OpenClaw reads env vars from the parent process plus:
- `.env` from the current working directory (if present)
- `~/.openclaw/.env` (global fallback)
Neither file overrides existing env vars. You can also set inline env vars in config:
```json5
{
env: {
OPENROUTER_API_KEY: "sk-or-...",
vars: { GROQ_API_KEY: "gsk-..." },
},
}
```
<Accordion title="Shell env import (optional)">
If enabled and expected keys aren't set, OpenClaw runs your login shell and imports only the missing keys:
```json5
{
env: {
shellEnv: { enabled: true, timeoutMs: 15000 },
},
}
```
Env var equivalent: `OPENCLAW_LOAD_SHELL_ENV=1`. Default `timeoutMs`: `15000`.
</Accordion>
<Accordion title="Env var substitution in config values">
Reference env vars in any config string value with `${VAR_NAME}`:
```json5
{
gateway: { auth: { token: "${OPENCLAW_GATEWAY_TOKEN}" } },
models: { providers: { custom: { apiKey: "${CUSTOM_API_KEY}" } } },
}
```
Rules:
- Only uppercase names matched: `[A-Z_][A-Z0-9_]*`
- Missing/empty vars throw an error at load time
- Escape with `$${VAR}` for literal output
- Works inside `$include` files
- Inline substitution: `"${BASE}/v1"` → `"https://api.example.com/v1"`
</Accordion>
<Accordion title="Secret refs (env, file, exec)">
For fields that support SecretRef objects, you can use:
```json5
{
models: {
providers: {
openai: { apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" } },
},
},
skills: {
entries: {
"image-lab": {
apiKey: {
source: "file",
provider: "filemain",
id: "/skills/entries/image-lab/apiKey",
},
},
},
},
channels: {
googlechat: {
serviceAccountRef: {
source: "exec",
provider: "vault",
id: "channels/googlechat/serviceAccount",
},
},
},
}
```
SecretRef details (including `secrets.providers` for `env`/`file`/`exec`) are in [Secrets Management](/gateway/secrets).
Supported credential paths are listed in [SecretRef Credential Surface](/reference/secretref-credential-surface).
</Accordion>
See [Environment](/help/environment) for full precedence and sources.
## Full reference
For the complete field-by-field reference, see **[Configuration Reference](/gateway/configuration-reference)**.
---
_Related: [Configuration Examples](/gateway/configuration-examples) · [Configuration Reference](/gateway/configuration-reference) · [Doctor](/gateway/doctor)_
## Related
- [Configuration reference](/gateway/configuration-reference)
- [Configuration examples](/gateway/configuration-examples)
- [Gateway runbook](/gateway)

200
docs/gateway/diagnostics.md Normal file
View File

@@ -0,0 +1,200 @@
---
summary: "Create shareable Gateway diagnostics bundles for bug reports"
title: "Diagnostics export"
read_when:
- Preparing a bug report or support request
- Debugging Gateway crashes, restarts, memory pressure, or oversized payloads
- Reviewing what diagnostics data is recorded or redacted
---
OpenClaw can build a local diagnostics `.zip` for bug reports: sanitized Gateway
status, health, logs, config shape, and recent payload-free stability events.
Treat diagnostics bundles like secrets until reviewed. Payloads and credentials
are redacted by design, but the bundle still summarizes local Gateway logs and
host-level runtime state.
## Quick start
```bash
openclaw gateway diagnostics export
```
Prints the written zip path. Choose an output path:
```bash
openclaw gateway diagnostics export --output openclaw-diagnostics.zip
```
For automation:
```bash
openclaw gateway diagnostics export --json
```
## Chat command
Owners can run `/diagnostics [note]` in any conversation to request a local
Gateway export as one copy-pasteable support report:
1. Send `/diagnostics`, optionally with a short note (`/diagnostics bad tool choice`).
2. OpenClaw sends a preamble and asks for one explicit exec approval, which runs
`openclaw gateway diagnostics export --json`. Do not approve diagnostics via
an allow-all rule.
3. After approval, OpenClaw replies with the local bundle path, manifest
summary, privacy notes, and relevant session ids.
In group chats, an owner can still run `/diagnostics`, but OpenClaw sends the
export result, approval prompts, and Codex session/thread breakdown to the
owner privately. The group only sees a short notice that diagnostics were sent
privately. If no private owner route exists, the command fails closed and asks
the owner to run it from a DM.
When the active session uses the native OpenAI Codex harness, the same exec
approval also covers an OpenAI feedback upload for the Codex threads OpenClaw
knows about. That upload is separate from the local Gateway zip and only
happens for Codex harness sessions. The approval prompt states that approving
also sends Codex feedback, without listing Codex session or thread ids. After
approval, the reply lists channels, OpenClaw session ids, Codex thread ids, and
local resume commands for the threads that were sent to OpenAI. Denying or
ignoring the approval skips the export, the Codex feedback upload, and the
Codex id list.
That makes the Codex debugging loop short: notice bad behavior in a channel,
run `/diagnostics`, approve once, share the report, then run the printed
`codex resume <thread-id>` command locally if you want to inspect the thread
yourself. See [Codex harness](/plugins/codex-harness#inspect-codex-threads-locally).
## What the export contains
- `summary.md`: human-readable overview for support.
- `diagnostics.json`: machine-readable summary of config, logs, status, health,
and stability data.
- `manifest.json`: export metadata and file list.
- Sanitized config shape and non-secret config details.
- Sanitized log summaries and recent redacted log lines.
- Best-effort Gateway status and health snapshots.
- `stability/latest.json`: newest persisted stability bundle, when available.
The export is still useful when the Gateway is unhealthy: if status/health
requests fail, local logs, config shape, and the latest stability bundle are
still collected when available.
## Privacy model
Kept: subsystem names, plugin ids, provider ids, channel ids, configured
modes, status codes, durations, byte counts, queue state, memory readings,
sanitized log metadata, redacted operational messages, config shape, and
non-secret feature settings.
Omitted or redacted: chat text, prompts, instructions, webhook bodies, tool
outputs, credentials, API keys, tokens, cookies, secret values, raw
request/response bodies, account ids, message ids, raw session ids,
hostnames, and local usernames.
When a log message looks like user, chat, prompt, or tool payload text, the
export keeps only that a message was omitted plus its byte count.
## Stability recorder
The Gateway records a bounded, payload-free stability stream by default when
diagnostics are enabled. It captures operational facts, not content.
The same heartbeat also samples liveness when the event loop or CPU looks
saturated, emitting `diagnostic.liveness.warning` events with event-loop delay,
event-loop utilization, CPU-core ratio, active/waiting/queued session counts,
the current startup/runtime phase (when known), recent phase spans, and
bounded work labels. These become Gateway `warn`-level log lines only when
work is waiting or queued, or when active work overlaps sustained event-loop
delay; otherwise they log at `debug`. Idle liveness samples are still recorded
as diagnostic events but never escalate to a warning by themselves.
Startup phases emit `diagnostic.phase.completed` events with wall-clock and
CPU timing. Stalled embedded-run diagnostics mark `terminalProgressStale=true`
when the last bridge progress looked terminal (for example a raw response
item or response-completion event) but the Gateway still considers the
embedded run active.
Inspect the live recorder:
```bash
openclaw gateway stability
openclaw gateway stability --type payload.large
openclaw gateway stability --json
```
Inspect the newest persisted bundle after a fatal exit, shutdown timeout, or
restart startup failure:
```bash
openclaw gateway stability --bundle latest
```
Create a diagnostics zip from the newest persisted bundle:
```bash
openclaw gateway stability --bundle latest --export
```
Persisted bundles live under `~/.openclaw/logs/stability/` when events exist.
## Useful options
```bash
openclaw gateway diagnostics export \
--output openclaw-diagnostics.zip \
--log-lines 5000 \
--log-bytes 1000000
```
| Flag | Default | Description |
| ----------------------- | ----------------------------------------------------------------------------- | -------------------------------------------------- |
| `--output <path>` | `$OPENCLAW_STATE_DIR/logs/support/openclaw-diagnostics-<timestamp>-<pid>.zip` | Write to a specific zip path (or directory). |
| `--log-lines <count>` | `5000` | Maximum sanitized log lines to include. |
| `--log-bytes <bytes>` | `1000000` | Maximum log bytes to inspect. |
| `--url <url>` | - | Gateway WebSocket URL for status/health snapshots. |
| `--token <token>` | - | Gateway token for status/health snapshots. |
| `--password <password>` | - | Gateway password for status/health snapshots. |
| `--timeout <ms>` | `3000` | Status/health snapshot timeout. |
| `--no-stability-bundle` | off | Skip persisted stability bundle lookup. |
| `--json` | off | Print machine-readable export metadata. |
## Disable diagnostics
Diagnostics are enabled by default. To disable the stability recorder and
diagnostic event collection:
```json5
{
diagnostics: {
enabled: false,
},
}
```
Disabling diagnostics reduces bug-report detail; it does not affect normal
Gateway logging.
Critical memory pressure snapshots are off by default. To capture the
pre-OOM stability snapshot in addition to normal diagnostics events:
```json5
{
diagnostics: {
memoryPressureSnapshot: true,
},
}
```
Use this only on hosts that can tolerate the extra file-system scan and
snapshot write during critical memory pressure. Normal memory pressure events
still record RSS, heap, threshold, and growth facts (`rss_threshold`,
`heap_threshold`, `rss_growth`) when the snapshot is off.
## Related
- [Health checks](/gateway/health)
- [Gateway CLI](/cli/gateway#gateway-diagnostics-export)
- [Gateway protocol](/gateway/protocol#rpc-method-families)
- [Logging](/logging)
- [OpenTelemetry export](/gateway/opentelemetry) - separate flow for streaming diagnostics to a collector

175
docs/gateway/discovery.md Normal file
View File

@@ -0,0 +1,175 @@
---
summary: "Node discovery and transports (Bonjour, Tailscale, SSH) for finding the gateway"
read_when:
- Implementing or changing Bonjour discovery/advertising
- Adjusting remote connection modes (direct vs SSH)
- Designing node discovery + pairing for remote nodes
title: "Discovery and transports"
---
OpenClaw has two related but distinct discovery problems:
1. **Operator remote control**: the macOS menu bar app controlling a gateway running elsewhere.
2. **Node pairing**: iOS/Android (and future nodes) finding a gateway and pairing securely.
All network discovery/advertising lives in the **Node Gateway**
(`openclaw gateway`); clients (mac app, iOS) are consumers only.
## Terms
- **Gateway**: a single long-running process that owns state (sessions,
pairing, node registry) and runs channels. Most setups use one per host;
isolated multi-gateway setups are possible.
- **Gateway WS (control plane)**: the WebSocket endpoint on `127.0.0.1:18789`
by default; bind it to LAN/tailnet via `gateway.bind`.
- **Direct WS transport**: a LAN/tailnet-facing Gateway WS endpoint (no SSH).
- **SSH transport (fallback)**: remote control by forwarding
`127.0.0.1:18789` over SSH.
- **Legacy TCP bridge (removed)**: older node transport (see
[Bridge protocol](/gateway/bridge-protocol)); no longer advertised for
discovery and no longer part of current builds.
Protocol details: [Gateway protocol](/gateway/protocol),
[Bridge protocol (legacy)](/gateway/bridge-protocol).
## Why direct and SSH both exist
- **Direct WS** is the best UX on the same network and within a tailnet: LAN
auto-discovery via Bonjour, pairing tokens and ACLs owned by the gateway,
and no shell access required.
- **SSH** is the universal fallback: works anywhere you have SSH access, even
across unrelated networks, survives multicast/mDNS issues, and needs no new
inbound port besides SSH.
## Discovery inputs
### 1) Bonjour / DNS-SD
Multicast Bonjour is best-effort and does not cross networks. OpenClaw also
supports browsing the same gateway beacon via a configured wide-area DNS-SD
domain, so discovery can cover both `local.` on the same LAN and a configured
unicast DNS-SD domain for cross-network discovery.
The **gateway** advertises its WS endpoint via Bonjour when the bundled
`bonjour` plugin is enabled; clients browse and show a "pick a gateway" list,
then store the chosen endpoint.
Troubleshooting and beacon details: [Bonjour](/gateway/bonjour).
#### Service beacon details
- Service type: `_openclaw-gw._tcp` (gateway transport beacon).
- TXT keys (non-secret):
| Key | Notes |
| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `role=gateway` | Always present. |
| `transport=gateway` | Always present. |
| `displayName=<name>` | Operator-configured display name. |
| `lanHost=<hostname>.local` | LAN mDNS advertiser only; not written by wide-area DNS-SD. |
| `gatewayPort=18789` | Gateway WS + HTTP port. |
| `gatewayTls=1` | Only when TLS is enabled. |
| `gatewayTlsSha256=<sha256>` | Only when TLS is enabled and a fingerprint is available. |
| `tailnetDns=<magicdns>` | Optional hint; auto-detected when Tailscale is available. |
| `sshPort=<port>` | Present only when `discovery.mdns.mode="full"`; omitted (SSH defaults to `22`) in the default `"minimal"` mode, on both the LAN advertiser and wide-area DNS-SD. |
| `cliPath=<path>` | Same `discovery.mdns.mode="full"` gate as `sshPort`; a remote-install hint for the CLI path. |
A `canvasPort` TXT key is defined in the plugin discovery contract for a
future canvas host port, but no current code path sets a value, so it is
never emitted today.
Security notes:
- Bonjour/mDNS TXT records are **unauthenticated**. Clients must treat TXT
values as UX hints only.
- Routing (host/port) should prefer the **resolved service endpoint**
(SRV + A/AAAA) over TXT-provided `lanHost`, `tailnetDns`, or `gatewayPort`.
- TLS pinning must never let an advertised `gatewayTlsSha256` override a
previously stored pin.
- iOS/Android nodes should require an explicit "trust this fingerprint"
confirmation before storing a first-time pin (out-of-band verification)
whenever the chosen route is secure/TLS-based.
Enable, disable, and override:
- `openclaw plugins enable bonjour` enables LAN multicast advertising.
- `discovery.mdns.mode` in `openclaw.json` controls mDNS broadcast:
`"minimal"` (default), `"full"` (adds `cliPath`/`sshPort` to both the LAN
beacon and any wide-area DNS-SD zone), or `"off"` (disables mDNS).
- `OPENCLAW_DISABLE_BONJOUR=1` force-disables advertising; `discovery.mdns.mode="off"`
disables it independently. `OPENCLAW_DISABLE_BONJOUR=0` is an explicit
opt-in that overrides the plugin's auto-disable inside a detected container
(Docker, containerd, Kubernetes, LXC); it does not override
`discovery.mdns.mode="off"`. The bundled `bonjour` plugin auto-starts on
macOS hosts (`enabledByDefaultOnPlatforms: ["darwin"]`) and auto-disables
inside detected containers; Linux, Windows, and other containerized
deployments need explicit `plugins enable bonjour`.
- `gateway.bind` in `~/.openclaw/openclaw.json` controls the Gateway bind mode.
- `OPENCLAW_SSH_PORT` overrides the advertised SSH port (only takes effect
when `discovery.mdns.mode="full"`).
- `OPENCLAW_TAILNET_DNS` publishes a `tailnetDns` hint (MagicDNS).
- `OPENCLAW_CLI_PATH` overrides the advertised CLI path.
### 2) Tailnet (cross-network)
For gateways on different physical networks, Bonjour will not help. The
recommended direct target is a Tailscale MagicDNS name (preferred) or a
stable tailnet IP.
If the gateway detects it is running under Tailscale, it publishes
`tailnetDns` as an optional hint for clients (including wide-area beacons).
The macOS app prefers MagicDNS names over raw Tailscale IPs for gateway
discovery, which stays reliable when tailnet IPs change (node restarts,
CGNAT reassignment) since MagicDNS resolves to the current IP automatically.
For mobile node pairing, discovery hints never relax transport security on
tailnet/public routes:
- iOS/Android still require a secure first-time tailnet/public connect path
(`wss://` or Tailscale Serve/Funnel).
- A discovered raw tailnet IP is a routing hint, not permission to use
plaintext remote `ws://`.
- Private LAN direct-connect `ws://` remains supported.
- For the simplest Tailscale path on mobile nodes, use Tailscale Serve so
discovery and setup both resolve to the same secure MagicDNS endpoint.
### 3) Manual / SSH target
When there is no direct route (or direct is disabled), clients can always
connect via SSH by forwarding the loopback gateway port. See
[Remote access](/gateway/remote).
## Transport selection (client policy)
1. If a paired direct endpoint is configured and reachable, use it.
2. Else, if discovery finds a gateway on `local.` or the configured wide-area
domain, offer a one-tap "Use this gateway" choice and save it as the
direct endpoint.
3. Else, if a tailnet DNS/IP is configured, try direct. For mobile nodes on
tailnet/public routes, direct means a secure endpoint, not plaintext
remote `ws://`.
4. Else, fall back to SSH.
## Pairing and auth (direct transport)
The gateway is the source of truth for node/client admission:
- Pairing requests are created/approved/rejected in the gateway (see
[Gateway pairing](/gateway/pairing)).
- The gateway enforces auth (token/keypair), scopes/ACLs (it is not a raw
proxy to every method), and rate limits.
## Responsibilities by component
- **Gateway**: advertises discovery beacons, owns pairing decisions, hosts
the WS endpoint.
- **macOS app**: helps you pick a gateway, shows pairing prompts, uses SSH
only as a fallback.
- **iOS/Android nodes**: browse Bonjour as a convenience, connect to the
paired Gateway WS.
## Related
- [Remote access](/gateway/remote)
- [Tailscale](/gateway/tailscale)
- [Bonjour discovery](/gateway/bonjour)

558
docs/gateway/doctor.md Normal file
View File

@@ -0,0 +1,558 @@
---
summary: "Doctor command: health checks, config migrations, and repair steps"
read_when:
- Adding or modifying doctor migrations
- Introducing breaking config changes
title: "Doctor"
sidebarTitle: "Doctor"
---
`openclaw doctor` is the repair and migration tool for OpenClaw. It fixes stale config/state, checks health, and provides actionable repair steps.
## Quick start
```bash
openclaw doctor
```
### Headless and automation modes
<Tabs>
<Tab title="--yes">
```bash
openclaw doctor --yes
```
Accept defaults without prompting (including restart/service/sandbox repair steps when applicable).
</Tab>
<Tab title="--fix">
```bash
openclaw doctor --fix
```
Apply recommended repairs without prompting (`--repair` is an alias).
</Tab>
<Tab title="--lint">
```bash
openclaw doctor --lint
openclaw doctor --lint --json
```
Run structured health checks for CI or preflight automation. Read-only: no
prompts, repairs, migrations, restarts, or state writes.
</Tab>
<Tab title="--fix --force">
```bash
openclaw doctor --fix --force
```
Apply aggressive repairs too (overwrites custom supervisor configs).
</Tab>
<Tab title="--non-interactive">
```bash
openclaw doctor --non-interactive
```
Run without prompts, applying only safe migrations (config normalization +
on-disk state moves). Skips restart/service/sandbox actions that need human
confirmation. Legacy state migrations still run automatically when detected.
</Tab>
<Tab title="--deep">
```bash
openclaw doctor --deep
```
Scan system services for extra gateway installs (launchd/systemd/schtasks).
</Tab>
</Tabs>
To review changes before writing, open the config file first:
```bash
cat ~/.openclaw/openclaw.json
```
## Read-only lint mode
`openclaw doctor --lint` is the automation-friendly sibling of `openclaw doctor --fix`. Both run the same health checks; only the posture differs:
| Mode | Prompts | Writes config/state | Output | Use it for |
| ------------------------ | --------- | ----------------------- | ---------------------- | ------------------------------- |
| `openclaw doctor` | yes | no | friendly health report | a human checking status |
| `openclaw doctor --fix` | sometimes | yes, with repair policy | friendly repair log | applying approved repairs |
| `openclaw doctor --lint` | no | no | structured findings | CI, preflight, and review gates |
Health checks may provide an optional `repair()` implementation; `doctor --fix` applies it when present and falls back to the legacy doctor repair flow otherwise. The contract separates `detect()` (reports findings) from `repair()` (reports changes/diffs/side effects), which keeps a path open for a future `doctor --fix --dry-run` without turning lint checks into mutation planners.
```bash
openclaw doctor --lint
openclaw doctor --lint --severity-min warning
openclaw doctor --lint --json
openclaw doctor --lint --all
openclaw doctor --lint --only core/doctor/gateway-config --json
```
JSON output fields:
- `ok`: whether any finding met the selected severity threshold
- `checksRun` / `checksSkipped`: counts (skipped by profile, `--only`, or `--skip`)
- `findings`: structured diagnostics with `checkId`, `severity`, `message`, and optional `path`, `line`, `column`, `ocPath`, `source`, `target`, `requirement`, `fixHint`
Exit codes:
| Code | Meaning |
| ---- | -------------------------------------------------------- |
| `0` | no findings at or above the selected threshold |
| `1` | one or more findings met the selected threshold |
| `2` | command/runtime failure before findings could be emitted |
Flags:
- `--severity-min info|warning|error` (default `warning`): controls both what prints and what causes a non-zero exit.
- `--all`: runs every registered check, including opt-in checks excluded from the default automation set.
- `--only <id>` (repeatable): run only the named check id(s); an unknown id is reported as an error finding.
- `--skip <id>` (repeatable): exclude a check while keeping the rest of the run active.
- `--json`, `--severity-min`, `--all`, `--only`, and `--skip` require `--lint`; plain `openclaw doctor` and `--fix` runs reject them.
## What it does (summary)
<AccordionGroup>
<Accordion title="Health, UI, and updates">
- Optional pre-flight update for git installs (interactive only).
- UI protocol freshness check (rebuilds Control UI when the protocol schema is newer).
- Health check + restart prompt.
- Skills status summary (eligible/missing/blocked) and plugin status.
</Accordion>
<Accordion title="Config and migrations">
- Config normalization for legacy value shapes.
- Talk config migration from legacy flat `talk.*` fields into `talk.provider` + `talk.providers.<provider>`.
- Browser migration checks for legacy Chrome extension configs and Chrome MCP readiness.
- OpenCode provider override warnings (`models.providers.opencode` / `opencode-zen` / `opencode-go`).
- Legacy OpenAI Codex provider/profile migration (`openai-codex` → `openai`) and shadowing warnings for stale `models.providers.openai-codex`.
- OAuth TLS prerequisites check for OpenAI Codex OAuth profiles.
- Plugin/tool allowlist warnings when `plugins.allow` is restrictive but tool policy still asks for wildcard or plugin-owned tools.
- Legacy on-disk state migration (sessions/agent dir/WhatsApp auth).
- Legacy plugin manifest contract key migration (`speechProviders`, `realtimeTranscriptionProviders`, `realtimeVoiceProviders`, `mediaUnderstandingProviders`, `imageGenerationProviders`, `videoGenerationProviders`, `webFetchProviders`, `webSearchProviders` → `contracts`).
- Legacy cron store migration (`jobId`, `schedule.cron`, top-level delivery/payload fields, payload `provider`, `notify: true` webhook fallback jobs).
- Codex CLI runtime pin repair (`agentRuntime.id: "codex-cli"` → `"codex"`) across `agents.defaults`, `agents.list[]`, and `models.providers.*` (including per-model entries).
- Stale plugin config cleanup when plugins are enabled; when `plugins.enabled=false`, stale plugin references are preserved as inert containment config.
</Accordion>
<Accordion title="State and integrity">
- Session lock file inspection and stale lock cleanup.
- Session transcript repair for duplicated prompt-rewrite branches created by affected 2026.4.24 builds.
- Wedged subagent restart-recovery tombstone detection, with `--fix` support for clearing stale aborted recovery flags so startup does not keep treating the child as restart-aborted.
- State integrity and permissions checks (sessions, transcripts, state dir).
- Config file permission checks (chmod 600) when running locally.
- Model auth health: checks OAuth expiry, can refresh expiring tokens, and reports auth-profile cooldown/disabled states.
</Accordion>
<Accordion title="Gateway, services, and supervisors">
- Sandbox image repair when sandboxing is enabled.
- Legacy service migration and extra gateway detection.
- Matrix channel legacy state migration (in `--fix` / `--repair` mode).
- Gateway runtime checks (service installed but not running; cached launchd label).
- Channel status warnings (probed from the running gateway).
- Channel-specific permission checks live under `openclaw channels capabilities`; for example, Discord voice channel permissions are audited with `openclaw channels capabilities --channel discord --target channel:<channel-id>`.
- WhatsApp responsiveness checks for degraded Gateway event-loop health with local TUI clients still running; `--fix` stops only verified local TUI clients.
- Codex route repair for legacy `openai-codex/*` model refs in primary models, fallbacks, image/video generation models, heartbeat/subagent/compaction overrides, hooks, channel model overrides, and session route pins; `--fix` rewrites them to `openai/*`, migrates `openai-codex:*` auth profiles/order to `openai:*`, removes stale session/whole-agent runtime pins, and leaves canonical OpenAI agent refs on the default Codex harness.
- Supervisor config audit (launchd/systemd/schtasks) with optional repair.
- Embedded proxy environment cleanup for gateway services that captured shell `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` values during install or update.
- Gateway runtime best-practice checks (Node vs Bun, version-manager paths).
- Gateway port collision diagnostics (default `18789`).
</Accordion>
<Accordion title="Auth, security, and pairing">
- Security warnings for open DM policies.
- Gateway auth checks for local token mode (offers token generation when no token source exists; does not overwrite token SecretRef configs).
- Device pairing trouble detection (pending first-time pair requests, pending role/scope upgrades, stale local device-token cache drift, and paired-record auth drift).
</Accordion>
<Accordion title="Workspace and shell">
- systemd linger check on Linux.
- Workspace bootstrap file size check (truncation/near-limit warnings for context files).
- Skills readiness check for the default agent; reports allowed skills with missing bins, env, config, or OS requirements, and `--fix` can disable unavailable skills in `skills.entries`.
- Shell completion status check and auto-install/upgrade.
- Memory search embedding provider readiness check (local model, remote API key, or QMD binary).
- Source install checks (pnpm workspace mismatch, missing UI assets, missing tsx binary).
- Writes updated config + wizard metadata.
</Accordion>
</AccordionGroup>
## Dreams UI backfill and reset
The Control UI Dreams scene includes **Backfill**, **Reset**, and **Clear Grounded** actions for the grounded dreaming workflow. These use gateway doctor-style RPC methods but are **not** part of `openclaw doctor` CLI repair/migration.
| Action | What it does |
| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Backfill | Scans historical `memory/YYYY-MM-DD.md` files in the active workspace, runs the grounded REM diary pass, and writes reversible backfill entries into `DREAMS.md`. |
| Reset | Removes only the marked backfill diary entries from `DREAMS.md`. |
| Clear Grounded | Removes only staged grounded-only short-term entries from historical replay that have not accumulated live recall or daily support yet. |
None of these edit `MEMORY.md`, run full doctor migrations, or stage grounded candidates into the live short-term promotion store on their own. To feed grounded historical replay into the normal deep promotion lane, use the CLI flow instead:
```bash
openclaw memory rem-backfill --path ./memory --stage-short-term
```
That stages grounded durable candidates into the short-term dreaming store while `DREAMS.md` stays the review surface.
## Detailed behavior and rationale
<AccordionGroup>
<Accordion title="0. Optional update (git installs)">
If this is a git checkout and doctor is running interactively, it offers to update (fetch/rebase/build) before running doctor.
</Accordion>
<Accordion title="1. Config normalization">
Doctor normalizes legacy value shapes into the current schema. Current Talk speech config is `talk.provider` + `talk.providers.<provider>`, with realtime voice config under `talk.realtime.*`. Doctor rewrites old `talk.voiceId` / `talk.voiceAliases` / `talk.modelId` / `talk.outputFormat` / `talk.apiKey` shapes into the provider map, and rewrites legacy top-level realtime selectors (`talk.mode`, `talk.transport`, `talk.brain`, `talk.model`, `talk.voice`) into `talk.realtime`.
Doctor also warns when `plugins.allow` is non-empty and tool policy uses wildcard or plugin-owned tool entries. `tools.allow: ["*"]` only matches tools from plugins that actually load; it does not bypass the exclusive plugin allowlist.
</Accordion>
<Accordion title="2. Legacy config key migrations">
When the config contains a deprecated key with an active migration, other commands refuse to run and ask you to run `openclaw doctor`. Doctor explains which legacy keys were found, shows the migration it applied, and rewrites `~/.openclaw/openclaw.json` with the updated schema. Gateway startup refuses legacy config formats and asks you to run `openclaw doctor --fix`; it does not rewrite `openclaw.json` on startup. Cron job store migrations are also handled by `openclaw doctor --fix`.
<Note>
Doctor only carries automatic migrations for roughly two months after a
key is retired. Older legacy keys (for example the original
`routing.queue`, `routing.bindings`, `routing.agents`/`defaultAgentId`,
`routing.transcribeAudio`, top-level `agent.*`, or top-level `identity`
from the pre-multi-agent config shape) no longer have a migration path;
config using them now fails validation instead of being rewritten. Fix
those keys by hand against the current config reference before doctor
can proceed.
</Note>
Active migrations:
| Legacy key | Current key |
| ----------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| `routing.allowFrom` | `channels.whatsapp.allowFrom` |
| `routing.groupChat.requireMention` | `channels.whatsapp/telegram/imessage.groups."*".requireMention` |
| `routing.groupChat.historyLimit` | `messages.groupChat.historyLimit` |
| `routing.groupChat.mentionPatterns` | `messages.groupChat.mentionPatterns` |
| `channels.telegram.requireMention` | `channels.telegram.groups."*".requireMention` |
| `channels.webchat`, `gateway.webchat` | removed (WebChat is retired) |
| `channels.feishu.accounts.<accountId>.botName` | `channels.feishu.accounts.<accountId>.name` |
| `session.threadBindings.ttlHours`, `channels.<id>.threadBindings.ttlHours` (and per-account) | `...threadBindings.idleHours` |
| legacy `talk.voiceId`/`talk.voiceAliases`/`talk.modelId`/`talk.outputFormat`/`talk.apiKey` | `talk.provider` + `talk.providers.<provider>` |
| legacy top-level realtime Talk selectors (`talk.mode`/`talk.transport`/`talk.brain`/`talk.model`/`talk.voice`) | `talk.realtime` |
| `messages.tts.<provider>` (`openai`/`elevenlabs`/`microsoft`/`edge`) | `messages.tts.providers.<provider>` |
| `messages.tts.provider: "edge"` / `messages.tts.providers.edge` | `messages.tts.provider: "microsoft"` / `messages.tts.providers.microsoft` |
| TTS speaker fields `voice`/`voiceName`/`voiceId` | `speakerVoice`/`speakerVoiceId` |
| `channels.<id>.tts.<provider>` / `channels.<id>.accounts.<accountId>.tts.<provider>` (all channels except Discord) | `...tts.providers.<provider>` |
| `channels.<id>.voice.tts.<provider>` / `channels.<id>.accounts.<accountId>.voice.tts.<provider>` (all channels, including Discord) | `...voice.tts.providers.<provider>` |
| `plugins.entries.voice-call.config.tts.<provider>` (`openai`/`elevenlabs`/`microsoft`/`edge`) | `plugins.entries.voice-call.config.tts.providers.<provider>` |
| `plugins.entries.voice-call.config.tts.provider: "edge"` / `...tts.providers.edge` | `provider: "microsoft"` / `...tts.providers.microsoft` |
| `plugins.entries.voice-call.config.provider: "log"` | `"mock"` |
| `plugins.entries.voice-call.config.twilio.from` | `plugins.entries.voice-call.config.fromNumber` |
| `plugins.entries.voice-call.config.streaming.sttProvider` | `plugins.entries.voice-call.config.streaming.provider` |
| `plugins.entries.voice-call.config.streaming.openaiApiKey`/`sttModel`/`silenceDurationMs`/`vadThreshold` | `plugins.entries.voice-call.config.streaming.providers.openai.*` |
| `models.providers.*.api: "openai"` | `"openai-completions"` (gateway startup also skips providers whose `api` is a future/unknown enum value rather than failing closed) |
| `browser.ssrfPolicy.allowPrivateNetwork` | `browser.ssrfPolicy.dangerouslyAllowPrivateNetwork` |
| `browser.profiles.*.driver: "extension"` | `"existing-session"` |
| `browser.relayBindHost` | removed (legacy Chrome extension relay setting) |
| `mcp.servers.*.type` (CLI-native aliases) | `mcp.servers.*.transport` |
| `plugins.entries.codex.config.codexDynamicToolsProfile` | removed (Codex app-server always keeps Codex-native workspace tools native) |
| `commands.modelsWrite` | removed (`/models add` is deprecated) |
| `agents.defaults/list[].silentReplyRewrite`, `surfaces.*.silentReplyRewrite` | removed (exact `NO_REPLY` is no longer rewritten to visible fallback text) |
| `agents.defaults/list[].systemPromptOverride` | removed (OpenClaw owns the generated system prompt) |
| `agents.defaults/list[].embeddedPi` | `embeddedAgent` |
| `agents.defaults/list[].sandbox.perSession` | `sandbox.scope` |
| `agents.defaults.llm` | removed (use `models.providers.<id>.timeoutSeconds` for slow model/provider timeouts, kept below the agent/run timeout ceiling) |
| top-level `memorySearch` | `agents.defaults.memorySearch` |
| `memorySearch.provider: "auto"` | `"openai"` |
| `memorySearch.store.path` (any level) | removed (memory indexes live in each agent database) |
| top-level `heartbeat` | `agents.defaults.heartbeat` / `channels.defaults.heartbeat` |
| `plugins.openai-codex` policy ids | `plugins.openai` |
| `tools.web.x_search.apiKey` | `plugins.entries.xai.config.webSearch.apiKey` |
| `session.maintenance.rotateBytes`, `session.parentForkMaxTokens` | removed (deprecated) |
| `diagnostics.memoryPressureBundle` | `diagnostics.memoryPressureSnapshot` |
<Note>
The `plugins.entries.voice-call.config.*` rows above are normalized by
the Voice Call plugin itself on every config load, not by `openclaw
doctor`. The plugin also logs a startup warning pointing at `openclaw
doctor --fix`, but doctor does not currently rewrite
`openclaw.json` for these keys; the plugin's own normalization is what
applies the change at runtime.
</Note>
Account-default guidance for multi-account channels:
- If two or more `channels.<channel>.accounts` entries are configured without `channels.<channel>.defaultAccount` or `accounts.default`, doctor warns that fallback routing can pick an unexpected account.
- If `channels.<channel>.defaultAccount` is set to an unknown account ID, doctor warns and lists configured account IDs.
</Accordion>
<Accordion title="2b. OpenCode provider overrides">
If you have added `models.providers.opencode`, `opencode-zen`, or `opencode-go` manually, it overrides the built-in OpenCode catalog from `openclaw/plugin-sdk/llm`. That can force models onto the wrong API or zero out costs. Doctor warns so you can remove the override and restore per-model API routing + costs.
</Accordion>
<Accordion title="2c. Browser migration and Chrome MCP readiness">
If your browser config still points at the removed Chrome extension path, doctor normalizes it to the current host-local Chrome MCP attach model (`browser.profiles.*.driver: "extension"` → `"existing-session"`; `browser.relayBindHost` removed).
Doctor also audits the host-local Chrome MCP path when you use `defaultProfile: "user"` or a configured `existing-session` profile:
- checks whether Google Chrome is installed on the same host for default auto-connect profiles
- checks the detected Chrome version and warns when it is below Chrome 144
- reminds you to enable remote debugging in the browser inspect page (for example `chrome://inspect/#remote-debugging`, `brave://inspect/#remote-debugging`, or `edge://inspect/#remote-debugging`)
Doctor cannot enable the Chrome-side setting for you. Host-local Chrome MCP still requires a Chromium-based browser 144+ on the gateway/node host, running locally, with remote debugging enabled and the first attach consent prompt approved in the browser.
Readiness here only covers local attach prerequisites. Existing-session keeps the current Chrome MCP route limits; advanced routes like `responsebody`, PDF export, download interception, and batch actions still require a managed browser or raw CDP profile. This check does not apply to Docker, sandbox, remote-browser, or other headless flows, which continue to use raw CDP.
</Accordion>
<Accordion title="2d. OAuth TLS prerequisites">
When an OpenAI Codex OAuth profile is configured, doctor probes the OpenAI authorization endpoint to verify that the local Node/OpenSSL TLS stack can validate the certificate chain. If the probe fails with a certificate error (for example `UNABLE_TO_GET_ISSUER_CERT_LOCALLY`, expired cert, or self-signed cert), doctor prints platform-specific fix guidance. On macOS with a Homebrew Node, the fix is usually `brew postinstall ca-certificates`. With `--deep`, the probe runs even if the gateway is healthy.
</Accordion>
<Accordion title="2e. Codex OAuth provider overrides">
If you previously added legacy OpenAI transport settings under `models.providers.openai-codex`, they can shadow the built-in Codex OAuth provider path that newer releases use automatically. Doctor warns when it sees those old transport settings alongside Codex OAuth so you can remove or rewrite the stale transport override and get the built-in routing/fallback behavior back. Custom proxies and header-only overrides are still supported and do not trigger this warning.
</Accordion>
<Accordion title="2f. Codex route repair">
Doctor checks for legacy `openai-codex/*` model refs. Native Codex harness routing uses canonical `openai/*` model refs; OpenAI agent turns go through the Codex app-server harness instead of the OpenClaw OpenAI provider path.
In `--fix` / `--repair` mode, doctor rewrites affected default-agent and per-agent refs, including primary models, fallbacks, image/video generation models, heartbeat/subagent/compaction overrides, hooks, channel model overrides, and stale persisted session route state:
- `openai-codex/gpt-*` becomes `openai/gpt-*`.
- Codex intent moves to provider/model-scoped `agentRuntime.id: "codex"` entries for repaired agent model refs.
- Stale whole-agent runtime config and persisted session runtime pins are removed because runtime selection is provider/model-scoped.
- Existing provider/model runtime policy is preserved unless the repaired legacy model ref needs Codex routing to keep the old auth path.
- Existing model fallback lists are preserved with their legacy entries rewritten; copied per-model settings move from the legacy key to the canonical `openai/*` key.
- Persisted session `modelProvider`/`providerOverride`, `model`/`modelOverride`, fallback notices, and auth-profile pins are repaired across all discovered agent session stores.
- Doctor separately repairs stale `agentRuntime.id: "codex-cli"` pins (a distinct legacy runtime id) to `"codex"` across `agents.defaults`, `agents.list[]`, and `models.providers.*` model entries.
- `/codex ...` means "control or bind a native Codex conversation from chat."
- `/acp ...` or `runtime: "acp"` means "use the external ACP/acpx adapter."
</Accordion>
<Accordion title="2g. Session route cleanup">
Doctor also scans discovered agent session stores for stale auto-created route state after you move configured models or runtime away from a plugin-owned route such as Codex.
`openclaw doctor --fix` can clear auto-created stale state such as `modelOverrideSource: "auto"` model pins, runtime model metadata, pinned harness ids, CLI session bindings, and auto auth-profile overrides when their owning route is no longer configured. Explicit user or legacy session model choices are reported for manual review and left untouched; switch them with `/model ...`, `/new`, or reset the session when that route is no longer intended.
</Accordion>
<Accordion title="3. Legacy state migrations (disk layout)">
Doctor can migrate older on-disk layouts into the current structure:
- Sessions store + transcripts: from `~/.openclaw/sessions/` to `~/.openclaw/agents/<agentId>/sessions/`
- Agent dir: from `~/.openclaw/agent/` to `~/.openclaw/agents/<agentId>/agent/`
- WhatsApp auth state (Baileys): from legacy `~/.openclaw/credentials/*.json` (except `oauth.json`) to `~/.openclaw/credentials/whatsapp/<accountId>/...` (default account id: `default`)
These migrations are best-effort and idempotent; doctor emits warnings when it leaves any legacy folders behind as backups. The Gateway/CLI also auto-migrates the legacy sessions + agent dir on startup so history/auth/models land in the per-agent path without a manual doctor run. WhatsApp auth is intentionally only migrated via `openclaw doctor`. Talk provider/provider-map normalization compares by structural equality, so key-order-only diffs no longer trigger repeat no-op `doctor --fix` changes.
</Accordion>
<Accordion title="3a. Legacy plugin manifest migrations">
Doctor scans all installed plugin manifests for deprecated top-level capability keys (`speechProviders`, `realtimeTranscriptionProviders`, `realtimeVoiceProviders`, `mediaUnderstandingProviders`, `imageGenerationProviders`, `videoGenerationProviders`, `webFetchProviders`, `webSearchProviders`). When found, it offers to move them into the `contracts` object and rewrite the manifest file in-place. This migration is idempotent; if `contracts` already has the same values, the legacy key is removed without duplicating data.
</Accordion>
<Accordion title="3b. Legacy cron store migrations">
Doctor also checks the cron job store (`~/.openclaw/cron/jobs.json` by default, or `cron.store` when overridden) for old job shapes that the scheduler still accepts for compatibility.
Current cron cleanups include:
- `jobId` → `id`
- `schedule.cron` → `schedule.expr`
- top-level payload fields (`message`, `model`, `thinking`, ...) → `payload`
- top-level delivery fields (`deliver`, `channel`, `to`, `provider`, ...) → `delivery`
- payload `provider` delivery aliases → explicit `delivery.channel`
- legacy `notify: true` webhook fallback jobs → explicit webhook delivery from `cron.webhook` when set; announce jobs keep their chat delivery and get `delivery.completionDestination`. When `cron.webhook` is unset, the inert top-level `notify` marker is removed for no-target jobs (existing delivery, including announce, is preserved) since runtime delivery never reads it.
The Gateway also sanitizes malformed cron rows at load time so valid jobs keep running. Raw malformed rows are copied to `jobs-quarantine.json` next to the active store before removal from `jobs.json`; doctor reports quarantined rows so you can review or repair them manually.
Gateway startup normalizes the runtime projection and ignores the top-level `notify` marker, but leaves the persisted cron config for doctor repair. When `cron.webhook` is unset, doctor removes the inert marker for jobs with no migration target (`delivery.mode` none/absent, an unusable webhook target, or existing announce/chat delivery), leaving existing delivery untouched, so repeated `doctor --fix` runs no longer re-warn about the same job. If `cron.webhook` is set but not a valid HTTP(S) URL, doctor still warns and leaves the marker so you can fix the URL.
On Linux, doctor also warns when the user's crontab still invokes legacy `~/.openclaw/bin/ensure-whatsapp.sh`. That host-local script is not maintained by current OpenClaw and can write false `Gateway inactive` messages to `~/.openclaw/logs/whatsapp-health.log` when cron cannot reach the systemd user bus. Remove the stale crontab entry with `crontab -e`; use `openclaw channels status --probe`, `openclaw doctor`, and `openclaw gateway status` for current health checks.
</Accordion>
<Accordion title="3c. Session lock cleanup">
Doctor scans every agent session directory for stale write-lock files left behind when a session exited abnormally. For each lock file found it reports: the path, PID, whether the PID is still alive, lock age, and whether it is considered stale (dead PID, malformed owner metadata, older than 30 minutes, or a live PID proven to belong to a non-OpenClaw process). In `--fix` / `--repair` mode it removes locks with dead, orphaned, recycled, malformed-old, or non-OpenClaw owners automatically. Old locks still owned by a live OpenClaw process are reported but left in place so doctor does not cut off an active transcript writer.
</Accordion>
<Accordion title="3d. Session transcript branch repair">
Doctor scans agent session JSONL files for the duplicated branch shape created by the 2026.4.24 prompt transcript rewrite bug: an abandoned user turn with OpenClaw internal runtime context plus an active sibling containing the same visible user prompt. In `--fix` / `--repair` mode, doctor backs up each affected file next to the original and rewrites the transcript to the active branch so gateway history and memory readers no longer see duplicate turns.
</Accordion>
<Accordion title="4. State integrity checks (session persistence, routing, and safety)">
The state directory is the operational brainstem. If it vanishes, you lose sessions, credentials, logs, and config unless you have backups elsewhere.
Doctor checks:
- **State dir missing**: warns about catastrophic state loss, prompts to recreate the directory, and reminds you that it cannot recover missing data.
- **State dir permissions**: verifies writability; offers to repair permissions (and emits a `chown` hint when owner/group mismatch is detected).
- **macOS cloud-synced state dir**: warns when state resolves under iCloud Drive (`~/Library/Mobile Documents/com~apple~CloudDocs/...`) or `~/Library/CloudStorage/...`, because sync-backed paths can cause slower I/O and lock/sync races.
- **Linux SD or eMMC state dir**: warns when state resolves to an `mmcblk*` mount source, because SD/eMMC-backed random I/O can be slower and wear faster under session and credential writes.
- **Linux volatile state dir**: warns when state resolves to `tmpfs` or `ramfs`, because sessions, credentials, config, and SQLite state (with WAL/journal sidecars) disappear on reboot. Docker `overlay` mounts are intentionally not flagged because their writable layers persist across host reboots while the container remains.
- **Session dirs missing**: `sessions/` and the session store directory are required to persist history and avoid `ENOENT` crashes.
- **Transcript mismatch**: warns when recent session entries have missing transcript files.
- **Main session "1-line JSONL"**: flags when the main transcript has only one line (history is not accumulating).
- **Multiple state dirs**: warns when multiple `~/.openclaw` folders exist across home directories, or when `OPENCLAW_STATE_DIR` points elsewhere (history can split between installs).
- **Remote mode reminder**: if `gateway.mode=remote`, doctor reminds you to run it on the remote host (the state lives there).
- **Config file permissions**: warns if `~/.openclaw/openclaw.json` is group/world readable and offers to tighten to `600`.
</Accordion>
<Accordion title="5. Model auth health (OAuth expiry)">
Doctor inspects OAuth profiles in the auth store, warns when tokens are expiring/expired, and can refresh them when safe. If the Anthropic OAuth/token profile is stale, it suggests an Anthropic API key or the Anthropic setup-token path. Refresh prompts only appear when running interactively (TTY); `--non-interactive` skips refresh attempts.
When an OAuth refresh fails permanently (for example `refresh_token_reused`, `invalid_grant`, or a provider telling you to sign in again), doctor reports that re-auth is required and prints the exact `openclaw models auth login --provider ...` command to run.
Doctor also reports auth profiles that are temporarily unusable due to short cooldowns (rate limits/timeouts/auth failures) or longer disables (billing/credit failures).
Legacy Codex OAuth profiles whose tokens live in macOS Keychain (older onboarding before the file-based sidecar layout) are repaired only by doctor. Run `openclaw doctor --fix` once from an interactive terminal to migrate Keychain-backed legacy tokens inline into `auth-profiles.json`; after that, embedded turns (Telegram, cron, sub-agent dispatch) resolve them as canonical OpenAI OAuth profiles.
</Accordion>
<Accordion title="6. Hooks model validation">
If `hooks.gmail.model` is set, doctor validates the model reference against the catalog and allowlist and warns when it will not resolve or is disallowed.
</Accordion>
<Accordion title="7. Sandbox image repair">
When sandboxing is enabled, doctor checks Docker images and offers to build or switch to legacy names if the current image is missing.
</Accordion>
<Accordion title="7b. Plugin install cleanup">
Doctor removes legacy OpenClaw-generated plugin dependency staging state in `openclaw doctor --fix` / `openclaw doctor --repair` mode: stale generated dependency roots, old install-stage directories, package-local debris from earlier bundled-plugin dependency repair code, and orphaned or recovered managed npm copies of bundled `@openclaw/*` plugins that can shadow the current bundled manifest. Doctor also relinks the host `openclaw` package into managed npm plugins that declare `peerDependencies.openclaw`, so package-local runtime imports such as `openclaw/plugin-sdk/*` keep resolving after updates or npm repairs.
Doctor can also reinstall missing downloadable plugins when config references them but the local plugin registry cannot find them (material `plugins.entries`, configured channel/provider/search settings, configured agent runtimes). During package updates, doctor avoids running package-manager plugin repair while the core package is being swapped; run `openclaw doctor --fix` again after the update if a configured plugin still needs recovery. Gateway startup and config reload do not run package managers; plugin installs remain explicit doctor/install/update work.
</Accordion>
<Accordion title="8. Gateway service migrations and cleanup hints">
Doctor detects legacy gateway services (launchd/systemd/schtasks) and offers to remove them and install the OpenClaw service using the current gateway port. It can also scan for extra gateway-like services and print cleanup hints. Profile-named OpenClaw gateway services are considered first-class and are not flagged as "extra."
On Linux, if the user-level gateway service is missing but a system-level OpenClaw gateway service exists, doctor does not install a second user-level service automatically. Inspect with `openclaw gateway status --deep` or `openclaw doctor --deep`, then remove the duplicate or set `OPENCLAW_SERVICE_REPAIR_POLICY=external` when a system supervisor owns the gateway lifecycle.
</Accordion>
<Accordion title="8b. Startup Matrix migration">
When a Matrix channel account has a pending or actionable legacy state migration, doctor (in `--fix` / `--repair` mode) creates a pre-migration snapshot and then runs the best-effort migration steps: legacy Matrix state migration and legacy encrypted-state preparation. Both steps are non-fatal; errors are logged and startup continues. In read-only mode (`openclaw doctor` without `--fix`) this check is skipped entirely.
</Accordion>
<Accordion title="8c. Device pairing and auth drift">
Doctor inspects device-pairing state as part of the normal health pass, reporting:
- pending first-time pairing requests
- pending role or scope upgrades for already-paired devices
- public-key mismatch repairs where the device id still matches but the device identity no longer matches the approved record
- paired records missing an active token for an approved role
- paired tokens whose scopes drift outside the approved pairing baseline
- local cached device-token entries for the current machine that predate a gateway-side token rotation or carry stale scope metadata
Doctor does not auto-approve pair requests or auto-rotate device tokens. It prints the exact next steps:
- inspect pending requests with `openclaw devices list`
- approve the exact request with `openclaw devices approve <requestId>`
- rotate a fresh token with `openclaw devices rotate --device <deviceId> --role <role>`
- remove and re-approve a stale record with `openclaw devices remove <deviceId>`
This distinguishes first-time pairing from pending role/scope upgrades and from stale token/device-identity drift, closing the common "already paired but still getting pairing required" hole.
</Accordion>
<Accordion title="9. Security warnings">
Doctor emits warnings when a provider is open to DMs without an allowlist, or when a policy is configured in a dangerous way.
</Accordion>
<Accordion title="10. systemd linger (Linux)">
If running as a systemd user service, doctor ensures lingering is enabled so the gateway stays alive after logout.
</Accordion>
<Accordion title="11. Workspace status (skills, plugins, and TaskFlows)">
Doctor prints a summary of the workspace state for the default agent:
- **Skills status**: counts eligible, missing-requirements, and allowlist-blocked skills.
- **Plugin status**: counts enabled/disabled/errored plugins; lists plugin IDs for any errors; reports bundle plugin capabilities.
- **Plugin compatibility warnings**: flags plugins that have compatibility issues with the current runtime.
- **Plugin diagnostics**: surfaces any load-time warnings or errors emitted by the plugin registry.
- **TaskFlow recovery**: surfaces suspicious managed TaskFlows that need manual inspection or cancellation.
</Accordion>
<Accordion title="11b. Bootstrap file size">
Doctor checks whether workspace bootstrap files (for example `AGENTS.md`, `CLAUDE.md`, or other injected context files) are near or over the configured character budget. It reports per-file raw vs. injected character counts, truncation percentage, truncation cause (`max/file` or `max/total`), and total injected characters as a fraction of the total budget. When files are truncated or near the limit, doctor prints tips for tuning `agents.defaults.bootstrapMaxChars` and `agents.defaults.bootstrapTotalMaxChars`.
</Accordion>
<Accordion title="11c. Shell completion">
Doctor checks whether tab completion is installed for the current shell (zsh, bash, fish, or PowerShell):
- If the shell profile uses a slow dynamic completion pattern (`source <(openclaw completion ...)`), doctor upgrades it to the faster cached file variant.
- If completion is configured in the profile but the cache file is missing, doctor regenerates the cache automatically.
- If no completion is configured at all, doctor prompts to install it (interactive mode only; skipped with `--non-interactive`).
Run `openclaw completion --write-state` to regenerate the cache manually.
</Accordion>
<Accordion title="11d. Stale channel plugin cleanup">
When `openclaw doctor --fix` removes a missing channel plugin, it also removes the dangling channel-scoped config that referenced that plugin: `channels.<id>` entries, heartbeat targets that named the channel, and `agents.*.models["<channel>/*"]` overrides. This prevents Gateway boot loops where the channel runtime is gone but config still asks the gateway to bind to it.
</Accordion>
<Accordion title="12. Gateway auth checks (local token)">
Doctor checks local gateway token auth readiness.
- If token mode needs a token and no token source exists, doctor offers to generate one.
- If `gateway.auth.token` is SecretRef-managed but unavailable, doctor warns and does not overwrite it with plaintext.
- `openclaw doctor --generate-gateway-token` forces generation only when no token SecretRef is configured.
</Accordion>
<Accordion title="12b. Read-only SecretRef-aware repairs">
Some repair flows need to inspect configured credentials without weakening runtime fail-fast behavior.
- `openclaw doctor --fix` uses the same read-only SecretRef summary model as status-family commands for targeted config repairs.
- Example: Telegram `allowFrom` / `groupAllowFrom` `@username` repair tries to use configured bot credentials when available.
- If the Telegram bot token is configured via SecretRef but unavailable in the current command path, doctor reports that the credential is configured-but-unavailable and skips auto-resolution instead of crashing or misreporting the token as missing.
</Accordion>
<Accordion title="13. Gateway health check + restart">
Doctor runs a health check and offers to restart the gateway when it looks unhealthy.
</Accordion>
<Accordion title="13b. Memory search readiness">
Doctor checks whether the configured memory search embedding provider is ready for the default agent. The behavior depends on the configured backend and provider:
- **QMD backend**: probes whether the `qmd` binary is available and startable. If not, prints fix guidance including `npm install -g @tobilu/qmd` (or the Bun equivalent) and a manual binary path option.
- **Explicit local provider**: checks for a local model file or a recognized remote/downloadable model URL. If missing, suggests switching to a remote provider.
- **Explicit remote provider** (`openai`, `voyage`, etc.): verifies an API key is present in the environment or auth store. Prints actionable fix hints if missing.
- **Legacy auto provider**: treats `memorySearch.provider: "auto"` as OpenAI, checks OpenAI readiness, and `doctor --fix` rewrites it to `provider: "openai"`.
When a cached gateway probe result is available (gateway was healthy at the time of the check), doctor cross-references its result with the CLI-visible config and notes any discrepancy. Doctor does not start a fresh embedding ping on the default path; use the deep memory status command when you want a live provider check.
Use `openclaw memory status --deep` to verify embedding readiness at runtime.
</Accordion>
<Accordion title="14. Channel status warnings">
If the gateway is healthy, doctor runs a channel status probe and reports warnings with suggested fixes.
</Accordion>
<Accordion title="15. Supervisor config audit + repair">
Doctor checks the installed supervisor config (launchd/systemd/schtasks) for missing or outdated defaults (for example systemd network-online dependencies and restart delay). When it finds a mismatch, it recommends an update and can rewrite the service file/task to the current defaults.
Notes:
- `openclaw doctor` prompts before rewriting supervisor config.
- `openclaw doctor --yes` accepts the default repair prompts.
- `openclaw doctor --fix` applies recommended fixes without prompts (`--repair` is an alias).
- `openclaw doctor --fix --force` overwrites custom supervisor configs.
- `OPENCLAW_SERVICE_REPAIR_POLICY=external` keeps doctor read-only for gateway service lifecycle. It still reports service health and runs non-service repairs, but skips service install/start/restart/bootstrap, supervisor config rewrites, and legacy service cleanup because an external supervisor owns that lifecycle.
- On Linux, doctor does not rewrite command/entrypoint metadata while the matching systemd gateway unit is active. It also ignores inactive non-legacy extra gateway-like units during the duplicate-service scan so companion service files do not create cleanup noise.
- If token auth requires a token and `gateway.auth.token` is SecretRef-managed, doctor service install/repair validates the SecretRef but does not persist resolved plaintext token values into supervisor service environment metadata.
- Doctor detects managed `.env`/SecretRef-backed service environment values that older LaunchAgent, systemd, or Windows Scheduled Task installs embedded inline and rewrites the service metadata so those values load from the runtime source instead of the supervisor definition.
- Doctor detects when the service command still pins an old `--port` after `gateway.port` changes and rewrites the service metadata to the current port.
- If token auth requires a token and the configured token SecretRef is unresolved, doctor blocks the install/repair path with actionable guidance.
- If both `gateway.auth.token` and `gateway.auth.password` are configured and `gateway.auth.mode` is unset, doctor blocks install/repair until mode is set explicitly.
- For Linux user-systemd units, doctor token drift checks include both `Environment=` and `EnvironmentFile=` sources when comparing service auth metadata.
- Doctor service repairs refuse to rewrite, stop, or restart a gateway service from an older OpenClaw binary when the config was last written by a newer version. See [Gateway troubleshooting](/gateway/troubleshooting#split-brain-installs-and-newer-config-guard).
- You can always force a full rewrite via `openclaw gateway install --force`.
</Accordion>
<Accordion title="16. Gateway runtime + port diagnostics">
Doctor inspects the service runtime (PID, last exit status) and warns when the service is installed but not actually running. It also checks for port collisions on the gateway port (default `18789`) and reports likely causes (gateway already running, SSH tunnel).
</Accordion>
<Accordion title="17. Gateway runtime best practices">
Doctor warns when the gateway service runs on Bun or a version-managed Node path (`nvm`, `fnm`, `volta`, `asdf`, etc.). WhatsApp and Telegram channels require Node, and version-manager paths can break after upgrades because the service does not load your shell init. Doctor offers to migrate to a system Node install when available (Homebrew/apt/choco).
Newly installed or repaired macOS LaunchAgents use a canonical system PATH (`/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin`) instead of copying the interactive shell PATH, so Homebrew-managed system binaries stay available while Volta, asdf, fnm, pnpm, and other version-manager directories do not change which Node child processes resolve. Linux services still keep explicit environment roots (`NVM_DIR`, `FNM_DIR`, `VOLTA_HOME`, `ASDF_DATA_DIR`, `BUN_INSTALL`, `PNPM_HOME`) and stable user-bin directories, but guessed version-manager fallback directories are only written to the service PATH when those directories exist on disk.
</Accordion>
<Accordion title="18. Config write + wizard metadata">
Doctor persists any config changes and stamps wizard metadata to record the doctor run.
</Accordion>
<Accordion title="19. Workspace tips (backup + memory system)">
Doctor suggests a workspace memory system when missing and prints a backup tip if the workspace is not already under git.
See [/concepts/agent-workspace](/concepts/agent-workspace) for a full guide to workspace structure and git backup (recommended private GitHub or GitLab).
</Accordion>
</AccordionGroup>
## Related
- [Gateway runbook](/gateway)
- [Gateway troubleshooting](/gateway/troubleshooting)

View File

@@ -0,0 +1,86 @@
---
summary: "Current integration path for external apps, scripts, dashboards, CI jobs, and IDE extensions"
title: "Gateway integrations for external apps"
sidebarTitle: "External apps"
read_when:
- You are building an external app, script, dashboard, CI job, or IDE extension that talks to OpenClaw
- You are choosing between Gateway RPC and the Plugin SDK
- You are integrating with Gateway agent runs, sessions, events, approvals, models, or tools
---
External apps talk to OpenClaw through the Gateway protocol: WebSocket
transport plus RPC methods. Use it when a script, dashboard, CI job, IDE
extension, or another process wants to start agent runs, stream events, wait
for results, cancel work, or inspect Gateway resources.
<Warning>
There is no public npm client package yet. Do not add OpenClaw client package
names as application dependencies until release notes announce a published
package and this page includes install instructions.
</Warning>
<Note>
This page is for code outside the OpenClaw process. Plugin code that runs
inside OpenClaw should use documented `openclaw/plugin-sdk/*` subpaths instead.
</Note>
## What is available today
| Surface | Status | Use it for |
| --------------------------------------- | ------ | --------------------------------------------------------------------------------------------- |
| [Gateway protocol](/gateway/protocol) | Ready | WebSocket transport, connect handshake, auth scopes, protocol versioning, and events. |
| [Gateway RPC reference](/reference/rpc) | Ready | Current Gateway methods for agents, sessions, tasks, models, tools, artifacts, and approvals. |
| [`openclaw agent`](/cli/agent) | Ready | One-shot script integration when shelling out to the CLI is enough. |
| [`openclaw message`](/cli/message) | Ready | Sending messages or channel actions from scripts. |
A future client library package is in progress internally, but it is not a
public install surface yet. Treat it as preview implementation detail until a
release announces a published, versioned package.
## Recommended path
1. Run or discover a Gateway.
2. Connect over the [Gateway protocol](/gateway/protocol).
3. Call documented RPC methods from [Gateway RPC reference](/reference/rpc).
4. Pin the OpenClaw version you test against.
5. Recheck the RPC reference when upgrading OpenClaw.
For agent runs, start with the `agent` RPC and pair it with `agent.wait` for a
terminal result. For durable conversation state, use the `sessions.*` methods.
For UI integrations, subscribe to Gateway events and render only the event
families your app understands.
## App code vs plugin code
Use Gateway RPC when code lives outside OpenClaw:
- Node scripts that start or observe agent runs
- CI jobs that call a Gateway
- dashboards and admin panels
- IDE extensions
- external bridges that do not need to become channel plugins
- integration tests with fake or real Gateway transports
Use the Plugin SDK when code runs inside OpenClaw:
- provider plugins
- channel plugins
- tool or lifecycle hooks
- agent harness plugins
- trusted runtime helpers
External apps should not import `openclaw/plugin-sdk/*`; those subpaths are for
plugins loaded by OpenClaw.
## Related
- [Gateway protocol](/gateway/protocol)
- [Gateway RPC reference](/reference/rpc)
- [CLI agent command](/cli/agent)
- [CLI message command](/cli/message)
- [Agent loop](/concepts/agent-loop)
- [Agent runtimes](/concepts/agent-runtimes)
- [Sessions](/concepts/session)
- [Background tasks](/automation/tasks)
- [ACP agents](/tools/acp-agents)
- [Plugin SDK overview](/plugins/sdk-overview)

View File

@@ -0,0 +1,59 @@
---
summary: "Gateway singleton guard: file lock plus WebSocket/HTTP bind"
read_when:
- Running or debugging the gateway process
- Investigating single-instance enforcement
title: "Gateway lock"
---
## Why
- Only one gateway process should own a given config + port on a host; run additional gateways with isolated profiles and unique ports.
- Survive crashes/SIGKILL without leaving stale lock files behind.
- Fail fast with a clear error when another gateway already owns the port.
## Two layers
Startup enforces single-instance ownership in two independent steps, in order:
1. **File lock** acquires a per-config lock file under the state lock directory. As part of acquiring it, startup probes the configured port for a live listener to detect a stale (crashed) lock owner.
2. **Socket bind** binds the HTTP/WebSocket listener (default `ws://127.0.0.1:18789`) as an exclusive TCP listener.
Each layer can fail independently and throws its own `GatewayLockError`.
### File lock
- If the lock file is missing, the recorded owner process is gone, or the owner's port probe shows no live listener, startup reclaims the lock and continues.
- If the lock is actively held and none of the above apply, startup retries for up to 5 seconds (default) before giving up:
```text
GatewayLockError("gateway already running (pid <pid>); lock timeout after <ms>ms")
```
### Socket bind
- On `EADDRINUSE`, startup retries the bind for up to 20 attempts at 500ms intervals (roughly 10 seconds total) to ride out a `TIME_WAIT` window after a recently exited process.
- If the port is still in use after retries:
```text
GatewayLockError("another gateway instance is already listening on ws://127.0.0.1:<port>")
```
- Other bind failures:
```text
GatewayLockError("failed to bind gateway socket on ws://127.0.0.1:<port>: <cause>")
```
On shutdown, the gateway closes the HTTP/WebSocket server and removes the lock file.
## Operational notes
- If the port is occupied by a different, non-gateway process, the error is the same; free the port or choose another with `openclaw gateway --port <port>`.
- Under a service supervisor, a new gateway process that hits either error above first probes `/healthz` on the existing process. If that process is healthy, the new process leaves it in control instead of failing. On systemd, it exits with code `78`; the unit's `RestartPreventExitStatus=78` stops `Restart=always` from looping on a lock or `EADDRINUSE` conflict. If the existing process never becomes healthy, the health-probe retry is time-bounded and startup then fails with the lock error above instead of looping forever.
- The macOS app keeps its own lightweight PID guard before spawning the gateway; the file lock and socket bind above are the actual runtime enforcement.
## Related
- [Multiple Gateways](/gateway/multiple-gateways) - running multiple instances with unique ports
- [Troubleshooting](/gateway/troubleshooting) - diagnosing `EADDRINUSE` and port conflicts

90
docs/gateway/health.md Normal file
View File

@@ -0,0 +1,90 @@
---
summary: "Health check commands and gateway health monitoring"
read_when:
- Diagnosing channel connectivity or gateway health
- Understanding health check CLI commands and options
title: "Health checks"
---
Short guide to verify channel connectivity without guessing.
## Quick checks
- `openclaw status` - local summary: gateway reachability/mode, update hint, linked channel auth age, sessions + recent activity.
- `openclaw status --all` - full local diagnosis (read-only, color, safe to paste for debugging).
- `openclaw status --deep` - asks the running gateway for a live probe (`health` with `probe:true`), including per-account channel probes when supported.
- `openclaw status --usage` - show model provider usage/quota snapshots.
- `openclaw health` - asks the running gateway for its health snapshot (WS-only; no direct channel sockets from the CLI).
- `openclaw health --verbose` (alias `--debug`) - forces a live health probe and prints gateway connection details.
- `openclaw health --json` - machine-readable health snapshot output.
- Send `/status` as a standalone chat command in any channel to get a status reply without invoking the agent.
- Logs: tail `/tmp/openclaw/openclaw-*.log` and filter for `web-heartbeat`, `web-reconnect`, `web-auto-reply`, `web-inbound`.
For Discord and other chat providers, session rows are not socket liveness.
`openclaw sessions`, Gateway `sessions.list`, and the agent `sessions_list` tool
read stored conversation state. A provider can reconnect and show healthy channel
status before any new session row is materialized. Use the channel status and
health commands above for live connectivity checks.
## Deep diagnostics
- Creds on disk: `ls -l ~/.openclaw/credentials/whatsapp/<accountId>/creds.json` (mtime should be recent).
- Session store: `ls -l ~/.openclaw/agents/<agentId>/sessions/sessions.json` (path can be overridden in config). Count and recent recipients are surfaced via `status`.
- Relink flow: `openclaw channels logout && openclaw channels login --verbose` when status codes 409-515 or `loggedOut` appear in logs. The QR login flow auto-restarts once for status 515 after pairing.
- Diagnostics are enabled by default (`diagnostics.enabled: false` disables them). Memory events record RSS/heap byte counts and threshold/growth pressure; critical memory pressure logs through the gateway logger and, when `diagnostics.memoryPressureSnapshot: true` is set, also writes a pre-OOM stability bundle (V8 heap stats, Linux cgroup counters when available, active resource counts, largest session/transcript files by redacted relative path). Liveness warnings record event-loop delay/utilization, CPU-core ratio, and active/waiting/queued session counts when the process is running but saturated. Oversized-payload events record what was rejected/truncated/chunked plus sizes and limits, never message text, attachment contents, webhook bodies, raw request/response bodies, tokens, cookies, or secret values.
- The same heartbeat drives the bounded stability recorder: `openclaw gateway stability` (or the `diagnostics.stability` Gateway RPC). Fatal Gateway exits, shutdown timeouts, restart startup failures, and (when `diagnostics.memoryPressureSnapshot: true`) critical memory pressure persist the latest snapshot under `~/.openclaw/logs/stability/`. Inspect the newest bundle with `openclaw gateway stability --bundle latest`.
- For bug reports, run `openclaw gateway diagnostics export` and attach the generated zip: a Markdown summary, the newest stability bundle, sanitized log metadata, sanitized Gateway status/health snapshots, and config shape. Chat text, webhook bodies, tool outputs, credentials, cookies, account/message identifiers, and secret values are omitted or redacted. See [Diagnostics Export](/gateway/diagnostics).
## Health monitor config
- `gateway.channelHealthCheckMinutes`: how often the gateway checks channel health. Default: `5`. Set `0` to disable health-monitor restarts globally.
- `gateway.channelStaleEventThresholdMinutes`: how long a connected channel can stay idle before the health monitor treats it as stale and restarts it. Default: `30`. Keep this greater than or equal to `gateway.channelHealthCheckMinutes`.
- `gateway.channelMaxRestartsPerHour`: rolling one-hour cap for health-monitor restarts per channel/account. Default: `10`.
- `channels.<provider>.healthMonitor.enabled`: disable health-monitor restarts for a specific channel while leaving global monitoring enabled.
- `channels.<provider>.accounts.<accountId>.healthMonitor.enabled`: multi-account override that wins over the channel-level setting.
- These per-channel overrides apply to the built-in channels that expose them today: Discord, Google Chat, iMessage, IRC, Microsoft Teams, Signal, Slack, Telegram, and WhatsApp.
## Uptime monitoring
External uptime monitoring services should use the dedicated `/health` endpoint, not `/v1/chat/completions`.
- **DO use:** `GET /health` - instant response, no session created, no LLM call, returns `{"ok":true,"status":"live"}`
- **DON'T use:** `/v1/chat/completions` for health checks - each request creates a full agent session with skill snapshot, context assembly, and LLM calls
When no `x-openclaw-session-key` header or `user` field is provided, `/v1/chat/completions` generates a new random session for each request. Monitoring services that ping every 15 minutes create ~96 sessions/day, each consuming 4-22KB. Over time this causes session store bloat and can lead to context window overflow.
### Monitoring service setup examples
- **BetterStack:** Set health check URL to `https://<your-gateway-host>:<port>/health`
- **UptimeRobot:** Add a new HTTP monitor with URL `https://<your-gateway-host>:<port>/health`
- **Generic:** Any HTTP GET to `/health` returns 200 with `{"ok":true}` when the gateway is healthy
## When something fails
- `logged out` or status 409-515 -> relink with `openclaw channels logout` then `openclaw channels login`.
- Gateway unreachable -> start it: `openclaw gateway --port 18789` (use `--force` if the port is busy).
- No inbound messages -> confirm linked phone is online and the sender is allowed (`channels.whatsapp.allowFrom`); for group chats, ensure allowlist + mention rules match (`channels.whatsapp.groups`, `agents.list[].groupChat.mentionPatterns`).
## Dedicated "health" command
`openclaw health` asks the running gateway for its health snapshot (no direct channel
sockets from the CLI). By default it returns a fresh cached gateway snapshot and the
gateway refreshes that cache in the background; `--verbose` forces a live probe instead.
The command reports linked creds/auth age when available, per-channel probe summaries,
session-store summary, and probe duration. It exits non-zero if the gateway is
unreachable or the probe fails/times out.
Options:
- `--json`: machine-readable JSON output
- `--timeout <ms>`: override the default 10s probe timeout
- `--verbose`: force a live probe and print gateway connection details
- `--debug`: alias for `--verbose`
The health snapshot includes: `ok` (boolean), `ts` (timestamp), `durationMs` (probe time), per-channel status, agent availability, and session-store summary.
## Related
- [Gateway runbook](/gateway)
- [Diagnostics export](/gateway/diagnostics)
- [Gateway troubleshooting](/gateway/troubleshooting)

516
docs/gateway/heartbeat.md Normal file
View File

@@ -0,0 +1,516 @@
---
summary: "Heartbeat polling messages and notification rules"
read_when:
- Adjusting heartbeat cadence or messaging
- Deciding between heartbeat and cron for scheduled tasks
title: "Heartbeat"
sidebarTitle: "Heartbeat"
---
<Note>
**Heartbeat vs cron?** See [Automation](/automation) for guidance on when to use each.
</Note>
Heartbeat runs **periodic agent turns** in the main session so the model can surface anything that needs attention without spamming you.
Heartbeat is a scheduled main-session turn - it does **not** create [background task](/automation/tasks) records. Task records are for detached work (ACP runs, subagents, isolated cron jobs).
Troubleshooting: [Scheduled Tasks](/automation/cron-jobs#troubleshooting)
## Quick start (beginner)
<Steps>
<Step title="Pick a cadence">
Leave heartbeats enabled (default is `30m`, or `1h` when Anthropic OAuth/token auth is configured, including Claude CLI reuse) or set your own cadence.
</Step>
<Step title="Add HEARTBEAT.md (optional)">
Create a tiny `HEARTBEAT.md` checklist or `tasks:` block in the agent workspace.
</Step>
<Step title="Decide where heartbeat messages should go">
`target: "none"` is the default; set `target: "last"` to route to the last contact.
</Step>
<Step title="Optional tuning">
- Enable heartbeat reasoning delivery for transparency.
- Use lightweight bootstrap context if heartbeat runs only need `HEARTBEAT.md`.
- Enable isolated sessions to avoid sending full conversation history each heartbeat.
- Restrict heartbeats to active hours (local time).
</Step>
</Steps>
Example config:
```json5
{
agents: {
defaults: {
heartbeat: {
every: "30m",
target: "last", // explicit delivery to last contact (default is "none")
directPolicy: "allow", // default: allow direct/DM targets; set "block" to suppress
lightContext: true, // optional: only inject HEARTBEAT.md from bootstrap files
isolatedSession: true, // optional: fresh session each run (no conversation history)
skipWhenBusy: true, // optional: also defer when this agent's subagent or nested lanes are busy
// activeHours: { start: "08:00", end: "24:00" },
// includeReasoning: true, // optional: send separate `Thinking` message too
},
},
},
}
```
## Defaults
- Interval: `30m`. Applying Anthropic provider defaults bumps this to `1h` when the resolved auth mode is OAuth/token (including Claude CLI reuse), but only while `heartbeat.every` is unset. Set `agents.defaults.heartbeat.every` or per-agent `agents.list[].heartbeat.every`; use `0m` to disable.
- Prompt body (configurable via `agents.defaults.heartbeat.prompt`): `Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`
- Timeout: unset heartbeat turns use `agents.defaults.timeoutSeconds` when set. Otherwise, they use the heartbeat cadence capped at 600 seconds. Set `agents.defaults.heartbeat.timeoutSeconds` or per-agent `agents.list[].heartbeat.timeoutSeconds` for longer heartbeat work.
- The heartbeat prompt is sent **verbatim** as the user message. The system prompt includes a "Heartbeats" section only when heartbeats are enabled for the default agent (and `includeSystemPromptSection` is not `false`), and the run is flagged internally.
- When heartbeats are disabled with `0m`, normal runs also omit `HEARTBEAT.md` from bootstrap context so the model does not see heartbeat-only instructions.
- Active hours (`heartbeat.activeHours`) are checked in the configured timezone. Outside the window, heartbeats are skipped until the next tick inside the window.
- Heartbeats automatically defer while cron work is active or queued. Set `heartbeat.skipWhenBusy: true` to also defer an agent on its own session-keyed subagent or nested command lanes; sibling agents no longer pause just because another agent has subagent work in flight.
## What the heartbeat prompt is for
The default prompt is intentionally broad:
- **Background tasks**: "Consider outstanding tasks" nudges the agent to review follow-ups (inbox, calendar, reminders, queued work) and surface anything urgent.
- **Human check-in**: "Checkup sometimes on your human during day time" nudges an occasional lightweight "anything you need?" message, but avoids night-time spam by using your configured local timezone (see [Timezone](/concepts/timezone)).
Heartbeat can react to completed [background tasks](/automation/tasks), but a heartbeat run itself does not create a task record.
If you want a heartbeat to do something very specific (e.g. "check Gmail PubSub stats" or "verify gateway health"), set `agents.defaults.heartbeat.prompt` (or `agents.list[].heartbeat.prompt`) to a custom body (sent verbatim).
## Response contract
- If nothing needs attention, reply with **`HEARTBEAT_OK`**.
- Heartbeat runs may instead call `heartbeat_respond` with `notify: false` for no visible update, or `notify: true` plus `notificationText` for an alert. When present, the structured tool response takes precedence over the text fallback.
- During heartbeat runs, OpenClaw treats `HEARTBEAT_OK` as an ack when it appears at the **start or end** of the reply. The token is stripped and the reply is dropped if the remaining content is **`ackMaxChars`** (default: 300).
- If `HEARTBEAT_OK` appears in the **middle** of a reply, it is not treated specially.
- For alerts, **do not** include `HEARTBEAT_OK`; return only the alert text.
Outside heartbeats, stray `HEARTBEAT_OK` at the start/end of a message is stripped and logged; a message that is only `HEARTBEAT_OK` is dropped.
## Config
```json5
{
agents: {
defaults: {
heartbeat: {
every: "30m", // default: 30m (0m disables)
model: "anthropic/claude-opus-4-6",
includeReasoning: false, // default: false (deliver separate Thinking message when available)
lightContext: false, // default: false; true keeps only HEARTBEAT.md from workspace bootstrap files
isolatedSession: false, // default: false; true runs each heartbeat in a fresh session (no conversation history)
skipWhenBusy: false, // default: false; true also waits for this agent's subagent/nested lanes
target: "last", // default: none | options: last | none | <channel id> (core or plugin, e.g. "imessage")
to: "+15551234567", // optional channel-specific override
accountId: "ops-bot", // optional multi-account channel id
prompt: "Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.",
includeSystemPromptSection: true, // default: true; false omits the ## Heartbeats system prompt section for the default agent
ackMaxChars: 300, // max chars allowed after HEARTBEAT_OK
},
},
},
}
```
### Scope and precedence
- `agents.defaults.heartbeat` sets global heartbeat behavior.
- `agents.list[].heartbeat` merges on top; if any agent has a `heartbeat` block, **only those agents** run heartbeats.
- `channels.defaults.heartbeat` sets visibility defaults for all channels.
- `channels.<channel>.heartbeat` overrides channel defaults.
- `channels.<channel>.accounts.<id>.heartbeat` (multi-account channels) overrides per-channel settings.
### Per-agent heartbeats
If any `agents.list[]` entry includes a `heartbeat` block, **only those agents** run heartbeats. The per-agent block merges on top of `agents.defaults.heartbeat` (so you can set shared defaults once and override per agent).
Example: two agents, only the second agent runs heartbeats.
```json5
{
agents: {
defaults: {
heartbeat: {
every: "30m",
target: "last", // explicit delivery to last contact (default is "none")
},
},
list: [
{ id: "main", default: true },
{
id: "ops",
heartbeat: {
every: "1h",
target: "whatsapp",
to: "+15551234567",
timeoutSeconds: 45,
prompt: "Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.",
},
},
],
},
}
```
### Active hours example
Restrict heartbeats to business hours in a specific timezone:
```json5
{
agents: {
defaults: {
heartbeat: {
every: "30m",
target: "last", // explicit delivery to last contact (default is "none")
activeHours: {
start: "09:00",
end: "22:00",
timezone: "America/New_York", // optional; uses your userTimezone if set, otherwise host tz
},
},
},
},
}
```
Outside this window (before 9am or after 10pm Eastern), heartbeats are skipped. The next scheduled tick inside the window will run normally.
### 24/7 setup
If you want heartbeats to run all day, use one of these patterns:
- Omit `activeHours` entirely (no time-window restriction; this is the default behavior).
- Set a full-day window: `activeHours: { start: "00:00", end: "24:00" }`.
<Warning>
Do not set the same `start` and `end` time (for example `08:00` to `08:00`). That is treated as a zero-width window, so heartbeats are always skipped.
</Warning>
### Multi-account example
Use `accountId` to target a specific account on multi-account channels like Telegram:
```json5
{
agents: {
list: [
{
id: "ops",
heartbeat: {
every: "1h",
target: "telegram",
to: "12345678:topic:42", // optional: route to a specific topic/thread
accountId: "ops-bot",
},
},
],
},
channels: {
telegram: {
accounts: {
"ops-bot": { botToken: "YOUR_TELEGRAM_BOT_TOKEN" },
},
},
},
}
```
### Field notes
<ParamField path="every" type="string">
Heartbeat interval (duration string; default unit = minutes).
</ParamField>
<ParamField path="model" type="string">
Optional model override for heartbeat runs (`provider/model`).
</ParamField>
<ParamField path="includeReasoning" type="boolean" default="false">
When enabled, also deliver the separate `Thinking` message when available (same shape as `/reasoning on`).
</ParamField>
<ParamField path="lightContext" type="boolean" default="false">
When true, heartbeat runs use lightweight bootstrap context and keep only `HEARTBEAT.md` from workspace bootstrap files.
</ParamField>
<ParamField path="isolatedSession" type="boolean" default="false">
When true, each heartbeat runs in a fresh session with no prior conversation history. Uses the same isolation pattern as cron `sessionTarget: "isolated"`. Dramatically reduces per-heartbeat token cost. Combine with `lightContext: true` for maximum savings. Delivery routing still uses the main session context.
</ParamField>
<ParamField path="skipWhenBusy" type="boolean" default="false">
When true, heartbeat runs defer on that agent's extra busy lanes: its own session-keyed subagent or nested command work. Cron lanes always defer heartbeats, even without this flag, so local-model hosts do not run cron and heartbeat prompts at the same time.
</ParamField>
<ParamField path="session" type="string">
Optional session key for heartbeat runs.
- `main` (default): agent main session.
- Explicit session key (copy from `openclaw sessions --json` or the [sessions CLI](/cli/sessions)).
- Session key formats: see [Sessions](/concepts/session) and [Groups](/channels/groups).
</ParamField>
<ParamField path="target" type="string">
- `last`: deliver to the last used external channel.
- explicit channel: any configured channel or plugin id, for example `discord`, `matrix`, `telegram`, or `whatsapp`.
- `none` (default): run the heartbeat but **do not deliver** externally.
</ParamField>
<ParamField path="directPolicy" type='"allow" | "block"' default="allow">
Controls direct/DM delivery behavior. `allow`: allow direct/DM heartbeat delivery. `block`: suppress direct/DM delivery (`reason=dm-blocked`).
</ParamField>
<ParamField path="to" type="string">
Optional recipient override (channel-specific id, e.g. E.164 for WhatsApp or a Telegram chat id). For Telegram topics/threads, use `<chatId>:topic:<messageThreadId>`.
</ParamField>
<ParamField path="accountId" type="string">
Optional account id for multi-account channels. When `target: "last"`, the account id applies to the resolved last channel if it supports accounts; otherwise it is ignored. If the account id does not match a configured account for the resolved channel, delivery is skipped.
</ParamField>
<ParamField path="prompt" type="string">
Overrides the default prompt body (not merged).
</ParamField>
<ParamField path="includeSystemPromptSection" type="boolean" default="true">
Whether the default agent's `## Heartbeats` system prompt section is injected. Set `false` to keep heartbeat runtime behavior (cadence, delivery, HEARTBEAT.md) while omitting the heartbeat instructions from the agent system prompt.
</ParamField>
<ParamField path="ackMaxChars" type="number" default="300">
Max chars allowed after `HEARTBEAT_OK` before delivery.
</ParamField>
<ParamField path="suppressToolErrorWarnings" type="boolean">
When true, suppresses tool error warning payloads during heartbeat runs.
</ParamField>
<ParamField path="timeoutSeconds" type="number" default="global timeout or min(every, 600)">
Maximum seconds allowed for a heartbeat agent turn before it is aborted. Leave unset to use `agents.defaults.timeoutSeconds` when set, otherwise the heartbeat cadence capped at 600 seconds.
</ParamField>
<ParamField path="activeHours" type="object">
Restricts heartbeat runs to a time window. Object with `start` (HH:MM, inclusive; use `00:00` for start-of-day), `end` (HH:MM exclusive; `24:00` allowed for end-of-day), and optional `timezone`.
- Omitted or `"user"`: uses your `agents.defaults.userTimezone` if set, otherwise falls back to the host system timezone.
- `"local"`: always uses the host system timezone.
- Any IANA identifier (e.g. `America/New_York`): used directly; if invalid, falls back to the `"user"` behavior above.
- `start` and `end` must not be equal for an active window; equal values are treated as zero-width (always outside the window).
- Outside the active window, heartbeats are skipped until the next tick inside the window.
</ParamField>
## Delivery behavior
<AccordionGroup>
<Accordion title="Session and target routing">
- Heartbeats run in the agent's main session by default (`agent:<id>:<mainKey>`), or `global` when `session.scope = "global"`. Set `session` to override to a specific channel session (Discord/WhatsApp/etc.).
- `session` only affects the run context; delivery is controlled by `target` and `to`.
- To deliver to a specific channel/recipient, set `target` + `to`. With `target: "last"`, delivery uses the last external channel for that session.
- Heartbeat deliveries allow direct/DM targets by default. Set `directPolicy: "block"` to suppress direct-target sends while still running the heartbeat turn.
- If the main queue, target session lane, cron lane, or an active cron job is busy, the heartbeat is skipped and retried later.
- If `skipWhenBusy: true`, this agent's session-keyed subagent and nested lanes also defer heartbeat runs. Other agents' busy lanes do not defer this agent.
- If `target` resolves to no external destination, the run still happens but no outbound message is sent.
</Accordion>
<Accordion title="Visibility and skip behavior">
- If `showOk`, `showAlerts`, and `useIndicator` are all disabled, the run is skipped up front as `reason=alerts-disabled`.
- If only alert delivery is disabled, OpenClaw can still run the heartbeat, update due-task timestamps, restore the session idle timestamp, and suppress the outward alert payload.
- If the resolved heartbeat target supports typing, OpenClaw shows typing while the heartbeat run is active. This uses the same target the heartbeat would send chat output to, and it is disabled by `typingMode: "never"`.
</Accordion>
<Accordion title="Session lifecycle and audit">
- Heartbeat-only replies do **not** keep the session alive. Heartbeat metadata may update the session row, but idle expiry uses `lastInteractionAt` from the last real user/channel message, and daily expiry uses `sessionStartedAt`.
- Control UI and WebChat history hide heartbeat prompts and OK-only acknowledgments. The underlying session transcript can still contain those turns for audit/replay.
- Detached [background tasks](/automation/tasks) can enqueue a system event and wake heartbeat when the main session should notice something quickly. That wake does not make the heartbeat run a background task.
</Accordion>
</AccordionGroup>
## Visibility controls
By default, `HEARTBEAT_OK` acknowledgments are suppressed while alert content is delivered. You can adjust this per channel or per account:
```yaml
channels:
defaults:
heartbeat:
showOk: false # Hide HEARTBEAT_OK (default)
showAlerts: true # Show alert messages (default)
useIndicator: true # Emit indicator events (default)
telegram:
heartbeat:
showOk: true # Show OK acknowledgments on Telegram
whatsapp:
accounts:
work:
heartbeat:
showAlerts: false # Suppress alert delivery for this account
```
Precedence: per-account → per-channel → channel defaults → built-in defaults.
### What each flag does
- `showOk`: sends a `HEARTBEAT_OK` acknowledgment when the model returns an OK-only reply.
- `showAlerts`: sends the alert content when the model returns a non-OK reply.
- `useIndicator`: emits indicator events for UI status surfaces.
If **all three** are false, OpenClaw skips the heartbeat run entirely (no model call).
### Per-channel vs per-account examples
```yaml
channels:
defaults:
heartbeat:
showOk: false
showAlerts: true
useIndicator: true
slack:
heartbeat:
showOk: true # all Slack accounts
accounts:
ops:
heartbeat:
showAlerts: false # suppress alerts for the ops account only
telegram:
heartbeat:
showOk: true
```
### Common patterns
| Goal | Config |
| ---------------------------------------- | ---------------------------------------------------------------------------------------- |
| Default behavior (silent OKs, alerts on) | _(no config needed)_ |
| Fully silent (no messages, no indicator) | `channels.defaults.heartbeat: { showOk: false, showAlerts: false, useIndicator: false }` |
| Indicator-only (no messages) | `channels.defaults.heartbeat: { showOk: false, showAlerts: false, useIndicator: true }` |
| OKs in one channel only | `channels.telegram.heartbeat: { showOk: true }` |
## HEARTBEAT.md (optional)
If a `HEARTBEAT.md` file exists in the workspace, the default prompt tells the agent to read it. Think of it as your "heartbeat checklist": small, stable, and safe to consider every 30 minutes.
On normal runs, `HEARTBEAT.md` is only injected when heartbeat guidance is enabled for the default agent. Disabling the heartbeat cadence with `0m` or setting `includeSystemPromptSection: false` omits it from normal bootstrap context.
On the native Codex harness, `HEARTBEAT.md` content is not injected into the turn like other bootstrap files. If the file exists and has non-whitespace content, a heartbeat collaboration-mode note points Codex at the file and tells it to read the file before proceeding.
If `HEARTBEAT.md` exists but is effectively empty (only blank lines, Markdown/HTML comments, Markdown headings like `# Heading`, fence markers, or empty checklist stubs), OpenClaw skips the heartbeat run to save API calls. That skip is reported as `reason=empty-heartbeat-file`. If the file is missing, the heartbeat still runs and the model decides what to do.
Keep it tiny (short checklist or reminders) to avoid prompt bloat.
Example `HEARTBEAT.md`:
```md
# Heartbeat checklist
- Quick scan: anything urgent in inboxes?
- If it's daytime, do a lightweight check-in if nothing else is pending.
- If a task is blocked, write down _what is missing_ and ask Peter next time.
```
### `tasks:` blocks
`HEARTBEAT.md` also supports a small structured `tasks:` block for interval-based checks inside heartbeat itself.
Example:
```md
tasks:
- name: inbox-triage
interval: 30m
prompt: "Check for urgent unread emails and flag anything time sensitive."
- name: calendar-scan
interval: 2h
prompt: "Check for upcoming meetings that need prep or follow-up."
# Additional instructions
- Keep alerts short.
- If nothing needs attention after all due tasks, reply HEARTBEAT_OK.
```
<AccordionGroup>
<Accordion title="Behavior">
- OpenClaw parses the `tasks:` block and checks each task against its own `interval`.
- Only **due** tasks are included in the heartbeat prompt for that tick.
- If no tasks are due, the heartbeat is skipped entirely (`reason=no-tasks-due`) to avoid a wasted model call.
- Non-task content in `HEARTBEAT.md` is preserved and appended as additional context after the due-task list.
- Task last-run timestamps are stored in session state (`heartbeatTaskState`), so intervals survive normal restarts.
- Task timestamps are only advanced after a heartbeat run completes its normal reply path. Skipped `empty-heartbeat-file` / `no-tasks-due` runs do not mark tasks as completed.
</Accordion>
</AccordionGroup>
Task mode is useful when you want one heartbeat file to hold several periodic checks without paying for all of them every tick.
### Can the agent update HEARTBEAT.md?
Yes - if you ask it to.
`HEARTBEAT.md` is just a normal file in the agent workspace, so you can tell the agent (in a normal chat) something like:
- "Update `HEARTBEAT.md` to add a daily calendar check."
- "Rewrite `HEARTBEAT.md` so it's shorter and focused on inbox follow-ups."
If you want this to happen proactively, you can also include an explicit line in your heartbeat prompt like: "If the checklist becomes stale, update HEARTBEAT.md with a better one."
<Warning>
Don't put secrets (API keys, phone numbers, private tokens) into `HEARTBEAT.md` - it becomes part of the prompt context.
</Warning>
## Manual wake (on-demand)
Use `openclaw system event` to enqueue a system event and optionally trigger an immediate heartbeat:
```bash
openclaw system event --text "Check for urgent follow-ups" --mode now
```
| Flag | Description |
| ---------------------------- | ------------------------------------------------------------------------------------------------ |
| `--text <text>` | System event text (required). |
| `--mode <mode>` | `now` runs an immediate heartbeat; `next-heartbeat` (default) waits for the next scheduled tick. |
| `--session-key <sessionKey>` | Target a specific session for the event; defaults to the agent's main session. |
| `--json` | Output JSON. |
If no `--session-key` is given and multiple agents have `heartbeat` configured, `--mode now` runs each of those agent heartbeats immediately.
Related heartbeat controls in the same CLI group:
```bash
openclaw system heartbeat last # show the last heartbeat event
openclaw system heartbeat enable # enable heartbeats
openclaw system heartbeat disable # disable heartbeats
```
## Reasoning delivery (optional)
By default, heartbeats deliver only the final "answer" payload.
If you want transparency, enable:
- `agents.defaults.heartbeat.includeReasoning: true`
When enabled, heartbeats will also deliver a separate message prefixed `Thinking` (same shape as `/reasoning on`). This can be useful when the agent is managing multiple sessions/codexes and you want to see why it decided to ping you - but it can also leak more internal detail than you want. Prefer keeping it off in group chats.
## Cost awareness
Heartbeats run full agent turns. Shorter intervals burn more tokens. To reduce cost:
- Use `isolatedSession: true` to avoid sending full conversation history (~100K tokens down to ~2-5K per run).
- Use `lightContext: true` to limit bootstrap files to just `HEARTBEAT.md`.
- Set a cheaper `model` (e.g. `ollama/llama3.2:1b`).
- Keep `HEARTBEAT.md` small.
- Use `target: "none"` if you only want internal state updates.
## Context overflow after heartbeat
Heartbeats preserve the shared session's existing runtime model after the run completes, so a heartbeat that switched a session to a smaller local model (for example an Ollama model with a 32k window) can leave that model in place for the next main-session turn. If that next turn then reports context overflow, and the session's last runtime model matches configured `heartbeat.model`, OpenClaw's recovery message calls out heartbeat model bleed as the likely cause and suggests a fix.
To avoid this: use `isolatedSession: true` to run heartbeats in a fresh session (optionally combined with `lightContext: true` for the smallest prompt), or choose a heartbeat model with a context window large enough for the shared session.
## Related
- [Automation](/automation) - all automation mechanisms at a glance
- [Background Tasks](/automation/tasks) - how detached work is tracked
- [Timezone](/concepts/timezone) - how timezone affects heartbeat scheduling
- [Troubleshooting](/automation/cron-jobs#troubleshooting) - debugging automation issues

361
docs/gateway/index.md Normal file
View File

@@ -0,0 +1,361 @@
---
summary: "Runbook for the Gateway service, lifecycle, and operations"
read_when:
- Running or debugging the gateway process
title: "Gateway runbook"
---
Use this page for day-1 startup and day-2 operations of the Gateway service.
<CardGroup cols={2}>
<Card title="Deep troubleshooting" icon="siren" href="/gateway/troubleshooting">
Symptom-first diagnostics with exact command ladders and log signatures.
</Card>
<Card title="Configuration" icon="sliders" href="/gateway/configuration">
Task-oriented setup guide + full configuration reference.
</Card>
<Card title="Secrets management" icon="key-round" href="/gateway/secrets">
SecretRef contract, runtime snapshot behavior, and migrate/reload operations.
</Card>
<Card title="Secrets plan contract" icon="shield-check" href="/gateway/secrets-plan-contract">
Exact `secrets apply` target/path rules and ref-only auth-profile behavior.
</Card>
</CardGroup>
## 5-minute local startup
<Steps>
<Step title="Start the Gateway">
```bash
openclaw gateway --port 18789
# debug/trace mirrored to stdio
openclaw gateway --port 18789 --verbose
# force-kill listener on selected port, then start
openclaw gateway --force
```
</Step>
<Step title="Verify service health">
```bash
openclaw gateway status
openclaw status
openclaw logs --follow
```
Healthy baseline: `Runtime: running`, `Connectivity probe: ok`, and a `Capability` line that matches what you expect. Use `openclaw gateway status --require-rpc` for read-scope RPC proof, not just reachability.
</Step>
<Step title="Validate channel readiness">
```bash
openclaw channels status --probe
```
With a reachable gateway this runs live per-account channel probes and optional audits. If the gateway is unreachable, the CLI falls back to config-only channel summaries.
</Step>
</Steps>
<Note>
Gateway config reload watches the active config file path (resolved from profile/state defaults, or `OPENCLAW_CONFIG_PATH` when set). Default mode is `gateway.reload.mode="hybrid"`. After the first successful load, the running process serves the active in-memory config snapshot; a successful reload swaps that snapshot atomically.
</Note>
## Runtime model
- One always-on process for routing, control plane, and channel connections.
- Single multiplexed port for:
- WebSocket control/RPC
- HTTP APIs (`/v1/models`, `/v1/embeddings`, `/v1/chat/completions`, `/v1/responses`, `/tools/invoke`)
- Plugin HTTP routes, such as optional `/api/v1/admin/rpc`
- Control UI and hooks
- Default bind mode: `loopback`. Inside a detected container environment the effective default is `auto` (resolves to `0.0.0.0` for port-forwarding), unless Tailscale serve/funnel is active, which always forces `loopback`.
- Auth is required by default. Shared-secret setups use `gateway.auth.token` / `gateway.auth.password` (or `OPENCLAW_GATEWAY_TOKEN` / `OPENCLAW_GATEWAY_PASSWORD`), and non-loopback reverse-proxy setups can use `gateway.auth.mode: "trusted-proxy"`.
## OpenAI-compatible endpoints
OpenClaw's highest-leverage compatibility surface:
- `GET /v1/models`
- `GET /v1/models/{id}`
- `POST /v1/embeddings`
- `POST /v1/chat/completions`
- `POST /v1/responses`
Why this set matters:
- Most Open WebUI, LobeChat, and LibreChat integrations probe `/v1/models` first.
- Many RAG and memory pipelines expect `/v1/embeddings`.
- Agent-native clients increasingly prefer `/v1/responses`.
`/v1/models` is agent-first: it returns `openclaw`, `openclaw/default`, and `openclaw/<agentId>` for every configured agent. `openclaw/default` is the stable alias that always maps to the configured default agent. Send `x-openclaw-model` when you want a backend provider/model override; otherwise the selected agent's normal model and embedding setup stays in control.
All of these run on the main Gateway port and use the same trusted operator auth boundary as the rest of the Gateway HTTP API.
Admin HTTP RPC (`POST /api/v1/admin/rpc`) is a separate, default-off plugin route for host tooling that cannot use WebSocket RPC. See [Admin HTTP RPC](/plugins/admin-http-rpc).
### Port and bind precedence
| Setting | Resolution order |
| ------------ | -------------------------------------------------------------------- |
| Gateway port | `--port``OPENCLAW_GATEWAY_PORT``gateway.port``18789` |
| Bind mode | CLI/override → `gateway.bind``loopback` (or `auto` in containers) |
Installed gateway services record the resolved `--port` in supervisor metadata. After changing `gateway.port`, run `openclaw doctor --fix` or `openclaw gateway install --force` so launchd/systemd/schtasks starts the process on the new port.
Gateway startup uses the same effective port and bind when it seeds local Control UI origins for non-loopback binds. For example, `--bind lan --port 3000` seeds `http://localhost:3000` and `http://127.0.0.1:3000` before runtime validation runs. Add any remote browser origins, such as HTTPS proxy URLs, to `gateway.controlUi.allowedOrigins` explicitly.
### Hot reload modes
| `gateway.reload.mode` | Behavior |
| --------------------- | ------------------------------------------ |
| `off` | No config reload |
| `hot` | Apply only hot-safe changes |
| `restart` | Restart on reload-required changes |
| `hybrid` (default) | Hot-apply when safe, restart when required |
## Operator command set
```bash
openclaw gateway status
openclaw gateway status --deep # adds a system-level service scan
openclaw gateway status --json
openclaw gateway install
openclaw gateway restart
openclaw gateway stop
openclaw secrets reload
openclaw logs --follow
openclaw doctor
```
`gateway status --deep` is for extra service discovery (LaunchDaemons/systemd system units/schtasks), not a deeper RPC health probe.
## Multiple gateways (same host)
Most installs should run one gateway per machine. A single gateway can host multiple agents and channels. You only need multiple gateways when you intentionally want isolation or a rescue bot.
Useful checks:
```bash
openclaw gateway status --deep
openclaw gateway probe
```
What to expect:
- `gateway status --deep` can report `Other gateway-like services detected (best effort)` and print cleanup hints when stale launchd/systemd/schtasks installs are still around.
- `gateway probe` can warn about `multiple reachable gateway identities` when distinct gateways answer, or when OpenClaw cannot prove reachable targets are the same gateway. An SSH tunnel, proxy URL, or configured remote URL to the same gateway is one gateway with multiple transports, even when transport ports differ.
- If that is intentional, isolate ports, config/state, and workspace roots per gateway.
Checklist per instance:
- Unique `gateway.port`
- Unique `OPENCLAW_CONFIG_PATH`
- Unique `OPENCLAW_STATE_DIR`
- Unique `agents.defaults.workspace`
Example:
```bash
OPENCLAW_CONFIG_PATH=~/.openclaw/a.json OPENCLAW_STATE_DIR=~/.openclaw-a openclaw gateway --port 19001
OPENCLAW_CONFIG_PATH=~/.openclaw/b.json OPENCLAW_STATE_DIR=~/.openclaw-b openclaw gateway --port 19002
```
Detailed setup: [/gateway/multiple-gateways](/gateway/multiple-gateways).
## Remote access
Preferred: Tailscale/VPN.
Fallback: SSH tunnel.
```bash
ssh -N -L 18789:127.0.0.1:18789 user@gateway-host
```
Then connect clients locally to `ws://127.0.0.1:18789`.
<Warning>
SSH tunnels do not bypass gateway auth. For shared-secret auth, clients still
must send `token`/`password` even over the tunnel. For identity-bearing modes,
the request still has to satisfy that auth path.
</Warning>
See: [Remote Gateway](/gateway/remote), [Authentication](/gateway/authentication), [Tailscale](/gateway/tailscale).
## Supervision and service lifecycle
Use supervised runs for production-like reliability.
<Tabs>
<Tab title="macOS (launchd)">
```bash
openclaw gateway install
openclaw gateway status
openclaw gateway restart
openclaw gateway stop
```
Use `openclaw gateway restart` for restarts. Do not chain `openclaw gateway stop` and `openclaw gateway start` as a restart substitute.
On macOS, `gateway stop` uses `launchctl bootout` by default. This removes the LaunchAgent from the current boot session without persisting a disable, so KeepAlive auto-recovery still works after unexpected crashes and `gateway start` re-enables cleanly. To persistently suppress auto-respawn across reboots, pass `--disable`: `openclaw gateway stop --disable`.
LaunchAgent labels are `ai.openclaw.gateway` (default) or `ai.openclaw.<profile>` (named profile). `openclaw doctor` audits and repairs service config drift.
</Tab>
<Tab title="Linux (systemd user)">
```bash
openclaw gateway install
systemctl --user enable --now openclaw-gateway[-<profile>].service
openclaw gateway status
```
For persistence after logout, enable lingering:
```bash
sudo loginctl enable-linger $(whoami)
```
On a headless server without a desktop session, also make sure `XDG_RUNTIME_DIR` is set (`export XDG_RUNTIME_DIR=/run/user/$(id -u)`) before retrying `systemctl --user` commands.
Manual user-unit example when you need a custom install path:
```ini
[Unit]
Description=OpenClaw Gateway
After=network-online.target
Wants=network-online.target
StartLimitBurst=5
StartLimitIntervalSec=60
[Service]
ExecStart=/usr/local/bin/openclaw gateway --port 18789
Restart=always
RestartSec=5
RestartPreventExitStatus=78
TimeoutStopSec=30
TimeoutStartSec=30
SuccessExitStatus=0 143
OOMPolicy=continue
KillMode=control-group
[Install]
WantedBy=default.target
```
</Tab>
<Tab title="Windows (native)">
```powershell
openclaw gateway install
openclaw gateway status --json
openclaw gateway restart
openclaw gateway stop
```
Native Windows managed startup uses a Scheduled Task named `OpenClaw Gateway`
(or `OpenClaw Gateway (<profile>)` for named profiles). If Scheduled Task
creation is denied, OpenClaw falls back to a per-user Startup-folder launcher
that points at `gateway.cmd` inside the state directory.
</Tab>
<Tab title="Linux (system service)">
Use a system unit for multi-user/always-on hosts.
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now openclaw-gateway[-<profile>].service
```
Use the same service body as the user unit, but install it under
`/etc/systemd/system/openclaw-gateway[-<profile>].service` and adjust
`ExecStart=` if your `openclaw` binary lives elsewhere.
Do not also let `openclaw doctor --fix` install a user-level gateway service for the same profile/port. Doctor refuses that automatic install when it finds a system-level OpenClaw gateway service; use `OPENCLAW_SERVICE_REPAIR_POLICY=external` when the system unit owns the lifecycle.
</Tab>
</Tabs>
## Dev profile quick path
```bash
openclaw --dev setup
openclaw --dev gateway --allow-unconfigured
openclaw --dev status
```
Defaults include isolated state/config and base gateway port `19001`.
## Protocol quick reference (operator view)
- First client frame must be `connect`.
- Gateway returns a `hello-ok` frame with a `snapshot` (`presence`, `health`, `stateVersion`, `uptimeMs`) plus `policy` limits (`maxPayload`, `maxBufferedBytes`, `tickIntervalMs`).
- `hello-ok.features.methods` / `events` are a conservative discovery list, not
a generated dump of every callable helper route.
- Requests: `req(method, params)``res(ok/payload|error)`.
- Common events include `connect.challenge`, `agent`, `chat`,
`session.message`, `session.operation`, `session.tool`, `sessions.changed`,
`presence`, `tick`, `health`, `heartbeat`, pairing/approval lifecycle events,
and `shutdown`.
Agent runs are two-stage:
1. Immediate accepted ack (`status:"accepted"`)
2. Final completion response (`status:"ok"|"error"`), with streamed `agent` events in between.
See full protocol docs: [Gateway Protocol](/gateway/protocol).
## Operational checks
### Liveness
- Open WS and send `connect`.
- Expect `hello-ok` response with snapshot.
### Readiness
```bash
openclaw gateway status
openclaw channels status --probe
openclaw health
```
### Gap recovery
Events are not replayed. On sequence gaps, refresh state (`health`, `system-presence`) before continuing.
## Common failure signatures
| Signature | Likely issue |
| -------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| `refusing to bind gateway ... without auth` | Non-loopback bind without a valid gateway auth path |
| `another gateway instance is already listening` / `EADDRINUSE` | Port conflict |
| `Gateway start blocked: set gateway.mode=local` | Config set to remote mode, or `gateway.mode` is missing from a damaged config |
| `unauthorized` during connect | Auth mismatch between client and gateway |
For full diagnosis ladders, use [Gateway Troubleshooting](/gateway/troubleshooting).
## Safety guarantees
- Gateway protocol clients fail fast when Gateway is unavailable (no implicit direct-channel fallback).
- Invalid/non-connect first frames are rejected and closed.
- Graceful shutdown emits `shutdown` event before socket close.
## Related
- [Configuration](/gateway/configuration)
- [Gateway troubleshooting](/gateway/troubleshooting)
- [Background process](/gateway/background-process)
- [Health](/gateway/health)
- [Doctor](/gateway/doctor)
- [Authentication](/gateway/authentication)
- [Remote access](/gateway/remote)
- [Secrets management](/gateway/secrets)

View File

@@ -0,0 +1,180 @@
---
summary: "Start local model servers on demand before OpenClaw model requests"
read_when:
- You want OpenClaw to start a local model server only when its model is selected
- You run ds4, inferrs, vLLM, llama.cpp, MLX, or another OpenAI-compatible local server
- You need to control cold start, readiness, and idle shutdown for local providers
title: "Local model services"
---
`models.providers.<id>.localService` starts a provider-owned local model server on demand. When a request selects a model from that provider, OpenClaw probes the health endpoint, starts the process if it is down, waits for readiness, then sends the request. Use it to avoid keeping expensive local servers running all day.
## How it works
1. A model request resolves to a configured provider.
2. If that provider has `localService`, OpenClaw probes `healthUrl`.
3. On a successful probe, OpenClaw uses the already-running server.
4. On a failed probe, OpenClaw spawns `command` with `args`.
5. OpenClaw polls the health endpoint until `readyTimeoutMs` expires.
6. The model request goes through the normal provider transport.
7. If OpenClaw started the process and `idleStopMs` is set, it stops the process after the last in-flight request has been idle that long.
OpenClaw does not install launchd, systemd, Docker, or any daemon for this. The server is a plain child process of whichever OpenClaw process first needed it.
Startup is serialized per provider command/argument/env set, so concurrent requests for the same service do not spawn duplicate servers. If another OpenClaw process already has a healthy server at the same `healthUrl`, this process reuses it without adopting it (each process only manages the child it personally started). Active streaming responses hold a lease, so idle shutdown waits until response handling completes.
## Config shape
```json5
{
models: {
providers: {
local: {
baseUrl: "http://127.0.0.1:8000/v1",
apiKey: "local-model",
api: "openai-completions",
timeoutSeconds: 300,
localService: {
command: "/absolute/path/to/server",
args: ["--host", "127.0.0.1", "--port", "8000"],
cwd: "/absolute/path/to/working-dir",
env: { LOCAL_MODEL_CACHE: "/absolute/path/to/cache" },
healthUrl: "http://127.0.0.1:8000/v1/models",
readyTimeoutMs: 180000,
idleStopMs: 0,
},
models: [
{
id: "my-local-model",
name: "My Local Model",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 131072,
maxTokens: 8192,
},
],
},
},
},
}
```
Set `timeoutSeconds` on the provider entry (not `localService`) so slow cold starts and long generations do not hit the default model request timeout. Set an explicit `healthUrl` whenever your server exposes readiness somewhere other than `/models` on the base URL.
## Fields
| Field | Required | Description |
| ---------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `command` | yes | Absolute executable path. No shell PATH lookup. |
| `args` | no | Process arguments. No shell expansion, pipes, globbing, or quoting. |
| `cwd` | no | Working directory for the process. |
| `env` | no | Environment variables merged over the OpenClaw process environment. |
| `healthUrl` | no | Readiness URL. Defaults to `baseUrl` with `/models` appended (`http://127.0.0.1:8000/v1` becomes `http://127.0.0.1:8000/v1/models`). |
| `readyTimeoutMs` | no | Startup readiness deadline. Default: `120000`. |
| `idleStopMs` | no | Idle shutdown delay for an OpenClaw-started process. `0` or omitted keeps it alive until OpenClaw exits. |
## Inferrs example
Inferrs is a custom OpenAI-compatible `/v1` backend, so the same `localService` API works with an `inferrs` provider entry:
```json5
{
agents: {
defaults: {
model: { primary: "inferrs/google/gemma-4-E2B-it" },
},
},
models: {
mode: "merge",
providers: {
inferrs: {
baseUrl: "http://127.0.0.1:8080/v1",
apiKey: "inferrs-local",
api: "openai-completions",
timeoutSeconds: 300,
localService: {
command: "/opt/homebrew/bin/inferrs",
args: [
"serve",
"google/gemma-4-E2B-it",
"--host",
"127.0.0.1",
"--port",
"8080",
"--device",
"metal",
],
healthUrl: "http://127.0.0.1:8080/v1/models",
readyTimeoutMs: 180000,
idleStopMs: 0,
},
models: [
{
id: "google/gemma-4-E2B-it",
name: "Gemma 4 E2B (inferrs)",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 131072,
maxTokens: 4096,
compat: { requiresStringContent: true },
},
],
},
},
},
}
```
Replace `command` with the result of `which inferrs` on the machine running OpenClaw. Full inferrs setup: [Inferrs](/providers/inferrs).
## ds4 example
```json5
{
models: {
providers: {
ds4: {
baseUrl: "http://127.0.0.1:18000/v1",
apiKey: "ds4-local",
api: "openai-completions",
timeoutSeconds: 300,
localService: {
command: "<DS4_DIR>/ds4-server",
args: [
"--model",
"<DS4_DIR>/ds4flash.gguf",
"--host",
"127.0.0.1",
"--port",
"18000",
"--ctx",
"32768",
"--tokens",
"128",
],
cwd: "<DS4_DIR>",
healthUrl: "http://127.0.0.1:18000/v1/models",
readyTimeoutMs: 300000,
idleStopMs: 0,
},
models: [],
},
},
},
}
```
Full setup, context sizing, and verification commands: [ds4](/providers/ds4).
## Related
<CardGroup cols={2}>
<Card title="Local models" href="/gateway/local-models" icon="server">
Local model setup, provider choices, and safety guidance.
</Card>
<Card title="Inferrs" href="/providers/inferrs" icon="cpu">
Run OpenClaw through the inferrs OpenAI-compatible local server.
</Card>
</CardGroup>

View File

@@ -0,0 +1,287 @@
---
summary: "Run OpenClaw on local LLMs (LM Studio, vLLM, LiteLLM, custom OpenAI endpoints)"
read_when:
- You want to serve models from your own GPU box
- You are wiring LM Studio or an OpenAI-compatible proxy
- You need the safest local model guidance
title: "Local models"
---
Local models work, but they raise the bar on hardware, context size, and prompt-injection defense: small or aggressively quantized models truncate context and skip provider-side safety filters. This page covers higher-end local stacks and custom OpenAI-compatible servers. For the lowest-friction path, start with [LM Studio](/providers/lmstudio) or [Ollama](/providers/ollama) and `openclaw onboard`.
For local servers that should start only when a selected model needs them, see [Local model services](/gateway/local-model-services).
## Hardware floor
Aim for **2+ maxed-out Mac Studios or an equivalent GPU rig (~$30k+)** for a comfortable agent loop. A single **24 GB** GPU only handles lighter prompts at higher latency. Always run the **largest / full-size variant you can host** - small or heavily quantized checkpoints raise prompt-injection risk (see [Security](/gateway/security)).
## Pick a backend
| Backend | Use when |
| ---------------------------------------------------- | --------------------------------------------------------------------------- |
| [ds4](/providers/ds4) | Local DeepSeek V4 Flash on macOS Metal with OpenAI-compatible tool calls |
| [LM Studio](/providers/lmstudio) | First-time local setup, GUI loader, native Responses API |
| LiteLLM / OAI-proxy / custom OpenAI-compatible proxy | You front another model API and need OpenClaw to treat it as OpenAI |
| MLX / vLLM / SGLang | High-throughput self-hosted serving with an OpenAI-compatible HTTP endpoint |
| [Ollama](/providers/ollama) | CLI workflow, model library, hands-off systemd service |
Use `api: "openai-responses"` when the backend supports it (LM Studio does). Otherwise use `api: "openai-completions"`. If `api` is omitted on a custom provider with a `baseUrl`, OpenClaw defaults to `openai-completions`.
<Warning>
**WSL2 + Ollama + NVIDIA/CUDA:** the official Ollama Linux installer enables a systemd service with `Restart=always`. On WSL2 GPU setups, autostart can reload the last model during boot and pin host memory, causing repeated VM restarts. See [WSL2 crash loop](/providers/ollama#troubleshooting).
</Warning>
## LM Studio + large local model (Responses API)
This is the best current local stack. Load a large model in LM Studio (a full-size Qwen, DeepSeek, or Llama build), enable the local server (default `http://127.0.0.1:1234`), and use the Responses API to keep reasoning separate from final text.
```json5
{
agents: {
defaults: {
model: { primary: "lmstudio/my-local-model" },
models: {
"anthropic/claude-opus-4-6": { alias: "Opus" },
"lmstudio/my-local-model": { alias: "Local" },
},
},
},
models: {
mode: "merge",
providers: {
lmstudio: {
baseUrl: "http://127.0.0.1:1234/v1",
apiKey: "lmstudio",
api: "openai-responses",
models: [
{
id: "my-local-model",
name: "Local Model",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 196608,
maxTokens: 8192,
},
],
},
},
},
}
```
Setup checklist:
- Install LM Studio: [https://lmstudio.ai](https://lmstudio.ai)
- Download the **largest available model build** (avoid "small"/heavily quantized variants), start the server, confirm `http://127.0.0.1:1234/v1/models` lists it.
- Replace `my-local-model` with the actual model ID shown in LM Studio.
- Keep the model loaded; cold-load adds startup latency.
- Adjust `contextWindow`/`maxTokens` if your LM Studio build differs.
- For WhatsApp, stick to the Responses API so only final text is sent.
- Keep `models.mode: "merge"` so hosted models stay available as fallbacks.
### Hybrid config: hosted primary, local fallback
```json5
{
agents: {
defaults: {
model: {
primary: "anthropic/claude-sonnet-4-6",
fallbacks: ["lmstudio/my-local-model", "anthropic/claude-opus-4-6"],
},
models: {
"anthropic/claude-sonnet-4-6": { alias: "Sonnet" },
"lmstudio/my-local-model": { alias: "Local" },
"anthropic/claude-opus-4-6": { alias: "Opus" },
},
},
},
models: {
mode: "merge",
providers: {
lmstudio: {
baseUrl: "http://127.0.0.1:1234/v1",
apiKey: "lmstudio",
api: "openai-responses",
models: [
{
id: "my-local-model",
name: "Local Model",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 196608,
maxTokens: 8192,
},
],
},
},
},
}
```
For local-first with a hosted safety net, swap `primary`/`fallbacks` order and keep the same `providers` block and `models.mode: "merge"`.
### Regional hosting / data routing
Hosted MiniMax/Kimi/GLM variants also exist on OpenRouter with region-pinned endpoints (for example, US-hosted). Pick the regional variant to keep traffic in your chosen jurisdiction while keeping `models.mode: "merge"` for Anthropic/OpenAI fallbacks. Local-only is still the strongest privacy path; hosted regional routing is the middle ground when you need provider features but want control over data flow.
## Other OpenAI-compatible local proxies
MLX (`mlx_lm.server`), vLLM, SGLang, LiteLLM, OAI-proxy, or any custom gateway works if it exposes an OpenAI-style `/v1/chat/completions` endpoint. Use `openai-completions` unless the backend explicitly documents `/v1/responses` support.
```json5
{
agents: {
defaults: {
model: { primary: "local/my-local-model" },
},
},
models: {
mode: "merge",
providers: {
local: {
baseUrl: "http://127.0.0.1:8000/v1",
apiKey: "sk-local",
api: "openai-completions",
timeoutSeconds: 300,
models: [
{
id: "my-local-model",
name: "Local Model",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 120000,
maxTokens: 8192,
},
],
},
},
},
}
```
Custom/local provider entries trust their exact configured `baseUrl` origin for guarded model requests, including loopback, LAN, tailnet, and private DNS hosts. Metadata/link-local origins are always blocked regardless. Requests to other private origins still need `models.providers.<id>.request.allowPrivateNetwork: true`; set the trust flag to `false` to opt out of exact-origin trust.
`models.providers.<id>.models[].id` is provider-local - do not include the provider prefix. For an MLX server started with `mlx_lm.server --model mlx-community/Qwen3-30B-A3B-6bit`:
- `models.providers.mlx.models[].id: "mlx-community/Qwen3-30B-A3B-6bit"`
- `agents.defaults.model.primary: "mlx/mlx-community/Qwen3-30B-A3B-6bit"`
Set `input: ["text", "image"]` on local or proxied vision models so image attachments get injected into agent turns. Interactive custom-provider onboarding infers common vision model IDs and only asks about unknown names; non-interactive onboarding uses the same inference, with `--custom-image-input` / `--custom-text-input` to override it.
Use `models.providers.<id>.timeoutSeconds` for slow local/remote model servers before raising `agents.defaults.timeoutSeconds`. The provider timeout covers connect, headers, body streaming, and the total guarded-fetch abort for model HTTP requests only - if the agent/run timeout is lower, raise that too, since the provider timeout cannot extend the whole run.
<Note>
For custom OpenAI-compatible providers, a non-secret local marker such as `apiKey: "ollama-local"` is accepted when `baseUrl` resolves to loopback, a private LAN, `.local`, or a bare hostname - OpenClaw treats it as a valid local credential instead of reporting a missing key. Use a real value for any provider that accepts a public hostname.
</Note>
Behavior notes for local/proxied `/v1` backends:
- OpenClaw treats these as proxy-style OpenAI-compatible routes, not native OpenAI endpoints.
- Native-OpenAI-only request shaping does not apply: no `service_tier`, no Responses `store`, no OpenAI reasoning-compat payload shaping, no prompt-cache hints.
- Hidden OpenClaw attribution headers (`originator`, `version`, `User-Agent`) are not injected on custom proxy URLs.
Compat overrides for stricter OpenAI-compatible backends:
- **String-only content**: some servers accept only string `messages[].content`, not structured content-part arrays. Set `models.providers.<provider>.models[].compat.requiresStringContent: true`.
- **Strict message keys**: if the server rejects message entries with more than `role`/`content`, set `compat.strictMessageKeys: true`.
- **Bracketed tool text**: some local models emit standalone bracketed tool requests as text, like `[tool_name]` followed by JSON and `[END_TOOL_REQUEST]`. OpenClaw promotes those to real tool calls only when the name exactly matches a registered tool for the turn; otherwise it stays as hidden, unsupported text.
- **Unstructured tool-call-looking text**: if a model emits JSON/XML/ReAct-style text that looks like a tool call but wasn't a structured invocation, OpenClaw leaves it as text and logs a warning with the run id, provider/model, detected pattern, and tool name when available. That is provider/model incompatibility, not a completed tool run.
- **Forcing tool use**: if tools show up as assistant text (raw JSON/XML/ReAct, or an empty `tool_calls` array), first confirm the server's chat template/parser supports tool calls. If the parser only works when tool use is forced, override the default proxy value of `tool_choice: "auto"` per model:
```json5
{
agents: {
defaults: {
models: {
"local/my-local-model": {
params: {
extra_body: {
tool_choice: "required",
},
},
},
},
},
},
}
```
Use this only where every normal turn should call a tool. Replace `local/my-local-model` with the exact ref from `openclaw models list`, or set it via CLI:
```bash
openclaw config set agents.defaults.models '{"local/my-local-model":{"params":{"extra_body":{"tool_choice":"required"}}}}' --strict-json --merge
```
- **Extra reasoning efforts**: if a custom OpenAI-compatible model accepts OpenAI reasoning efforts beyond the built-in profile, declare them in the model's compat block. Adding `"xhigh"` exposes it for that model ref in `/think xhigh`, session pickers, Gateway validation, and `llm-task` validation:
```json5
{
models: {
providers: {
local: {
baseUrl: "http://127.0.0.1:8000/v1",
apiKey: "sk-local",
api: "openai-responses",
models: [
{
id: "gpt-5.4",
name: "GPT 5.4 via local proxy",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 196608,
maxTokens: 8192,
compat: {
supportedReasoningEfforts: ["low", "medium", "high", "xhigh"],
reasoningEffortMap: { xhigh: "xhigh" },
},
},
],
},
},
},
}
```
## Smaller or stricter backends
If the model loads cleanly but full agent turns misbehave, work top-down: confirm transport first, then narrow the surface.
1. **Confirm the local model responds** - no tools, no agent context:
```bash
openclaw infer model run --local --model <provider/model> --prompt "Reply with exactly: pong" --json
```
2. **Confirm Gateway routing** - sends only the prompt, skipping transcript, AGENTS bootstrap, context-engine assembly, tools, and bundled MCP servers, but still exercises Gateway routing, auth, and provider selection:
```bash
openclaw infer model run --gateway --model <provider/model> --prompt "Reply with exactly: pong" --json
```
3. **Try lean mode** if both probes pass but real agent turns fail with malformed tool calls or oversized prompts: set `agents.defaults.experimental.localModelLean: true`. It drops the three heaviest default tools (`browser`, `cron`, `message` - unless a run must keep direct `message` delivery semantics) and defaults larger tool catalogs behind structured Tool Search controls. See [Experimental Features -> Local model lean mode](/concepts/experimental-features#local-model-lean-mode) for details and how to confirm it's on.
4. **Disable tools entirely as a last resort** by setting `models.providers.<provider>.models[].compat.supportsTools: false` for that model - the agent then runs without tool calls.
5. **Past that, the bottleneck is upstream.** If the backend still fails only on larger OpenClaw runs after lean mode and `supportsTools: false`, the remaining issue is usually the model or server itself - context window, GPU memory, kv-cache eviction, or a backend bug - not OpenClaw's transport layer.
## Troubleshooting
- **Gateway can't reach the proxy?** `curl http://127.0.0.1:1234/v1/models`.
- **LM Studio model unloaded?** Reload; cold start is a common "hanging" cause.
- **Local server says `terminated`, `ECONNRESET`, or closes the stream mid-turn?** OpenClaw records a low-cardinality `model.call.error.failureKind` plus the OpenClaw process RSS/heap snapshot in diagnostics. For LM Studio/Ollama memory pressure, match that timestamp against the server log or a macOS crash/jetsam log to confirm whether the model server was killed.
- **Context errors?** OpenClaw derives context-window preflight thresholds from the detected model window (or the capped window when `agents.defaults.contextTokens` lowers it), warning below 20% with an **8k** floor and hard-blocking below 10% with a **4k** floor (capped to the effective context window so oversized model metadata can't reject a valid user cap). Lower `contextWindow` or raise the server/model context limit.
- **`messages[].content ... expected a string`?** Add `compat.requiresStringContent: true` on that model entry.
- **`validation.keys`, or "message entries only allow `role` and `content`"?** Add `compat.strictMessageKeys: true` on that model entry.
- **Direct `/v1/chat/completions` calls work, but `openclaw infer model run --local` fails on Gemma or another local model?** Check the provider URL, model ref, auth marker, and server logs first - `model run` skips agent tools entirely. If `model run` succeeds but larger agent turns fail, reduce the tool surface with `localModelLean` or `compat.supportsTools: false`.
- **Tool calls show up as raw JSON/XML/ReAct text, or the provider returns an empty `tool_calls` array?** Do not add a proxy that blindly converts assistant text into tool execution - fix the server's chat template/parser first. If the model only works when tool use is forced, add the `params.extra_body.tool_choice: "required"` override above and use that model entry only for sessions where a tool call is expected every turn.
- **Safety**: local models skip provider-side filters. Keep agents narrow and compaction on to limit prompt-injection blast radius.
## Related
- [Configuration reference](/gateway/configuration-reference)
- [Model failover](/concepts/model-failover)

116
docs/gateway/logging.md Normal file
View File

@@ -0,0 +1,116 @@
---
summary: "Logging surfaces, file logs, WS log styles, and console formatting"
read_when:
- Changing logging output or formats
- Debugging CLI or gateway output
title: "Gateway logging"
---
# Logging
For a user-facing overview (CLI + Control UI + config), see [/logging](/logging).
OpenClaw has two log surfaces:
- **Console output** - what you see in the terminal / Debug UI.
- **File logs** - JSON lines written by the gateway logger.
At startup, the Gateway logs the resolved default agent model plus the mode defaults that affect new sessions:
```text
agent model: openai/gpt-5.5 (thinking=medium, fast=on)
```
`thinking` comes from the default agent, model params, or the global agent default; when unset it shows `medium`. `fast` comes from the default agent or the model's `fastMode` params.
## File-based logger
- Default rolling log file is under `/tmp/openclaw/` (one file per day): `openclaw-YYYY-MM-DD.log`, dated by the gateway host's local timezone. If that directory is unsafe or unwritable (wrong owner, world-writable, a symlink), OpenClaw falls back to a user-scoped `os.tmpdir()/openclaw-<uid>` path instead; on Windows it always uses that OS-tmpdir fallback.
- Active log files rotate at `logging.maxFileBytes` (default: 100 MB), keeping up to five numbered archives (`.1` through `.5`) and continuing to write a fresh active file.
- Configure the log file path and level via `~/.openclaw/openclaw.json`: `logging.file`, `logging.level`.
- The file format is one JSON object per line.
Talk, realtime voice, and managed-room code paths use the shared file logger for bounded lifecycle records intended for operational debugging and OTLP log export. Transcript text, audio payloads, turn ids, call ids, and provider item ids are never copied into the log record.
The Control UI Logs tab tails this file via the gateway (`logs.tail`). The CLI does the same:
```bash
openclaw logs --follow
```
### Verbose vs. log levels
- **File logs** are controlled exclusively by `logging.level`.
- `--verbose` only affects **console verbosity** (and WS log style) - it does **not** raise the file log level.
- To capture verbose-only details in file logs, set `logging.level` to `debug` or `trace`.
- Trace logging also includes diagnostic timing summaries for selected hot paths, such as plugin tool factory preparation. See [/tools/plugin#slow-plugin-tool-setup](/tools/plugin#slow-plugin-tool-setup).
## Console capture
The CLI captures `console.log/info/warn/error/debug/trace`, writes them to file logs, and still prints to stdout/stderr.
Tune console verbosity independently:
- `logging.consoleLevel` (default `info`)
- `logging.consoleStyle` (`pretty` | `compact` | `json`; defaults to `pretty` on a TTY, `compact` otherwise)
## Redaction
OpenClaw masks sensitive tokens before log or transcript output leaves the process. This redaction policy applies at console, file-log, OTLP log-record, and session transcript text sinks, so matching secret values are masked before JSONL lines or messages are written to disk.
- `logging.redactSensitive`: `off` | `tools` (default: `tools`)
- `logging.redactPatterns`: array of regex strings (overrides defaults)
- Use raw regex strings (auto `gi`), or `/pattern/flags` for custom flags.
- Matches are masked keeping the first 6 + last 4 chars (values >= 18 chars); shorter values become `***`.
- Defaults cover common key assignments, CLI flags, JSON fields, bearer headers, PEM blocks, popular vendor token prefixes, and payment credential field names (card number, CVC/CVV, shared payment token, payment credential).
Some safety boundaries always redact regardless of `logging.redactSensitive`: Control UI tool-call events, `sessions_history` tool output, diagnostics support exports, provider error observations, exec approval command display, and Gateway WebSocket protocol logs. These surfaces still honor `logging.redactPatterns` as additional patterns, but `redactSensitive: "off"` does not make them emit raw secrets.
## Gateway WebSocket logs
The gateway prints WebSocket protocol logs in two modes:
- **Normal mode (no `--verbose`)**: only "interesting" RPC results print - errors (`ok=false`), slow calls (default threshold: `>= 50ms`), and parse errors.
- **Verbose mode (`--verbose`)**: prints all WS request/response traffic.
### WS log style
`openclaw gateway` supports a per-gateway style switch:
- `--ws-log auto` (default): normal mode is optimized; verbose mode uses compact output.
- `--ws-log compact`: compact output (paired request/response) when verbose.
- `--ws-log full`: full per-frame output when verbose.
- `--compact`: alias for `--ws-log compact`.
```bash
# optimized (only errors/slow)
openclaw gateway
# show all WS traffic (paired)
openclaw gateway --verbose --ws-log compact
# show all WS traffic (full meta)
openclaw gateway --verbose --ws-log full
```
## Console formatting (subsystem logging)
The console formatter is **TTY-aware** and prints consistent, prefixed lines. Subsystem loggers keep output grouped and scannable:
- **Subsystem prefixes** on every line (e.g. `[gateway]`, `[canvas]`, `[tailscale]`).
- **Subsystem colors** (stable per subsystem, hashed from the name) plus level coloring.
- **Color when output is a TTY** or the environment looks like a rich terminal (`TERM`/`COLORTERM`/`TERM_PROGRAM`); respects `NO_COLOR` and `FORCE_COLOR`.
- **Shortened subsystem prefixes**: drops a leading `gateway/`, `channels/`, or `providers/` segment, then keeps at most the last 2 remaining segments (e.g. `channels/turn/kernel` displays as `turn/kernel`). Known channel subsystems (`telegram`, `whatsapp`, `slack`, etc.) always collapse to just the channel name.
- **Sub-loggers by subsystem** (auto prefix + structured field `{ subsystem }`).
- **`logRaw()`** for QR/UX output (no prefix, no formatting).
- **Console styles**: `pretty` | `compact` | `json`.
- **Console log level** is separate from file log level (file keeps full detail when `logging.level` is `debug`/`trace`).
- **WhatsApp message bodies** log at `debug` (use `--verbose` to see them).
This keeps file logs stable while making interactive output scannable.
## Related
- [Logging](/logging)
- [OpenTelemetry export](/gateway/opentelemetry)
- [Diagnostics export](/gateway/diagnostics)

View File

@@ -0,0 +1,143 @@
---
summary: "Run multiple OpenClaw Gateways on one host (isolation, ports, and profiles)"
read_when:
- Running more than one Gateway on the same machine
- You need isolated config/state/ports per Gateway
title: "Multiple gateways"
---
Most setups need one Gateway - a single Gateway handles multiple messaging connections and agents. Run separate Gateways with isolated profiles/ports only when you need stronger isolation or redundancy (e.g., a rescue bot).
## Rescue-bot quickstart
The simplest rescue-bot setup:
- Keep the main bot on the default profile.
- Run the rescue bot on `--profile rescue`, with its own Telegram bot token.
- Put the rescue bot on a different base port, e.g. `19789`.
This keeps the rescue bot able to debug or apply config changes if the primary bot is down. Leave at least 20 ports between base ports so derived browser/CDP ports never collide.
```bash
# Rescue bot (separate Telegram bot, separate profile, port 19789)
openclaw --profile rescue onboard
openclaw --profile rescue gateway install --port 19789
```
If your main bot is already running, that's usually all you need. If onboarding already installed the rescue service, skip the final `gateway install`.
During `openclaw --profile rescue onboard`:
- Use a separate Telegram bot token, dedicated to the rescue account (easy to keep operator-only, independent from the main bot's channel/app install, and a simple DM-based recovery path).
- Keep the `rescue` profile name.
- Use a base port at least 20 higher than the main bot.
- Accept the default rescue workspace unless you already manage one yourself.
### What `--profile rescue onboard` changes
`--profile rescue onboard` runs the normal onboarding flow but writes everything into a separate profile, so the rescue bot gets its own:
- Profile/config file
- State directory
- Workspace (default: `~/.openclaw/workspace-rescue`)
- Managed service name
- Base port (plus derived ports)
- Telegram bot token
Prompts are otherwise identical to normal onboarding.
## General multi-gateway setup
The same isolation pattern works for any pair or group of Gateways on one host - give each extra Gateway its own named profile and base port:
```bash
# main (default profile)
openclaw setup
openclaw gateway --port 18789
# extra gateway
openclaw --profile ops setup
openclaw --profile ops gateway --port 19789
```
Named profiles on both sides also work:
```bash
openclaw --profile main setup
openclaw --profile main gateway --port 18789
openclaw --profile ops setup
openclaw --profile ops gateway --port 19789
```
Services follow the same pattern:
```bash
openclaw gateway install
openclaw --profile ops gateway install --port 19789
```
Use the rescue-bot quickstart for a fallback operator lane; use the general profile pattern for multiple long-lived Gateways across different channels, tenants, workspaces, or operational roles.
## Isolation checklist
Keep these unique per Gateway instance:
| Setting | Purpose |
| ---------------------------- | ------------------------------------ |
| `OPENCLAW_CONFIG_PATH` | Per-instance config file |
| `OPENCLAW_STATE_DIR` | Per-instance sessions, creds, caches |
| `agents.defaults.workspace` | Per-instance workspace root |
| `gateway.port` (or `--port`) | Unique per instance |
| Derived browser/CDP ports | See below |
Sharing any of these causes config races and port conflicts.
## Port mapping (derived)
Base port = `gateway.port` (or `OPENCLAW_GATEWAY_PORT` / `--port`).
- Browser control service port = base + 2 (loopback only).
- Canvas host is served on the Gateway HTTP server itself (same port as `gateway.port`).
- Browser profile CDP ports auto-allocate from `browser control port + 9` through `+ 108`.
Override any of these in config or env and you must keep them unique per instance.
## Browser/CDP notes (common footgun)
- Do **not** pin `browser.cdpUrl` to the same value on multiple instances.
- Each instance needs its own browser control port and CDP range (derived from its gateway port).
- For explicit CDP ports, set `browser.profiles.<name>.cdpPort` per instance.
- For remote Chrome, use `browser.profiles.<name>.cdpUrl` (per profile, per instance).
## Manual env example
```bash
OPENCLAW_CONFIG_PATH=~/.openclaw/main.json \
OPENCLAW_STATE_DIR=~/.openclaw \
openclaw gateway --port 18789
OPENCLAW_CONFIG_PATH=~/.openclaw/rescue.json \
OPENCLAW_STATE_DIR=~/.openclaw-rescue \
openclaw gateway --port 19789
```
## Quick checks
```bash
openclaw gateway status --deep
openclaw --profile rescue gateway status --deep
openclaw --profile rescue gateway probe
openclaw status
openclaw --profile rescue status
openclaw --profile rescue browser status
```
- `gateway status --deep` catches stale launchd/systemd/schtasks services from older installs.
- `gateway probe` warning text such as `multiple reachable gateway identities detected` is expected only when you intentionally run more than one isolated gateway, or when OpenClaw cannot prove reachable probe targets are the same gateway. An SSH tunnel, proxy URL, or configured remote URL to the same gateway is one gateway with multiple transports, even when transport ports differ.
## Related
- [Gateway runbook](/gateway)
- [Gateway lock](/gateway/gateway-lock)
- [Configuration](/gateway/configuration)

View File

@@ -0,0 +1,15 @@
---
summary: "Redirect to /network#core-model"
read_when:
- You want a concise view of the Gateway networking model
title: "Network model"
redirect: /network#core-model
---
This content has been merged into [Network — Core model](/network#core-model).
## Related
- [Remote access](/gateway/remote)
- [Trusted proxy auth](/gateway/trusted-proxy-auth)
- [Gateway protocol](/gateway/protocol)

View File

@@ -0,0 +1,316 @@
---
summary: "Expose an OpenAI-compatible /v1/chat/completions HTTP endpoint from the Gateway"
read_when:
- Integrating tools that expect OpenAI Chat Completions
title: "OpenAI chat completions"
---
The Gateway can serve a small OpenAI-compatible Chat Completions surface. It is **disabled by default**.
Once enabled, it serves all of these on the same port as the Gateway (WS + HTTP multiplex):
| Method | Path |
| ------ | ---------------------- |
| POST | `/v1/chat/completions` |
| GET | `/v1/models` |
| GET | `/v1/models/{id}` |
| POST | `/v1/embeddings` |
| POST | `/v1/responses` |
Requests run as a normal Gateway agent run (same codepath as `openclaw agent`), so routing, permissions, and config match your Gateway.
## Enabling the endpoint
```json5
{
gateway: {
http: {
endpoints: {
chatCompletions: { enabled: true },
},
},
},
}
```
Set `enabled: false` (or omit it) to disable.
## Security boundary (important)
Treat this endpoint as **full operator access** to the gateway instance:
- A valid Gateway token/password for this endpoint is equivalent to an owner/operator credential, not a narrow per-user scope.
- Requests run through the same control-plane agent path as trusted operator actions, so if the target agent's policy allows sensitive tools, this endpoint can use them.
- Keep it on loopback/tailnet/private ingress only. Do not expose it to the public internet.
Auth matrix:
| Auth path | Behavior |
| ---------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `gateway.auth.mode="token"` or `"password"` + `Authorization: Bearer ...` | Proves possession of the shared gateway secret. Ignores any `x-openclaw-scopes` header and restores the full default operator scope set: `operator.admin`, `operator.approvals`, `operator.pairing`, `operator.read`, `operator.talk.secrets`, `operator.write`. Treats chat turns as owner-sender turns. |
| Trusted identity-bearing HTTP (trusted-proxy auth, or `gateway.auth.mode="none"` on private ingress) | Honors `x-openclaw-scopes` when present; falls back to the default operator scope set when absent. Loses owner semantics only when the caller explicitly narrows scopes and omits `operator.admin`. Requires `operator.admin` for owner-level controls such as `x-openclaw-model`. |
See [Operator scopes](/gateway/operator-scopes), [Security](/gateway/security), and [Remote access](/gateway/remote).
## Authentication
Uses the Gateway auth configuration (see [Trusted proxy auth](/gateway/trusted-proxy-auth) for that mode's details):
| Mode | How to authenticate |
| ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `gateway.auth.mode="token"` | `Authorization: Bearer <token>`. Set via `gateway.auth.token` or `OPENCLAW_GATEWAY_TOKEN`. |
| `gateway.auth.mode="password"` | `Authorization: Bearer <password>`. Set via `gateway.auth.password` or `OPENCLAW_GATEWAY_PASSWORD`. |
| `gateway.auth.mode="trusted-proxy"` | Route through the configured identity-aware proxy; it injects the required identity headers. Same-host loopback proxies need explicit `gateway.auth.trustedProxy.allowLoopback = true`. |
| `gateway.auth.mode="none"` | No auth header required (private ingress only). |
Notes:
- Same-host callers that bypass the proxy on a `trusted-proxy` gateway can fall back to `gateway.auth.password` / `OPENCLAW_GATEWAY_PASSWORD` directly. Any `Forwarded`, `X-Forwarded-*`, or `X-Real-IP` header evidence keeps the request on the trusted-proxy path instead.
- If `gateway.auth.rateLimit` is configured and too many auth attempts fail, the endpoint returns `429` with a `Retry-After` header.
## When to use this endpoint
- Prefer this over adding a new built-in channel when your integration is just another operator/client surface for the same gateway.
- For native mobile clients that connect directly to a remote gateway, prefer [WebChat](/web/webchat) or the [Gateway Protocol](/gateway/protocol) with the paired-device bootstrap/device-token flow, so the device does not need a shared HTTP token/password.
- Build a channel plugin instead when integrating an external messaging network with its own users, rooms, webhook delivery, or outbound transport. See [Building plugins](/plugins/building-plugins).
## Agent-first model contract
OpenClaw treats the OpenAI `model` field as an **agent target**, not a raw provider model id.
| `model` value | Routes to |
| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `openclaw` | Configured default agent |
| `openclaw/default` | Configured default agent (stable alias; safe to hardcode even if the real default agent id changes between environments) |
| `openclaw/<agentId>` or `openclaw:<agentId>` | Specific agent |
| `agent:<agentId>` | Specific agent (compatibility alias) |
Optional request headers:
| Header | Effect |
| ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `x-openclaw-model: <provider/model-or-bare-id>` | Overrides the backend model for the selected agent. Shared-secret bearer callers can use this directly; identity-bearing callers (trusted-proxy, or private no-auth ingress with `x-openclaw-scopes`) need `operator.admin`, otherwise `403 missing scope: operator.admin`. |
| `x-openclaw-agent-id: <agentId>` | Compatibility override for agent selection. |
| `x-openclaw-session-key: <sessionKey>` | Explicit session routing. Rejected with `400 invalid_request_error` if it uses a reserved internal namespace (`subagent:`, `cron:`, `acp:`). |
| `x-openclaw-message-channel: <channel>` | Sets the synthetic ingress channel context for channel-aware prompts/policies. |
`/v1/models` lists top-level agent targets (`openclaw`, `openclaw/default`, `openclaw/<agentId>`), not backend provider models and not sub-agents; sub-agents stay internal execution topology. If you omit `x-openclaw-model`, the selected agent runs with its normal configured model.
`/v1/embeddings` uses the same agent-target `model` ids. Send `x-openclaw-model` (from a shared-secret caller, or an identity-bearing caller with `operator.admin`) to pick a specific embedding model; otherwise the request uses the selected agent's normal embedding setup.
## Session behavior
By default the endpoint is **stateless per request** (a new session key is generated each call).
If the request includes an OpenAI `user` string, the Gateway derives a stable session key from it so repeated calls can share an agent session. For custom apps, reuse the same `user` value per conversation thread; avoid account-level identifiers unless you want multiple conversations/devices to share one OpenClaw session. Use `x-openclaw-session-key` only when you need explicit routing control across multiple clients/threads, with application-owned keys that avoid the reserved namespaces above.
## Request limits (config)
Defaults can be tuned under `gateway.http.endpoints.chatCompletions`:
```json5
{
gateway: {
http: {
endpoints: {
chatCompletions: {
enabled: true,
maxBodyBytes: 20000000,
maxImageParts: 8,
maxTotalImageBytes: 20000000,
images: {
allowUrl: false,
urlAllowlist: ["cdn.example.com", "*.assets.example.com"],
allowedMimes: [
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/heic",
"image/heif",
],
maxBytes: 10485760,
maxRedirects: 3,
timeoutMs: 10000,
},
},
},
},
},
}
```
Defaults when omitted:
| Key | Default |
| --------------------- | --------------------------------------------------------------------------- |
| `maxBodyBytes` | 20MB |
| `maxImageParts` | 8 (max `image_url` parts read from the latest user message) |
| `maxTotalImageBytes` | 20MB (cumulative decoded bytes across all `image_url` parts in one request) |
| `images.allowUrl` | `false` (URL-sourced `image_url` parts are rejected unless enabled) |
| `images.maxBytes` | 10MB per image |
| `images.maxRedirects` | 3 |
| `images.timeoutMs` | 10s |
HEIC/HEIF `image_url` sources are accepted and normalized to JPEG before provider delivery through the shared OpenClaw image processor (Rastermill), which falls back to a system converter (`sips`, ImageMagick, GraphicsMagick, or ffmpeg) for formats needing external codec support.
Security note: allowlisting a hostname does not bypass private/internal IP blocking. For internet-exposed gateways, apply network egress controls in addition to app-level guards. See [Security](/gateway/security).
## Chat tool contract
`/v1/chat/completions` supports a function-tool subset compatible with common OpenAI Chat clients.
### Supported request fields
| Field | Notes |
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `tools` | Array of `{ "type": "function", "function": { ... } }` |
| `tool_choice` | `"auto"`, `"none"`, `"required"`, or `{ "type": "function", "function": { "name": "..." } }` |
| `messages[*].role: "tool"` | Follow-up turns |
| `messages[*].tool_call_id` | Binds a tool result back to a prior tool call |
| `max_completion_tokens` | Number; per-call cap on total completion tokens (reasoning tokens included). Current field name; used when both it and `max_tokens` are sent. |
| `max_tokens` | Number; legacy alias, ignored when `max_completion_tokens` is also present. |
| `temperature` | Number 0-2; best-effort, forwarded to the upstream provider. `400 invalid_request_error` if out of range. |
| `top_p` | Number 0-1; best-effort. `400 invalid_request_error` if out of range. |
| `frequency_penalty` | Number -2.0 to 2.0; best-effort. `400 invalid_request_error` if out of range. |
| `presence_penalty` | Number -2.0 to 2.0; best-effort. `400 invalid_request_error` if out of range. |
| `seed` | Integer; best-effort. `400 invalid_request_error` for non-integer values. |
| `stop` | String or array of up to 4 strings; best-effort. `400 invalid_request_error` for more than 4 sequences or non-string/empty entries. |
All sampling and token-cap fields ride the same agent stream-param channel and are forwarded best-effort:
- Token cap: the wire field name is chosen by the provider transport: `max_completion_tokens` for OpenAI-family endpoints, `max_tokens` for providers that only accept the legacy name (Mistral, Chutes).
- `stop` maps to the transport's stop field: `stop` for Chat Completions backends, `stop_sequences` for Anthropic. The OpenAI Responses API has no stop parameter, so `stop` is not applied on Responses-backed models.
- The ChatGPT-based Codex Responses backend uses fixed server-side sampling and strips `temperature`/`top_p` (along with `max_output_tokens`, `metadata`, `prompt_cache_retention`, `service_tier`) before the request reaches that backend.
### Unsupported variants
Returns `400 invalid_request_error` for:
- non-array `tools`, non-function tool entries, or missing `tool.function.name`
- `tool_choice` variants such as `allowed_tools` and `custom`
- `tool_choice.function.name` values that do not match a provided tool
For `tool_choice: "required"` and function-pinned `tool_choice`, the endpoint narrows the exposed client function-tool set, instructs the runtime to call a client tool before responding, and errors if the agent response has no matching structured client-tool call. This applies to the caller-supplied HTTP `tools` list, not every internal OpenClaw agent tool.
### Non-streaming tool response shape
When the agent calls tools, the response uses:
- `choices[0].finish_reason = "tool_calls"`
- `choices[0].message.tool_calls[]` entries with `id`, `type: "function"`, `function.name`, `function.arguments` (JSON string)
- Assistant commentary before the tool call, in `choices[0].message.content` (possibly empty)
### Streaming tool response shape
When `stream: true`, tool calls arrive as incremental SSE chunks: an initial assistant role delta, optional assistant commentary deltas, one or more `delta.tool_calls` chunks carrying tool identity and argument fragments, then a final chunk with `finish_reason: "tool_calls"` and `data: [DONE]`.
If `stream_options.include_usage=true`, a trailing usage chunk is emitted before `[DONE]`.
### Tool follow-up loop
After receiving `tool_calls`, execute the requested function(s) and send a follow-up request that includes the prior assistant tool-call message plus one or more `role: "tool"` messages with matching `tool_call_id`. This continues the same agent reasoning loop to produce the final answer.
## Streaming (SSE)
Set `stream: true` to receive Server-Sent Events:
- `Content-Type: text/event-stream`
- Each event line is `data: <json>`
- Stream ends with `data: [DONE]`
## Open WebUI quick setup
- Base URL: `http://127.0.0.1:18789/v1`
- Docker on macOS base URL: `http://host.docker.internal:18789/v1`
- API key: your Gateway bearer token
- Model: `openclaw/default`
Expected behavior: `GET /v1/models` lists `openclaw/default`, and Open WebUI uses it as the chat model id. For a specific backend provider/model, set the agent's normal default model, or send `x-openclaw-model` (shared-secret caller, or identity-bearing caller with `operator.admin`).
Quick smoke test:
```bash
curl -sS http://127.0.0.1:18789/v1/models \
-H 'Authorization: Bearer YOUR_TOKEN'
```
If that returns `openclaw/default`, most Open WebUI setups can connect with the same base URL and token.
## Examples
Stable session for one app conversation:
```bash
curl -sS http://127.0.0.1:18789/v1/chat/completions \
-H 'Authorization: Bearer YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-d '{
"model": "openclaw/default",
"user": "conv:YOUR_CONVERSATION_ID",
"messages": [{"role":"user","content":"Summarize my tasks for today"}]
}'
```
Reuse the same `user` value on later calls for that conversation to continue the same agent session.
Non-streaming:
```bash
curl -sS http://127.0.0.1:18789/v1/chat/completions \
-H 'Authorization: Bearer YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-d '{
"model": "openclaw/default",
"messages": [{"role":"user","content":"hi"}]
}'
```
Streaming:
```bash
curl -N http://127.0.0.1:18789/v1/chat/completions \
-H 'Authorization: Bearer YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-H 'x-openclaw-model: openai/gpt-5.4' \
-d '{
"model": "openclaw/research",
"stream": true,
"messages": [{"role":"user","content":"hi"}]
}'
```
List models:
```bash
curl -sS http://127.0.0.1:18789/v1/models \
-H 'Authorization: Bearer YOUR_TOKEN'
```
Fetch one model:
```bash
curl -sS http://127.0.0.1:18789/v1/models/openclaw%2Fdefault \
-H 'Authorization: Bearer YOUR_TOKEN'
```
Create embeddings:
```bash
curl -sS http://127.0.0.1:18789/v1/embeddings \
-H 'Authorization: Bearer YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-H 'x-openclaw-model: openai/text-embedding-3-small' \
-d '{
"model": "openclaw/default",
"input": ["alpha", "beta"]
}'
```
`/v1/embeddings` supports `input` as a string or array of strings.
## Related
- [Configuration reference](/gateway/configuration-reference)
- [Operator scopes](/gateway/operator-scopes)
- [OpenAI](/providers/openai)

View File

@@ -0,0 +1,272 @@
---
summary: "Expose an OpenResponses-compatible /v1/responses HTTP endpoint from the Gateway"
read_when:
- Integrating clients that speak the OpenResponses API
- You want item-based inputs, client tool calls, or SSE events
title: "OpenResponses API"
---
The Gateway can serve an OpenResponses-compatible `POST /v1/responses` endpoint. It is **disabled by default** and shares its port with the Gateway (WS + HTTP multiplex): `http://<gateway-host>:<port>/v1/responses`.
Requests run as a normal Gateway agent run (same codepath as `openclaw agent`), so routing, permissions, and config match your Gateway.
Enable or disable with `gateway.http.endpoints.responses.enabled`. When enabled, the same compatibility surface also serves `GET /v1/models`, `GET /v1/models/{id}`, `POST /v1/embeddings`, and `POST /v1/chat/completions`.
## Authentication, security, and routing
Operational behavior matches [OpenAI Chat Completions](/gateway/openai-http-api):
- Auth path matches `gateway.auth.mode`: shared-secret (`token`/`password`) uses `Authorization: Bearer <token-or-password>`; trusted-proxy uses identity-aware proxy headers (same-host loopback proxies need `gateway.auth.trustedProxy.allowLoopback = true`, with a same-host direct fallback via `gateway.auth.password` / `OPENCLAW_GATEWAY_PASSWORD` when no `Forwarded`/`X-Forwarded-*`/`X-Real-IP` header is present); `none` on private ingress needs no auth header. See [Trusted proxy auth](/gateway/trusted-proxy-auth).
- Treat the endpoint as full operator access to the gateway instance.
- Shared-secret auth modes ignore a narrower bearer-declared `x-openclaw-scopes` and restore the full default operator scope set: `operator.admin`, `operator.approvals`, `operator.pairing`, `operator.read`, `operator.talk.secrets`, `operator.write`. Chat turns on this endpoint are treated as owner-sender turns.
- Trusted identity-bearing HTTP modes (trusted-proxy, or `gateway.auth.mode="none"`) honor `x-openclaw-scopes` when present, otherwise fall back to the operator default scope set. Owner semantics are lost only when the caller explicitly narrows scopes and omits `operator.admin`.
- Select agents with `model: "openclaw"`, `"openclaw/default"`, `"openclaw/<agentId>"`, or the `x-openclaw-agent-id` header.
- Use `x-openclaw-model` to override the selected agent's backend model (requires `operator.admin` on identity-bearing auth paths).
- Use `x-openclaw-session-key` for explicit session routing (rejected with `400 invalid_request_error` if it uses a reserved namespace: `subagent:`, `cron:`, `acp:`).
- Use `x-openclaw-message-channel` for a non-default synthetic ingress channel context.
For the canonical explanation of agent-target models, `openclaw/default`, embeddings pass-through, and backend model overrides, see [OpenAI Chat Completions](/gateway/openai-http-api#agent-first-model-contract).
See [Operator scopes](/gateway/operator-scopes) and [Security](/gateway/security).
## Session behavior
By default the endpoint is **stateless per request** (a new session key is generated each call).
If the request includes an OpenResponses `user` string, the Gateway derives a stable session key from it so repeated calls can share an agent session.
`previous_response_id` reuses the earlier response's session when the request stays within the same agent/user/requested-session scope (matched by auth subject, agent id, and `x-openclaw-session-key`).
## Request shape
| Field | Support |
| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `input` | String or array of item objects. |
| `instructions` | Merged into the system prompt. |
| `tools` | Client tool definitions (function tools). |
| `tool_choice` | `"auto"`, `"none"`, `"required"`, or `{ "type": "function", "name": "..." }` to filter or require client tools. |
| `stream` | Enables SSE streaming. |
| `max_output_tokens` | Best-effort output limit (provider dependent). |
| `temperature` | Best-effort sampling temperature. Ignored by the ChatGPT-based Codex Responses backend, which uses fixed server-side sampling. |
| `top_p` | Best-effort nucleus sampling. Same Codex Responses caveat as `temperature`. |
| `user` | Stable session routing. |
| `previous_response_id` | Session continuity (see above). |
| `max_tool_calls`, `reasoning`, `metadata`, `store`, `truncation` | Accepted but currently ignored. |
## Items (input)
### `message`
Roles: `system`, `developer`, `user`, `assistant`.
- `system` and `developer` are appended to the system prompt.
- The most recent `user` or `function_call_output` item becomes the "current message."
- Earlier user/assistant messages are included as history for context.
### `function_call_output` (turn-based tools)
Send tool results back to the model:
```json
{
"type": "function_call_output",
"call_id": "call_123",
"output": "{\"temperature\": \"72F\"}"
}
```
### `reasoning` and `item_reference`
Accepted for schema compatibility but ignored when building the prompt.
## Tools (client-side function tools)
Provide tools with `tools: [{ type: "function", name, description?, parameters? }]`.
If the agent calls a tool, the response returns a `function_call` output item. Send a follow-up request with `function_call_output` to continue the turn.
For `tool_choice: "required"` and function-pinned `tool_choice`, the endpoint narrows the exposed client function-tool set, instructs the runtime to call a client tool before responding, and rejects the turn if it does not include a matching structured client-tool call, matching the `/v1/chat/completions` contract. Non-streaming requests return `502` with an `api_error`; streaming requests emit a `response.failed` event.
## Images (`input_image`)
Supports base64 or URL sources:
```json
{
"type": "input_image",
"source": { "type": "url", "url": "https://example.com/image.png" }
}
```
Allowed MIME types (default): `image/jpeg`, `image/png`, `image/gif`, `image/webp`, `image/heic`, `image/heif`. Max size (default): 10MB.
## Files (`input_file`)
Supports base64 or URL sources:
```json
{
"type": "input_file",
"source": {
"type": "base64",
"media_type": "text/plain",
"data": "SGVsbG8gV29ybGQh",
"filename": "hello.txt"
}
}
```
Allowed MIME types (default): `text/plain`, `text/markdown`, `text/html`, `text/csv`, `application/json`, `application/pdf`. Max size (default): 5MB.
Current behavior:
- File content is decoded and added to the **system prompt**, not the user message, so it stays ephemeral (not persisted in session history).
- Decoded file text is wrapped as **untrusted external content** before it is added, so file bytes are treated as data, not trusted instructions. The injected block uses explicit boundary markers (`<<<EXTERNAL_UNTRUSTED_CONTENT id="...">>>` / `<<<END_EXTERNAL_UNTRUSTED_CONTENT id="...">>>`) and a `Source: External` metadata line. It intentionally omits the long `SECURITY NOTICE:` banner to preserve prompt budget; the boundary markers and metadata still apply.
- PDFs are parsed for text first. If little text is found, the first pages are rasterized into images and passed to the model, and the injected file block uses the placeholder `[PDF content rendered to images]`.
PDF parsing is provided by the bundled `document-extract` plugin, which uses `clawpdf` and its packaged PDFium WebAssembly runtime for text extraction and page rendering.
URL fetch defaults:
- `files.allowUrl`: `true`
- `images.allowUrl`: `true`
- `maxUrlParts`: `8` (total URL-based `input_file` + `input_image` parts per request)
- Requests are guarded (DNS resolution, private IP blocking, redirect caps, timeouts).
- Optional hostname allowlists are supported per input type (`files.urlAllowlist`, `images.urlAllowlist`): exact host (`"cdn.example.com"`) or wildcard subdomains (`"*.assets.example.com"`, does not match the apex). Empty or omitted allowlists mean no hostname allowlist restriction.
- To disable URL-based fetches entirely, set `files.allowUrl: false` and/or `images.allowUrl: false`.
## File + image limits (config)
Defaults can be tuned under `gateway.http.endpoints.responses`:
```json5
{
gateway: {
http: {
endpoints: {
responses: {
enabled: true,
maxBodyBytes: 20000000,
maxUrlParts: 8,
files: {
allowUrl: true,
urlAllowlist: ["cdn.example.com", "*.assets.example.com"],
allowedMimes: [
"text/plain",
"text/markdown",
"text/html",
"text/csv",
"application/json",
"application/pdf",
],
maxBytes: 5242880,
maxChars: 60000,
maxRedirects: 3,
timeoutMs: 10000,
pdf: {
maxPages: 4,
maxPixels: 4000000,
minTextChars: 200,
},
},
images: {
allowUrl: true,
urlAllowlist: ["images.example.com"],
allowedMimes: [
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/heic",
"image/heif",
],
maxBytes: 10485760,
maxRedirects: 3,
timeoutMs: 10000,
},
},
},
},
},
}
```
Defaults when omitted:
| Key | Default |
| ------------------------ | --------- |
| `maxBodyBytes` | 20MB |
| `maxUrlParts` | 8 |
| `files.maxBytes` | 5MB |
| `files.maxChars` | 60k |
| `files.maxRedirects` | 3 |
| `files.timeoutMs` | 10s |
| `files.pdf.maxPages` | 4 |
| `files.pdf.maxPixels` | 4,000,000 |
| `files.pdf.minTextChars` | 200 |
| `images.maxBytes` | 10MB |
| `images.maxRedirects` | 3 |
| `images.timeoutMs` | 10s |
HEIC/HEIF `input_image` sources are normalized to JPEG before provider delivery through the shared OpenClaw image processor (Rastermill), which falls back to a system converter (`sips`, ImageMagick, GraphicsMagick, or ffmpeg) for formats needing external codec support.
Security note: URL allowlists are enforced before fetch and on redirect hops. Allowlisting a hostname does not bypass private/internal IP blocking. For internet-exposed gateways, apply network egress controls in addition to app-level guards. See [Security](/gateway/security).
## Streaming (SSE)
Set `stream: true` to receive Server-Sent Events:
- `Content-Type: text/event-stream`
- Each event line is `event: <type>` and `data: <json>`
- Stream ends with `data: [DONE]`
Event types currently emitted: `response.created`, `response.in_progress`, `response.output_item.added`, `response.content_part.added`, `response.output_text.delta`, `response.output_text.done`, `response.content_part.done`, `response.output_item.done`, `response.completed`, `response.failed` (on error).
## Usage
`usage` is populated when the underlying provider reports token counts. OpenClaw normalizes common OpenAI-style aliases before those counters reach downstream status/session surfaces, including `input_tokens` / `output_tokens` and `prompt_tokens` / `completion_tokens`.
## Errors
Errors use a JSON object like:
```json
{ "error": { "message": "...", "type": "invalid_request_error" } }
```
Common cases: `400` invalid request body, `401` missing/invalid auth, `403` missing operator scope, `405` wrong method, `429` too many failed auth attempts (with `Retry-After`).
## Examples
Non-streaming:
```bash
curl -sS http://127.0.0.1:18789/v1/responses \
-H 'Authorization: Bearer YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-H 'x-openclaw-agent-id: main' \
-d '{
"model": "openclaw",
"input": "hi"
}'
```
Streaming:
```bash
curl -N http://127.0.0.1:18789/v1/responses \
-H 'Authorization: Bearer YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-H 'x-openclaw-agent-id: main' \
-d '{
"model": "openclaw",
"stream": true,
"input": "hi"
}'
```
## Related
- [OpenAI chat completions](/gateway/openai-http-api)
- [Operator scopes](/gateway/operator-scopes)
- [OpenAI](/providers/openai)

298
docs/gateway/openshell.md Normal file
View File

@@ -0,0 +1,298 @@
---
summary: "Use OpenShell as a managed sandbox backend for OpenClaw agents"
title: OpenShell
read_when:
- You want cloud-managed sandboxes instead of local Docker
- You are setting up the OpenShell plugin
- You need to choose between mirror and remote workspace modes
---
OpenShell is a managed sandbox backend: instead of running Docker containers
locally, OpenClaw delegates sandbox lifecycle to the `openshell` CLI, which
provisions remote environments and executes commands over SSH.
The plugin reuses the same SSH transport and remote filesystem bridge as the
generic [SSH backend](/gateway/sandboxing#ssh-backend), and adds OpenShell
lifecycle (`sandbox create/get/delete/ssh-config`) plus an optional `mirror`
workspace sync mode.
## Prerequisites
- OpenShell plugin installed (`openclaw plugins install @openclaw/openshell-sandbox`)
- `openshell` CLI on `PATH` (or a custom path via
`plugins.entries.openshell.config.command`)
- An OpenShell account with sandbox access
- OpenClaw Gateway running on the host
## Quick start
```bash
openclaw plugins install @openclaw/openshell-sandbox
```
```json5
{
agents: {
defaults: {
sandbox: {
mode: "all",
backend: "openshell",
scope: "session",
workspaceAccess: "rw",
},
},
},
plugins: {
entries: {
openshell: {
enabled: true,
config: {
from: "openclaw",
mode: "remote",
},
},
},
},
}
```
Restart the Gateway. On the next agent turn OpenClaw creates an OpenShell
sandbox and routes tool execution through it. Verify with:
```bash
openclaw sandbox list
openclaw sandbox explain
```
## Workspace modes
This is the most important OpenShell decision.
### mirror (default)
`plugins.entries.openshell.config.mode: "mirror"` keeps the **local workspace
canonical**:
- Before `exec`, OpenClaw syncs the local workspace into the sandbox.
- After `exec`, OpenClaw syncs the remote workspace back to local.
- File tools go through the sandbox bridge, but local stays source of truth
between turns.
Best for development workflows: local edits outside OpenClaw show up on the
next exec, and the sandbox behaves close to the Docker backend.
Tradeoff: upload + download cost on every exec turn.
### remote
`mode: "remote"` makes the **OpenShell workspace canonical**:
- On first sandbox creation, OpenClaw seeds the remote workspace from local
once.
- After that, `exec`, `read`, `write`, `edit`, and `apply_patch` operate
directly on the remote workspace. OpenClaw does **not** sync remote changes
back to local.
- Prompt-time media reads still work (file/media tools read through the
sandbox bridge).
Best for long-running agents and CI: lower per-turn overhead, and host-local
edits cannot silently clobber remote state.
<Warning>
Editing files on the host outside OpenClaw after the initial seed is invisible to the remote sandbox. Run `openclaw sandbox recreate` to re-seed.
</Warning>
### Choosing a mode
| | `mirror` | `remote` |
| ------------------------ | -------------------------- | ------------------------- |
| **Canonical workspace** | Local host | Remote OpenShell |
| **Sync direction** | Bidirectional (every exec) | One-time seed |
| **Per-turn overhead** | Higher (upload + download) | Lower (direct remote ops) |
| **Local edits visible?** | Yes, on next exec | No, until recreate |
| **Best for** | Development workflows | Long-running agents, CI |
## Configuration reference
All OpenShell config lives under `plugins.entries.openshell.config`:
| Key | Type | Default | Description |
| ------------------------- | ------------------------ | ------------- | -------------------------------------------------------------------------------------- |
| `mode` | `"mirror"` or `"remote"` | `"mirror"` | Workspace sync mode |
| `command` | `string` | `"openshell"` | Path or name of the `openshell` CLI |
| `from` | `string` | `"openclaw"` | Sandbox source for first-time create |
| `gateway` | `string` | unset | OpenShell gateway name (top-level `--gateway`) |
| `gatewayEndpoint` | `string` | unset | OpenShell gateway endpoint (top-level `--gateway-endpoint`) |
| `policy` | `string` | unset | OpenShell policy ID for sandbox creation |
| `providers` | `string[]` | `[]` | Provider names attached at sandbox creation (deduped, one `--provider` flag per entry) |
| `gpu` | `boolean` | `false` | Request GPU resources (`--gpu`) |
| `autoProviders` | `boolean` | `true` | Pass `--auto-providers` (or `--no-auto-providers` when false) during create |
| `remoteWorkspaceDir` | `string` | `"/sandbox"` | Primary writable workspace inside the sandbox |
| `remoteAgentWorkspaceDir` | `string` | `"/agent"` | Agent workspace mount path (read-only when workspace access is not `rw`) |
| `timeoutSeconds` | `number` | `120` | Timeout for `openshell` CLI operations |
`remoteWorkspaceDir` and `remoteAgentWorkspaceDir` must be absolute paths and
stay under the managed roots `/sandbox` or `/agent`; other absolute paths are
rejected.
Sandbox-level settings (`mode`, `scope`, `workspaceAccess`) live under
`agents.defaults.sandbox` like any backend. See
[Sandboxing](/gateway/sandboxing) for the full matrix.
## Examples
### Minimal remote setup
```json5
{
agents: {
defaults: {
sandbox: {
mode: "all",
backend: "openshell",
},
},
},
plugins: {
entries: {
openshell: {
enabled: true,
config: {
from: "openclaw",
mode: "remote",
},
},
},
},
}
```
### Mirror mode with GPU
```json5
{
agents: {
defaults: {
sandbox: {
mode: "all",
backend: "openshell",
scope: "agent",
workspaceAccess: "rw",
},
},
},
plugins: {
entries: {
openshell: {
enabled: true,
config: {
from: "openclaw",
mode: "mirror",
gpu: true,
providers: ["openai"],
timeoutSeconds: 180,
},
},
},
},
}
```
### Per-agent OpenShell with custom gateway
```json5
{
agents: {
defaults: {
sandbox: { mode: "off" },
},
list: [
{
id: "researcher",
sandbox: {
mode: "all",
backend: "openshell",
scope: "agent",
workspaceAccess: "rw",
},
},
],
},
plugins: {
entries: {
openshell: {
enabled: true,
config: {
from: "openclaw",
mode: "remote",
gateway: "lab",
gatewayEndpoint: "https://lab.example",
policy: "strict",
},
},
},
},
}
```
## Lifecycle management
```bash
# List all sandbox runtimes (Docker + OpenShell)
openclaw sandbox list
# Inspect effective policy
openclaw sandbox explain
# Recreate (deletes remote workspace, re-seeds on next use)
openclaw sandbox recreate --all
```
For `remote` mode, recreate is especially important: it deletes the canonical
remote workspace for that scope, and the next use seeds a fresh one from
local. For `mirror` mode, recreate mainly resets the remote execution
environment since local stays canonical.
Recreate after changing any of:
- `agents.defaults.sandbox.backend`
- `plugins.entries.openshell.config.from`
- `plugins.entries.openshell.config.mode`
- `plugins.entries.openshell.config.policy`
## Security hardening
The mirror-mode filesystem bridge pins the local workspace root and rechecks
canonical paths (via realpath) before every read, write, mkdir, remove, and
rename, rejecting mid-path symlinks. A symlink swap or remounted workspace
cannot redirect file access outside the mirrored tree.
## Current limitations
- Sandbox browser is not supported on the OpenShell backend.
- `sandbox.docker.binds` does not apply to OpenShell; sandbox creation fails
if binds are configured.
- Docker-specific runtime knobs under `sandbox.docker.*` (other than `env`)
apply only to the Docker backend.
## How it works
1. OpenClaw runs `sandbox get` for the sandbox name (with any configured
`--gateway`/`--gateway-endpoint`); if that fails it creates one with
`sandbox create`, passing `--name`, `--from`, `--policy` when set, `--gpu`
when enabled, `--auto-providers`/`--no-auto-providers`, and one
`--provider` flag per configured provider.
2. OpenClaw runs `sandbox ssh-config` for the sandbox name to fetch SSH
connection details.
3. Core writes the SSH config to a temp file and opens an SSH session through
the same remote filesystem bridge as the generic SSH backend.
4. In `mirror` mode: sync local to remote before exec, run, sync back after.
5. In `remote` mode: seed once on create, then operate directly on the remote
workspace.
## Related
- [Sandboxing](/gateway/sandboxing) - modes, scopes, and backend comparison
- [Sandbox vs Tool Policy vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated) - debugging blocked tools
- [Multi-Agent Sandbox and Tools](/tools/multi-agent-sandbox-tools) - per-agent overrides
- [Sandbox CLI](/cli/sandbox) - `openclaw sandbox` commands

View File

@@ -0,0 +1,468 @@
---
summary: "Export OpenClaw diagnostics to OpenTelemetry collectors or stdout JSONL via the diagnostics-otel plugin"
title: "OpenTelemetry export"
read_when:
- You want to send OpenClaw model usage, message flow, or session metrics to an OpenTelemetry collector
- You are wiring traces, metrics, or logs into Grafana, Datadog, Honeycomb, New Relic, Tempo, or another OTLP backend
- You need the exact metric names, span names, or attribute shapes to build dashboards or alerts
---
OpenClaw exports diagnostics through the official `diagnostics-otel` plugin
using **OTLP/HTTP (protobuf)**. Logs can also be written as stdout JSONL for
container and sandbox log pipelines. Any collector or backend that accepts
OTLP/HTTP works without code changes. For local file logs, see
[Logging](/logging).
- **Diagnostics events** are structured, in-process records emitted by the
Gateway and bundled plugins for model runs, message flow, sessions, queues,
and exec.
- **`diagnostics-otel`** subscribes to those events and exports them as
OpenTelemetry **metrics**, **traces**, and **logs** over OTLP/HTTP, and can
mirror log records to stdout JSONL.
- **Provider calls** receive a W3C `traceparent` header from OpenClaw's
trusted model-call span context when the provider transport accepts custom
headers. Plugin-emitted trace context is not propagated.
- Exporters attach only when both the diagnostics surface and the plugin are
enabled, so in-process cost stays near zero by default.
## Quick start
```bash
openclaw plugins install clawhub:@openclaw/diagnostics-otel
```
```json5
{
plugins: {
allow: ["diagnostics-otel"],
entries: {
"diagnostics-otel": { enabled: true },
},
},
diagnostics: {
enabled: true,
otel: {
enabled: true,
endpoint: "http://otel-collector:4318",
protocol: "http/protobuf",
serviceName: "openclaw-gateway",
traces: true,
metrics: true,
logs: true,
sampleRate: 0.2,
flushIntervalMs: 60000,
},
},
}
```
Or enable the plugin from the CLI: `openclaw plugins enable diagnostics-otel`.
<Note>
`protocol` supports `http/protobuf` only. Since `traces` and `metrics` default to enabled, any other value (including `grpc`) aborts the entire diagnostics-otel subscription with an `unsupported protocol` warning - this also stops stdout log export. Explicitly set `traces: false` and `metrics: false` if you only want `logsExporter: "stdout"` with a non-OTLP protocol value.
</Note>
## Signals exported
| Signal | What goes in it |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Metrics** | Counters/histograms for token usage, cost, run duration, failover, skill usage, message flow, Talk events, queue lanes, session state/recovery, tool execution, exec, memory, liveness, and exporter health. |
| **Traces** | Spans for model usage, model calls, harness lifecycle, skill usage, tool execution, exec, webhook/message processing, context assembly, and tool loops. |
| **Logs** | Structured `logging.file` records exported over OTLP or stdout JSONL when `diagnostics.otel.logs` is enabled; log bodies are withheld unless content capture is explicitly enabled. |
Toggle `traces`, `metrics`, and `logs` independently. Traces and metrics
default to on when `diagnostics.otel.enabled` is true; logs default to off
and export only when `diagnostics.otel.logs` is explicitly `true`. Log export
defaults to OTLP; set `diagnostics.otel.logsExporter` to `stdout` for JSONL on
stdout, or `both` for both.
## Configuration reference
```json5
{
diagnostics: {
enabled: true,
otel: {
enabled: true,
endpoint: "http://otel-collector:4318",
tracesEndpoint: "http://otel-collector:4318/v1/traces",
metricsEndpoint: "http://otel-collector:4318/v1/metrics",
logsEndpoint: "http://otel-collector:4318/v1/logs",
protocol: "http/protobuf", // grpc disables OTLP export
serviceName: "openclaw-gateway", // unset falls back to OTEL_SERVICE_NAME, then "openclaw"
headers: { "x-collector-token": "..." },
traces: true,
metrics: true,
logs: true,
logsExporter: "otlp", // otlp | stdout | both
sampleRate: 0.2, // root-span sampler, 0.0..1.0
flushIntervalMs: 60000, // metric export interval (min 1000ms)
captureContent: {
enabled: false,
inputMessages: false,
outputMessages: false,
toolInputs: false,
toolOutputs: false,
systemPrompt: false,
toolDefinitions: false,
},
},
},
}
```
### Environment variables
| Variable | Purpose |
| ----------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | Fallback for `diagnostics.otel.endpoint` when the config key is unset. |
| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` / `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` / `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` | Signal-specific endpoint fallbacks used when the matching `diagnostics.otel.*Endpoint` config key is unset. Signal-specific config wins over signal-specific env, which wins over the shared endpoint. |
| `OTEL_SERVICE_NAME` | Fallback for `diagnostics.otel.serviceName` when the config key is unset. Default service name is `openclaw`. |
| `OTEL_EXPORTER_OTLP_PROTOCOL` | Fallback for the wire protocol when `diagnostics.otel.protocol` is unset. Only `http/protobuf` enables export. |
| `OTEL_SEMCONV_STABILITY_OPT_IN` | Set to `gen_ai_latest_experimental` to emit the latest GenAI inference span shape: `{gen_ai.operation.name} {gen_ai.request.model}` span names, `CLIENT` span kind, and `gen_ai.provider.name` instead of the legacy `gen_ai.system`. GenAI metrics always use bounded, low-cardinality attributes regardless. |
| `OPENCLAW_OTEL_PRELOADED` | Set to `1` when another preload or host process already registered the global OpenTelemetry SDK. The plugin then skips its own NodeSDK lifecycle but still wires diagnostic listeners and honors `traces`/`metrics`/`logs`. |
## Privacy and content capture
Raw model/tool content is **not** exported by default. Spans carry bounded
identifiers (channel, provider, model, error category, hash-only request ids,
tool source, tool owner, skill name/source) and never include prompt text,
response text, tool inputs, tool outputs, skill file paths, or session keys.
Values that look like scoped agent session keys (for example starting with
`agent:`) are replaced with `unknown` on low-cardinality attributes. OTLP log
records keep severity, logger, code location, trusted trace context, and
sanitized attributes by default; the raw log message body is exported only
when `diagnostics.otel.captureContent` is boolean `true`. Granular
`captureContent.*` subkeys never enable log bodies. Talk metrics export only
bounded event metadata (mode, transport, provider, event type) - no
transcripts, audio payloads, session ids, turn ids, call ids, room ids, or
handoff tokens.
Outbound model requests may include a W3C `traceparent` header generated only
from OpenClaw-owned diagnostic trace context for the active model call.
Existing caller-supplied `traceparent` headers are replaced, so plugins or
custom provider options cannot spoof cross-service trace ancestry.
Set `diagnostics.otel.captureContent.*` to `true` only when your collector
and retention policy are approved for prompt, response, tool, or
system-prompt text. Each subkey is independent:
- `inputMessages` - user prompt content.
- `outputMessages` - model response content.
- `toolInputs` - tool argument payloads.
- `toolOutputs` - tool result payloads.
- `systemPrompt` - assembled system/developer prompt.
- `toolDefinitions` - model tool names, descriptions, and schemas.
When any subkey is enabled, model and tool spans get bounded, redacted
`openclaw.content.*` attributes for that class only.
<Note>
Boolean `captureContent: true` enables `inputMessages`, `outputMessages`, `toolInputs`, `toolOutputs`, `toolDefinitions`, and OTLP log bodies together, but **not** `systemPrompt` - set `captureContent.systemPrompt: true` explicitly if you also need the assembled system prompt.
</Note>
`toolInputs`/`toolOutputs` content is captured for the built-in agent
runtime's tool executions (`openclaw.content.tool_input` on
completed/error spans, `openclaw.content.tool_output` on completed spans).
External harness tool calls (Codex, Claude CLI) emit `tool.execution.*` spans
without content payloads. Captured content travels on a trusted,
listener-only channel and is never placed on the public diagnostic event bus.
## Sampling and flushing
- **Traces:** `diagnostics.otel.sampleRate` sets a `TraceIdRatioBasedSampler`
on the root span only (`0.0` drops all, `1.0` keeps all). Unset uses the
OpenTelemetry SDK default (always-on).
- **Metrics:** `diagnostics.otel.flushIntervalMs` (clamped to a minimum of
`1000`); unset uses the SDK's periodic-export default.
- **Logs:** OTLP logs respect `logging.level` (file log level) and use the
diagnostic log-record redaction path, not console formatting. High-volume
installs should prefer OTLP collector sampling/filtering over local
sampling. Set `diagnostics.otel.logsExporter: "stdout"` when your platform
already ships stdout/stderr to a log processor and you have no OTLP logs
collector. Stdout records are one JSON object per line with `ts`, `signal`,
`service.name`, severity, body, redacted attributes, and trusted trace
fields when available.
- **File-log correlation:** JSONL file logs include top-level `traceId`,
`spanId`, `parentSpanId`, and `traceFlags` when the log call carries a valid
diagnostic trace context, letting log processors join local log lines with
exported spans.
- **Request correlation:** Gateway HTTP requests and WebSocket frames create
an internal request trace scope. Logs and diagnostic events inside that
scope inherit the request trace by default, while agent run and model-call
spans are created as children so provider `traceparent` headers stay on the
same trace.
- **Model-call correlation:** `openclaw.model.call` spans include safe prompt
component sizes by default and per-call token attributes when the provider
result exposes usage. `openclaw.model.usage` remains the run-level
accounting span for aggregate cost, context, and channel dashboards, and
stays on the same diagnostic trace when the emitting runtime has trusted
trace context.
## Exported metrics
### Model usage
- `openclaw.tokens` (counter, attrs: `openclaw.token`, `openclaw.channel`, `openclaw.provider`, `openclaw.model`, `openclaw.agent`)
- `openclaw.cost.usd` (counter, attrs: `openclaw.channel`, `openclaw.provider`, `openclaw.model`)
- `openclaw.run.duration_ms` (histogram, attrs: `openclaw.channel`, `openclaw.provider`, `openclaw.model`)
- `openclaw.context.tokens` (histogram, attrs: `openclaw.context`, `openclaw.channel`, `openclaw.provider`, `openclaw.model`)
- `gen_ai.client.token.usage` (histogram, GenAI semantic-conventions metric, attrs: `gen_ai.token.type` = `input`/`output`, `gen_ai.provider.name`, `gen_ai.operation.name`, `gen_ai.request.model`)
- `gen_ai.client.operation.duration` (histogram, seconds, GenAI semantic-conventions metric, attrs: `gen_ai.provider.name`, `gen_ai.operation.name`, `gen_ai.request.model`, optional `error.type`)
- `openclaw.model_call.duration_ms` (histogram, attrs: `openclaw.provider`, `openclaw.model`, `openclaw.api`, `openclaw.transport`, plus `openclaw.errorCategory` and `openclaw.failureKind` on classified errors)
- `openclaw.model_call.request_bytes` (histogram, UTF-8 byte size of the final model request payload; no raw payload content)
- `openclaw.model_call.response_bytes` (histogram, UTF-8 byte size of streamed response chunk payloads; high-frequency text, thinking, and tool-call deltas count only incremental `delta` bytes; no raw response content)
- `openclaw.model_call.time_to_first_byte_ms` (histogram, elapsed time before the first streamed response event)
- `openclaw.model.failover` (counter, attrs: `openclaw.provider`, `openclaw.model`, `openclaw.failover.to_provider`, `openclaw.failover.to_model`, `openclaw.failover.reason`, `openclaw.failover.suspended`, `openclaw.lane`)
- `openclaw.skill.used` (counter, attrs: `openclaw.skill.name`, `openclaw.skill.source`, `openclaw.skill.activation`, optional `openclaw.agent`, optional `openclaw.toolName`)
### Message flow
- `openclaw.webhook.received` (counter, attrs: `openclaw.channel`, `openclaw.webhook`)
- `openclaw.webhook.error` (counter, attrs: `openclaw.channel`, `openclaw.webhook`)
- `openclaw.webhook.duration_ms` (histogram, attrs: `openclaw.channel`, `openclaw.webhook`)
- `openclaw.message.queued` (counter, attrs: `openclaw.channel`, `openclaw.source`)
- `openclaw.message.received` (counter, attrs: `openclaw.channel`, `openclaw.source`)
- `openclaw.message.dispatch.started` (counter, attrs: `openclaw.channel`, `openclaw.source`)
- `openclaw.message.dispatch.completed` (counter, attrs: `openclaw.channel`, `openclaw.outcome`, `openclaw.reason`, `openclaw.source`)
- `openclaw.message.dispatch.duration_ms` (histogram, attrs: `openclaw.channel`, `openclaw.outcome`, `openclaw.reason`, `openclaw.source`)
- `openclaw.message.processed` (counter, attrs: `openclaw.channel`, `openclaw.outcome`)
- `openclaw.message.duration_ms` (histogram, attrs: `openclaw.channel`, `openclaw.outcome`)
- `openclaw.message.delivery.started` (counter, attrs: `openclaw.channel`, `openclaw.delivery.kind`)
- `openclaw.message.delivery.duration_ms` (histogram, attrs: `openclaw.channel`, `openclaw.delivery.kind`, `openclaw.outcome`, `openclaw.errorCategory`)
### Talk
- `openclaw.talk.event` (counter, attrs: `openclaw.talk.event_type`, `openclaw.talk.mode`, `openclaw.talk.transport`, `openclaw.talk.brain`, `openclaw.talk.provider`)
- `openclaw.talk.event.duration_ms` (histogram, attrs: same as `openclaw.talk.event`; emitted when a Talk event reports duration)
- `openclaw.talk.audio.bytes` (histogram, attrs: same as `openclaw.talk.event`; emitted for Talk audio frame events that report byte length)
### Queues and sessions
- `openclaw.queue.lane.enqueue` (counter, attrs: `openclaw.lane`)
- `openclaw.queue.lane.dequeue` (counter, attrs: `openclaw.lane`)
- `openclaw.queue.depth` (histogram, attrs: `openclaw.lane` or `openclaw.channel=heartbeat`)
- `openclaw.queue.wait_ms` (histogram, attrs: `openclaw.lane`)
- `openclaw.session.state` (counter, attrs: `openclaw.state`, `openclaw.reason`)
- `openclaw.session.stuck` (counter, attrs: `openclaw.state`; emitted for recoverable stale session bookkeeping)
- `openclaw.session.stuck_age_ms` (histogram, attrs: `openclaw.state`; emitted for recoverable stale session bookkeeping)
- `openclaw.session.turn.created` (counter, attrs: `openclaw.agent`, `openclaw.channel`, `openclaw.trigger`)
- `openclaw.session.recovery.requested` (counter, attrs: `openclaw.state`, `openclaw.action`, `openclaw.active_work_kind`, `openclaw.reason`)
- `openclaw.session.recovery.completed` (counter, attrs: `openclaw.state`, `openclaw.action`, `openclaw.status`, `openclaw.active_work_kind`, `openclaw.reason`)
- `openclaw.session.recovery.age_ms` (histogram, attrs: same as the matching recovery counter)
- `openclaw.run.attempt` (counter, attrs: `openclaw.attempt`)
### Session liveness telemetry
`diagnostics.stuckSessionWarnMs` is the no-progress age threshold for session
liveness diagnostics. A `processing` session does not age toward this
threshold while OpenClaw observes reply, tool, status, block, or ACP runtime
progress. Typing keepalives do not count as progress, so a silent model or
harness can still be detected.
OpenClaw classifies sessions by the work it can still observe:
- `session.long_running`: active embedded work, model calls, or tool calls
are still making progress. Owned model calls that stay silent past
`diagnostics.stuckSessionWarnMs` also report as long-running before
`diagnostics.stuckSessionAbortMs`, so slow or non-streaming model providers
do not look like stalled gateway sessions while abort-observable.
- `session.stalled`: active work exists, but the active run has not reported
recent progress. Owned model calls switch from `session.long_running` to
`session.stalled` at or after `diagnostics.stuckSessionAbortMs`; ownerless
stale model/tool activity is not treated as harmless long-running work.
Stalled embedded runs stay observe-only at first, then abort-drain after
`diagnostics.stuckSessionAbortMs` with no progress so queued turns behind
the lane can resume. When unset, the abort threshold defaults to the safer
extended window of at least 5 minutes and 3x
`diagnostics.stuckSessionWarnMs`.
- `session.stuck`: stale session bookkeeping with no active work, or an idle
queued session with stale ownerless model/tool activity. This releases the
affected session lane immediately after recovery gates pass.
Recovery emits structured `session.recovery.requested` and
`session.recovery.completed` events. Diagnostic session state is marked idle
only after a mutating recovery outcome (`aborted` or `released`) and only if
the same processing generation is still current.
Only `session.stuck` emits the `openclaw.session.stuck` counter, the
`openclaw.session.stuck_age_ms` histogram, and the `openclaw.session.stuck`
span. Repeated `session.stuck` diagnostics back off while the session remains
unchanged, so dashboards should alert on sustained increases rather than
every heartbeat tick. For the config knob and defaults, see
[Configuration reference](/gateway/configuration-reference#diagnostics).
Liveness warnings also emit:
- `openclaw.liveness.warning` (counter, attrs: `openclaw.liveness.reason`)
- `openclaw.liveness.event_loop_delay_p99_ms` (histogram, attrs: `openclaw.liveness.reason`)
- `openclaw.liveness.event_loop_delay_max_ms` (histogram, attrs: `openclaw.liveness.reason`)
- `openclaw.liveness.event_loop_utilization` (histogram, attrs: `openclaw.liveness.reason`)
- `openclaw.liveness.cpu_core_ratio` (histogram, attrs: `openclaw.liveness.reason`)
### Harness lifecycle
- `openclaw.harness.duration_ms` (histogram, attrs: `openclaw.harness.id`, `openclaw.harness.plugin`, `openclaw.outcome`, `openclaw.harness.phase` on errors)
### Tool execution and loop detection
- `openclaw.tool.execution.duration_ms` (histogram, attrs: `gen_ai.tool.name`, `openclaw.toolName`, `openclaw.tool.source`, `openclaw.tool.owner`, `openclaw.tool.params.kind`, plus `openclaw.errorCategory` on errors)
- `openclaw.tool.execution.blocked` (counter, attrs: `gen_ai.tool.name`, `openclaw.toolName`, `openclaw.tool.source`, `openclaw.tool.owner`, `openclaw.tool.params.kind`, `openclaw.deniedReason`)
- `openclaw.tool.loop` (counter, attrs: `openclaw.toolName`, `openclaw.loop.level`, `openclaw.loop.action`, `openclaw.loop.detector`, `openclaw.loop.count`, optional `openclaw.loop.paired_tool`; emitted when a repetitive tool-call loop is detected)
### Exec
- `openclaw.exec.duration_ms` (histogram, attrs: `openclaw.exec.target`, `openclaw.exec.mode`, `openclaw.outcome`, `openclaw.failureKind`)
### Diagnostics internals (memory, payloads, exporter health)
- `openclaw.payload.large` (counter, attrs: `openclaw.payload.surface`, `openclaw.payload.action`, `openclaw.channel`, `openclaw.plugin`, `openclaw.reason`)
- `openclaw.payload.large_bytes` (histogram, attrs: same as `openclaw.payload.large`)
- `openclaw.memory.rss_bytes` / `openclaw.memory.heap_used_bytes` / `openclaw.memory.heap_total_bytes` / `openclaw.memory.external_bytes` / `openclaw.memory.array_buffers_bytes` (histograms, no attrs; process memory samples)
- `openclaw.memory.pressure` (counter, attrs: `openclaw.memory.level`, `openclaw.memory.reason`)
- `openclaw.diagnostic.async_queue.dropped` (counter, attrs: `openclaw.diagnostic.async_queue.drop_class`; internal diagnostic-queue backpressure drops)
- `openclaw.telemetry.exporter.events` (counter, attrs: `openclaw.exporter`, `openclaw.signal`, `openclaw.status`, optional `openclaw.reason`, optional `openclaw.errorCategory`; exporter lifecycle/failure self-telemetry)
## Exported spans
- `openclaw.model.usage`
- `openclaw.channel`, `openclaw.provider`, `openclaw.model`
- `openclaw.tokens.*` (input/output/cache_read/cache_write/total)
- `gen_ai.system` by default, or `gen_ai.provider.name` when the latest GenAI semantic conventions are opted in
- `gen_ai.request.model`, `gen_ai.operation.name`, `gen_ai.usage.*`
- `openclaw.run`
- `openclaw.outcome`, `openclaw.channel`, `openclaw.provider`, `openclaw.model`, `openclaw.errorCategory`
- `openclaw.model.call`
- `gen_ai.system` by default, or `gen_ai.provider.name` when the latest GenAI semantic conventions are opted in
- `gen_ai.request.model`, `gen_ai.operation.name`, `openclaw.provider`, `openclaw.model`, `openclaw.api`, `openclaw.transport`
- `openclaw.errorCategory`, `error.type`, and optional `openclaw.failureKind` on errors
- `openclaw.model_call.request_bytes`, `openclaw.model_call.response_bytes`, `openclaw.model_call.time_to_first_byte_ms`
- `openclaw.model_call.prompt.input_messages_count`, `openclaw.model_call.prompt.input_messages_chars`, `openclaw.model_call.prompt.system_prompt_chars`, `openclaw.model_call.prompt.tool_definitions_count`, `openclaw.model_call.prompt.tool_definitions_chars`, `openclaw.model_call.prompt.total_chars` (safe component sizes only, no prompt text)
- `openclaw.model_call.usage.*` and `gen_ai.usage.*` when the model-call result carries provider usage for that individual call
- Span event `openclaw.provider.request` with attribute `openclaw.upstreamRequestIdHash` (bounded, hash-based) when the upstream provider result exposes a request id; raw ids are never exported
- With `OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental`, model-call spans use the latest GenAI inference span name `{gen_ai.operation.name} {gen_ai.request.model}` and `CLIENT` span kind instead of `openclaw.model.call`.
- `openclaw.harness.run`
- `openclaw.harness.id`, `openclaw.harness.plugin`, `openclaw.outcome`, `openclaw.provider`, `openclaw.model`, `openclaw.channel`
- On completion: `openclaw.harness.result_classification`, `openclaw.harness.yield_detected`, `openclaw.harness.items.started`, `openclaw.harness.items.completed`, `openclaw.harness.items.active`
- On error: `openclaw.harness.phase`, `openclaw.errorCategory`, optional `openclaw.harness.cleanup_failed`
- `openclaw.tool.execution`
- `gen_ai.tool.name`, `openclaw.toolName`, `openclaw.tool.source`, optional `openclaw.tool.owner`, `openclaw.tool.params.*`
- Optional `openclaw.errorCategory`/`openclaw.errorCode` on errors, `openclaw.deniedReason` and `openclaw.outcome=blocked` when denied by policy or sandbox
- `openclaw.exec`
- `openclaw.exec.target`, `openclaw.exec.mode`, `openclaw.outcome`, `openclaw.failureKind`, `openclaw.exec.command_length`, `openclaw.exec.exit_code`, `openclaw.exec.exit_signal`, `openclaw.exec.timed_out`
- `openclaw.webhook.processed`
- `openclaw.channel`, `openclaw.webhook`
- `openclaw.webhook.error`
- `openclaw.channel`, `openclaw.webhook`, `openclaw.error`
- `openclaw.message.processed`
- `openclaw.channel`, `openclaw.outcome`, `openclaw.reason`
- `openclaw.message.delivery`
- `openclaw.channel`, `openclaw.delivery.kind`, `openclaw.outcome`, `openclaw.errorCategory`, `openclaw.delivery.result_count`
- `openclaw.session.stuck`
- `openclaw.state`, `openclaw.ageMs`, `openclaw.queueDepth`
- `openclaw.context.assembled`
- `openclaw.prompt.size`, `openclaw.history.size`, `openclaw.context.tokens`, `openclaw.errorCategory` (no prompt, history, response, or session-key content)
- `openclaw.tool.loop`
- `openclaw.toolName`, `openclaw.loop.level`, `openclaw.loop.action`, `openclaw.loop.detector`, `openclaw.loop.count`, optional `openclaw.loop.paired_tool` (no loop messages, params, or tool output)
- `openclaw.memory.pressure`
- `openclaw.memory.level`, `openclaw.memory.reason`, `openclaw.memory.rss_bytes`, `openclaw.memory.heap_used_bytes`, `openclaw.memory.heap_total_bytes`, `openclaw.memory.external_bytes`, `openclaw.memory.array_buffers_bytes`, optional `openclaw.memory.threshold_bytes`/`openclaw.memory.rss_growth_bytes`/`openclaw.memory.window_ms`
When content capture is explicitly enabled, model and tool spans can also
include bounded, redacted `openclaw.content.*` attributes for the specific
content classes you opted into.
## Diagnostic event catalog
The events below back the metrics and spans above. Plugins can also
subscribe to them directly without OTLP export.
**Model usage**
- `model.usage` - tokens, cost, duration, context, provider/model/channel,
session ids. `usage` is provider/turn accounting for cost and telemetry;
`context.used` is the current prompt/context snapshot and can be lower than
provider `usage.total` when cached input or tool-loop calls are involved.
**Message flow**
- `webhook.received` / `webhook.processed` / `webhook.error`
- `message.queued` / `message.processed`
- `message.delivery.started` / `message.delivery.completed` / `message.delivery.error`
**Queue and session**
- `queue.lane.enqueue` / `queue.lane.dequeue`
- `session.state` / `session.long_running` / `session.stalled` / `session.stuck`
- `run.attempt` / `run.progress`
- `diagnostic.heartbeat` (aggregate counters: webhooks/queue/session)
**Harness lifecycle**
- `harness.run.started` / `harness.run.completed` / `harness.run.error` -
per-run lifecycle for the agent harness. Includes `harnessId`, optional
`pluginId`, provider/model/channel, and run id. Completion adds
`durationMs`, `outcome`, optional `resultClassification`, `yieldDetected`,
and `itemLifecycle` counts. Errors add `phase`
(`prepare`/`start`/`send`/`resolve`/`cleanup`), `errorCategory`, and
optional `cleanupFailed`.
**Exec**
- `exec.process.completed` - terminal outcome, duration, target, mode, exit
code, and failure kind. Command text and working directories are not
included.
- `exec.approval.followup_suppressed` - stale approval follow-up dropped
after a session rebound. Includes `approvalId`, `reason`
(`session_rebound`), `phase` (`direct_delivery` or `gateway_preflight`),
and the dispatcher timestamp. Session keys, routes, and command text are
not included.
## Without an exporter
Keep diagnostics events available to plugins or custom sinks without running
`diagnostics-otel`:
```json5
{
diagnostics: { enabled: true },
}
```
For targeted debug output without raising `logging.level`, use diagnostics
flags. Flags are case-insensitive and support wildcards (`telegram.*` or
`*`):
```json5
{
diagnostics: { flags: ["telegram.http"] },
}
```
Or as a one-off env override:
```bash
OPENCLAW_DIAGNOSTICS=telegram.http,telegram.payload openclaw gateway
```
Flag output goes to the standard log file (`logging.file`) and is still
redacted by `logging.redactSensitive`. Full guide:
[Diagnostics flags](/diagnostics/flags).
## Disable
```json5
{
diagnostics: { otel: { enabled: false } },
}
```
Or leave `diagnostics-otel` out of `plugins.allow`, or run
`openclaw plugins disable diagnostics-otel`.
## Related
- [Logging](/logging) - file logs, console output, CLI tailing, and the Control UI Logs tab
- [Gateway logging internals](/gateway/logging) - WS log styles, subsystem prefixes, and console capture
- [Diagnostics flags](/diagnostics/flags) - targeted debug-log flags
- [Diagnostics export](/gateway/diagnostics) - operator support-bundle tool (separate from OTEL export)
- [Configuration reference](/gateway/configuration-reference#diagnostics) - full `diagnostics.*` field reference

View File

@@ -0,0 +1,119 @@
---
summary: "Operator roles, scopes, and approval-time checks for Gateway clients"
read_when:
- Debugging missing operator scope errors
- Reviewing device or node pairing approvals
- Adding or classifying Gateway RPC methods
title: "Operator scopes"
---
Operator scopes gate what a Gateway client can do after it authenticates.
They are a control-plane guardrail inside one trusted Gateway operator domain,
not hostile multi-tenant isolation. For strong separation between people,
teams, or machines, run separate Gateways under separate OS users or hosts.
Related: [Security](/gateway/security), [Gateway protocol](/gateway/protocol),
[Gateway pairing](/gateway/pairing), [Devices CLI](/cli/devices).
## Roles
Every Gateway WebSocket client connects with one role:
- `operator`: control-plane clients such as CLI, Control UI, automation, and
trusted helper processes.
- `node`: capability hosts (macOS, iOS, Android, headless) that expose
commands through `node.invoke`.
Operator RPC methods require the `operator` role; node-originated methods
require the `node` role.
## Scope levels
| Scope | Meaning |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `operator.read` | Read-only status, lists, catalog, logs, session reads, and other non-mutating calls. |
| `operator.write` | Mutating operator actions: sending messages, invoking tools, updating talk/voice settings, node command relay. Also satisfies `operator.read`. |
| `operator.admin` | Administrative access. Satisfies every `operator.*` scope. Required for config mutation, updates, native hooks, reserved namespaces, and high-risk approvals. |
| `operator.pairing` | Device and node pairing management: list, approve, reject, remove, rotate, revoke. |
| `operator.approvals` | Exec and plugin approval APIs. |
| `operator.talk.secrets` | Reading Talk configuration with secrets included. |
Unknown future `operator.*` scopes require an exact match unless the caller
already holds `operator.admin`.
## Method scope is only the first gate
Each Gateway RPC has a least-privilege method scope that decides whether a
request reaches its handler. Some handlers then apply stricter checks based on
the concrete thing being approved or mutated:
- `device.pair.approve` is reachable with `operator.pairing`, but approving an
operator device can only mint or preserve scopes the caller already holds.
- `node.pair.approve` is reachable with `operator.pairing`, then derives extra
approval scopes from the pending node's declared command list.
- `chat.send` is a write-scoped method, but the `/config set` and
`/config unset` chat commands require `operator.admin` on top of that,
regardless of the caller's chat-send scope.
This lets lower-scope operators perform low-risk pairing actions without
making all pairing approval admin-only.
## Device pairing approvals
Device pairing records are the durable source of approved roles and scopes.
An already-paired device does not get broader access silently: a reconnect
that asks for a broader role or broader scopes creates a new pending upgrade
request.
Approving a device request:
- A request with no operator role does not need operator scope approval.
- A request for a non-operator device role (for example `node`) requires
`operator.admin`, even though `device.pair.approve` itself only needs
`operator.pairing`.
- A request for `operator.read`, `operator.write`, `operator.approvals`,
`operator.pairing`, or `operator.talk.secrets` requires the caller to already
hold that scope, or `operator.admin`.
- A request for `operator.admin` requires `operator.admin`.
- A repair request with no explicit scopes can inherit the existing operator
token's scopes; if that token is admin-scoped, approval still requires
`operator.admin`.
Non-admin shared-secret and trusted-proxy sessions can only approve
operator-device requests within their own declared operator scopes; approving
non-operator roles is admin-only even when those sessions can otherwise use
`operator.pairing`.
For paired-device token sessions, management is self-scoped unless the caller
has `operator.admin`: a non-admin caller sees only its own pairing entries, and
can approve, reject, rotate, revoke, or remove only its own device entry.
## Node pairing approvals
Legacy `node.pair.*` methods use a separate Gateway-owned node pairing store.
WS nodes use device pairing (`role: node`) instead, but the same approval
vocabulary applies. See [Gateway pairing](/gateway/pairing) for how the two
stores relate.
`node.pair.approve` derives extra required scopes from the pending request's
command list:
| Declared commands | Required scopes |
| ----------------------------------------------------- | ------------------------------------- |
| none | `operator.pairing` |
| non-exec node commands | `operator.pairing` + `operator.write` |
| `system.run`, `system.run.prepare`, or `system.which` | `operator.pairing` + `operator.admin` |
Node pairing establishes identity and trust; it does not replace a node's own
`system.run` exec approval policy.
## Shared-secret auth
Shared gateway token/password auth is treated as trusted operator access for
that Gateway. OpenAI-compatible HTTP surfaces, `/tools/invoke`, and HTTP
session-history endpoints restore the full default operator scope set for
shared-secret bearer auth, even if a caller sends narrower declared scopes.
Identity-bearing modes, such as trusted proxy auth or private-ingress `none`,
can still honor explicit declared scopes. Use separate Gateways for real trust
boundary separation.

233
docs/gateway/pairing.md Normal file
View File

@@ -0,0 +1,233 @@
---
summary: "Gateway-owned node pairing (Option B) for iOS and other remote nodes"
read_when:
- Implementing node pairing approvals without macOS UI
- Adding CLI flows for approving remote nodes
- Extending gateway protocol with node management
title: "Gateway-owned pairing"
---
In Gateway-owned pairing, the **Gateway** is the source of truth for which
nodes may join. UIs (macOS app, future clients) are just frontends that
approve or reject pending requests.
**Important:** WS nodes use **device pairing** (role `node`) during `connect`.
`node.pair.*` is a separate, legacy pairing store and does **not** gate the WS
handshake. Only clients that explicitly call `node.pair.*` use this flow.
## Concepts
- **Pending request**: a node asked to join; requires approval.
- **Paired node**: approved node with an issued auth token.
- **Transport**: the Gateway WS endpoint forwards requests but does not decide
membership. Legacy TCP bridge support has been removed.
## How pairing works
1. A node connects to the Gateway WS and requests pairing.
2. The Gateway stores a **pending request** and emits `node.pair.requested`.
3. You approve or reject the request (CLI or UI).
4. On approval, the Gateway issues a **new token** (tokens rotate on re-pair).
5. The node reconnects using the token and is now paired.
Pending requests expire automatically after **5 minutes**.
## CLI workflow (headless friendly)
```bash
openclaw nodes pending
openclaw nodes approve <requestId>
openclaw nodes reject <requestId>
openclaw nodes status
openclaw nodes remove --node <id|name|ip>
openclaw nodes rename --node <id|name|ip> --name "Living Room iPad"
```
`nodes status` shows paired/connected nodes and their capabilities.
## API surface (gateway protocol)
Events:
- `node.pair.requested` - emitted when a new pending request is created.
- `node.pair.resolved` - emitted when a request is approved, rejected, or
expired.
Methods:
- `node.pair.request` - create or reuse a pending request.
- `node.pair.list` - list pending and paired nodes (`operator.pairing`).
- `node.pair.approve` - approve a pending request (issues a token).
- `node.pair.reject` - reject a pending request.
- `node.pair.remove` - remove a paired node. For a device-backed pairing, this
revokes the device's `node` role: it mutates `devices/paired.json` and
invalidates/disconnects that device's node-role sessions. A **mixed-role**
device (for example one that also holds `operator`) keeps its row and only
loses the `node` role; a node-only device row is deleted. It also clears any
matching legacy gateway-owned node pairing entry. Authz: `operator.pairing`
may remove non-operator node rows; a device-token caller revoking its
**own** node role on a mixed-role device additionally needs
`operator.admin`.
- `node.pair.verify` - verify `{ nodeId, token }`.
Notes:
- `node.pair.request` is idempotent per node: repeated calls return the same
pending request.
- Repeated requests for the same pending node refresh the stored node
metadata and the latest allowlisted declared command snapshot for operator
visibility.
- Approval **always** generates a fresh token; `node.pair.request` never
returns a token.
- Operator scope levels and approval-time checks are summarized in
[Operator scopes](/gateway/operator-scopes).
- Requests may include `silent: true` as a hint for auto-approval flows.
- `node.pair.approve` uses the pending request's declared commands to enforce
extra approval scopes:
- commandless request: `operator.pairing`
- non-exec command request: `operator.pairing` + `operator.write`
- `system.run` / `system.run.prepare` / `system.which` request:
`operator.pairing` + `operator.admin`
<Warning>
Node pairing is a trust and identity flow plus token issuance. It does **not** pin the live node command surface per node.
- Live node commands come from what the node declares on connect, filtered by
the gateway's global node command policy (`gateway.nodes.allowCommands` and
`denyCommands`).
- Per-node `system.run` allow and ask policy lives on the node in
`exec.approvals.node.*`, not in the pairing record.
</Warning>
## Node command gating (2026.3.31+)
<Warning>
**Breaking change:** starting with `2026.3.31`, node commands are disabled until node pairing is approved. Device pairing alone is no longer enough to expose declared node commands.
</Warning>
When a node connects for the first time, pairing is requested automatically.
Until that request is approved, all pending node commands from that node are
filtered and will not execute. Once pairing is approved, the node's declared
commands become available, subject to the normal command policy.
This means:
- Nodes that previously relied on device pairing alone to expose commands must
now also complete node pairing.
- Commands queued before pairing approval are dropped, not deferred.
## Node event trust boundaries (2026.3.31+)
<Warning>
**Breaking change:** node-originated runs now stay on a reduced trusted surface.
</Warning>
Node-originated summaries and related session events are restricted to the
intended trusted surface. Notification-driven or node-triggered flows that
previously relied on broader host or session tool access may need adjustment.
This hardening keeps node events from escalating into host-level tool access
beyond what the node's trust boundary permits.
Durable node presence updates follow the same identity boundary: the
`node.presence.alive` event is accepted only from authenticated node device
sessions, and updates pairing metadata only when the device/node identity is
already paired. A self-declared `client.id` value is not enough to write
last-seen state.
## Auto-approval (macOS app)
The macOS app can attempt a **silent approval** when:
- the request is marked `silent`, and
- the app can verify an SSH connection to the gateway host using the same
user.
If silent approval fails, it falls back to the normal Approve/Reject prompt.
## Trusted-CIDR device auto-approval
WS device pairing for `role: node` stays manual by default. For private node
networks where the Gateway already trusts the network path, operators can opt
in with explicit CIDRs or exact IPs:
```json5
{
gateway: {
nodes: {
pairing: {
autoApproveCidrs: ["192.168.1.0/24"],
},
},
},
}
```
Security boundary:
- Disabled when `gateway.nodes.pairing.autoApproveCidrs` is unset.
- No blanket LAN or private-network auto-approve mode exists.
- Only a fresh `role: node` device pairing request with no requested scopes is
eligible.
- Operator, browser, Control UI, and WebChat clients stay manual.
- Role, scope, metadata, and public-key upgrades stay manual.
- Same-host loopback trusted-proxy header paths are not eligible, because that
path can be spoofed by local callers.
## Metadata-upgrade auto-approval
When an already-paired device reconnects with only non-sensitive metadata
changes (for example display name or client platform hints), OpenClaw treats
that as a `metadata-upgrade`. Silent auto-approval is narrow: it applies only
to trusted non-browser local reconnects that already proved possession of
local or shared credentials, including same-host native app reconnects after
OS version metadata changes. Browser/Control UI clients and remote clients
still use the explicit re-approval flow. Scope upgrades (read to
write/admin) and public key changes are **not** eligible for
metadata-upgrade auto-approval; they stay explicit re-approval requests.
## QR pairing helpers
`/pair qr` renders the pairing payload as structured media so mobile and
browser clients can scan it directly.
Deleting a device also sweeps any stale pending pairing requests for that
device id, so `nodes pending` does not show orphaned rows after a revoke.
## Locality and forwarded headers
Gateway pairing treats a connection as loopback only when both the raw socket
and any upstream proxy evidence agree. If a request arrives on loopback but
carries `Forwarded`, any `X-Forwarded-*`, or `X-Real-IP` header evidence, that
forwarded-header evidence disqualifies the loopback locality claim, and the
pairing path requires explicit approval instead of silently treating the
request as a same-host connect. See
[Trusted Proxy Auth](/gateway/trusted-proxy-auth) for the equivalent rule on
operator auth.
## Storage (local, private)
Pairing state is stored under the Gateway state directory (default
`~/.openclaw`):
- `~/.openclaw/nodes/paired.json`
- `~/.openclaw/nodes/pending.json`
If you override `OPENCLAW_STATE_DIR`, the `nodes/` folder moves with it.
Security notes:
- Tokens are secrets; treat `paired.json` as sensitive.
- Rotating a token requires re-approval (or deleting the node entry).
## Transport behavior
- The transport is **stateless**; it does not store membership.
- If the Gateway is offline or pairing is disabled, nodes cannot pair.
- In remote mode, pairing happens against the remote Gateway's store.
## Related
- [Channel pairing](/channels/pairing)
- [Nodes CLI](/cli/nodes)
- [Devices CLI](/cli/devices)

256
docs/gateway/prometheus.md Normal file
View File

@@ -0,0 +1,256 @@
---
summary: "Expose OpenClaw diagnostics as Prometheus text metrics through the diagnostics-prometheus plugin"
title: "Prometheus metrics"
sidebarTitle: "Prometheus"
read_when:
- You want Prometheus, Grafana, VictoriaMetrics, or another scraper to collect OpenClaw Gateway metrics
- You need the Prometheus metric names and label policy for dashboards or alerts
- You want metrics without running an OpenTelemetry collector
---
OpenClaw can expose diagnostics metrics through the official
`diagnostics-prometheus` plugin. It listens to trusted diagnostics plus
internally tagged, dispatcher-owned diagnostic events (queue, memory, and
session-recovery signals), and renders a Prometheus text endpoint at:
```text
GET /api/diagnostics/prometheus
```
Content type is `text/plain; version=0.0.4; charset=utf-8`, the standard
Prometheus exposition format.
<Warning>
The route uses Gateway authentication (operator scope, trusted-operator surface). Do not expose it as a public unauthenticated `/metrics` endpoint. Scrape it through the same auth path you use for other operator APIs.
</Warning>
For traces, logs, OTLP push, and OpenTelemetry GenAI semantic attributes, see [OpenTelemetry export](/gateway/opentelemetry).
## Quick start
<Steps>
<Step title="Install the plugin">
```bash
openclaw plugins install clawhub:@openclaw/diagnostics-prometheus
```
</Step>
<Step title="Enable the plugin">
<Tabs>
<Tab title="Config">
```json5
{
plugins: {
allow: ["diagnostics-prometheus"],
entries: {
"diagnostics-prometheus": { enabled: true },
},
},
diagnostics: {
enabled: true,
},
}
```
</Tab>
<Tab title="CLI">
```bash
openclaw plugins enable diagnostics-prometheus
```
</Tab>
</Tabs>
</Step>
<Step title="Restart the Gateway">
The HTTP route is registered at plugin startup, so reload after enabling.
</Step>
<Step title="Scrape the protected route">
Send the same gateway auth your operator clients use:
```bash
curl -H "Authorization: Bearer $OPENCLAW_GATEWAY_TOKEN" \
http://127.0.0.1:18789/api/diagnostics/prometheus
```
</Step>
<Step title="Wire Prometheus">
```yaml
# prometheus.yml
scrape_configs:
- job_name: openclaw
scrape_interval: 30s
metrics_path: /api/diagnostics/prometheus
authorization:
credentials_file: /etc/prometheus/openclaw-gateway-token
static_configs:
- targets: ["openclaw-gateway:18789"]
```
</Step>
</Steps>
<Note>
`diagnostics.enabled` defaults to `true`; set it to `false` only in tightly constrained environments. If it is `false`, the plugin still registers the HTTP route, but no diagnostic events flow into the exporter, so the response is empty.
</Note>
## Metrics exported
| Metric | Type | Labels |
| ------------------------------------------------ | --------- | ----------------------------------------------------------------------------------------- |
| `openclaw_run_completed_total` | counter | `channel`, `model`, `outcome`, `provider`, `trigger` |
| `openclaw_run_duration_seconds` | histogram | `channel`, `model`, `outcome`, `provider`, `trigger` |
| `openclaw_model_call_total` | counter | `api`, `error_category`, `model`, `outcome`, `provider`, `transport` |
| `openclaw_model_call_duration_seconds` | histogram | `api`, `error_category`, `model`, `outcome`, `provider`, `transport` |
| `openclaw_model_failover_total` | counter | `from_model`, `from_provider`, `lane`, `reason`, `suspended`, `to_model`, `to_provider` |
| `openclaw_model_tokens_total` | counter | `agent`, `channel`, `model`, `provider`, `token_type` |
| `openclaw_gen_ai_client_token_usage` | histogram | `model`, `provider`, `token_type` |
| `openclaw_model_cost_usd_total` | counter | `agent`, `channel`, `model`, `provider` |
| `openclaw_model_usage_duration_seconds` | histogram | `agent`, `channel`, `model`, `provider` |
| `openclaw_skill_used_total` | counter | `activation`, `agent`, `skill`, `source` |
| `openclaw_tool_execution_total` | counter | `error_category`, `outcome`, `params_kind`, `tool`, `tool_owner`, `tool_source` |
| `openclaw_tool_execution_duration_seconds` | histogram | `error_category`, `outcome`, `params_kind`, `tool`, `tool_owner`, `tool_source` |
| `openclaw_tool_execution_blocked_total` | counter | `denied_reason`, `params_kind`, `tool`, `tool_owner`, `tool_source` |
| `openclaw_harness_run_total` | counter | `channel`, `error_category`, `harness`, `model`, `outcome`, `phase`, `plugin`, `provider` |
| `openclaw_harness_run_duration_seconds` | histogram | `channel`, `error_category`, `harness`, `model`, `outcome`, `phase`, `plugin`, `provider` |
| `openclaw_webhook_received_total` | counter | `channel`, `webhook` |
| `openclaw_webhook_error_total` | counter | `channel`, `webhook` |
| `openclaw_webhook_duration_seconds` | histogram | `channel`, `webhook` |
| `openclaw_message_received_total` | counter | `channel`, `source` |
| `openclaw_message_dispatch_started_total` | counter | `channel`, `source` |
| `openclaw_message_dispatch_completed_total` | counter | `channel`, `outcome`, `reason`, `source` |
| `openclaw_message_dispatch_duration_seconds` | histogram | `channel`, `outcome`, `reason`, `source` |
| `openclaw_message_processed_total` | counter | `channel`, `outcome`, `reason` |
| `openclaw_message_processed_duration_seconds` | histogram | `channel`, `outcome`, `reason` |
| `openclaw_message_delivery_started_total` | counter | `channel`, `delivery_kind` |
| `openclaw_message_delivery_total` | counter | `channel`, `delivery_kind`, `error_category`, `outcome` |
| `openclaw_message_delivery_duration_seconds` | histogram | `channel`, `delivery_kind`, `error_category`, `outcome` |
| `openclaw_talk_event_total` | counter | `brain`, `event_type`, `mode`, `provider`, `transport` |
| `openclaw_talk_event_duration_seconds` | histogram | `brain`, `event_type`, `mode`, `provider`, `transport` |
| `openclaw_talk_audio_bytes` | histogram | `brain`, `event_type`, `mode`, `provider`, `transport` |
| `openclaw_queue_lane_size` | gauge | `lane` |
| `openclaw_queue_lane_wait_seconds` | histogram | `lane` |
| `openclaw_session_state_total` | counter | `reason`, `state` |
| `openclaw_session_queue_depth` | gauge | `state` |
| `openclaw_session_turn_created_total` | counter | `agent`, `channel`, `trigger` |
| `openclaw_session_stuck_total` | counter | `reason`, `state` |
| `openclaw_session_stuck_age_seconds` | histogram | `reason`, `state` |
| `openclaw_session_recovery_total` | counter | `action`, `active_work_kind`, `state`, `status` |
| `openclaw_session_recovery_age_seconds` | histogram | `action`, `active_work_kind`, `state`, `status` |
| `openclaw_liveness_warning_total` | counter | `reason` |
| `openclaw_liveness_sessions` | gauge | `state` |
| `openclaw_liveness_event_loop_delay_p99_seconds` | histogram | `reason` |
| `openclaw_liveness_event_loop_delay_max_seconds` | histogram | `reason` |
| `openclaw_liveness_event_loop_utilization_ratio` | histogram | `reason` |
| `openclaw_liveness_cpu_core_ratio` | histogram | `reason` |
| `openclaw_payload_large_total` | counter | `action`, `channel`, `plugin`, `reason`, `surface` |
| `openclaw_payload_large_bytes` | histogram | `action`, `channel`, `plugin`, `reason`, `surface` |
| `openclaw_memory_bytes` | gauge | `kind` |
| `openclaw_memory_rss_bytes` | histogram | none |
| `openclaw_memory_pressure_total` | counter | `level`, `reason` |
| `openclaw_telemetry_exporter_total` | counter | `exporter`, `reason`, `signal`, `status` |
| `openclaw_prometheus_series_dropped_total` | counter | none |
| `openclaw_diagnostic_async_queue_dropped_total` | counter | `drop_class` |
| `openclaw_diagnostic_async_queue_length` | gauge | none |
## Label policy
<AccordionGroup>
<Accordion title="Bounded, low-cardinality labels">
Prometheus labels stay bounded and low-cardinality. The exporter does not emit raw diagnostic identifiers such as `runId`, `sessionKey`, `sessionId`, `callId`, `toolCallId`, message IDs, chat IDs, or provider request IDs.
Label values are redacted and must match OpenClaw's low-cardinality character policy. Values that fail the policy are replaced with `unknown`, `other`, or `none`, depending on the metric. Labels that look like scoped agent session keys are also replaced with `unknown`.
</Accordion>
<Accordion title="Series cap and overflow accounting">
The exporter caps retained time series in memory at **2048** series across counters, gauges, and histograms combined. New series beyond that cap are dropped, and `openclaw_prometheus_series_dropped_total` increments by one each time.
Watch this counter as a hard signal that an attribute upstream is leaking high-cardinality values. The exporter never lifts the cap automatically; if it climbs, fix the source rather than disabling the cap.
</Accordion>
<Accordion title="What never appears in Prometheus output">
- prompt text, response text, tool inputs, tool outputs, system prompts
- Talk transcripts, audio payloads, call ids, room ids, handoff tokens, turn ids, and raw session ids
- raw provider request IDs (only bounded hashes, where applicable, on spans — never on metrics)
- session keys and session IDs
- hostnames, file paths, secret values
</Accordion>
</AccordionGroup>
## PromQL recipes
```promql
# Tokens per minute, split by provider
sum by (provider) (rate(openclaw_model_tokens_total[1m]))
# Spend (USD) over the last hour, by model
sum by (model) (increase(openclaw_model_cost_usd_total[1h]))
# 95th percentile model run duration
histogram_quantile(
0.95,
sum by (le, provider, model)
(rate(openclaw_run_duration_seconds_bucket[5m]))
)
# Queue wait time SLO (95p under 2s)
histogram_quantile(
0.95,
sum by (le, lane) (rate(openclaw_queue_lane_wait_seconds_bucket[5m]))
) < 2
# Skill usage, split by bounded source
sum by (skill, source) (increase(openclaw_skill_used_total[24h]))
# Dropped Prometheus series (cardinality alarm)
increase(openclaw_prometheus_series_dropped_total[15m]) > 0
```
<Tip>
Prefer `gen_ai_client_token_usage` for cross-provider dashboards: it follows the OpenTelemetry GenAI semantic conventions and is consistent with metrics from non-OpenClaw GenAI services.
</Tip>
## Choosing between Prometheus and OpenTelemetry export
OpenClaw supports both surfaces independently. You can run either, both, or neither.
<Tabs>
<Tab title="diagnostics-prometheus">
- **Pull** model: Prometheus scrapes `/api/diagnostics/prometheus`.
- No external collector required.
- Authenticated through normal Gateway auth.
- Surface is metrics only (no traces or logs).
- Best for stacks already standardized on Prometheus + Grafana.
</Tab>
<Tab title="diagnostics-otel">
- **Push** model: OpenClaw sends OTLP/HTTP to a collector or OTLP-compatible backend.
- Surface includes metrics, traces, and logs.
- Bridges to Prometheus through an OpenTelemetry Collector (`prometheus` or `prometheusremotewrite` exporter) when you need both.
- See [OpenTelemetry export](/gateway/opentelemetry) for the full catalog.
</Tab>
</Tabs>
## Troubleshooting
<AccordionGroup>
<Accordion title="Empty response body">
- Check that `diagnostics.enabled` is not set to `false` in config (it defaults to `true`).
- Confirm the plugin is enabled and loaded with `openclaw plugins list --enabled`.
- Generate some traffic; counters and histograms only emit lines after at least one event.
</Accordion>
<Accordion title="401 / unauthorized">
The endpoint requires the Gateway operator scope (`auth: "gateway"` with `gatewayRuntimeScopeSurface: "trusted-operator"`). Use the same token or password Prometheus uses for any other Gateway operator route. There is no public unauthenticated mode.
</Accordion>
<Accordion title="`openclaw_prometheus_series_dropped_total` is climbing">
A new attribute is exceeding the **2048**-series cap. Inspect recent metrics for an unexpectedly high-cardinality label and fix it at the source. The exporter intentionally drops new series instead of silently rewriting labels.
</Accordion>
<Accordion title="Prometheus shows stale series after a restart">
The plugin keeps state in memory only. After a Gateway restart, counters reset to zero and gauges restart at their next reported value. Use PromQL `rate()` and `increase()` to handle resets cleanly.
</Accordion>
</AccordionGroup>
## Related
- [Diagnostics export](/gateway/diagnostics) — local diagnostics zip for support bundles
- [Health and readiness](/gateway/health) — `/healthz` and `/readyz` probes
- [Logging](/logging) — file-based logging
- [OpenTelemetry export](/gateway/opentelemetry) — OTLP push for traces, metrics, and logs

883
docs/gateway/protocol.md Normal file
View File

@@ -0,0 +1,883 @@
---
summary: "Gateway WebSocket protocol: handshake, frames, versioning"
read_when:
- Implementing or updating gateway WS clients
- Debugging protocol mismatches or connect failures
- Regenerating protocol schema/models
title: "Gateway protocol"
---
The Gateway WS protocol is the single control plane and node transport for
OpenClaw. Every client (CLI, web UI, macOS app, iOS/Android nodes, headless
nodes) connects over WebSocket and declares a **role** and **scope** at
handshake time.
## Transport and framing
- WebSocket, text frames, JSON payloads.
- First frame **must** be a `connect` request.
- Pre-connect frames are capped at 64 KiB (`MAX_PREAUTH_PAYLOAD_BYTES`). After
handshake, follow `hello-ok.policy.maxPayload` and
`hello-ok.policy.maxBufferedBytes`. With diagnostics enabled, oversized
inbound frames and slow outbound buffers emit `payload.large` events before
the gateway closes or drops the frame. These events carry `surface`, byte
sizes, limits, and a safe reason code, never message bodies, attachment
contents, raw frame bytes, tokens, cookies, or secrets.
Frame shapes:
- Request: `{type:"req", id, method, params}`
- Response: `{type:"res", id, ok, payload|error}`
- Event: `{type:"event", event, payload, seq?, stateVersion?}`
Side-effecting methods require idempotency keys (see schema).
## Handshake
Gateway sends a pre-connect challenge:
```json
{
"type": "event",
"event": "connect.challenge",
"payload": { "nonce": "…", "ts": 1737264000000 }
}
```
Client replies with `connect`:
```json
{
"type": "req",
"id": "…",
"method": "connect",
"params": {
"minProtocol": 4,
"maxProtocol": 4,
"client": {
"id": "cli",
"version": "1.2.3",
"platform": "macos",
"mode": "operator"
},
"role": "operator",
"scopes": ["operator.read", "operator.write"],
"caps": [],
"commands": [],
"permissions": {},
"auth": { "token": "…" },
"locale": "en-US",
"userAgent": "openclaw-cli/1.2.3",
"device": {
"id": "device_fingerprint",
"publicKey": "…",
"signature": "…",
"signedAt": 1737264000000,
"nonce": "…"
}
}
}
```
Gateway responds with `hello-ok`:
```json
{
"type": "res",
"id": "…",
"ok": true,
"payload": {
"type": "hello-ok",
"protocol": 4,
"server": { "version": "…", "connId": "…" },
"features": { "methods": ["…"], "events": ["…"] },
"snapshot": { "…": "…" },
"auth": {
"role": "operator",
"scopes": ["operator.read", "operator.write"]
},
"policy": {
"maxPayload": 26214400,
"maxBufferedBytes": 52428800,
"tickIntervalMs": 15000
}
}
}
```
`server`, `features`, `snapshot`, `policy`, and `auth` are all required by
`HelloOkSchema` (`packages/gateway-protocol/src/schema/frames.ts`). `auth`
reports the negotiated role/scopes even when no device token is issued (shape
above). `pluginSurfaceUrls` is optional and maps plugin surface names (e.g.
`canvas`) to scoped hosted URLs; it may expire, so nodes call
`node.pluginSurface.refresh` with `{ "surface": "canvas" }` for a fresh entry.
The deprecated `canvasHostUrl` / `canvasCapability` / `node.canvas.capability.refresh`
path is not supported; use plugin surfaces.
While the gateway is still finishing startup sidecars, `connect` can return a
retryable `UNAVAILABLE` error with `details.reason: "startup-sidecars"` and
`retryAfterMs`. Retry within your connection budget instead of treating it as
a terminal handshake failure.
When a device token is issued, `hello-ok.auth` adds it:
```json
{
"auth": {
"deviceToken": "…",
"role": "operator",
"scopes": ["operator.read", "operator.write"]
}
}
```
Built-in QR/setup-code bootstrap is a mobile handoff path. A successful
baseline setup-code connect returns a primary node token plus one bounded
operator token:
```json
{
"auth": {
"deviceToken": "…",
"role": "node",
"scopes": [],
"deviceTokens": [
{
"deviceToken": "…",
"role": "operator",
"scopes": ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"]
}
]
}
}
```
This operator handoff is bounded on purpose: enough to start the mobile
operator loop and native setup, including `operator.talk.secrets` for Talk
config reads, but no pairing-mutation scopes and no `operator.admin`. Broader
pairing/admin access needs a separate approved pairing or token flow. Persist
`hello-ok.auth.deviceTokens` only when bootstrap auth ran over a trusted
transport (`wss://` or loopback/local pairing).
Trusted same-process backend clients (`client.id: "gateway-client"`,
`client.mode: "backend"`) may omit `device` on direct loopback connections when
authenticating with the shared gateway token/password. This path is reserved
for internal control-plane RPCs (e.g. subagent session updates) and avoids
stale CLI/device pairing baselines blocking local backend work. Remote,
browser-origin, node, and explicit device-token/device-identity clients still
go through normal pairing and scope-upgrade checks.
### Node connect example
```json
{
"type": "req",
"id": "…",
"method": "connect",
"params": {
"minProtocol": 4,
"maxProtocol": 4,
"client": {
"id": "ios-node",
"version": "1.2.3",
"platform": "ios",
"mode": "node"
},
"role": "node",
"scopes": [],
"caps": ["camera", "canvas", "screen", "location", "voice"],
"commands": ["camera.snap", "canvas.navigate", "screen.record", "location.get"],
"permissions": { "camera.capture": true, "screen.record": false },
"auth": { "token": "…" },
"locale": "en-US",
"userAgent": "openclaw-ios/1.2.3",
"device": {
"id": "device_fingerprint",
"publicKey": "…",
"signature": "…",
"signedAt": 1737264000000,
"nonce": "…"
}
}
}
```
Nodes declare capability claims at connect time:
- `caps`: high-level categories such as `camera`, `canvas`, `screen`,
`location`, `voice`, `talk`.
- `commands`: command allowlist for invoke.
- `permissions`: granular toggles (e.g. `screen.record`, `camera.capture`).
The gateway treats these as claims and enforces server-side allowlists.
## Roles and scopes
For the full operator scope model, approval-time checks, and shared-secret
semantics, see [Operator scopes](/gateway/operator-scopes).
Roles:
- `operator`: control-plane client (CLI/UI/automation).
- `node`: capability host (camera/screen/canvas/system.run).
Operator scopes (`src/gateway/operator-scopes.ts`), the full closed set:
- `operator.read`
- `operator.write`
- `operator.admin`
- `operator.approvals`
- `operator.pairing`
- `operator.talk.secrets`
`talk.config` with `includeSecrets: true` requires `operator.talk.secrets` (or
`operator.admin`). When secrets are included, read the active Talk provider
credential from `talk.resolved.config.apiKey`; `talk.providers.<id>.apiKey`
stays source-shaped and may be a SecretRef object or a redacted string.
Plugin-registered gateway RPC methods may request their own operator scope,
but these reserved core prefixes always resolve to `operator.admin`
(`src/shared/gateway-method-policy.ts`): `config.*`, `exec.approvals.*`,
`wizard.*`, `update.*`.
Method scope is only the first gate. Some slash commands reached through
`chat.send` apply stricter command-level checks: persistent `/config set` and
`/config unset` writes require `operator.admin` even for gateway clients that
already hold a lower operator scope.
`node.pair.approve` has an extra approval-time scope check on top of the base
method scope (`operator.pairing`), based on the pending request's declared
`commands` (`src/infra/node-pairing-authz.ts`):
| Declared commands | Required scopes |
| -------------------------------------------------------------- | ------------------------------------- |
| none | `operator.pairing` |
| non-exec commands | `operator.pairing` + `operator.write` |
| includes `system.run`, `system.run.prepare`, or `system.which` | `operator.pairing` + `operator.admin` |
## Presence
- `system-presence` returns entries keyed by device identity, including
`deviceId`, `roles`, and `scopes`, so UIs can show one row per device even
when it connects as both operator and node.
- `node.list` includes optional `lastSeenAtMs` and `lastSeenReason`. Connected
nodes report current connection time with reason `connect`; paired nodes can
also report durable background presence via a trusted node event.
### Node background alive event
Nodes call `node.event` with `event: "node.presence.alive"` to record that a
paired node was alive during a background wake, without marking it connected:
```json
{
"event": "node.presence.alive",
"payloadJSON": "{\"trigger\":\"silent_push\",\"sentAtMs\":1737264000000,\"displayName\":\"Peter's iPhone\",\"version\":\"2026.4.28\",\"platform\":\"iOS 18.4.0\",\"deviceFamily\":\"iPhone\",\"modelIdentifier\":\"iPhone17,1\",\"pushTransport\":\"relay\"}"
}
```
`trigger` is a closed enum: `background`, `silent_push`, `bg_app_refresh`,
`significant_location`, `manual`, `connect`. Unknown values normalize to
`background` (`src/shared/node-presence.ts`). The event only persists for
authenticated node device sessions; device-less or unpaired sessions return
`handled: false`.
Successful gateways return a structured result:
```json
{
"ok": true,
"event": "node.presence.alive",
"handled": true,
"reason": "persisted"
}
```
Older gateways may return only `{ "ok": true }` for `node.event`; treat that
as an acknowledged RPC, not durable presence persistence.
## Broadcast event scoping
Server-pushed broadcast events are scope-gated so pairing-scoped or node-only
sessions do not passively receive session content
(`src/gateway/server-broadcast.ts`):
- Chat, agent, and tool-result frames (streamed `agent` events, tool-result
events) require at least `operator.read`. Sessions without it skip these
frames entirely.
- Plugin-defined `plugin.*` broadcasts are gated to `operator.write` or
`operator.admin` by default; explicit entries such as
`plugin.approval.requested` / `plugin.approval.resolved` use
`operator.approvals` instead.
- Status/transport events (`heartbeat`, `presence`, `tick`, connect/disconnect
lifecycle) stay unrestricted so transport health is observable to every
authenticated session.
- Unknown broadcast event families are scope-gated by default (fail-closed)
unless a registered handler explicitly relaxes them.
Each client connection keeps its own per-client sequence number, so broadcasts
stay monotonically ordered on that socket even when different clients see
different scope-filtered subsets of the event stream.
## RPC method families
`hello-ok.features.methods` is a conservative discovery list built from
`src/gateway/server-methods-list.ts` plus loaded plugin/channel method
exports — it is not a generated dump of every method, and some methods (for
example `push.test`, `web.login.start`, `web.login.wait`, `sessions.usage`)
are intentionally excluded from discovery even though they are real, callable
methods. Treat this as feature discovery, not a full enumeration of
`src/gateway/server-methods/*.ts`.
<AccordionGroup>
<Accordion title="System and identity">
- `health` returns the cached or freshly probed gateway health snapshot.
- `diagnostics.stability` returns the recent bounded diagnostic stability recorder: event names, counts, byte sizes, memory readings, queue/session state, channel/plugin names, session ids. No chat text, webhook bodies, tool outputs, raw request/response bodies, tokens, cookies, or secrets. Requires `operator.read`.
- `status` returns the `/status`-style gateway summary; sensitive fields only for admin-scoped operator clients.
- `gateway.identity.get` returns the gateway device identity used by relay and pairing flows.
- `system-presence` returns the current presence snapshot for connected operator/node devices.
- `system-event` appends a system event and can update/broadcast presence context.
- `last-heartbeat` returns the latest persisted heartbeat event.
- `set-heartbeats` toggles heartbeat processing on the gateway.
</Accordion>
<Accordion title="Models and usage">
- `models.list` returns the runtime-allowed model catalog. See "`models.list` views" below.
- `usage.status` returns provider usage windows/remaining quota summaries.
- `usage.cost` returns aggregated cost usage summaries for a date range. Pass `agentId` for one agent, or `agentScope: "all"` to aggregate configured agents.
- `doctor.memory.status` returns vector-memory / cached embedding readiness for the active default agent workspace. Pass `{ "probe": true }` or `{ "deep": true }` only for an explicit live embedding provider ping. Pass `{ "agentId": "agent-id" }` to scope Dreaming store stats to one agent workspace; omitting it aggregates configured Dreaming workspaces.
- `doctor.memory.dreamDiary`, `doctor.memory.backfillDreamDiary`, `doctor.memory.resetDreamDiary`, `doctor.memory.resetGroundedShortTerm`, `doctor.memory.repairDreamingArtifacts`, and `doctor.memory.dedupeDreamDiary` accept optional `{ "agentId": "agent-id" }`; omitted, they operate on the configured default agent workspace.
- `doctor.memory.remHarness` returns a bounded, read-only REM harness preview for remote control-plane clients, including workspace paths, memory snippets, rendered grounded markdown, and deep promotion candidates. Requires `operator.read`.
- `sessions.usage` returns per-session usage summaries. Pass `agentId` for one agent, or `agentScope: "all"` to list configured agents together.
- `sessions.usage.timeseries` returns timeseries usage for one session.
- `sessions.usage.logs` returns usage log entries for one session.
</Accordion>
<Accordion title="Channels and login helpers">
- `channels.status` returns built-in + bundled channel/plugin status summaries.
- `channels.logout` logs out a specific channel/account where the channel supports it.
- `web.login.start` starts a QR/web login flow for the current QR-capable web channel provider.
- `web.login.wait` waits for that flow to complete and starts the channel on success.
- `push.test` sends a test APNs push to a registered iOS node.
- `voicewake.get` returns the stored wake-word triggers.
- `voicewake.set` updates wake-word triggers and broadcasts the change.
</Accordion>
<Accordion title="Messaging and logs">
- `send` is the direct outbound-delivery RPC for channel/account/thread-targeted sends outside the chat runner.
- `logs.tail` returns the configured gateway file-log tail with cursor/limit and max-byte controls.
</Accordion>
<Accordion title="Operator terminal">
- `terminal.open` starts a host PTY for an explicit `agentId` or the default agent and returns the resolved agent, working directory, shell, and confinement state.
- `terminal.input`, `terminal.resize`, and `terminal.close` operate only on sessions owned by the calling connection.
- `terminal.data` and `terminal.exit` events stream only to the connection that owns the session.
- Sessions whose connection drops are detached, not killed: they stay reattachable for `gateway.terminal.detachedSessionTimeoutSeconds` (default 300; `0` restores kill-on-disconnect) while recent output accumulates in a bounded server-side buffer.
- `terminal.list` returns attachable sessions; `terminal.attach` rebinds a live-or-detached session to the calling connection and returns the replay buffer (tmux-style take-over — a previous live owner receives `terminal.exit` with reason `detached`); `terminal.text` reads the buffer as plain text without attaching.
- Every terminal method requires `operator.admin`; `gateway.terminal.enabled` must be explicitly true. Fully sandboxed agents are refused, and an agent policy change closes existing and in-flight PTYs, detached ones included.
</Accordion>
<Accordion title="Talk and TTS">
- `talk.catalog` returns the read-only Talk provider catalog for speech, streaming transcription, and realtime voice: canonical provider ids, registry aliases, labels, configured state, an optional group-level `ready` result, exposed model/voice ids, canonical modes, transports, brain strategies, and realtime audio/capability flags, without returning provider secrets or mutating global config. Current gateways set `ready` after applying runtime provider selection; treat its absence as unverified on older gateways.
- `talk.config` returns the effective Talk config payload; `includeSecrets` requires `operator.talk.secrets` (or `operator.admin`).
- `talk.session.create` creates a gateway-owned Talk session for `realtime/gateway-relay`, `transcription/gateway-relay`, or `stt-tts/managed-room`. For `stt-tts/managed-room`, `operator.write` callers that pass `sessionKey` must also pass `spawnedBy` for scoped session-key visibility; unscoped `sessionKey` creation and `brain: "direct-tools"` require `operator.admin`.
- `talk.session.join` validates a managed-room session token, emits `session.ready` or `session.replaced` as needed, and returns room/session metadata plus recent Talk events, never the plaintext token or its hash.
- `talk.session.appendAudio` appends base64 PCM input audio to gateway-owned realtime relay and transcription sessions.
- `talk.session.startTurn`, `talk.session.endTurn`, and `talk.session.cancelTurn` drive managed-room turn lifecycle with stale-turn rejection before state clears.
- `talk.session.cancelOutput` stops assistant audio output, primarily for VAD-gated barge-in in gateway relay sessions.
- `talk.session.submitToolResult` completes a provider tool call emitted by a gateway-owned realtime relay session. Pass `options: { willContinue: true }` for interim tool output when a final result follows, or `options: { suppressResponse: true }` when the tool result should satisfy the provider call without starting another realtime response.
- `talk.session.steer` sends active-run voice control into a gateway-owned agent-backed Talk session: `{ sessionId, text, mode? }`, where `mode` is `status`, `steer`, `cancel`, or `followup`; omitted mode is classified from the spoken text.
- `talk.session.close` closes a gateway-owned relay, transcription, or managed-room session and emits terminal Talk events.
- `talk.mode` sets/broadcasts the current Talk mode state for WebChat/Control UI clients.
- `talk.client.create` creates a client-owned realtime provider session using `webrtc` or `provider-websocket` while the gateway owns config, credentials, instructions, and tool policy.
- `talk.client.toolCall` lets client-owned realtime transports forward provider tool calls to gateway policy. The first supported tool is `openclaw_agent_consult`; clients get a run id and wait for normal chat lifecycle events before submitting the provider-specific tool result.
- `talk.client.steer` sends active-run voice control for client-owned realtime transports. The gateway resolves the active embedded run from `sessionKey` and returns a structured accepted/rejected result instead of silently dropping steering.
- `talk.event` is the single Talk event channel for realtime, transcription, STT/TTS, managed-room, telephony, and meeting adapters.
- `talk.speak` synthesizes speech through the active Talk speech provider.
- `tts.status` returns TTS enabled state, active provider, fallback providers, and provider config state.
- `tts.providers` returns the visible TTS provider inventory.
- `tts.enable` and `tts.disable` toggle TTS prefs state.
- `tts.setProvider` updates the preferred TTS provider.
- `tts.convert` runs one-shot text-to-speech conversion.
</Accordion>
<Accordion title="Secrets, config, update, and wizard">
- `secrets.reload` re-resolves active SecretRefs and swaps runtime secret state only on full success.
- `secrets.resolve` resolves command-target secret assignments for a specific command/target set.
- `config.get` returns the current config snapshot and hash.
- `config.set` writes a validated config payload.
- `config.patch` merges a partial config update. Destructive array replacement requires the affected path in `replacePaths`; nested arrays under array entries use `[]` paths such as `agents.list[].skills`.
- `config.apply` validates + replaces the full config payload.
- `config.schema` returns the live config schema payload used by Control UI and CLI tooling: schema, `uiHints`, version, generation metadata, plugin + channel schema metadata when loadable. It includes `title` / `description` metadata from the same labels/help text as the UI, including nested object, wildcard, array-item, and `anyOf` / `oneOf` / `allOf` composition branches when matching field documentation exists.
- `config.schema.lookup` returns a path-scoped lookup payload for one config path: normalized path, a shallow schema node, matched hint + `hintPath`, optional `reloadKind`, and immediate child summaries for UI/CLI drill-down. `reloadKind` is one of `restart`, `hot`, or `none` (`src/config/schema.ts`) and mirrors the gateway config reload planner for the requested path. Lookup schema nodes keep the user-facing docs and common validation fields (`title`, `description`, `type`, `enum`, `const`, `format`, `pattern`, numeric/string/array/object bounds, `additionalProperties`, `deprecated`, `readOnly`, `writeOnly`). Child summaries expose `key`, normalized `path`, `type`, `required`, `hasChildren`, optional `reloadKind`, plus the matched `hint` / `hintPath`.
- `update.run` runs the gateway update flow and schedules a restart only if the update succeeded; callers with a session can include `continuationMessage` so startup resumes one follow-up agent turn through the restart continuation queue. Package-manager updates and supervised git-checkout updates from the control plane use a detached managed-service handoff instead of replacing the package tree or mutating checkout/build output inside the live gateway. A started handoff returns `ok: true` with `result.reason: "managed-service-handoff-started"` and `handoff.status: "started"`; unavailable or failed handoffs return `ok: false` with `managed-service-handoff-unavailable` or `managed-service-handoff-failed`, plus `handoff.command` when a manual shell update is required. Unavailable means OpenClaw lacks a safe supervisor boundary or durable service identity, such as `OPENCLAW_SYSTEMD_UNIT` for systemd. During a started handoff, the restart sentinel may briefly report `stats.reason: "restart-health-pending"`; the continuation is delayed until the CLI verifies the restarted gateway and writes the final `ok` sentinel.
- `update.status` refreshes and returns the latest update restart sentinel, including the post-restart running version when available.
- `wizard.start`, `wizard.next`, `wizard.status`, and `wizard.cancel` expose the onboarding wizard over WS RPC.
</Accordion>
<Accordion title="Agent and workspace helpers">
- `agents.list` returns configured agent entries, including effective model and runtime metadata.
- `agents.create`, `agents.update`, and `agents.delete` manage agent records and workspace wiring.
- `agents.files.list`, `agents.files.get`, and `agents.files.set` manage the bootstrap workspace files exposed for an agent.
- `tasks.list`, `tasks.get`, and `tasks.cancel` expose the gateway task ledger to SDK and operator clients. See [Task ledger RPCs](#task-ledger-rpcs) below.
- `artifacts.list`, `artifacts.get`, and `artifacts.download` expose transcript-derived artifact summaries and downloads for an explicit `sessionKey`, `runId`, or `taskId` scope. Run and task queries resolve the owning session server-side and only return transcript media with matching provenance; unsafe or local URL sources return unsupported downloads instead of fetching server-side.
- `environments.list` and `environments.status` expose read-only gateway-local and node environment discovery for SDK clients.
- `agent.identity.get` returns the effective assistant identity for an agent or session.
- `agent.wait` waits for a run to finish and returns the terminal snapshot when available.
</Accordion>
<Accordion title="Session control">
- `sessions.list` returns the current session index, including per-row `agentRuntime` metadata when an agent runtime backend is configured.
- `sessions.subscribe` and `sessions.unsubscribe` toggle session change event subscriptions for the current WS client.
- `sessions.messages.subscribe` and `sessions.messages.unsubscribe` toggle transcript/message event subscriptions for one session.
- `sessions.preview` returns bounded transcript previews for specific session keys.
- `sessions.describe` returns one gateway session row for an exact session key.
- `sessions.resolve` resolves or canonicalizes a session target.
- `sessions.create` creates a new session entry.
- `sessions.send` sends a message into an existing session.
- `sessions.steer` is the interrupt-and-steer variant for an active session.
- `sessions.abort` aborts active work for a session. Pass `key` plus optional `runId`, or `runId` alone for active runs the gateway can resolve to a session.
- `sessions.patch` updates session metadata/overrides and reports the resolved canonical model plus effective `agentRuntime`.
- `sessions.reset`, `sessions.delete`, and `sessions.compact` perform session maintenance.
- `sessions.get` returns the full stored session row.
- Chat execution still uses `chat.history`, `chat.send`, `chat.abort`, and `chat.inject`. `chat.history` is display-normalized for UI clients: inline directive tags are stripped from visible text, plain-text tool-call XML payloads (`<tool_call>...</tool_call>`, `<function_call>...</function_call>`, `<tool_calls>...</tool_calls>`, `<function_calls>...</function_calls>`, and truncated tool-call blocks) and leaked ASCII/full-width model control tokens are stripped, pure silent-token assistant rows (exact `NO_REPLY` / `no_reply`) are omitted, and oversized rows can be replaced with placeholders.
- `chat.message.get` is the additive bounded full-message reader for a single visible transcript entry. Pass `sessionKey`, optional `agentId` when session selection is agent-scoped, and a transcript `messageId` previously surfaced through `chat.history`; the gateway returns the same display-normalized projection without the lightweight history truncation cap when the stored entry is still available and not oversized.
- `chat.send` accepts one-turn `fastMode: "auto"` to use fast mode for model calls started before the auto cutoff, then start later retry, fallback, tool-result, or continuation calls without fast mode. The cutoff defaults to 60 seconds (`DEFAULT_FAST_MODE_AUTO_ON_SECONDS`) and can be configured per model with `agents.defaults.models["<provider>/<model>"].params.fastAutoOnSeconds`. A `chat.send` caller can pass one-turn `fastAutoOnSeconds` to override the cutoff for that request.
</Accordion>
<Accordion title="Device pairing and device tokens">
- `device.pair.list` returns pending and approved paired devices.
- `device.pair.setupCode` creates a mobile setup code and, by default, a PNG QR data URL. It requires `operator.admin` and is intentionally omitted from advertised discovery. The result includes `setupCode`, optional `qrDataUrl`, `gatewayUrl`, the non-secret `auth` label, and `urlSource`.
- `device.pair.approve`, `device.pair.reject`, and `device.pair.remove` manage device-pairing records.
- `device.token.rotate` rotates a paired device token within its approved role and caller scope bounds.
- `device.token.revoke` revokes a paired device token within its approved role and caller scope bounds.
The setup code embeds a short-lived bootstrap credential. Clients must not
log or persist it beyond the pairing flow.
</Accordion>
<Accordion title="Node pairing, invoke, and pending work">
- `node.pair.request`, `node.pair.list`, `node.pair.approve`, `node.pair.reject`, `node.pair.remove`, and `node.pair.verify` cover node pairing and bootstrap verification.
- `node.list` and `node.describe` return known/connected node state.
- `node.rename` updates a paired node label.
- `node.invoke` forwards a command to a connected node.
- `node.invoke.result` returns the result for an invoke request.
- `node.event` carries node-originated events back into the gateway.
- `node.pending.pull` and `node.pending.ack` are the connected-node queue APIs.
- `node.pending.enqueue` and `node.pending.drain` manage durable pending work for offline/disconnected nodes.
</Accordion>
<Accordion title="Approval families">
- `exec.approval.request`, `exec.approval.get`, `exec.approval.list`, and `exec.approval.resolve` cover one-shot exec approval requests plus pending approval lookup/replay.
- `exec.approval.waitDecision` waits on one pending exec approval and returns the final decision (or `null` on timeout).
- `exec.approvals.get` and `exec.approvals.set` manage gateway exec approval policy snapshots.
- `exec.approvals.node.get` and `exec.approvals.node.set` manage node-local exec approval policy via node relay commands.
- `plugin.approval.request`, `plugin.approval.list`, `plugin.approval.waitDecision`, and `plugin.approval.resolve` cover plugin-defined approval flows.
</Accordion>
<Accordion title="Automation, skills, and tools">
- Automation: `wake` schedules an immediate or next-heartbeat wake text injection; `cron.get`, `cron.list`, `cron.status`, `cron.add`, `cron.update`, `cron.remove`, `cron.run`, `cron.runs` manage scheduled work.
- `cron.run` remains an enqueue-style RPC for manual runs. Clients that need completion semantics should read the returned `runId` and poll `cron.runs`.
- `cron.runs` accepts an optional non-empty `runId` filter so clients can follow one queued manual run without racing against other history entries for the same job.
- Skills and tools: `commands.list`, `skills.*`, `tools.catalog`, `tools.effective`, `tools.invoke`. See [Operator helper methods](#operator-helper-methods) below.
</Accordion>
</AccordionGroup>
### Common event families
- `chat`: UI chat updates such as `chat.inject` and other transcript-only chat
events. In protocol v4, delta payloads carry `deltaText`; `message` remains
the cumulative assistant snapshot. Non-prefix replacements set
`replace=true` and use `deltaText` as the replacement text.
- `session.message`, `session.operation`, `session.tool`: transcript, in-flight
session operation, and event-stream updates for a subscribed session.
- `sessions.changed`: session index or metadata changed.
- `presence`: system presence snapshot updates.
- `tick`: periodic keepalive/liveness event.
- `health`: gateway health snapshot update.
- `heartbeat`: heartbeat event stream update.
- `cron`: cron run/job change event.
- `shutdown`: gateway shutdown notification.
- `node.pair.requested` / `node.pair.resolved`: node pairing lifecycle.
- `node.invoke.request`: node invoke request broadcast.
- `device.pair.requested` / `device.pair.resolved`: paired-device lifecycle.
- `voicewake.changed`: wake-word trigger config changed.
- `exec.approval.requested` / `exec.approval.resolved`: exec approval
lifecycle.
- `plugin.approval.requested` / `plugin.approval.resolved`: plugin approval
lifecycle.
### Node helper methods
Nodes may call `skills.bins` to fetch the current list of skill executables
for auto-allow checks.
## Task ledger RPCs
Operator clients inspect and cancel gateway background task records through
the task ledger RPCs (`packages/gateway-protocol/src/schema/tasks.ts`). These
return sanitized task summaries, not raw runtime state.
- `tasks.list` requires `operator.read`.
- Params: optional `status` (`"queued"`, `"running"`, `"completed"`,
`"failed"`, `"cancelled"`, or `"timed_out"`) or an array of those statuses,
optional `agentId`, optional `sessionKey`, optional `limit` from `1` to
`500`, and optional string `cursor`.
- Result: `{ "tasks": TaskSummary[], "nextCursor"?: string }`.
- `tasks.get` requires `operator.read`.
- Params: `{ "taskId": string }`.
- Result: `{ "task": TaskSummary }`.
- Missing task ids return the gateway not-found error shape.
- `tasks.cancel` requires `operator.write`.
- Params: `{ "taskId": string, "reason"?: string }`.
- Result: `{ "found": boolean, "cancelled": boolean, "reason"?: string, "task"?: TaskSummary }`.
- `found` reports whether the ledger had a matching task. `cancelled`
reports whether the runtime accepted or recorded cancellation.
`TaskSummary` includes `id`, `status`, and optional metadata: `kind`,
`runtime`, `title`, `agentId`, `sessionKey`, `childSessionKey`, `ownerKey`,
`runId`, `taskId`, `flowId`, `parentTaskId`, `sourceId`, timestamps, progress,
terminal summary, and sanitized error text. `agentId` identifies the agent
executing the task; `sessionKey` and `ownerKey` preserve requester and control
context.
## Operator helper methods
- `commands.list` (`operator.read`) fetches the runtime command inventory for
an agent.
- `agentId` is optional; omit it to read the default agent workspace.
- `scope` controls which surface the primary `name` targets: `text` returns
the primary text command token without the leading `/`; `native` and the
default `both` path return provider-aware native names when available.
- `textAliases` carries exact slash aliases such as `/model` and `/m`.
- `nativeName` carries the provider-aware native command name when one
exists.
- `provider` is optional and only affects native naming plus native plugin
command availability.
- `includeArgs=false` omits serialized argument metadata from the response.
- `tools.catalog` (`operator.read`) fetches the runtime tool catalog for an
agent. The response includes grouped tools and provenance metadata:
- `source`: `core` or `plugin`
- `pluginId`: plugin owner when `source="plugin"`
- `optional`: whether a plugin tool is optional
- `tools.effective` (`operator.read`) fetches the runtime-effective tool
inventory for a session.
- `sessionKey` is required.
- The gateway derives trusted runtime context from the session server-side
instead of accepting caller-supplied auth or delivery context.
- The response is a session-scoped server-derived projection of the active
inventory, including core, plugin, channel, and already-discovered MCP
server tools.
- `tools.effective` is read-only for MCP: it may project a warm session MCP
catalog through the final tool policy, but does not create MCP runtimes,
connect transports, or issue `tools/list`. If no matching warm catalog
exists, the response may include a notice such as `mcp-not-yet-connected`,
`mcp-not-yet-listed`, or `mcp-stale-catalog`.
- Effective tool entries use `source="core"`, `source="plugin"`,
`source="channel"`, or `source="mcp"`.
- `tools.invoke` (`operator.write`) invokes one available tool through the
same gateway policy path as `/tools/invoke`.
- `name` is required. `args`, `sessionKey`, `agentId`, `confirm`, and
`idempotencyKey` are optional.
- If both `sessionKey` and `agentId` are present, the resolved session agent
must match `agentId`.
- Owner-only core wrappers such as `cron`, `gateway`, and `nodes` require
owner/admin identity (`operator.admin`) even though `tools.invoke` itself
is `operator.write`.
- The response is an SDK-facing envelope with `ok`, `toolName`, optional
`output`, and typed `error` fields. Approval or policy refusals return
`ok:false` in the payload rather than bypassing the gateway tool policy
pipeline.
- `skills.status` (`operator.read`) fetches the visible skill inventory for an
agent.
- `agentId` is optional; omit it to read the default agent workspace.
- The response includes eligibility, missing requirements, config checks,
and sanitized install options without exposing raw secret values.
- `skills.search` and `skills.detail` (`operator.read`) return ClawHub
discovery metadata.
- `skills.upload.begin`, `skills.upload.chunk`, and `skills.upload.commit`
(`operator.admin`) stage a private skill archive before installing it. This
is a separate admin upload path for trusted clients, not the normal ClawHub
skill install flow, and is disabled by default unless
`skills.install.allowUploadedArchives` is enabled.
- `skills.upload.begin({ kind: "skill-archive", slug, sizeBytes, sha256?, force?, idempotencyKey? })`
creates an upload bound to that slug and force value.
- `skills.upload.chunk({ uploadId, offset, dataBase64 })` appends bytes at
the exact decoded offset.
- `skills.upload.commit({ uploadId, sha256? })` verifies the final size and
SHA-256. Commit only finalizes the upload; it does not install the skill.
- Uploaded skill archives are zip archives containing a `SKILL.md` root. The
archive's internal directory name never selects the install target.
- `skills.install` (`operator.admin`) has three modes:
- ClawHub mode: `{ source: "clawhub", slug, version?, force? }` installs a
skill folder into the default agent workspace `skills/` directory.
- Upload mode: `{ source: "upload", uploadId, slug, force?, sha256?, timeoutMs? }`
installs a committed upload into the default agent workspace
`skills/<slug>` directory. The slug and force value must match the
original `skills.upload.begin` request. Rejected unless
`skills.install.allowUploadedArchives` is enabled; the setting does not
affect ClawHub installs.
- Gateway installer mode: `{ name, installId, timeoutMs? }` runs a declared
`metadata.openclaw.install` action on the gateway host. Older clients may
still send `dangerouslyForceUnsafeInstall`; this field is deprecated,
accepted only for protocol compatibility, and ignored. Use
`security.installPolicy` for operator-owned install decisions.
- `skills.update` (`operator.admin`) has two modes:
- ClawHub mode updates one tracked slug or all tracked ClawHub installs in
the default agent workspace.
- Config mode patches `skills.entries.<skillKey>` values such as `enabled`,
`apiKey`, and `env`.
### `models.list` views
`models.list` accepts an optional `view` parameter
(`src/agents/model-catalog-visibility.ts`):
- Omitted or `"default"`: if `agents.defaults.models` is configured, the
response is the allowed catalog, including dynamically discovered models
for `provider/*` entries. Otherwise the response is the full gateway
catalog.
- `"configured"`: picker-sized behavior. If `agents.defaults.models` is
configured, it still wins, including provider-scoped discovery for
`provider/*` entries. Without an allowlist, the response uses explicit
`models.providers.<provider>.models` entries, falling back to the full
catalog only when no configured model rows exist.
- `"all"`: full gateway catalog, bypassing `agents.defaults.models`. Use for
diagnostics/discovery UIs, not normal model pickers.
## Exec approvals
- When an exec request needs approval, the gateway broadcasts
`exec.approval.requested`.
- Operator clients resolve by calling `exec.approval.resolve` (requires
`operator.approvals`).
- For `host=node`, `exec.approval.request` must include `systemRunPlan`
(canonical `argv`/`cwd`/`rawCommand`/session metadata). Requests missing
`systemRunPlan` are rejected.
- After approval, forwarded `node.invoke system.run` calls reuse that
canonical `systemRunPlan` as the authoritative command/cwd/session context.
- If a caller mutates `command`, `rawCommand`, `cwd`, `agentId`, or
`sessionKey` between prepare and the final approved `system.run` forward,
the gateway rejects the run instead of trusting the mutated payload.
## Agent delivery fallback
- `agent` requests can include `deliver=true` to request outbound delivery.
- `bestEffortDeliver=false` (the default) keeps strict behavior: unresolved or
internal-only delivery targets return `INVALID_REQUEST`.
- `bestEffortDeliver=true` allows fallback to session-only execution when no
external deliverable route can be resolved (for example internal/webchat
sessions or ambiguous multi-channel configs).
- Final `agent` results may include `result.deliveryStatus` when delivery was
requested, using the same `sent`, `suppressed`, `partial_failed`, and
`failed` statuses documented for
[`openclaw agent --json --deliver`](/cli/agent#json-delivery-status).
## Versioning
- `PROTOCOL_VERSION` and `MIN_CLIENT_PROTOCOL_VERSION` live in
`packages/gateway-protocol/src/version.ts`. Both are currently `4`.
- Clients send `minProtocol` + `maxProtocol`; the gateway accepts a connect
when `maxProtocol >= PROTOCOL_VERSION && minProtocol <= PROTOCOL_VERSION`
(`src/gateway/server/ws-connection/message-handler.ts`). Current clients and
servers both run protocol v4.
- Schemas and models are generated from TypeBox definitions:
- `pnpm protocol:gen`
- `pnpm protocol:gen:swift`
- `pnpm protocol:check`
### Client constants
The reference client implementation lives in `packages/gateway-client/src/`
(OpenClaw wraps it via the thin `src/gateway/client.ts` facade). These
defaults are stable across protocol v4 and are the expected baseline for
third-party clients.
| Constant | Default | Source |
| ----------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `PROTOCOL_VERSION` | `4` | `packages/gateway-protocol/src/version.ts` |
| `MIN_CLIENT_PROTOCOL_VERSION` | `4` | `packages/gateway-protocol/src/version.ts` |
| Request timeout (per RPC) | `30_000` ms | `packages/gateway-client/src/client.ts` (`requestTimeoutMs`) |
| Preauth / connect-challenge timeout | `15_000` ms | `packages/gateway-client/src/timeouts.ts` (`OPENCLAW_HANDSHAKE_TIMEOUT_MS` env can raise the paired server/client budget) |
| Initial reconnect backoff | `1_000` ms | `packages/gateway-client/src/client.ts` (`backoffMs`) |
| Max reconnect backoff | `30_000` ms | `packages/gateway-client/src/client.ts` (`scheduleReconnect`) |
| Fast-retry clamp after device-token close | `250` ms | `packages/gateway-client/src/client.ts` |
| Force-stop grace before `terminate()` | `250` ms | `FORCE_STOP_TERMINATE_GRACE_MS` |
| `stopAndWait()` default timeout | `1_000` ms | `STOP_AND_WAIT_TIMEOUT_MS` |
| Default tick interval (pre `hello-ok`) | `30_000` ms | `packages/gateway-client/src/client.ts` |
| Tick-timeout close | code `4000` when silence exceeds `tickIntervalMs * 2` | `packages/gateway-client/src/client.ts` |
| `MAX_PAYLOAD_BYTES` | `25 * 1024 * 1024` (25 MB) | `src/gateway/server-constants.ts` |
The server advertises the effective `policy.tickIntervalMs`,
`policy.maxPayload`, and `policy.maxBufferedBytes` in `hello-ok`; clients
should honor those values rather than the pre-handshake defaults.
## Auth
- Shared-secret gateway auth uses `connect.params.auth.token` or
`connect.params.auth.password`, depending on the configured
`gateway.auth.mode` (`"none" | "token" | "password" | "trusted-proxy"`).
- Identity-bearing modes such as Tailscale Serve (`gateway.auth.allowTailscale: true`)
or non-loopback `gateway.auth.mode: "trusted-proxy"` satisfy the connect
auth check from request headers instead of `connect.params.auth.*`.
- Private-ingress `gateway.auth.mode: "none"` skips shared-secret connect auth
entirely; do not expose that mode on public/untrusted ingress.
- After pairing, the gateway issues a device token scoped to the connection
role + scopes, returned in `hello-ok.auth.deviceToken`. Clients should
persist it after any successful connect.
- Reconnecting with that stored device token should also reuse the stored
approved scope set for that token. This preserves read/probe/status access
already granted and avoids silently collapsing reconnects to a narrower
implicit admin-only scope.
- Client-side connect auth assembly (`selectConnectAuth` in
`packages/gateway-client/src/client.ts`):
- `auth.password` is orthogonal and always forwarded when set.
- `auth.token` is populated in priority order: explicit shared token first,
then an explicit `deviceToken`, then a stored per-device token (keyed by
`deviceId` + `role`).
- `auth.bootstrapToken` is sent only when none of the above resolved
`auth.token`. A shared token or any resolved device token suppresses it.
- Auto-promotion of a stored device token on the one-shot
`AUTH_TOKEN_MISMATCH` retry is gated to trusted endpoints only: loopback,
or `wss://` with a pinned `tlsFingerprint`. Public `wss://` without pinning
does not qualify.
- Built-in setup-code bootstrap returns the primary node
`hello-ok.auth.deviceToken` plus a bounded operator token in
`hello-ok.auth.deviceTokens` for trusted mobile handoff. The operator token
includes `operator.talk.secrets` for native Talk configuration reads, but
excludes pairing-mutation scopes and `operator.admin`.
- While a non-baseline setup-code bootstrap waits for approval,
`PAIRING_REQUIRED` details include `recommendedNextStep: "wait_then_retry"`,
`retryable: true`, and `pauseReconnect: false`. Keep reconnecting with the
same bootstrap token until the request is approved or the token becomes
invalid.
- Persist `hello-ok.auth.deviceTokens` only when the connect used bootstrap
auth on a trusted transport such as `wss://` or loopback/local pairing.
- If a client supplies an explicit `deviceToken` or explicit `scopes`, that
caller-requested scope set remains authoritative; cached scopes are only
reused when the client is reusing the stored per-device token.
- Device tokens can be rotated/revoked via `device.token.rotate` and
`device.token.revoke` (requires `operator.pairing`). Rotating or revoking a
node or other non-operator role also requires `operator.admin`.
- `device.token.rotate` returns rotation metadata. It echoes the replacement
bearer token only for same-device calls already authenticated with that
device token, so token-only clients can persist their replacement before
reconnecting. Shared/admin rotations do not echo the bearer token.
- Token issuance, rotation, and revocation stay bounded to the approved role
set recorded in that device's pairing entry; token mutation cannot expand or
target a device role that pairing approval never granted.
- For paired-device token sessions, device management is self-scoped unless
the caller also has `operator.admin`: non-admin callers can manage only the
operator token for their own device entry. Node and other non-operator token
management is admin-only, even for the caller's own device.
- `device.token.rotate` and `device.token.revoke` also check the target
operator token scope set against the caller's current session scopes.
Non-admin callers cannot rotate or revoke a broader operator token than they
already hold.
- Auth failures include `error.details.code` plus recovery hints:
- `error.details.canRetryWithDeviceToken` (boolean)
- `error.details.recommendedNextStep`: one of `retry_with_device_token`,
`update_auth_configuration`, `update_auth_credentials`,
`wait_then_retry`, `review_auth_configuration`
(`packages/gateway-protocol/src/connect-error-details.ts`).
- Client behavior for `AUTH_TOKEN_MISMATCH`:
- Trusted clients may attempt one bounded retry with a cached per-device
token.
- If that retry fails, stop automatic reconnect loops and surface operator
action guidance.
- `AUTH_SCOPE_MISMATCH` means the device token was recognized but does not
cover the requested role/scopes. Do not present this as a bad token; prompt
the operator to re-pair or approve the narrower/broader scope contract.
## Device identity and pairing
- Nodes should include a stable device identity (`device.id`) derived from a
keypair fingerprint.
- Gateways issue tokens per device + role.
- Pairing approvals are required for new device IDs unless local
auto-approval is enabled.
- Pairing auto-approval is centered on direct local loopback connects.
- OpenClaw also has a narrow backend/container-local self-connect path for
trusted shared-secret helper flows.
- Same-host tailnet or LAN connects are still treated as remote for pairing
and require approval.
- WS clients normally include `device` identity during `connect` (operator +
node). The only device-less operator exceptions are explicit trust paths:
- `gateway.controlUi.allowInsecureAuth=true` for localhost-only insecure
HTTP compatibility.
- successful `gateway.auth.mode: "trusted-proxy"` operator Control UI auth.
- `gateway.controlUi.dangerouslyDisableDeviceAuth=true` (break-glass, severe
security downgrade).
- direct-loopback `gateway-client` backend RPCs on the reserved internal
helper path.
- Omitting device identity has scope consequences. When a device-less
operator connection is allowed through an explicit trust path, OpenClaw
still clears self-declared scopes to an empty set unless that path has a
named scope-preservation exception. Scope-gated methods then fail with
`missing scope`.
- `gateway.controlUi.dangerouslyDisableDeviceAuth=true` is a Control UI
break-glass scope-preservation path. It does not grant scopes to arbitrary
custom backend or CLI-shaped WebSocket clients.
- The reserved direct-loopback `gateway-client` backend helper path preserves
scopes only for internal local control-plane RPCs; custom backend IDs do
not receive this exception.
- All connections must sign the server-provided `connect.challenge` nonce.
### Device auth migration diagnostics
For legacy clients that still use pre-challenge signing behavior, `connect`
returns `DEVICE_AUTH_*` detail codes under `error.details.code` with a stable
`error.details.reason`.
Common migration failures:
| Message | details.code | details.reason | Meaning |
| --------------------------- | -------------------------------- | ------------------------ | -------------------------------------------------- |
| `device nonce required` | `DEVICE_AUTH_NONCE_REQUIRED` | `device-nonce-missing` | Client omitted `device.nonce` (or sent blank). |
| `device nonce mismatch` | `DEVICE_AUTH_NONCE_MISMATCH` | `device-nonce-mismatch` | Client signed with a stale/wrong nonce. |
| `device signature invalid` | `DEVICE_AUTH_SIGNATURE_INVALID` | `device-signature` | Signature payload does not match v2 payload. |
| `device signature expired` | `DEVICE_AUTH_SIGNATURE_EXPIRED` | `device-signature-stale` | Signed timestamp is outside allowed skew. |
| `device identity mismatch` | `DEVICE_AUTH_DEVICE_ID_MISMATCH` | `device-id-mismatch` | `device.id` does not match public key fingerprint. |
| `device public key invalid` | `DEVICE_AUTH_PUBLIC_KEY_INVALID` | `device-public-key` | Public key format/canonicalization failed. |
Migration target:
- Always wait for `connect.challenge`.
- Sign the v2 payload that includes the server nonce.
- Send the same nonce in `connect.params.device.nonce`.
- Preferred signature payload is `v3`
(`buildDeviceAuthPayloadV3` in `packages/gateway-client/src/device-auth.ts`),
which binds `platform` and `deviceFamily` in addition to
device/client/role/scopes/token/nonce fields.
- Legacy `v2` signatures remain accepted for compatibility, but paired-device
metadata pinning still controls command policy on reconnect.
## TLS and pinning
- TLS is supported for WS connections (`gateway.tls` config).
- Clients may optionally pin the gateway cert fingerprint via
`gateway.remote.tlsFingerprint` or CLI `--tls-fingerprint`.
## Scope
This protocol exposes the full gateway API: status, channels, models, chat,
agent, sessions, nodes, approvals, and more. The exact surface is defined by
the TypeBox schemas re-exported from `packages/gateway-protocol/src/schema.ts`.
## Related
- [Bridge protocol](/gateway/bridge-protocol)
- [Gateway runbook](/gateway)

View File

@@ -0,0 +1,60 @@
---
summary: "SSH tunnel setup for OpenClaw.app connecting to a remote gateway"
read_when: "Connecting the macOS app to a remote gateway over SSH"
title: "Remote gateway setup"
---
<Note>
This content now lives in [Remote Access](/gateway/remote#macos-persistent-ssh-tunnel-via-launchagent). Use that page for the current guide; this page stays as a redirect target.
</Note>
# Running OpenClaw.app with a Remote Gateway
OpenClaw.app reaches a remote Gateway over an SSH tunnel: an SSH `LocalForward` maps a local port to the Gateway WebSocket port on the remote host.
```mermaid
flowchart TB
subgraph Client["Client Machine"]
direction TB
A["OpenClaw.app"]
B["ws://127.0.0.1:18789\n(local port)"]
T["SSH Tunnel"]
A --> B
B --> T
end
subgraph Remote["Remote Machine"]
direction TB
C["Gateway WebSocket"]
D["ws://127.0.0.1:18789"]
C --> D
end
T --> C
```
## Setup
1. Add an SSH config entry with `LocalForward 18789 127.0.0.1:18789` (see [Remote Access](/gateway/remote#macos-persistent-ssh-tunnel-via-launchagent) for the full config block).
2. Copy your SSH key to the remote host with `ssh-copy-id`.
3. Set `gateway.remote.token` (or `gateway.remote.password`) via `openclaw config set gateway.remote.token "<your-token>"`.
4. Start the tunnel: `ssh -N remote-gateway &`.
5. Quit and reopen OpenClaw.app.
For a tunnel that survives reboots and reconnects automatically, use the LaunchAgent setup on the [Remote Access](/gateway/remote#macos-persistent-ssh-tunnel-via-launchagent) page instead of a manual `ssh -N`.
## How it works
| Component | What it does |
| ------------------------------------ | ------------------------------------------------------------- |
| `LocalForward 18789 127.0.0.1:18789` | Forwards local port 18789 to remote port 18789 |
| `ssh -N` | SSH without executing remote commands (port forwarding only) |
| `KeepAlive` | Restarts the tunnel automatically if it crashes (LaunchAgent) |
| `RunAtLoad` | Starts the tunnel when the LaunchAgent loads (LaunchAgent) |
OpenClaw.app connects to `ws://127.0.0.1:18789` on the client. The tunnel forwards that connection to port 18789 on the remote host running the Gateway.
## Related
- [Remote access](/gateway/remote)
- [Tailscale](/gateway/tailscale)

230
docs/gateway/remote.md Normal file
View File

@@ -0,0 +1,230 @@
---
summary: "Remote access using Gateway WS, SSH tunnels, and tailnets"
read_when:
- Running or troubleshooting remote gateway setups
title: "Remote access"
---
OpenClaw runs one Gateway (the master) on a host and connects every client to it. The Gateway owns sessions, auth profiles, channels, and state; everything else is a client.
- **Operators** (you, or the macOS app): direct LAN/Tailnet WebSocket is simplest when the Gateway is reachable; SSH tunneling is the universal fallback.
- **Nodes** (iOS/Android and other devices): connect to the Gateway **WebSocket** (LAN/tailnet or SSH tunnel).
## The core idea
The Gateway WebSocket binds to **loopback** by default, on port `18789` (`gateway.port`). For remote use, either expose it through Tailscale Serve / a trusted LAN-Tailnet bind, or forward the loopback port over SSH.
## Topology options
| Setup | Where the Gateway runs | Best for |
| --------------------------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| Always-on Gateway in your tailnet | Persistent host (VPS or home server), reached via Tailscale or SSH | Laptops that sleep often but need the agent always-on. See [exe.dev](/install/exe-dev) (easy VM) or [Hetzner](/install/hetzner) (production VPS). |
| Home desktop | Desktop; laptop connects remotely via the macOS app's remote mode (Settings → Connection → OpenClaw runs) | Keeping the agent on hardware that stays powered on. Runbook: [macOS remote access](/platforms/mac/remote). |
| Laptop | Laptop, exposed safely via SSH tunnel or Tailscale Serve (keep `gateway.bind: "loopback"`) | Single-machine setups. See [Tailscale](/gateway/tailscale) and [Web](/web). |
For the always-on and laptop setups, prefer keeping `gateway.bind: "loopback"` and using **Tailscale Serve** for the Control UI, or a trusted LAN/Tailnet bind with `gateway.remote.transport: "direct"`. SSH tunnel is the fallback that works from any machine.
## Command flow (what runs where)
One Gateway owns state and channels; nodes are peripherals. Example (Telegram message routed to a node tool):
1. Telegram message arrives at the **Gateway**.
2. Gateway runs the **agent**, which decides whether to call a node tool.
3. Gateway calls the **node** over the Gateway WebSocket (`node.invoke` RPC).
4. Node returns the result; Gateway replies to Telegram.
Nodes do not run the Gateway service. Only one Gateway should run per host unless you intentionally run isolated profiles (see [Multiple gateways](/gateway/multiple-gateways)). macOS app "node mode" is just a node client over the Gateway WebSocket.
## SSH tunnel (CLI + tools)
```bash
ssh -N -L 18789:127.0.0.1:18789 user@gateway-host
```
With the tunnel up, `openclaw health` and `openclaw status --deep` reach the remote Gateway via `ws://127.0.0.1:18789`. `openclaw gateway status`, `openclaw gateway health`, `openclaw gateway probe`, and `openclaw gateway call` can also target a forwarded URL via `--url`.
<Note>
Replace `18789` with your configured `gateway.port` (or `--port` / `OPENCLAW_GATEWAY_PORT`).
</Note>
<Warning>
`--url` never falls back to config or environment credentials. Pass `--token` or `--password` explicitly; without them the client sends no credentials and the connection fails if the target Gateway requires auth.
</Warning>
## CLI remote defaults
Persist a remote target so CLI commands use it by default:
```json5
{
gateway: {
mode: "remote",
remote: {
url: "ws://127.0.0.1:18789",
token: "your-token",
},
},
}
```
When the Gateway is loopback-only, keep the URL at `ws://127.0.0.1:18789` and open the SSH tunnel first. In the macOS app's SSH-tunnel transport, the discovered Gateway hostname goes in `gateway.remote.sshTarget` (`user@host` or `user@host:port`); `gateway.remote.url` stays the local tunnel URL. If the remote port differs from the local one, set `gateway.remote.remotePort`.
Host-key verification is strict by default (`gateway.remote.sshHostKeyPolicy: "strict"`). Set it to `"openssh"` to delegate to your effective OpenSSH config instead; review your user and system SSH settings before enabling it.
For a Gateway already reachable on a trusted LAN or Tailnet, use direct mode:
```json5
{
gateway: {
mode: "remote",
remote: {
transport: "direct",
url: "ws://192.168.0.202:18789",
token: "your-token",
},
},
}
```
## Credential precedence
Gateway credential resolution follows one shared contract across call/probe/status paths and Discord exec-approval monitoring. Node-host uses the same contract with one local-mode exception (it ignores `gateway.remote.*`).
- Explicit credentials (`--token`, `--password`, or a tool's `gatewayToken`) always win on call paths that accept explicit auth.
- URL override safety:
- CLI `--url` never reuses implicit config/env credentials.
- Env `OPENCLAW_GATEWAY_URL` may use env credentials only (`OPENCLAW_GATEWAY_TOKEN` / `OPENCLAW_GATEWAY_PASSWORD`).
- Local mode defaults:
- token: `OPENCLAW_GATEWAY_TOKEN` -> `gateway.auth.token` -> `gateway.remote.token` (remote fallback only when the local token is unset)
- password: `OPENCLAW_GATEWAY_PASSWORD` -> `gateway.auth.password` -> `gateway.remote.password` (remote fallback only when the local password is unset)
- Remote mode defaults:
- token: `gateway.remote.token` -> `OPENCLAW_GATEWAY_TOKEN` -> `gateway.auth.token`
- password: `OPENCLAW_GATEWAY_PASSWORD` -> `gateway.remote.password` -> `gateway.auth.password`
- Node-host local-mode exception: `gateway.remote.token` / `gateway.remote.password` are ignored.
- Remote probe/status token checks are strict by default: they use `gateway.remote.token` only (no local token fallback) when targeting remote mode.
- Gateway env overrides use `OPENCLAW_GATEWAY_*` only.
## Chat UI remote access
WebChat has no separate HTTP port; the SwiftUI chat UI connects directly to the Gateway WebSocket.
- Forward `18789` over SSH (see above), then connect clients to `ws://127.0.0.1:18789`.
- For LAN/Tailnet direct mode, connect clients to the configured private `ws://` or secure `wss://` URL.
- On macOS, the app's remote mode manages the selected transport automatically.
## macOS app remote mode
The macOS menu bar app drives the same setup end-to-end: remote status checks, WebChat, and Voice Wake forwarding. Runbook: [macOS remote access](/platforms/mac/remote).
## Security rules (remote/VPN)
Keep the Gateway **loopback-only** unless you are sure you need a bind.
- **Loopback + SSH/Tailscale Serve** is the safest default (no public exposure).
- Plaintext `ws://` is accepted for loopback, private/LAN (RFC 1918), link-local, CGNAT, `.local`, and `.ts.net` hosts. Public remote hosts must use `wss://`.
- **Non-loopback binds** (`lan`/`tailnet`/`custom`, or `auto` when loopback is unavailable) must use Gateway auth: token, password, or an identity-aware reverse proxy with `gateway.auth.mode: "trusted-proxy"`.
- `gateway.remote.token` / `.password` are client credential sources; they do not configure server auth by themselves.
- Local call paths can use `gateway.remote.*` as a fallback only when `gateway.auth.*` is unset.
- If `gateway.auth.token` / `gateway.auth.password` is explicitly configured via SecretRef and unresolved, resolution fails closed (no remote fallback masking).
- `gateway.remote.tlsFingerprint` pins the remote TLS cert for `wss://`, including macOS direct mode. Without a stored pin, macOS only pins on first use after normal system trust passes; self-signed or private-CA Gateways need an explicit fingerprint or Remote over SSH.
- **Tailscale Serve** can authenticate Control UI/WebSocket traffic via identity headers when `gateway.auth.allowTailscale: true`. HTTP API endpoints do not use that header auth and instead follow the Gateway's normal HTTP auth mode. This tokenless flow assumes the Gateway host is trusted; set it to `false` for shared-secret auth everywhere.
- **Trusted-proxy** auth expects a non-loopback identity-aware proxy by default. Same-host loopback reverse proxies require explicit `gateway.auth.trustedProxy.allowLoopback = true`.
- Treat browser control like operator access: tailnet-only plus deliberate node pairing.
Deep dive: [Security](/gateway/security).
### macOS: persistent SSH tunnel via LaunchAgent
For macOS clients, the easiest persistent setup uses an SSH `LocalForward` config entry plus a LaunchAgent that keeps the tunnel alive across reboots and crashes.
#### Step 1: add SSH config
Edit `~/.ssh/config`:
```ssh
Host remote-gateway
HostName <REMOTE_IP>
User <REMOTE_USER>
LocalForward 18789 127.0.0.1:18789
IdentityFile ~/.ssh/id_rsa
```
Replace `<REMOTE_IP>` and `<REMOTE_USER>` with your values.
#### Step 2: copy SSH key (one-time)
```bash
ssh-copy-id -i ~/.ssh/id_rsa <REMOTE_USER>@<REMOTE_IP>
```
#### Step 3: configure the gateway token
```bash
openclaw config set gateway.remote.token "<your-token>"
```
Use `gateway.remote.password` instead if the remote Gateway uses password auth. `OPENCLAW_GATEWAY_TOKEN` is still valid as a shell-level override, but the durable remote-client setup is `gateway.remote.token` / `gateway.remote.password`.
#### Step 4: create the LaunchAgent
Save as `~/Library/LaunchAgents/ai.openclaw.ssh-tunnel.plist`:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>ai.openclaw.ssh-tunnel</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/ssh</string>
<string>-N</string>
<string>remote-gateway</string>
</array>
<key>KeepAlive</key>
<true/>
<key>RunAtLoad</key>
<true/>
</dict>
</plist>
```
#### Step 5: load the LaunchAgent
```bash
launchctl bootstrap gui/$UID ~/Library/LaunchAgents/ai.openclaw.ssh-tunnel.plist
```
The tunnel starts automatically at login, restarts on crash, and keeps the forwarded port live.
<Note>
If you have a leftover `com.openclaw.ssh-tunnel` LaunchAgent from an older setup, unload and delete it.
</Note>
#### Troubleshooting
```bash
# Check if the tunnel is running
ps aux | grep "ssh -N remote-gateway" | grep -v grep
lsof -i :18789
# Restart the tunnel
launchctl kickstart -k gui/$UID/ai.openclaw.ssh-tunnel
# Stop the tunnel
launchctl bootout gui/$UID/ai.openclaw.ssh-tunnel
```
| Config entry | What it does |
| ------------------------------------ | ------------------------------------------------------------ |
| `LocalForward 18789 127.0.0.1:18789` | Forwards local port 18789 to remote port 18789 |
| `ssh -N` | SSH without executing remote commands (port forwarding only) |
| `KeepAlive` | Restarts the tunnel automatically if it crashes |
| `RunAtLoad` | Starts the tunnel when the LaunchAgent loads at login |
## Related
- [Tailscale](/gateway/tailscale)
- [Authentication](/gateway/authentication)
- [Remote gateway setup](/gateway/remote-gateway-readme)

View File

@@ -0,0 +1,152 @@
---
summary: "Why a tool is blocked: sandbox runtime, tool allow/deny policy, and elevated exec gates"
title: Sandbox vs tool policy vs elevated
read_when: "You hit 'sandbox jail' or see a tool/elevated refusal and want the exact config key to change."
status: active
---
OpenClaw has three related but different controls:
1. **Sandbox** (`agents.defaults.sandbox.*` / `agents.list[].sandbox.*`) decides **where tools run** (sandbox backend vs host).
2. **Tool policy** (`tools.*`, `tools.sandbox.tools.*`, `agents.list[].tools.*`) decides **which tools are available/allowed**.
3. **Elevated** (`tools.elevated.*`, `agents.list[].tools.elevated.*`) is an **exec-only escape hatch** to run outside the sandbox when you are sandboxed (`gateway` by default, or `node` when the exec target is configured to `node`).
## Quick debug
Use the inspector to see what OpenClaw is _actually_ doing:
```bash
openclaw sandbox explain
openclaw sandbox explain --session agent:main:main
openclaw sandbox explain --agent work
openclaw sandbox explain --json
```
It prints:
- effective sandbox mode/scope/workspace access
- whether the session is currently sandboxed (main vs non-main)
- effective sandbox tool allow/deny (and whether it came from agent/global/default)
- elevated gates and fix-it key paths
## Sandbox: where tools run
Sandboxing is controlled by `agents.defaults.sandbox.mode`:
- `"off"`: everything runs on the host.
- `"non-main"`: only non-main sessions are sandboxed (common "surprise" for groups/channels).
- `"all"`: everything is sandboxed.
`agents.defaults.sandbox.workspaceAccess` controls what the sandbox can see: `"none"`, `"ro"`, or `"rw"`.
See [Sandboxing](/gateway/sandboxing) for the full matrix (scope, workspace mounts, images).
### Bind mounts (security quick check)
- `docker.binds` _pierces_ the sandbox filesystem: whatever you mount is visible inside the container with the mode you set (`:ro` or `:rw`).
- Default is read-write if you omit the mode; prefer `:ro` for source/secrets.
- `scope: "shared"` ignores per-agent binds (only global binds apply).
- OpenClaw validates bind sources twice: first on the normalized source path, then again after resolving through the deepest existing ancestor. Symlink-parent escapes do not bypass blocked-path or allowed-root checks.
- Non-existent leaf paths are still checked safely. If `/workspace/alias-out/new-file` resolves through a symlinked parent to a blocked path or outside the configured allowed roots, the bind is rejected.
- Binding `/var/run/docker.sock` effectively hands host control to the sandbox; only do this intentionally.
- Workspace access (`workspaceAccess`) is independent of bind modes.
## Tool policy: which tools exist/are callable
Two layers matter:
- **Tool profile**: `tools.profile` and `agents.list[].tools.profile` (base allowlist)
- **Provider tool profile**: `tools.byProvider[provider].profile` and `agents.list[].tools.byProvider[provider].profile`
- **Global/per-agent tool policy**: `tools.allow`/`tools.deny` and `agents.list[].tools.allow`/`agents.list[].tools.deny`
- **Provider tool policy**: `tools.byProvider[provider].allow/deny` and `agents.list[].tools.byProvider[provider].allow/deny`
- **Sandbox tool policy** (only applies when sandboxed): `tools.sandbox.tools.allow`/`tools.sandbox.tools.deny` and `agents.list[].tools.sandbox.tools.*`
Rules of thumb:
- `deny` always wins.
- If `allow` is non-empty, everything else is treated as blocked.
- Tool policy is the hard stop: `/exec` cannot override a denied `exec` tool.
- Tool policy filters tool availability by name; it does not inspect side effects inside `exec`. If `exec` is allowed, denying `write`, `edit`, or `apply_patch` does not make shell commands read-only.
- `/exec` only changes session defaults for authorized senders; it does not grant tool access.
- Provider tool keys accept either `provider` (e.g. `google-antigravity`) or `provider/model` (e.g. `openai/gpt-5.4`).
- Gateway logs include `agents/tool-policy` audit entries when a tool policy step removes tools or a sandbox tool policy blocks a call. Use `openclaw logs` to see the rule label, config key, and affected tool names.
### Tool groups (shorthands)
Tool policies (global, agent, sandbox) support `group:*` entries that expand to multiple tools:
```json5
{
tools: {
sandbox: {
tools: {
allow: ["group:runtime", "group:fs", "group:sessions", "group:memory"],
},
},
},
}
```
Available groups:
| Group | Tools |
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `group:runtime` | `exec`, `process`, `code_execution` (`bash` is accepted as an alias for `exec`) |
| `group:fs` | `read`, `write`, `edit`, `apply_patch` |
| `group:sessions` | `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `sessions_yield`, `subagents`, `session_status` |
| `group:memory` | `memory_search`, `memory_get` |
| `group:web` | `web_search`, `x_search`, `web_fetch` |
| `group:ui` | `browser`, `canvas` |
| `group:automation` | `heartbeat_respond`, `cron`, `gateway` |
| `group:messaging` | `message` |
| `group:nodes` | `nodes` |
| `group:agents` | `agents_list`, `get_goal`, `create_goal`, `update_goal`, `update_plan`, `skill_workshop` |
| `group:media` | `image`, `image_generate`, `music_generate`, `video_generate`, `tts` |
| `group:openclaw` | most built-in OpenClaw tools (excludes the `read`/`write`/`edit`/`apply_patch`/`exec`/`process` fs and runtime primitives, `canvas`, and provider plugins) |
| `group:plugins` | all loaded plugin-owned tools, including configured MCP servers exposed through `bundle-mcp` |
For read-only agents, deny `group:runtime` as well as mutating filesystem tools unless sandbox filesystem policy or a separate host boundary enforces the read-only constraint.
For sandboxed MCP servers, the sandbox tool policy is a second allow gate. If `mcp.servers` is configured but sandboxed turns only show built-in tools, add `bundle-mcp`, `group:plugins`, or a server-prefixed MCP tool name/glob such as `outlook__send_mail` or `outlook__*` to `tools.sandbox.tools.alsoAllow`, then restart/reload the gateway and recapture the tool list. Server globs use the provider-safe MCP server prefix: non-`[A-Za-z0-9_-]` characters become `-`, names that do not start with a letter get an `mcp-` prefix, and long or duplicate prefixes may be truncated or suffixed.
`openclaw doctor` currently checks this shape for OpenClaw-managed servers in `mcp.servers`. MCP servers loaded from bundled plugin manifests or Claude `.mcp.json` use the same sandbox gate, but this diagnostic does not enumerate those sources yet; use the same allowlist entries if their tools disappear in sandboxed turns.
## Elevated: exec-only "run on host"
Elevated does **not** grant extra tools; it only affects `exec`.
- If you are sandboxed, `/elevated on` (or `exec` with `elevated: true`) runs outside the sandbox (approvals may still apply).
- Use `/elevated full` to skip exec approvals for the session.
- If you are already running direct, elevated is effectively a no-op (still gated).
- Elevated is **not** skill-scoped and does **not** override tool allow/deny.
- Elevated does not grant arbitrary cross-host overrides from `host=auto`; it follows the normal exec target rules and only preserves `node` when the configured/session target is already `node`.
- `/exec` is separate from elevated. It only adjusts per-session exec defaults for authorized senders.
Gates:
- Enablement: `tools.elevated.enabled` (and optionally `agents.list[].tools.elevated.enabled`)
- Sender allowlists: `tools.elevated.allowFrom.<provider>` (and optionally `agents.list[].tools.elevated.allowFrom.<provider>`)
See [Elevated Mode](/tools/elevated).
## Common "sandbox jail" fixes
### "Tool X blocked by sandbox tool policy"
Fix-it keys (pick one):
- Disable sandbox: `agents.defaults.sandbox.mode=off` (or per-agent `agents.list[].sandbox.mode=off`)
- Allow the tool inside sandbox:
- remove it from `tools.sandbox.tools.deny` (or per-agent `agents.list[].tools.sandbox.tools.deny`)
- or add it to `tools.sandbox.tools.allow` (or per-agent allow)
- Check `openclaw logs` for the `agents/tool-policy` entry. It records the sandbox mode and whether the allow or deny rule blocked the tool.
### "I thought this was main, why is it sandboxed?"
In `"non-main"` mode, group/channel keys are _not_ main. Use the main session key (shown by `sandbox explain`) or switch mode to `"off"`.
## Related
- [Sandboxing](/gateway/sandboxing) -- full sandbox reference (modes, scopes, backends, images)
- [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) -- per-agent overrides and precedence
- [Elevated Mode](/tools/elevated)

393
docs/gateway/sandboxing.md Normal file
View File

@@ -0,0 +1,393 @@
---
summary: "How OpenClaw sandboxing works: modes, scopes, workspace access, and images"
title: "Sandboxing"
sidebarTitle: "Sandboxing"
read_when: "You want a dedicated explanation of sandboxing or need to tune agents.defaults.sandbox."
status: active
---
OpenClaw can run tool execution inside a sandbox backend to reduce blast radius. Sandboxing is off by default and controlled by `agents.defaults.sandbox` (global) or `agents.list[].sandbox` (per-agent). The Gateway process always stays on the host; only tool execution moves into the sandbox when enabled.
<Note>
This is not a perfect security boundary, but it materially limits filesystem and process access when the model does something dumb.
</Note>
## What gets sandboxed
- Tool execution: `exec`, `read`, `write`, `edit`, `apply_patch`, `process`, etc.
- The optional sandboxed browser (`agents.defaults.sandbox.browser`).
Not sandboxed:
- The Gateway process itself.
- Any tool explicitly allowed to run outside the sandbox via `tools.elevated`. Elevated exec bypasses sandboxing and runs on the configured escape path (`gateway` by default, or `node` when the exec target is `node`). If sandboxing is off, `tools.elevated` changes nothing since exec already runs on the host. See [Elevated Mode](/tools/elevated).
## Modes, scope, and backend
Three independent settings control sandbox behavior:
| Setting | Key | Values | Default |
| ------- | --------------------------------- | ---------------------------- | -------- |
| Mode | `agents.defaults.sandbox.mode` | `off`, `non-main`, `all` | `off` |
| Scope | `agents.defaults.sandbox.scope` | `agent`, `session`, `shared` | `agent` |
| Backend | `agents.defaults.sandbox.backend` | `docker`, `ssh`, `openshell` | `docker` |
**Mode** controls when sandboxing applies:
- `off`: no sandboxing.
- `non-main`: sandbox every session except the agent's main session. The main session key is always `agent:<agentId>:main` (or `global` when `session.scope` is `"global"`); it is not configurable. Group/channel sessions use their own keys, so they always count as non-main and get sandboxed.
- `all`: every session runs in a sandbox.
**Scope** controls how many containers/environments are created:
- `agent`: one container per agent.
- `session`: one container per session.
- `shared`: one container shared by all sandboxed sessions (per-agent `docker`/`ssh`/`browser` overrides are ignored under this scope).
**Backend** controls which runtime executes sandboxed tools. SSH-specific config lives under `agents.defaults.sandbox.ssh`; OpenShell-specific config lives under `plugins.entries.openshell.config`.
| | Docker | SSH | OpenShell |
| ------------------- | -------------------------------- | ------------------------------ | --------------------------------------------------- |
| **Where it runs** | Local container | Any SSH-accessible host | OpenShell managed sandbox |
| **Setup** | `scripts/sandbox-setup.sh` | SSH key + target host | OpenShell plugin enabled |
| **Workspace model** | Bind-mount or copy | Remote-canonical (seed once) | `mirror` or `remote` |
| **Network control** | `docker.network` (default: none) | Depends on remote host | Depends on OpenShell |
| **Browser sandbox** | Supported | Not supported | Not supported yet |
| **Bind mounts** | `docker.binds` | N/A | N/A |
| **Best for** | Local dev, full isolation | Offloading to a remote machine | Managed remote sandboxes with optional two-way sync |
## Docker backend
Docker is the default backend once sandboxing is enabled. It runs tools and sandbox browsers locally through the Docker daemon socket (`/var/run/docker.sock`); isolation comes from Docker namespaces.
Defaults: `network: "none"` (no egress), `readOnlyRoot: true`, `capDrop: ["ALL"]`, image `openclaw-sandbox:bookworm-slim`.
To expose host GPUs, set `agents.defaults.sandbox.docker.gpus` (or the per-agent override) to a value like `"all"` or `"device=GPU-uuid"`. This is passed to Docker's `--gpus` flag and requires a compatible host runtime such as NVIDIA Container Toolkit.
<Warning>
**Docker-out-of-Docker (DooD) constraints**
If you deploy the OpenClaw Gateway itself as a Docker container, it orchestrates sibling sandbox containers using the host's Docker socket (DooD). This introduces a path mapping constraint:
- **Config requires host paths**: `openclaw.json` `workspace` must contain the **host's absolute path** (e.g. `/home/user/.openclaw/workspaces`), not the internal Gateway container path. The Docker daemon evaluates paths relative to the host OS namespace, not the Gateway's own namespace.
- **Matching volume map required**: The Gateway process also writes heartbeat and bridge files to that `workspace` path. Give the Gateway container an identical volume map (`-v /home/user/.openclaw:/home/user/.openclaw`) so the same host path resolves correctly from inside the Gateway container too. Mismatched mappings surface as `EACCES` when the Gateway tries to write its heartbeat.
- **Codex code mode**: when an OpenClaw sandbox is active, OpenClaw disables Codex app-server native Code Mode, user MCP servers, and app-backed plugin execution for that turn (those run from the Gateway-host app-server process, not the OpenClaw sandbox backend), unless the sandbox tool policy exposes the required tools and you opt into the experimental sandbox exec-server path. Shell access then routes through OpenClaw sandbox-backed tools such as `sandbox_exec` and `sandbox_process`. Do not mount the host Docker socket into agent sandbox containers or custom Codex sandboxes. See [Codex Harness](/plugins/codex-harness) for the full behavior.
On Ubuntu/AppArmor hosts with Docker sandbox mode enabled, Codex app-server `workspace-write` shell execution needs unprivileged user namespaces inside the sandbox container, and this can fail before shell startup when the service user cannot create them. This needs an unprivileged network namespace too when Docker sandbox egress is disabled (`network: "none"`, the default). Common symptoms: `bwrap: setting up uid map: Permission denied` and `bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted`. Run `openclaw doctor`; if it reports a Codex bwrap namespace probe failure, prefer an AppArmor profile that grants the required namespaces to the OpenClaw service process. `kernel.apparmor_restrict_unprivileged_userns=0` is a host-wide fallback with security tradeoffs; use it only when that host posture is acceptable.
</Warning>
### Sandboxed browser
- The sandbox browser auto-starts (ensures CDP is reachable) when the browser tool needs it. Configure via `agents.defaults.sandbox.browser.autoStart` (default `true`) and `autoStartTimeoutMs` (default 12s).
- Sandbox browser containers use a dedicated Docker network (`openclaw-sandbox-browser`) instead of the global `bridge` network. Configure with `agents.defaults.sandbox.browser.network`.
- `agents.defaults.sandbox.browser.cdpSourceRange` restricts container-edge CDP ingress with a CIDR allowlist (for example `172.21.0.1/32`).
- noVNC observer access is password-protected by default; OpenClaw emits a short-lived token URL that serves a local bootstrap page and opens noVNC with the password in the URL fragment (not query string or header logs).
- `agents.defaults.sandbox.browser.allowHostControl` (default `false`) lets sandboxed sessions target the host browser explicitly.
- Optional allowlists gate `target: "custom"`: `allowedControlUrls`, `allowedControlHosts`, `allowedControlPorts`.
## SSH backend
Use `backend: "ssh"` to sandbox `exec`, file tools, and media reads on an arbitrary SSH-accessible machine.
```json5
{
agents: {
defaults: {
sandbox: {
mode: "all",
backend: "ssh",
scope: "session",
workspaceAccess: "rw",
ssh: {
target: "user@gateway-host:22",
workspaceRoot: "/tmp/openclaw-sandboxes",
strictHostKeyChecking: true,
updateHostKeys: true,
identityFile: "~/.ssh/id_ed25519",
certificateFile: "~/.ssh/id_ed25519-cert.pub",
knownHostsFile: "~/.ssh/known_hosts",
// Or use SecretRefs / inline contents instead of local files:
// identityData: { source: "env", provider: "default", id: "SSH_IDENTITY" },
// certificateData: { source: "env", provider: "default", id: "SSH_CERTIFICATE" },
// knownHostsData: { source: "env", provider: "default", id: "SSH_KNOWN_HOSTS" },
},
},
},
},
}
```
Defaults: `command: "ssh"`, `workspaceRoot: "/tmp/openclaw-sandboxes"`, `strictHostKeyChecking: true`, `updateHostKeys: true`.
- **Lifecycle**: OpenClaw creates a per-scope remote root under `sandbox.ssh.workspaceRoot`. On first use after create or recreate, it seeds that remote workspace from the local workspace once. After that, `exec`, `read`, `write`, `edit`, `apply_patch`, prompt media reads, and inbound media staging run directly against the remote workspace over SSH. OpenClaw does not sync remote changes back to the local workspace automatically.
- **Authentication material**: `identityFile`/`certificateFile`/`knownHostsFile` reference existing local files. `identityData`/`certificateData`/`knownHostsData` accept inline strings or SecretRefs, resolved through the normal secrets runtime snapshot, written to temp files with mode `0600`, and deleted when the SSH session ends. If both a `*File` and `*Data` variant are set for the same item, `*Data` wins for that session.
- **Remote-canonical consequences**: the remote SSH workspace becomes the real sandbox state after the initial seed. Host-local edits made outside OpenClaw after the seed step are not visible remotely until you recreate the sandbox. `openclaw sandbox recreate` deletes the per-scope remote root and seeds again from local on next use. Browser sandboxing is not supported on this backend, and `sandbox.docker.*` settings do not apply to it.
## OpenShell backend
Use `backend: "openshell"` to sandbox tools in an OpenShell-managed remote environment. OpenShell reuses the same SSH transport and remote filesystem bridge as the generic SSH backend, and adds OpenShell lifecycle (`sandbox create/get/delete/ssh-config`) plus an optional `mirror` workspace sync mode.
```json5
{
agents: {
defaults: {
sandbox: {
mode: "all",
backend: "openshell",
scope: "session",
workspaceAccess: "rw",
},
},
},
plugins: {
entries: {
openshell: {
enabled: true,
config: {
from: "openclaw",
mode: "remote", // mirror | remote
},
},
},
},
}
```
`mode: "mirror"` (default) keeps the local workspace canonical: OpenClaw syncs local into the sandbox before `exec` and syncs back after. `mode: "remote"` seeds the remote workspace once from local, then runs `exec`/`read`/`write`/`edit`/`apply_patch` directly against the remote workspace without syncing back; local edits after the seed are invisible until you `openclaw sandbox recreate`. Under `scope: "agent"` or `scope: "shared"`, that remote workspace is shared at the same scope. Current limitations: sandbox browser isn't supported yet, and `sandbox.docker.binds` doesn't apply to this backend.
`openclaw sandbox list`/`recreate`/prune all treat OpenShell runtimes the same as Docker runtimes; prune logic is backend-aware.
For the full prerequisites, configuration reference, workspace-mode comparison, and lifecycle details, see [OpenShell](/gateway/openshell).
## Workspace access
`agents.defaults.sandbox.workspaceAccess` controls what the sandbox can see:
| Value | Behavior |
| ---------------- | ----------------------------------------------------------------------------------------- |
| `none` (default) | Tools see an isolated sandbox workspace under `~/.openclaw/sandboxes`. |
| `ro` | Mounts the agent workspace read-only at `/agent` (disables `write`/`edit`/`apply_patch`). |
| `rw` | Mounts the agent workspace read/write at `/workspace`. |
With the OpenShell backend, `mirror` mode still uses the local workspace as the canonical source between exec turns, `remote` mode uses the remote OpenShell workspace as canonical after the initial seed, and `workspaceAccess: "ro"`/`"none"` still restrict write behavior the same way.
Inbound media is copied into the active sandbox workspace (`media/inbound/*`).
<Note>
**Skills**: the `read` tool is sandbox-rooted. With `workspaceAccess: "none"`, OpenClaw mirrors eligible skills into the sandbox workspace (`.../skills`) so they can be read. With `"rw"`, workspace skills are readable from `/workspace/skills`, and eligible managed, bundled, or plugin skills are materialized into the generated read-only path `/workspace/.openclaw/sandbox-skills/skills`.
</Note>
## Custom bind mounts
`agents.defaults.sandbox.docker.binds` mounts additional host directories into the container. Format: `host:container:mode` (e.g., `"/home/user/source:/source:rw"`).
Global and per-agent binds are merged (not replaced). Under `scope: "shared"`, per-agent binds are ignored.
`agents.defaults.sandbox.browser.binds` mounts additional host directories into the **sandbox browser** container only. When set (including `[]`), it replaces `docker.binds` for the browser container; when omitted, the browser container falls back to `docker.binds`.
```json5
{
agents: {
defaults: {
sandbox: {
docker: {
binds: ["/home/user/source:/source:ro", "/var/data/myapp:/data:ro"],
},
},
},
list: [
{
id: "build",
sandbox: {
docker: {
binds: ["/mnt/cache:/cache:rw"],
},
},
},
],
},
}
```
<Warning>
**Bind security**
- Binds bypass the sandbox filesystem: they expose host paths with whatever mode you set (`:ro` or `:rw`).
- OpenClaw blocks dangerous bind sources by default: system paths (`/etc`, `/proc`, `/sys`, `/dev`, `/root`, `/boot`), Docker socket directories (`/run`, `/var/run`, and their `docker.sock` variants), and common home-directory credential roots (`~/.aws`, `~/.cargo`, `~/.config`, `~/.docker`, `~/.gnupg`, `~/.netrc`, `~/.npm`, `~/.ssh`).
- Validation normalizes the source path, then resolves it again through the deepest existing ancestor before re-checking blocked paths and allowed roots, so symlink-parent escapes fail closed even when the final leaf doesn't exist yet (e.g. `/workspace/run-link/new-file` still resolves as `/var/run/...` if `run-link` points there).
- Bind targets that shadow the reserved container mount points (`/workspace`, `/agent`) are also blocked by default; override with `agents.defaults.sandbox.docker.dangerouslyAllowReservedContainerTargets: true`.
- Bind sources outside the workspace/agent-workspace allowlisted roots are blocked by default; override with `agents.defaults.sandbox.docker.dangerouslyAllowExternalBindSources: true`. Allowed roots are canonicalized the same way, so a path that only looks inside the allowlist before symlink resolution is still rejected as outside allowed roots.
- Sensitive mounts (secrets, SSH keys, service credentials) should be `:ro` unless absolutely required.
- Combine with `workspaceAccess: "ro"` if you only need read access to the workspace; bind modes stay independent.
- See [Sandbox vs Tool Policy vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated) for how binds interact with tool policy and elevated exec.
</Warning>
## Images and setup
Default Docker image: `openclaw-sandbox:bookworm-slim`
<Note>
**Source checkout vs npm install**
The `scripts/sandbox-setup.sh`, `scripts/sandbox-common-setup.sh`, and `scripts/sandbox-browser-setup.sh` helper scripts are only available when running from a [source checkout](https://github.com/openclaw/openclaw). They are not included in the npm package.
If you installed OpenClaw via `npm install -g openclaw`, use the inline `docker build` commands shown below instead.
</Note>
<Steps>
<Step title="Build the default image">
From a source checkout:
```bash
scripts/sandbox-setup.sh
```
From an npm install (no source checkout needed):
```bash
docker build -t openclaw-sandbox:bookworm-slim - <<'DOCKERFILE'
FROM debian:bookworm-slim
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
bash ca-certificates curl git jq python3 ripgrep \
&& rm -rf /var/lib/apt/lists/*
RUN useradd --create-home --shell /bin/bash sandbox
USER sandbox
WORKDIR /home/sandbox
CMD ["sleep", "infinity"]
DOCKERFILE
```
The default image does **not** include Node. If a skill needs Node (or other runtimes), either bake a custom image or install via `sandbox.docker.setupCommand` (requires network egress + writable root + root user).
OpenClaw does not silently substitute plain `debian:bookworm-slim` when `openclaw-sandbox:bookworm-slim` is missing. Sandbox runs that target the default image fail fast with a build instruction until you build it, because the bundled image carries `python3` for the sandbox write/edit helpers.
</Step>
<Step title="Optional: build the common image">
For a more functional sandbox image with common tooling (for example `curl`, `jq`, Node 24, pnpm, `python3`, and `git`):
From a source checkout:
```bash
scripts/sandbox-common-setup.sh
```
From an npm install, build the default image first (see above), then build the common image on top using [`scripts/docker/sandbox/Dockerfile.common`](https://github.com/openclaw/openclaw/blob/main/scripts/docker/sandbox/Dockerfile.common) from the repository.
Then set `agents.defaults.sandbox.docker.image` to `openclaw-sandbox-common:bookworm-slim`.
</Step>
<Step title="Optional: build the sandbox browser image">
From a source checkout:
```bash
scripts/sandbox-browser-setup.sh
```
From an npm install, build using [`scripts/docker/sandbox/Dockerfile.browser`](https://github.com/openclaw/openclaw/blob/main/scripts/docker/sandbox/Dockerfile.browser) from the repository.
</Step>
</Steps>
By default, Docker sandbox containers run with **no network**. Override with `agents.defaults.sandbox.docker.network`.
<AccordionGroup>
<Accordion title="Sandbox browser Chromium defaults">
The bundled sandbox browser image applies conservative Chromium startup flags for containerized workloads:
- `--remote-debugging-address=127.0.0.1`
- `--remote-debugging-port=<derived from OPENCLAW_BROWSER_CDP_PORT>`
- `--user-data-dir=${HOME}/.chrome`
- `--no-first-run`
- `--no-default-browser-check`
- `--disable-dev-shm-usage`
- `--disable-background-networking`
- `--disable-breakpad`
- `--disable-crash-reporter`
- `--no-zygote`
- `--metrics-recording-only`
- `--password-store=basic`
- `--use-mock-keychain`
- `--headless=new` when `browser.headless` is enabled.
- `--no-sandbox --disable-setuid-sandbox` when `browser.noSandbox` is enabled.
- `--disable-3d-apis`, `--disable-gpu`, `--disable-software-rasterizer` by default; these graphics-hardening flags help containers without GPU support. Set `OPENCLAW_BROWSER_DISABLE_GRAPHICS_FLAGS=0` if your workload needs WebGL or other 3D features.
- `--disable-extensions` by default; set `OPENCLAW_BROWSER_DISABLE_EXTENSIONS=0` for extension-reliant flows.
- `--renderer-process-limit=2` by default; controlled by `OPENCLAW_BROWSER_RENDERER_PROCESS_LIMIT=<N>`, where `0` keeps Chromium's default.
If you need a different runtime profile, use a custom browser image and provide your own entrypoint. For local (non-container) Chromium profiles, use `browser.extraArgs` to append additional startup flags.
</Accordion>
<Accordion title="Network security defaults">
- `network: "host"` is blocked.
- `network: "container:<id>"` is blocked by default (namespace join bypass risk).
- Break-glass override: `agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin: true`.
</Accordion>
</AccordionGroup>
Docker installs and the containerized gateway live here: [Docker](/install/docker)
For Docker gateway deployments, `scripts/docker/setup.sh` can bootstrap sandbox config. Set `OPENCLAW_SANDBOX=1` (or `true`/`yes`/`on`) to enable that path. Override the socket location with `OPENCLAW_DOCKER_SOCKET`. Full setup and env reference: [Docker](/install/docker#agent-sandbox).
## setupCommand (one-time container setup)
`setupCommand` runs **once** after the sandbox container is created (not on every run). It executes inside the container via `sh -lc`.
Paths:
- Global: `agents.defaults.sandbox.docker.setupCommand`
- Per-agent: `agents.list[].sandbox.docker.setupCommand`
<AccordionGroup>
<Accordion title="Common pitfalls">
- Default `docker.network` is `"none"` (no egress), so package installs will fail.
- `docker.network: "container:<id>"` requires `dangerouslyAllowContainerNamespaceJoin: true` and is break-glass only.
- `readOnlyRoot: true` prevents writes; set `readOnlyRoot: false` or bake a custom image.
- `user` must be root for package installs (omit `user` or set `user: "0:0"`).
- Sandbox exec does **not** inherit host `process.env`. Use `agents.defaults.sandbox.docker.env` (or a custom image) for skill API keys.
- Values in `agents.defaults.sandbox.docker.env` are passed as explicit Docker container environment variables. Anyone with Docker daemon access can inspect them with Docker metadata commands such as `docker inspect`. Use a custom image, mounted secret file, or another secret delivery path if that metadata exposure is not acceptable.
</Accordion>
</AccordionGroup>
## Tool policy and escape hatches
Tool allow/deny policies still apply before sandbox rules. If a tool is denied globally or per-agent, sandboxing doesn't bring it back.
`tools.elevated` is an explicit escape hatch that runs `exec` outside the sandbox (`gateway` by default, or `node` when the exec target is `node`). `/exec` directives only apply for authorized senders and persist per session; to hard-disable `exec`, use tool policy deny (see [Sandbox vs Tool Policy vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated)).
Debugging:
- `openclaw sandbox list` shows sandbox containers, status, image match, age, idle time, and associated session/agent.
- `openclaw sandbox explain [--session <key>] [--agent <id>]` inspects effective sandbox mode, tool policy, and fix-it config keys.
- `openclaw sandbox recreate [--all | --session <key> | --agent <id>] [--browser] [--force]` removes containers/environments so they get recreated with current config on next use.
- See [Sandbox vs Tool Policy vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated) for the "why is this blocked?" mental model.
## Multi-agent overrides
Each agent can override sandbox + tools: `agents.list[].sandbox` and `agents.list[].tools` (plus `agents.list[].tools.sandbox.tools` for sandbox tool policy). See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for precedence.
## Minimal enable example
```json5
{
agents: {
defaults: {
sandbox: {
mode: "non-main",
scope: "session",
workspaceAccess: "none",
},
},
},
}
```
## Related
- [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) -- per-agent overrides and precedence
- [OpenShell](/gateway/openshell) -- managed sandbox backend setup, workspace modes, and config reference
- [Sandbox configuration](/gateway/config-agents#agentsdefaultssandbox)
- [Sandbox vs Tool Policy vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated) -- debugging "why is this blocked?"
- [Security](/gateway/security)

View File

@@ -0,0 +1,147 @@
---
summary: "Contract for `secrets apply` plans: target validation, path matching, and `auth-profiles.json` target scope"
read_when:
- Generating or reviewing `openclaw secrets apply` plans
- Debugging `Invalid plan target path` errors
- Understanding target type and path validation behavior
title: "Secrets apply plan contract"
---
This page defines the strict contract enforced by `openclaw secrets apply`. If a target does not match these rules, apply fails before mutating any file.
## Plan file shape
`openclaw secrets apply --from <plan.json>` expects a `targets` array of plan targets:
```json5
{
version: 1,
protocolVersion: 1,
targets: [
{
type: "models.providers.apiKey",
path: "models.providers.openai.apiKey",
pathSegments: ["models", "providers", "openai", "apiKey"],
providerId: "openai",
ref: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
},
{
type: "auth-profiles.api_key.key",
path: "profiles.openai:default.key",
pathSegments: ["profiles", "openai:default", "key"],
agentId: "main",
ref: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
},
],
}
```
`openclaw secrets configure` generates plans in this shape. You can also hand-write or edit one.
## Provider upserts and deletes
Plans may also include two optional top-level fields that mutate the `secrets.providers` map alongside the per-target writes:
- `providerUpserts` -- an object keyed by provider alias. Each value is a provider definition (the same shape accepted under `secrets.providers.<alias>` in `openclaw.json`, e.g. an `exec` or `file` provider).
- `providerDeletes` -- an array of provider aliases to remove.
`providerUpserts` runs before `targets`, so a `target.ref.provider` may reference a provider alias that the same plan introduces in `providerUpserts`. Without this ordering, plans that reference an alias not yet configured in `openclaw.json` fail with `provider "<alias>" is not configured`.
```json5
{
version: 1,
protocolVersion: 1,
providerUpserts: {
onepassword_anthropic: {
source: "exec",
command: "/usr/bin/op",
args: ["read", "op://Vault/Anthropic/credential"],
},
},
providerDeletes: ["legacy_unused_alias"],
targets: [
{
type: "models.providers.apiKey",
path: "models.providers.anthropic.apiKey",
pathSegments: ["models", "providers", "anthropic", "apiKey"],
providerId: "anthropic",
ref: { source: "exec", provider: "onepassword_anthropic", id: "credential" },
},
],
}
```
Exec providers introduced via `providerUpserts` are still subject to the exec consent rules in [Exec provider consent behavior](#exec-provider-consent-behavior): plans containing exec providers require `--allow-exec` in write mode.
## Supported target scope
Plan targets are accepted for supported credential paths in [SecretRef Credential Surface](/reference/secretref-credential-surface).
## Target type behavior
`target.type` must be a recognized target type, and the normalized `target.path` must match that type's registered path shape.
Some target types accept a compatibility alias as `target.type` for existing plans, in addition to their canonical type name:
| Canonical type | Accepted alias |
| ------------------------------------ | ----------------------------------------------- |
| `models.providers.apiKey` | `models.providers.*.apiKey` |
| `skills.entries.apiKey` | `skills.entries.*.apiKey` |
| `channels.googlechat.serviceAccount` | `channels.googlechat.accounts.*.serviceAccount` |
## Path validation rules
Each target is validated with all of the following:
- `type` must be a recognized target type.
- `path` must be a non-empty dot path.
- `pathSegments` can be omitted. If provided, it must normalize to exactly the same path as `path`.
- Forbidden segments are rejected: `__proto__`, `prototype`, `constructor`.
- The normalized path must match the registered path shape for the target type.
- If `providerId` or `accountId` is set, it must match the id encoded in the path.
- `auth-profiles.json` targets require `agentId`.
- When creating a new `auth-profiles.json` mapping, include `authProfileProvider`.
## Failure behavior
If a target fails validation, apply exits with an error like:
```text
Invalid plan target path for models.providers.apiKey: models.providers.openai.baseUrl
```
No writes are committed for an invalid plan: target resolution and path validation run before any file is touched. Separately, once a valid plan starts writing, apply snapshots every touched file first and restores those snapshots if a later write in the same run fails, so a partial write never leaves config, auth-profile, or env state out of sync.
## Exec provider consent behavior
- `--dry-run` skips exec SecretRef checks by default.
- Plans containing exec SecretRefs/providers are rejected in write mode unless `--allow-exec` is set.
- When validating/applying exec-containing plans, pass `--allow-exec` in both dry-run and write commands.
## Runtime and audit scope notes
- Ref-only `auth-profiles.json` entries (`keyRef`/`tokenRef`) are included in runtime credential resolution and audit coverage.
- `secrets apply` writes supported `openclaw.json` targets, supported `auth-profiles.json` targets, and three optional scrub passes, each on by default: `scrubEnv` (removes migrated plaintext values from `.env`), `scrubAuthProfilesForProviderTargets` (clears plaintext/unused-ref residue in `auth-profiles.json` for providers a plan just migrated), and `scrubLegacyAuthJson` (drops migrated `api_key` entries from legacy `auth.json` stores). Set any of `options.scrubEnv`, `options.scrubAuthProfilesForProviderTargets`, `options.scrubLegacyAuthJson` to `false` in the plan to skip that pass.
## Operator checks
```bash
# Validate plan without writes
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --dry-run
# Then apply for real
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json
# For exec-containing plans, opt in explicitly in both modes
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --dry-run --allow-exec
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --allow-exec
```
If apply fails with an invalid target path message, regenerate the plan with `openclaw secrets configure` or fix the target path to a supported shape above.
## Related docs
- [Secrets Management](/gateway/secrets)
- [CLI `secrets`](/cli/secrets)
- [SecretRef Credential Surface](/reference/secretref-credential-surface)
- [Configuration Reference](/gateway/configuration-reference)

732
docs/gateway/secrets.md Normal file
View File

@@ -0,0 +1,732 @@
---
summary: "Secrets management: SecretRef contract, runtime snapshot behavior, and safe one-way scrubbing"
read_when:
- Configuring SecretRefs for provider credentials and `auth-profiles.json` refs
- Operating secrets reload, audit, configure, and apply safely in production
- Understanding startup fail-fast, inactive-surface filtering, and last-known-good behavior
title: "Secrets management"
sidebarTitle: "Secrets management"
---
OpenClaw supports additive SecretRefs so supported credentials do not need to live as plaintext in configuration.
<Note>
Plaintext still works. SecretRefs are opt-in per credential.
</Note>
<Warning>
Plaintext credentials remain agent-readable if they sit in files the agent can inspect, including `openclaw.json`, `auth-profiles.json`, `.env`, or generated `agents/*/agent/models.json` files. SecretRefs only reduce that local blast radius once every supported credential is migrated and `openclaw secrets audit --check` reports no plaintext residue.
</Warning>
## Runtime model
- Secrets resolve into an in-memory runtime snapshot, eagerly during activation, not lazily on request paths.
- Startup fails fast when an effectively active SecretRef cannot be resolved.
- Reload is an atomic swap: full success, or keep the last-known-good snapshot.
- Policy violations (for example an OAuth-mode auth profile combined with SecretRef input) fail activation before the runtime swap.
- Runtime requests read only the active in-memory snapshot. Outbound delivery paths (Discord reply/thread delivery, Telegram action sends) also read that snapshot and do not re-resolve refs per send.
This keeps secret-provider outages off hot request paths.
## Agent-access boundary
SecretRefs stop credentials from being persisted in config and generated model files, but they are not a process-isolation boundary. A plaintext credential left on disk in a path the agent can read is still readable via file or shell tools, bypassing API-level redaction.
For production deployments where agent-accessible files are in scope, treat migration as complete only when all of these hold:
- Supported credentials use SecretRefs instead of plaintext values.
- Legacy plaintext residue is scrubbed from `openclaw.json`, `auth-profiles.json`, `.env`, and generated `models.json` files.
- `openclaw secrets audit --check` is clean after migration.
- Any remaining unsupported or rotating credentials are protected by OS isolation, container isolation, or an external credential proxy.
This is why the audit/configure/apply workflow is a security migration gate, not just a convenience helper.
<Warning>
SecretRefs do not make arbitrary readable files safe. Backups, copied configs, old generated model catalogs, and unsupported credential classes stay production secrets until deleted, moved outside the agent trust boundary, or isolated separately.
</Warning>
## Active-surface filtering
SecretRefs are validated only on effectively active surfaces:
- **Enabled surfaces**: unresolved refs block startup/reload.
- **Inactive surfaces**: unresolved refs do not block startup/reload; they emit a non-fatal `SECRETS_REF_IGNORED_INACTIVE_SURFACE` diagnostic.
<Accordion title="Examples of inactive surfaces">
- Disabled channel/account entries.
- Top-level channel credentials that no enabled account inherits.
- Disabled tool/feature surfaces.
- Web search provider-specific keys not selected by `tools.web.search.provider`. In auto mode (provider unset), keys are consulted by precedence for auto-detection until one resolves; after selection, non-selected provider keys are inactive.
- Sandbox SSH auth material (`agents.defaults.sandbox.ssh.identityData`, `certificateData`, `knownHostsData`, plus per-agent overrides) is active only when the effective sandbox backend is `ssh` and sandbox mode is not `off`, for the default agent or an enabled agent.
- `gateway.remote.token` / `gateway.remote.password` SecretRefs are active if any of these hold:
- `gateway.mode=remote`
- `gateway.remote.url` is configured
- `gateway.tailscale.mode` is `serve` or `funnel`
- In local mode without those remote surfaces: `gateway.remote.token` is active when token auth can win and no env/auth token is configured; `gateway.remote.password` is active only when password auth can win and no env/auth password is configured.
- `gateway.auth.token` SecretRef is inactive for startup auth resolution when `OPENCLAW_GATEWAY_TOKEN` is set, because env token input wins for that runtime.
</Accordion>
## Gateway auth surface diagnostics
When a SecretRef is set on `gateway.auth.token`, `gateway.auth.password`, `gateway.remote.token`, or `gateway.remote.password`, gateway startup/reload logs the surface state under code `SECRETS_GATEWAY_AUTH_SURFACE`:
- `active`: the SecretRef is part of the effective auth surface and must resolve.
- `inactive`: another auth surface wins, or remote auth is disabled/not active.
The log entry includes the reason the active-surface policy used.
## Onboarding reference preflight
In interactive onboarding, choosing SecretRef storage runs preflight validation before saving:
- Env refs: validates the env var name and confirms a non-empty value is visible during setup.
- Provider refs (`file` or `exec`): validates provider selection, resolves `id`, and checks the resolved value type.
- Quickstart flow: when `gateway.auth.token` is already a SecretRef, onboarding resolves it before probe/dashboard bootstrap (for `env`, `file`, and `exec` refs) using the same fail-fast gate.
Validation failure shows the error and lets you retry.
## SecretRef contract
One object shape everywhere:
```json5
{ source: "env" | "file" | "exec", provider: "default", id: "..." }
```
<Tabs>
<Tab title="env">
```json5
{ source: "env", provider: "default", id: "OPENAI_API_KEY" }
```
Shorthand strings are also accepted on SecretInput fields:
```json5
"${OPENAI_API_KEY}"
"$OPENAI_API_KEY"
```
Validation:
- `provider` must match `^[a-z][a-z0-9_-]{0,63}$`
- `id` must match `^[A-Z][A-Z0-9_]{0,127}$`
</Tab>
<Tab title="file">
```json5
{ source: "file", provider: "filemain", id: "/providers/openai/apiKey" }
```
Validation:
- `provider` must match `^[a-z][a-z0-9_-]{0,63}$`
- `id` must be an absolute JSON pointer (`/...`), or the literal `value` for `singleValue` providers
- RFC 6901 escaping in segments: `~` becomes `~0`, `/` becomes `~1`
</Tab>
<Tab title="exec">
```json5
{ source: "exec", provider: "vault", id: "providers/openai/apiKey#value" }
```
Validation:
- `provider` must match `^[a-z][a-z0-9_-]{0,63}$`
- `id` must match `^[A-Za-z0-9][A-Za-z0-9._:/#-]{0,255}$` (supports selectors such as `secret#json_key`)
- `id` must not contain `.` or `..` as slash-delimited path segments (for example `a/../b` is rejected)
</Tab>
</Tabs>
## Provider config
Define providers under `secrets.providers`:
```json5
{
secrets: {
providers: {
default: { source: "env" },
filemain: {
source: "file",
path: "~/.openclaw/secrets.json",
mode: "json", // or "singleValue"
},
vault: {
source: "exec",
command: "/usr/local/bin/openclaw-vault-resolver",
args: ["--profile", "prod"],
passEnv: ["PATH", "VAULT_ADDR"],
jsonOnly: true,
},
"team-secrets": {
source: "exec",
pluginIntegration: {
pluginId: "acme-secrets",
integrationId: "secret-store",
},
},
},
defaults: {
env: "default",
file: "filemain",
exec: "vault",
},
resolution: {
maxProviderConcurrency: 4,
maxRefsPerProvider: 512,
maxBatchBytes: 262144,
},
},
}
```
<Accordion title="Env provider">
- Optional exact-name allowlist via `allowlist`.
- Missing or empty env values fail resolution.
</Accordion>
<Accordion title="File provider">
- Reads the local file at `path`.
- `mode: "json"` (default) expects a JSON object payload and resolves `id` as a JSON pointer.
- `mode: "singleValue"` expects ref id `"value"` and returns the raw file contents (trailing newline stripped).
- Path must pass ownership/permission checks; `timeoutMs` (default 5000) and `maxBytes` (default 1 MiB) bound the read.
- Windows fail-closed: if ACL verification is unavailable for the path, resolution fails. For trusted paths only, set `allowInsecurePath: true` on that provider to bypass the check.
</Accordion>
<Accordion title="Exec provider">
- Runs the configured absolute binary path directly, no shell.
- By default `command` must be a regular file, not a symlink. Set `allowSymlinkCommand: true` to allow symlink command paths (for example Homebrew shims), and pair it with `trustedDirs` (for example `["/opt/homebrew"]`) so only package-manager paths qualify.
- Supports `timeoutMs` (default 5000), `noOutputTimeoutMs` (default equals `timeoutMs`), `maxOutputBytes` (default 1 MiB), `env`/`passEnv` allowlist, and `trustedDirs`.
- `jsonOnly` defaults to `true`. With `jsonOnly: false` and a single requested id, plain non-JSON stdout is accepted as that id's value.
- Windows fail-closed: if ACL verification is unavailable for the command path, resolution fails. For trusted paths only, set `allowInsecurePath: true` on that provider to bypass the check.
- Plugin-managed exec providers can use `pluginIntegration` instead of a copied `command`/`args`. OpenClaw resolves the current command details from the installed plugin manifest during startup/reload; if the plugin is disabled, removed, untrusted, or no longer declares the integration, active SecretRefs on that provider fail closed.
Request payload (stdin):
```json
{ "protocolVersion": 1, "provider": "vault", "ids": ["providers/openai/apiKey"] }
```
Response payload (stdout):
```jsonc
{ "protocolVersion": 1, "values": { "providers/openai/apiKey": "<openai-api-key>" } } // pragma: allowlist secret
```
Optional per-id errors:
```json
{
"protocolVersion": 1,
"values": {},
"errors": { "providers/openai/apiKey": { "message": "not found" } }
}
```
</Accordion>
## File-backed API keys
Do not put `file:...` strings in the config `env` block. That block is literal and non-overriding, so `file:...` is never resolved there.
Use a file SecretRef on a supported credential field instead:
```json5
{
secrets: {
providers: {
xai_key_file: {
source: "file",
path: "~/.openclaw/secrets/xai-api-key.txt",
mode: "singleValue",
},
},
},
models: {
providers: {
xai: {
apiKey: { source: "file", provider: "xai_key_file", id: "value" },
},
},
},
}
```
For `mode: "singleValue"`, the SecretRef `id` is `"value"`. For `mode: "json"`, use an absolute JSON pointer such as `"/providers/xai/apiKey"`.
See [SecretRef Credential Surface](/reference/secretref-credential-surface) for the fields that accept SecretRefs.
## Exec integration examples
<AccordionGroup>
<Accordion title="1Password CLI">
```json5
{
secrets: {
providers: {
onepassword_openai: {
source: "exec",
command: "/opt/homebrew/bin/op",
allowSymlinkCommand: true, // required for Homebrew symlinked binaries
trustedDirs: ["/opt/homebrew"],
args: ["read", "op://Personal/OpenClaw QA API Key/password"],
passEnv: ["HOME"],
jsonOnly: false,
},
},
},
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
models: [{ id: "gpt-5", name: "gpt-5" }],
apiKey: { source: "exec", provider: "onepassword_openai", id: "value" },
},
},
},
}
```
</Accordion>
<Accordion title="Bitwarden Secrets Manager (`bws`)">
Use a resolver wrapper to map SecretRef ids to Bitwarden Secrets Manager item keys. The repository includes `scripts/secrets/openclaw-bws-resolver.mjs`; install or copy it to an absolute trusted path on the host that runs the Gateway.
Requirements:
- Bitwarden Secrets Manager CLI (`bws`) installed on the Gateway host.
- `BWS_ACCESS_TOKEN` available to the Gateway service.
- `PATH` passed to the resolver, or `BWS_BIN` set to the absolute `bws` binary path.
- `BWS_SERVER_URL` set in the environment when using a self-hosted Bitwarden instance.
```json5
{
secrets: {
providers: {
bws: {
source: "exec",
command: "/usr/local/bin/openclaw-bws-resolver.mjs",
passEnv: ["BWS_ACCESS_TOKEN", "BWS_SERVER_URL", "PATH", "BWS_BIN"],
jsonOnly: true,
},
},
},
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
models: [{ id: "gpt-5", name: "gpt-5" }],
apiKey: {
source: "exec",
provider: "bws",
id: "openclaw/providers/openai/apiKey",
},
},
},
},
}
```
The resolver batches requested ids, runs `bws secret list`, and returns values for matching secret `key` fields. Use keys that satisfy the exec SecretRef id contract, such as `openclaw/providers/openai/apiKey`; env-var-style keys with underscores are rejected before the resolver runs. If more than one visible Bitwarden secret shares the requested key, the resolver fails that id as ambiguous instead of guessing. After updating config, verify the resolver path:
```bash
openclaw secrets audit --allow-exec
```
</Accordion>
<Accordion title="HashiCorp Vault CLI">
```json5
{
secrets: {
providers: {
vault_openai: {
source: "exec",
command: "/opt/homebrew/bin/vault",
allowSymlinkCommand: true, // required for Homebrew symlinked binaries
trustedDirs: ["/opt/homebrew"],
args: ["kv", "get", "-field=OPENAI_API_KEY", "secret/openclaw"],
passEnv: ["VAULT_ADDR", "VAULT_TOKEN"],
jsonOnly: false,
},
},
},
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
models: [{ id: "gpt-5", name: "gpt-5" }],
apiKey: { source: "exec", provider: "vault_openai", id: "value" },
},
},
},
}
```
</Accordion>
<Accordion title="password-store (`pass`)">
Use a small resolver wrapper to map SecretRef ids directly to `pass` entries. Save this as an executable at an absolute path that passes your exec-provider path checks, for example `/usr/local/bin/openclaw-pass-resolver`. The `#!/usr/bin/env node` shebang resolves `node` from the resolver process `PATH`, so include `PATH` in `passEnv`. If `pass` is not on that `PATH`, set `PASS_BIN` in the parent environment and include it in `passEnv` too:
```js
#!/usr/bin/env node
const { spawnSync } = require("node:child_process");
let stdin = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => {
stdin += chunk;
});
process.stdin.on("error", (err) => {
process.stderr.write(`${err.message}\n`);
process.exit(1);
});
process.stdin.on("end", () => {
let request;
try {
request = JSON.parse(stdin || "{}");
} catch (err) {
process.stderr.write(`Failed to parse request: ${err.message}\n`);
process.exit(1);
}
const passBin = process.env.PASS_BIN || "pass";
const values = {};
const errors = {};
for (const id of request.ids ?? []) {
const result = spawnSync(passBin, ["show", id], { encoding: "utf8" });
if (result.status === 0) {
values[id] = result.stdout.split(/\r?\n/, 1)[0] ?? "";
} else {
errors[id] = { message: (result.stderr || `pass exited ${result.status}`).trim() };
}
}
process.stdout.write(JSON.stringify({ protocolVersion: 1, values, errors }));
});
```
Then configure the exec provider and point `apiKey` at the `pass` entry path:
```json5
{
secrets: {
providers: {
pass_store: {
source: "exec",
command: "/usr/local/bin/openclaw-pass-resolver",
passEnv: ["PATH", "HOME", "GNUPGHOME", "GPG_TTY", "PASSWORD_STORE_DIR", "PASS_BIN"],
jsonOnly: true,
},
},
},
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
models: [{ id: "gpt-5", name: "gpt-5" }],
apiKey: {
source: "exec",
provider: "pass_store",
id: "openclaw/providers/openai/apiKey",
},
},
},
},
}
```
Keep the secret on the first line of the `pass` entry, or customize the wrapper to return the full `pass show` output instead. After updating config, verify both the static audit and the exec resolver path:
```bash
openclaw secrets audit --check
openclaw secrets audit --allow-exec
```
</Accordion>
<Accordion title="sops">
```json5
{
secrets: {
providers: {
sops_openai: {
source: "exec",
command: "/opt/homebrew/bin/sops",
allowSymlinkCommand: true, // required for Homebrew symlinked binaries
trustedDirs: ["/opt/homebrew"],
args: ["-d", "--extract", '["providers"]["openai"]["apiKey"]', "/path/to/secrets.enc.json"],
passEnv: ["SOPS_AGE_KEY_FILE"],
jsonOnly: false,
},
},
},
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
models: [{ id: "gpt-5", name: "gpt-5" }],
apiKey: { source: "exec", provider: "sops_openai", id: "value" },
},
},
},
}
```
</Accordion>
</AccordionGroup>
## MCP server environment variables
MCP server env vars configured via `plugins.entries.acpx.config.mcpServers` accept SecretInput, keeping API keys and tokens out of plaintext config:
```json5
{
plugins: {
entries: {
acpx: {
enabled: true,
config: {
mcpServers: {
github: {
command: "npx",
args: ["-y", "@modelcontextprotocol/server-github"],
env: {
GITHUB_PERSONAL_ACCESS_TOKEN: {
source: "env",
provider: "default",
id: "MCP_GITHUB_PAT",
},
},
},
},
},
},
},
},
}
```
Plaintext string values still work. Env-template refs like `${MCP_SERVER_API_KEY}` and SecretRef objects resolve during gateway activation, before the MCP server process spawns. As with other SecretRef surfaces, unresolved refs only block activation when the `acpx` plugin is effectively active.
## Sandbox SSH auth material
The core `ssh` sandbox backend also supports SecretRefs for SSH auth material:
```json5
{
agents: {
defaults: {
sandbox: {
mode: "all",
backend: "ssh",
ssh: {
target: "user@gateway-host:22",
identityData: { source: "env", provider: "default", id: "SSH_IDENTITY" },
certificateData: { source: "env", provider: "default", id: "SSH_CERTIFICATE" },
knownHostsData: { source: "env", provider: "default", id: "SSH_KNOWN_HOSTS" },
},
},
},
},
}
```
Runtime behavior:
- OpenClaw resolves these refs during sandbox activation, not lazily on each SSH call.
- Resolved values are written to a temp directory with restrictive file permissions (`0o600`) and used in the generated SSH config.
- If the effective sandbox backend is not `ssh` (or sandbox mode is `off`), these refs stay inactive and do not block startup.
## Supported credential surface
Canonical supported and unsupported credentials are listed in [SecretRef Credential Surface](/reference/secretref-credential-surface).
<Note>
Runtime-minted or rotating credentials and OAuth refresh material are intentionally excluded from read-only SecretRef resolution.
</Note>
## Required behavior and precedence
- Field without a ref: unchanged.
- Field with a ref: required on active surfaces during activation.
- If both plaintext and ref are present, the ref takes precedence on supported precedence paths.
- The redaction sentinel `__OPENCLAW_REDACTED__` is reserved for internal config redaction/restore and is rejected as literal submitted config data.
Warning and audit signals:
- `SECRETS_REF_OVERRIDES_PLAINTEXT` (runtime warning)
- `REF_SHADOWED` (audit finding when `auth-profiles.json` credentials take precedence over `openclaw.json` refs)
Google Chat compatibility: `serviceAccountRef` takes precedence over plaintext `serviceAccount`; the plaintext value is ignored once the sibling ref is set.
## Activation triggers
Secret activation runs on:
- Startup (preflight plus final activation)
- Config reload hot-apply path
- Config reload restart-check path
- Manual reload via `secrets.reload`
- Gateway config write RPC preflight (`config.set` / `config.apply` / `config.patch`), checking active-surface SecretRef resolvability within the submitted config payload before persisting edits
Activation contract:
- Success swaps the snapshot atomically.
- Startup failure aborts gateway startup.
- Runtime reload failure keeps the last-known-good snapshot.
- Write-RPC preflight failure rejects the submitted config; both disk config and the active runtime snapshot stay unchanged.
- Providing an explicit per-call channel token to an outbound helper/tool call does not trigger SecretRef activation; activation points remain startup, reload, and explicit `secrets.reload`.
## Degraded and recovered signals
When reload-time activation fails after a healthy state, OpenClaw enters degraded secrets state, emitting one-shot system events and log codes:
- `SECRETS_RELOADER_DEGRADED`
- `SECRETS_RELOADER_RECOVERED`
Behavior:
- Degraded: runtime keeps the last-known-good snapshot.
- Recovered: emitted once after the next successful activation.
- Repeated failures while already degraded log warnings but do not re-emit the event.
- Startup fail-fast never emits a degraded event, because runtime never became active.
## Command-path resolution
Command paths can opt into supported SecretRef resolution via a gateway snapshot RPC. Two broad behaviors apply:
<Tabs>
<Tab title="Strict command paths">
For example `openclaw memory` remote-memory paths and `openclaw qr --remote` when it needs remote shared-secret refs. They read from the active snapshot and fail fast when a required SecretRef is unavailable.
</Tab>
<Tab title="Read-only command paths">
For example `openclaw status`, `openclaw status --all`, `openclaw channels status`, `openclaw channels resolve`, `openclaw security audit`, and read-only doctor/config repair flows. They also prefer the active snapshot, but degrade instead of aborting when a targeted SecretRef is unavailable.
Read-only behavior:
- When the gateway is running, these commands read from the active snapshot first.
- If gateway resolution is incomplete or the gateway is unavailable, they attempt a targeted local fallback for that command surface.
- If a targeted SecretRef is still unavailable, the command continues with degraded read-only output and an explicit diagnostic that the ref is configured but unavailable in this command path.
- This degraded behavior is command-local only; it does not weaken runtime startup, reload, or send/auth paths.
</Tab>
</Tabs>
Other notes:
- Snapshot refresh after backend secret rotation is handled by `openclaw secrets reload`.
- Gateway RPC method used by these command paths: `secrets.resolve`.
## Audit and configure workflow
Default operator flow:
<Steps>
<Step title="Audit current state">
```bash
openclaw secrets audit --check
```
</Step>
<Step title="Configure and apply SecretRefs">
```bash
openclaw secrets configure --apply
```
</Step>
<Step title="Re-audit">
```bash
openclaw secrets audit --check
```
</Step>
</Steps>
Do not treat the migration as complete until the re-audit is clean. If the audit still reports plaintext values at rest, the agent-access risk remains even when runtime APIs return redacted values.
If you save a plan instead of applying during `configure`, apply that saved plan with `openclaw secrets apply --from <plan-path>` before the re-audit.
<AccordionGroup>
<Accordion title="secrets audit">
Findings include:
- Plaintext values at rest (`openclaw.json`, `auth-profiles.json`, `.env`, and generated `agents/*/agent/models.json`).
- Plaintext sensitive provider header residues in generated `models.json` entries.
- Unresolved refs.
- Precedence shadowing (`auth-profiles.json` taking priority over `openclaw.json` refs).
- Legacy residues (`auth.json`, OAuth reminders).
Exec note: by default, audit skips exec SecretRef resolvability checks to avoid command side effects. Use `openclaw secrets audit --allow-exec` to execute exec providers during audit.
Header residue note: sensitive provider header detection is name-heuristic based (common auth/credential header names and fragments such as `authorization`, `x-api-key`, `token`, `secret`, `password`, and `credential`).
</Accordion>
<Accordion title="secrets configure">
Interactive helper that:
- Configures `secrets.providers` first (`env`/`file`/`exec`, add/edit/remove).
- Lets you select supported secret-bearing fields in `openclaw.json` plus `auth-profiles.json` for one agent scope.
- Can create a new `auth-profiles.json` mapping directly in the target picker.
- Captures SecretRef details (`source`, `provider`, `id`).
- Runs preflight resolution and can apply immediately.
Exec note: preflight skips exec SecretRef checks unless `--allow-exec` is set. If you apply directly from `configure --apply` and the plan includes exec refs/providers, keep `--allow-exec` set for the apply step too.
Helpful modes:
- `openclaw secrets configure --providers-only`
- `openclaw secrets configure --skip-provider-setup`
- `openclaw secrets configure --agent <id>`
`configure` apply defaults:
- Scrub matching static credentials from `auth-profiles.json` for targeted providers.
- Scrub legacy static `api_key` entries from `auth.json`.
- Scrub matching known secret lines from `<config-dir>/.env`.
</Accordion>
<Accordion title="secrets apply">
Apply a saved plan:
```bash
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --allow-exec
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --dry-run
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --dry-run --allow-exec
```
Exec note: dry-run skips exec checks unless `--allow-exec` is set; write mode rejects plans containing exec SecretRefs/providers unless `--allow-exec` is set.
For strict target/path contract details and exact rejection rules, see [Secrets Apply Plan Contract](/gateway/secrets-plan-contract).
</Accordion>
</AccordionGroup>
## One-way safety policy
<Warning>
OpenClaw intentionally does not write rollback backups containing historical plaintext secret values.
</Warning>
Safety model:
- Preflight must succeed before write mode.
- Runtime activation is validated before commit.
- Apply updates files using atomic file replacement and best-effort restore on failure.
## Legacy auth compatibility notes
For static credentials, runtime no longer depends on plaintext legacy auth storage.
- Runtime credential source is the resolved in-memory snapshot.
- Legacy static `api_key` entries are scrubbed when discovered.
- OAuth-related compatibility behavior remains separate.
## Web UI note
Some SecretInput unions are easier to configure in raw editor mode than in form mode.
## Related
- [Authentication](/gateway/authentication) - auth setup
- [CLI: secrets](/cli/secrets) - CLI commands
- [Environment Variables](/help/environment) - environment precedence
- [SecretRef Credential Surface](/reference/secretref-credential-surface) - credential surface
- [Secrets Apply Plan Contract](/gateway/secrets-plan-contract) - plan contract details
- [Security](/gateway/security) - security posture

View File

@@ -0,0 +1,153 @@
---
summary: "Reference catalog of checkIds emitted by openclaw security audit"
read_when:
- You saw a specific `checkId` in `openclaw security audit` output and want to know what it means
- You need the fix key/path for a given finding
- You are triaging severity across a security audit run
title: "Security audit checks"
---
`openclaw security audit` emits structured findings keyed by `checkId`. This
page is the reference catalog for those IDs. For the high-level threat model
and hardening guidance, see [Security](/gateway/security).
Some checks only run with `openclaw security audit --deep`: plugin/skill code
scans (`plugins.code_safety*`, `skills.code_safety*`) and live Gateway probe
checks (`gateway.probe_*`). Everything else in this table runs on a plain
`openclaw security audit`.
A severity like `warn/critical` means the same `checkId` can be emitted at
either level depending on config (for example, whether the Gateway is remotely
exposed). High-signal values you will most likely see in real deployments (not
exhaustive):
| `checkId` | Severity | Why it matters | Primary fix key/path | Auto-fix |
| --------------------------------------------------------------- | ------------------ | --------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | -------- |
| `fs.state_dir.perms_world_writable` | critical | Other users/processes can modify full OpenClaw state | filesystem perms on `~/.openclaw` | yes |
| `fs.state_dir.perms_group_writable` | warn | Group users can modify full OpenClaw state | filesystem perms on `~/.openclaw` | yes |
| `fs.state_dir.perms_readable` | warn | State dir is readable by others | filesystem perms on `~/.openclaw` | yes |
| `fs.state_dir.symlink` | warn | State dir target becomes another trust boundary | state dir filesystem layout | no |
| `fs.config.perms_writable` | critical | Others can change auth/tool policy/config | filesystem perms on `~/.openclaw/openclaw.json` | yes |
| `fs.config.symlink` | warn | Symlinked config files are unsupported for writes and add another trust boundary | replace with a regular config file or point `OPENCLAW_CONFIG_PATH` at the real file | no |
| `fs.config.perms_group_readable` | warn | Group users can read config tokens/settings | filesystem perms on config file | yes |
| `fs.config.perms_world_readable` | critical | Config can expose tokens/settings | filesystem perms on config file | yes |
| `fs.config_include.perms_writable` | critical | Config include file can be modified by others | include-file perms referenced from `openclaw.json` | yes |
| `fs.config_include.perms_group_readable` | warn | Group users can read included secrets/settings | include-file perms referenced from `openclaw.json` | yes |
| `fs.config_include.perms_world_readable` | critical | Included secrets/settings are world-readable | include-file perms referenced from `openclaw.json` | yes |
| `fs.auth_profiles.perms_writable` | critical | Others can inject or replace stored model credentials | `agents/<agentId>/agent/auth-profiles.json` perms | yes |
| `fs.auth_profiles.perms_readable` | warn | Others can read API keys and OAuth tokens | `agents/<agentId>/agent/auth-profiles.json` perms | yes |
| `fs.credentials_dir.perms_writable` | critical | Others can modify channel pairing/credential state | filesystem perms on `~/.openclaw/credentials` | yes |
| `fs.credentials_dir.perms_readable` | warn | Others can read channel credential state | filesystem perms on `~/.openclaw/credentials` | yes |
| `fs.sessions_store.perms_readable` | warn | Others can read session transcripts/metadata | session store perms | yes |
| `fs.log_file.perms_readable` | warn | Others can read redacted-but-still-sensitive logs | gateway log file perms | yes |
| `fs.synced_dir` | warn | State/config in iCloud/Dropbox/Drive broadens token/transcript exposure | move config/state off synced folders | no |
| `gateway.bind_no_auth` | critical | Remote bind without shared secret | `gateway.bind`, `gateway.auth.*` | no |
| `gateway.loopback_no_auth` | critical | Reverse-proxied loopback may become unauthenticated | `gateway.auth.*`, proxy setup | no |
| `gateway.trusted_proxies_missing` | warn | Reverse-proxy headers are present but not trusted | `gateway.trustedProxies` | no |
| `gateway.http.no_auth` | warn/critical | Gateway HTTP APIs reachable with `auth.mode="none"` | `gateway.auth.mode`, `gateway.http.endpoints.*`, `plugins.entries.admin-http-rpc` | no |
| `gateway.http.session_key_override_enabled` | info | HTTP API callers can override `sessionKey` | `gateway.http.allowSessionKeyOverride` | no |
| `gateway.tools_invoke_http.dangerous_allow` | warn/critical | Re-enables dangerous tools over HTTP API for owner/admin callers | `gateway.tools.allow` | no |
| `gateway.nodes.allow_commands_dangerous` | warn/critical | Enables high-impact node commands (camera/screen/contacts/calendar/SMS) | `gateway.nodes.allowCommands` | no |
| `gateway.nodes.deny_commands_ineffective` | warn | Pattern-like deny entries do not match shell text or groups | `gateway.nodes.denyCommands` | no |
| `gateway.tailscale_funnel` | critical | Public internet exposure | `gateway.tailscale.mode` | no |
| `gateway.tailscale_serve` | info | Tailnet exposure is enabled via Serve | `gateway.tailscale.mode` | no |
| `gateway.control_ui.allowed_origins_required` | critical | Non-loopback Control UI without explicit browser-origin allowlist | `gateway.controlUi.allowedOrigins` | no |
| `gateway.control_ui.allowed_origins_wildcard` | warn/critical | `allowedOrigins=["*"]` disables browser-origin allowlisting | `gateway.controlUi.allowedOrigins` | no |
| `gateway.control_ui.host_header_origin_fallback` | warn/critical | Enables Host-header origin fallback (DNS rebinding hardening downgrade) | `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback` | no |
| `gateway.control_ui.insecure_auth` | warn | Insecure-auth compatibility toggle enabled | `gateway.controlUi.allowInsecureAuth` | no |
| `gateway.control_ui.device_auth_disabled` | critical | Disables device identity check | `gateway.controlUi.dangerouslyDisableDeviceAuth` | no |
| `gateway.real_ip_fallback_enabled` | warn/critical | Trusting `X-Real-IP` fallback can enable source-IP spoofing via proxy misconfig | `gateway.allowRealIpFallback`, `gateway.trustedProxies` | no |
| `gateway.token_too_short` | warn | Short shared token is easier to brute force | `gateway.auth.token` | no |
| `gateway.auth_no_rate_limit` | warn | Exposed auth without rate limiting increases brute-force risk | `gateway.auth.rateLimit` | no |
| `gateway.trusted_proxy_auth` | critical | Proxy identity now becomes the auth boundary | `gateway.auth.mode="trusted-proxy"` | no |
| `gateway.trusted_proxy_no_proxies` | critical | Trusted-proxy auth without trusted proxy IPs is unsafe | `gateway.trustedProxies` | no |
| `gateway.trusted_proxy_no_user_header` | critical | Trusted-proxy auth cannot resolve user identity safely | `gateway.auth.trustedProxy.userHeader` | no |
| `gateway.trusted_proxy_no_allowlist` | warn | Trusted-proxy auth accepts any authenticated upstream user | `gateway.auth.trustedProxy.allowUsers` | no |
| `gateway.trusted_proxy_allow_loopback` | warn | Trusted-proxy auth accepts explicitly allowed loopback proxy sources | `gateway.auth.trustedProxy.allowLoopback` | no |
| `gateway.probe_auth_secretref_unavailable` | warn | Deep probe could not resolve auth SecretRefs in this command path | deep-probe auth source / SecretRef availability | no |
| `gateway.probe_failed` | warn | Live Gateway probe failed (`--deep` only) | gateway reachability/auth | no |
| `discovery.mdns_full_mode` | warn/critical | mDNS full mode advertises `cliPath`/`sshPort` metadata on local network | `discovery.mdns.mode`, `gateway.bind` | no |
| `config.insecure_or_dangerous_flags` | warn | One insecure/dangerous debug flag is enabled | key named in finding detail | no |
| `security.audit.suppressions.active` | info | Audit output has configured suppressions and may be filtered | `security.audit.suppressions` | no |
| `config.secrets.gateway_password_in_config` | warn | Gateway password is stored directly in config | `gateway.auth.password` | no |
| `config.secrets.hooks_token_in_config` | warn | Hook bearer token is stored directly in config | `hooks.token` | no |
| `hooks.token_reuse_gateway_token` | critical | Hook ingress token also unlocks Gateway auth | `hooks.token`, `gateway.auth.token`, `gateway.auth.password` | no |
| `hooks.token_too_short` | warn | Easier brute force on hook ingress | `hooks.token` | no |
| `hooks.default_session_key_unset` | warn | Hook agent runs fan out into generated per-request sessions | `hooks.defaultSessionKey` | no |
| `hooks.allowed_agent_ids_unrestricted` | warn/critical | Authenticated hook callers may route to any configured agent | `hooks.allowedAgentIds` | no |
| `hooks.request_session_key_enabled` | warn/critical | External caller can choose sessionKey | `hooks.allowRequestSessionKey` | no |
| `hooks.request_session_key_prefixes_missing` | warn/critical | No bound on external session key shapes | `hooks.allowedSessionKeyPrefixes` | no |
| `hooks.path_root` | critical | Hook path is `/`, making ingress easier to collide or misroute | `hooks.path` | no |
| `hooks.installs_unpinned_npm_specs` | warn | Hook install records are not pinned to immutable npm specs | hook install metadata | no |
| `hooks.installs_missing_integrity` | warn | Hook install records lack integrity metadata | hook install metadata | no |
| `hooks.installs_version_drift` | warn | Hook install records drift from installed packages | hook install metadata | no |
| `logging.redact_off` | warn | Sensitive values leak to logs/status | `logging.redactSensitive` | yes |
| `browser.control_invalid_config` | warn | Browser control config is invalid before runtime | `browser.*` | no |
| `browser.control_no_auth` | critical | Browser control exposed without token/password auth | `gateway.auth.*` | no |
| `browser.remote_cdp_http` | warn | Remote CDP over plain HTTP lacks transport encryption | browser profile `cdpUrl` | no |
| `browser.remote_cdp_private_host` | warn | Remote CDP targets a private/internal host | browser profile `cdpUrl`, `browser.ssrfPolicy.*` | no |
| `sandbox.docker_config_mode_off` | warn | Sandbox Docker config present but inactive | `agents.*.sandbox.mode` | no |
| `sandbox.bind_mount_non_absolute` | warn | Relative bind mounts can resolve unpredictably | `agents.*.sandbox.docker.binds[]` | no |
| `sandbox.dangerous_bind_mount` | critical | Sandbox bind mount targets blocked system, credential, or Docker socket paths | `agents.*.sandbox.docker.binds[]` | no |
| `sandbox.dangerous_network_mode` | critical | Sandbox Docker network uses `host` or `container:*` namespace-join mode | `agents.*.sandbox.docker.network` | no |
| `sandbox.dangerous_seccomp_profile` | critical | Sandbox seccomp profile weakens container isolation | `agents.*.sandbox.docker.securityOpt` | no |
| `sandbox.dangerous_apparmor_profile` | critical | Sandbox AppArmor profile weakens container isolation | `agents.*.sandbox.docker.securityOpt` | no |
| `sandbox.browser_cdp_bridge_unrestricted` | warn | Sandbox browser bridge is exposed without source-range restriction | `sandbox.browser.cdpSourceRange` | no |
| `sandbox.browser_container.non_loopback_publish` | critical | Existing browser container publishes CDP on non-loopback interfaces | browser sandbox container publish config | no |
| `sandbox.browser_container.hash_label_missing` | warn | Existing browser container predates current config-hash labels | `openclaw sandbox recreate --browser --all` | no |
| `sandbox.browser_container.hash_epoch_stale` | warn | Existing browser container predates current browser config epoch | `openclaw sandbox recreate --browser --all` | no |
| `sandbox.browser_container.docker_probe_timeout` | warn | Docker label probe for the browser container timed out | Docker daemon reachability | no |
| `tools.exec.host_sandbox_no_sandbox_defaults` | warn | `exec host=sandbox` fails closed when sandbox is off | `tools.exec.host`, `agents.defaults.sandbox.mode` | no |
| `tools.exec.host_sandbox_no_sandbox_agents` | warn | Per-agent `exec host=sandbox` fails closed when sandbox is off | `agents.list[].tools.exec.host`, `agents.list[].sandbox.mode` | no |
| `tools.exec.security_full_configured` | warn/critical | Host exec is running with `security="full"` | `tools.exec.security`, `agents.list[].tools.exec.security` | no |
| `tools.exec.agent_skill_mcp_boundary_drift` | warn | Agent skill allowlists are present while host exec can reach MCP clients/registries | `agents.list[].tools.exec.*`, sandbox/OS isolation, MCP server credentials | no |
| `tools.exec.fs_tools_disabled_but_exec_enabled` | warn | Filesystem tool policy does not make shell execution read-only | `tools.deny`, `agents.list[].tools.deny`, `agents.*.sandbox.workspaceAccess` | no |
| `tools.exec.auto_allow_skills_enabled` | warn | Exec approvals trust skill bins implicitly | host approvals file | no |
| `tools.exec.allowlist_interpreter_without_strict_inline_eval` | warn | Interpreter allowlists permit inline eval without forced reapproval | `tools.exec.strictInlineEval`, `agents.list[].tools.exec.strictInlineEval`, exec approvals allowlist | no |
| `tools.exec.safe_bins_interpreter_unprofiled` | warn | Interpreter/runtime bins in `safeBins` without explicit profiles broaden exec risk | `tools.exec.safeBins`, `tools.exec.safeBinProfiles`, `agents.list[].tools.exec.*` | no |
| `tools.exec.safe_bins_broad_behavior` | warn | Broad-behavior tools in `safeBins` weaken the low-risk stdin-filter trust model | `tools.exec.safeBins`, `agents.list[].tools.exec.safeBins` | no |
| `tools.exec.safe_bin_trusted_dirs_risky` | warn | `safeBinTrustedDirs` includes mutable or risky directories | `tools.exec.safeBinTrustedDirs`, `agents.list[].tools.exec.safeBinTrustedDirs` | no |
| `tools.elevated.allowFrom.<provider>.wildcard` | critical | `tools.elevated.allowFrom.<provider>` includes `"*"`, approving every sender | `tools.elevated.allowFrom.<provider>` | no |
| `tools.elevated.allowFrom.<provider>.large` | warn | Elevated allowlist for `<provider>` has more than 25 entries | `tools.elevated.allowFrom.<provider>` | no |
| `agents.claude_cli.permission_mode_overridden_by_yolo` | warn | Claude CLI `--permission-mode` is ignored because OpenClaw exec is fully unattended | `tools.exec.security`, `tools.exec.ask`, `cliBackends.claude-cli` args | no |
| `skills.workspace.symlink_escape` | warn | Workspace `skills/**/SKILL.md` resolves outside workspace root (symlink-chain drift) | workspace `skills/**` filesystem state | no |
| `skills.workspace.scan_truncated` | warn | Workspace skill scan hit its directory-visit cap before finishing | flatten/simplify the workspace `skills/` directory tree | no |
| `plugins.extensions_no_allowlist` | warn | Plugins are installed without an explicit plugin allowlist | `plugins.allowlist` | no |
| `plugins.allow_phantom_entries` | warn | `plugins.allow` lists an ID with no matching installed plugin | `plugins.allow` | no |
| `plugins.installs_unpinned_npm_specs` | warn | Plugin index records are not pinned to immutable npm specs | plugin install metadata | no |
| `plugins.installs_missing_integrity` | warn | Plugin index records lack integrity metadata | plugin install metadata | no |
| `plugins.installs_version_drift` | warn | Plugin index records drift from installed packages | plugin install metadata | no |
| `plugins.code_safety` | warn/critical | Plugin code scan found suspicious or dangerous patterns (`--deep` only) | plugin code / install source | no |
| `plugins.code_safety.entry_path` | warn | Plugin entry path points into hidden or `node_modules` locations | plugin manifest `entry` | no |
| `plugins.code_safety.entry_escape` | critical | Plugin entry escapes the plugin directory | plugin manifest `entry` | no |
| `plugins.code_safety.manifest_parse_error` | warn | Plugin manifest could not be parsed during the code-safety scan | plugin manifest file | no |
| `plugins.code_safety.scan_failed` | warn | Plugin code scan could not complete (`--deep` only) | plugin path / scan environment | no |
| `plugins.<pluginId>.security_audit_failed` | warn | A plugin-owned security audit collector threw an error | that plugin's security-audit collector | no |
| `skills.code_safety` | warn/critical | Skill installer metadata/code contains suspicious or dangerous patterns (`--deep` only) | skill install source | no |
| `skills.code_safety.scan_failed` | warn | Skill code scan could not complete (`--deep` only) | skill scan environment | no |
| `security.exposure.open_channels_with_exec` | warn/critical | Shared/public rooms can reach exec-enabled agents | `channels.*.dmPolicy`, `channels.*.groupPolicy`, `tools.exec.*`, `agents.list[].tools.exec.*` | no |
| `security.exposure.open_groups_with_elevated` | critical | Open DMs/groups + elevated tools create high-impact prompt-injection paths | top-level or nested DM policy paths, account overrides, `channels.*.groupPolicy` | no |
| `security.exposure.open_groups_with_runtime_or_fs` | critical/warn | Open DMs/groups can reach command/file tools without sandbox/workspace guards | DM/group policy paths, `tools.profile/deny`, `tools.fs.workspaceOnly`, `agents.*.sandbox.mode` | no |
| `security.trust_model.multi_user_heuristic` | warn | Config looks multi-user while gateway trust model is personal-assistant | split trust boundaries, or shared-user hardening (`sandbox.mode`, tool deny/workspace scoping) | no |
| `tools.profile_minimal_overridden` | warn | Agent overrides bypass global minimal profile | `agents.list[].tools.profile` | no |
| `plugins.tools_reachable_permissive_policy` | warn | Extension tools reachable in permissive contexts | `tools.profile` + tool allow/deny | no |
| `models.legacy` | warn | Legacy model families are still configured | model selection | no |
| `models.weak_tier` | warn | Configured models are below current recommended tiers | model selection | no |
| `models.small_params` | critical/info | Small models + unsafe tool surfaces raise injection risk | model choice + sandbox/tool policy | no |
| `channels.<provider>.dm.open` | critical | `<provider>` DM policy is `"open"`; anyone can DM the bot | `channels.<provider>.dmPolicy`, `.allowFrom` | no |
| `channels.<provider>.dm.open_invalid` | warn | `dmPolicy="open"` without `"*"` in `allowFrom` is inconsistent | `channels.<provider>.allowFrom` | no |
| `channels.<provider>.dm.scope_main_multiuser` | warn | Multiple DM senders currently share the main session | `session.dmScope` | no |
| `channels.<provider>.allowFrom.dangerous_name_matching_enabled` | info | `dangerouslyAllowNameMatching` re-enables mutable name/email/tag sender matching | disable `dangerouslyAllowNameMatching`, use stable sender IDs | no |
| `channels.<provider>.account.read_only_resolution` | warn | A channel account could not be fully resolved for audit (missing secret/gateway) | ensure referenced secrets are resolvable, or run against a live gateway snapshot | no |
| `channels.<provider>.warning.<n>` | info/warn/critical | Provider-specific security warning, classified from free-form plugin text | see finding detail | no |
| `summary.attack_surface` | info | Roll-up summary of auth, channel, tool, and exposure posture | multiple keys (see finding detail) | no |
`channels.<provider>.*` and `tools.elevated.allowFrom.<provider>.*` checkIds are
generated per configured channel/provider, so `<provider>` is a real channel id
(for example `telegram`, `discord`) in actual output, not a literal string.
## Related
- [Security](/gateway/security)
- [Configuration](/gateway/configuration)
- [Trusted proxy auth](/gateway/trusted-proxy-auth)

View File

@@ -0,0 +1,223 @@
---
summary: "Pre-flight and rollback checklist before exposing an OpenClaw Gateway beyond loopback"
title: "Gateway exposure runbook"
sidebarTitle: "Exposure runbook"
read_when:
- Exposing the Gateway over LAN, tailnet, Tailscale Serve, Funnel, or a reverse proxy
- Reviewing a deployment before allowing real messaging users
- Rolling back a risky remote access or DM configuration
---
<Warning>
Expose the Gateway only after you can explain who can reach it, how they are
authenticated, which agents they can trigger, and which tools those agents can
use. When in doubt, return to loopback-only access and re-run the audit.
</Warning>
This runbook turns the broader [Security](/gateway/security) guidance into an
operator checklist for remote access and messaging exposure.
## Choose the exposure pattern
Prefer the narrowest pattern that satisfies the workflow.
| Pattern | Recommended when | Required controls |
| -------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| Loopback + SSH tunnel | Personal use, admin access, debugging | Keep `gateway.bind: "loopback"` and tunnel `127.0.0.1:18789` |
| Loopback + Tailscale Serve | Personal tailnet access to Control UI/WebSocket | Keep Gateway loopback-only; Tailscale identity headers only authenticate the Control UI WebSocket surface, not other auth paths |
| Tailnet/LAN bind | Dedicated private network with known devices | Gateway auth, firewall allowlist, no public port-forward |
| Trusted reverse proxy | Organization SSO/OIDC in front of Gateway | `trusted-proxy` auth, strict `trustedProxies`, header overwrite/strip rules, explicit allowed users |
| Public internet | Rare, high-risk deployments | Identity-aware proxy, TLS, rate limits, strict allowlists, sandboxed non-main sessions |
Avoid direct public port-forwarding to the Gateway. If public access is
required, put an identity-aware proxy in front of it and make the proxy the
only network path to the Gateway.
## Pre-flight inventory
Record these before changing bind, proxy, Tailscale, or channel policy:
- Gateway host, OS user, and state directory (default `~/.openclaw`).
- Gateway URL and bind mode (`gateway.bind`; default port `18789`).
- Auth mode, token/password source, or trusted proxy identity source.
- Every enabled channel and whether it accepts DMs, groups, or webhooks.
- Agents reachable from non-local senders.
- Tool profile, sandbox mode, and elevated tool policy for each reachable agent.
- External credentials available to those agents.
- Backup location for `~/.openclaw/openclaw.json` and credentials.
If more than one person can message the bot, treat this as shared delegated
tool authority, not per-user host isolation.
## Baseline checks
Run before opening access:
```bash
openclaw doctor
openclaw security audit
openclaw security audit --deep
openclaw health
```
Resolve critical findings first. Accept warnings only when intentional and
documented for the deployment. See [Security audit checks](/gateway/security/audit-checks)
for what each `checkId` means and its fix key.
For remote CLI validation, pass credentials explicitly:
```bash
openclaw gateway probe --url ws://127.0.0.1:18789 --token "$OPENCLAW_GATEWAY_TOKEN"
```
Do not assume local config credentials apply to an explicit remote URL.
## Minimum safe baseline
Use this shape as the starting point for exposed deployments:
```json5
{
gateway: {
bind: "loopback",
auth: {
mode: "token",
token: "replace-with-a-long-random-token",
},
},
session: {
dmScope: "per-channel-peer",
},
agents: {
defaults: {
sandbox: { mode: "non-main" },
},
},
tools: {
profile: "messaging",
exec: { security: "deny", ask: "always" },
elevated: { enabled: false },
},
}
```
Widen one control at a time: add a specific channel allowlist before enabling
write-capable tools, or enable a reverse proxy before accepting remote Control
UI traffic.
`tools.exec.security: "deny"` blocks all exec calls, including benign
diagnostics. If diagnostics or low-risk commands are required, relax this only
after choosing the specific senders, agents, commands, and approval mode that
match your threat model.
## DM and group exposure
Messaging channels are untrusted input surfaces. Before allowing DMs or
groups:
- Prefer `dmPolicy: "pairing"` or a strict `allowFrom` list over `dmPolicy: "open"`.
- Do not combine `"*"` allowlists with broad tool access.
- Require mentions in groups unless the room is tightly controlled.
- Set `session.dmScope: "per-channel-peer"` (or `"per-account-channel-peer"` for
multi-account channels) when multiple people can DM the bot, so DM sessions
don't share context.
- Route shared channels to agents with minimal tools and no personal
credentials.
Pairing approves the sender to trigger the bot. It does not make that sender a
separate host security boundary.
## Reverse proxy checks
For identity-aware proxies:
- The proxy must authenticate users before forwarding to the Gateway.
- Firewall or network policy must block direct access to the Gateway port.
- `gateway.trustedProxies` must list only the proxy source IPs.
- The proxy must strip or overwrite client-supplied identity and forwarding
headers.
- Set `gateway.auth.trustedProxy.allowUsers` when the proxy serves more than
one audience.
- Use `gateway.auth.trustedProxy.allowLoopback` only for a same-host proxy
where local processes are trusted and the proxy owns the identity headers.
Run `openclaw security audit --deep` after proxy changes. Trusted-proxy
findings are high-signal because the proxy becomes the authentication
boundary.
## Tool and sandbox review
Before exposing an agent to remote senders:
- Confirm which sessions run on host versus sandbox.
- Deny or require approval for host exec.
- Keep elevated tools disabled unless a specific, trusted sender needs them.
- Avoid browser, canvas, node, cron, gateway, and session-spawn tools for open
or semi-open messaging surfaces.
- Keep bind mounts narrow; avoid credential, home, Docker socket, and system
paths.
- Use separate gateways, OS users, or hosts for materially different trust
boundaries.
If remote users are not fully trusted, isolation must come from separate
deployments, not only from prompts or session labels.
## Post-change validation
After each exposure change:
1. Re-run `openclaw security audit --deep`.
2. Confirm a successful authorized connection succeeds.
3. Confirm an unauthorized sender or browser session is denied.
4. Confirm logs redact secrets.
5. Confirm DM/group routing reaches only the intended agent.
6. Confirm high-impact tools ask for approval or are denied.
7. Document the accepted residual warnings.
Do not proceed to the next exposure change until the current one is
understood.
## Rollback plan
If the Gateway may be overexposed:
```json5
{
gateway: {
bind: "loopback",
},
channels: {
whatsapp: { dmPolicy: "disabled" },
telegram: { dmPolicy: "disabled" },
discord: { dmPolicy: "disabled" },
slack: { dmPolicy: "disabled" },
},
tools: {
exec: { security: "deny", ask: "always" },
elevated: { enabled: false },
},
}
```
Then:
1. Stop public forwarding, Tailscale Funnel, or reverse proxy routes.
2. Rotate Gateway tokens/passwords and affected integration credentials.
3. Remove `"*"` and unexpected senders from allowlists.
4. Review recent audit logs, run history, tool calls, and config changes.
5. Re-run `openclaw security audit --deep`.
6. Re-enable access with the narrowest pattern that satisfies the workflow.
## Review checklist
- Gateway remains loopback-only unless there is a documented reason.
- Non-loopback access has auth, firewalling, and no public direct route.
- Trusted-proxy deployments have strict proxy IPs and header controls.
- DMs use pairing or allowlists, not open access by default.
- Groups require mentions or explicit allowlists.
- Shared channels do not reach personal credentials.
- Non-main sessions run in sandbox mode.
- Host exec and elevated tools are denied or approval-gated.
- Logs redact secrets.
- Critical audit findings are resolved.
- Rollback steps are tested and documented.

View File

@@ -0,0 +1,839 @@
---
summary: "Security considerations and threat model for running an AI gateway with shell access"
read_when:
- Adding features that widen access or automation
title: "Security"
---
<Warning>
**Personal assistant trust model.** This guidance assumes one trusted
operator boundary per gateway (single-user, personal-assistant model).
OpenClaw is **not** a hostile multi-tenant security boundary for multiple
adversarial users sharing one agent or gateway. For mixed-trust or
adversarial-user operation, split trust boundaries: separate gateway +
credentials, ideally separate OS users or hosts.
</Warning>
## Scope: personal assistant security model
- Supported: one user/trust boundary per gateway (prefer one OS user/host/VPS per boundary).
- Not supported: one shared gateway/agent used by mutually untrusted or adversarial users.
- Adversarial-user isolation needs separate gateways (and ideally separate OS users/hosts).
- If several untrusted users can message one tool-enabled agent, they share that agent's delegated tool authority.
- If someone can modify Gateway host state/config (`~/.openclaw`, including `openclaw.json`), treat them as a trusted operator.
- Inside one Gateway, authenticated operator access is a trusted control-plane role, not a per-user tenant role.
- `sessionKey` (session IDs, labels) is a routing selector, not an authorization token.
Before changing remote access, DM policy, reverse proxy, or public exposure, run through the [Gateway exposure runbook](/gateway/security/exposure-runbook) as a pre-flight/rollback checklist.
## `openclaw security audit`
Run this after any config change or before exposing network surfaces:
```bash
openclaw security audit
openclaw security audit --deep # attempts a live Gateway probe
openclaw security audit --fix # apply safe remediations
openclaw security audit --json
```
`--fix` is intentionally narrow: it flips open group policies to allowlists, restores `logging.redactSensitive: "tools"`, tightens state/config/include-file permissions (`600` files, `700` dirs), and on Windows uses ACL resets instead of POSIX `chmod`.
### What the audit checks (high level)
- **Inbound access** - DM/group policies, allowlists: can strangers trigger the bot?
- **Tool blast radius** - elevated tools + open rooms: could prompt injection become shell/file/network actions?
- **Exec filesystem drift** - mutating filesystem tools denied while `exec`/`process` stay available without sandbox constraints.
- **Exec approval drift** - `security="full"`, `autoAllowSkills`, interpreter allowlists without `strictInlineEval`. `security="full"` alone is a broad posture warning, not proof of a bug - it is the chosen default for trusted personal-assistant setups; tighten it only when your threat model needs approval or allowlist guardrails.
- **Network exposure** - Gateway bind/auth, Tailscale Serve/Funnel, weak/short auth tokens.
- **Browser control exposure** - remote nodes, relay ports, remote CDP endpoints.
- **Local disk hygiene** - permissions, symlinks, config includes, synced-folder paths.
- **Plugins** - loading without an explicit allowlist.
- **Policy drift** - sandbox Docker settings configured but sandbox mode off; `gateway.nodes.denyCommands` entries that look effective but only match exact command IDs (for example `system.run`), not shell text inside the payload; dangerous `gateway.nodes.allowCommands` entries; global `tools.profile="minimal"` overridden per agent; plugin-owned tools reachable under a permissive policy.
- **Runtime expectation drift** - assuming implicit exec still means `sandbox` when `tools.exec.host` now defaults to `auto`, or setting `tools.exec.host="sandbox"` while sandbox mode is off.
- **Model hygiene** - warns on legacy configured models (soft warning, not a hard block).
Each finding has a structured `checkId` (for example `gateway.bind_no_auth`, `tools.exec.security_full_configured`). Prefixes: `fs.*` (permissions), `gateway.*` (bind/auth/Tailscale/Control UI/trusted-proxy), `hooks.*`/`browser.*`/`sandbox.*`/`tools.exec.*` (per-surface hardening), `plugins.*`/`skills.*` (supply chain), `security.exposure.*` (access policy x tool blast radius). Full catalog with severity and auto-fix support: [Security audit checks](/gateway/security/audit-checks). See also [Formal Verification](/security/formal-verification).
### Priority order when triaging findings
1. Anything "open" + tools enabled: lock down DMs/groups first (pairing/allowlists), then tighten tool policy/sandboxing.
2. Public network exposure (LAN bind, Funnel, missing auth): fix immediately.
3. Browser control remote exposure: treat like operator access (tailnet-only, pair nodes deliberately, no public exposure).
4. Permissions: state/config/credentials/auth must not be group/world-readable.
5. Plugins: load only what you explicitly trust.
6. Model choice: prefer modern, instruction-hardened models for any bot with tools.
## Hardened baseline in 60 seconds
```json5
{
gateway: {
mode: "local",
bind: "loopback",
auth: { mode: "token", token: "replace-with-long-random-token" },
},
session: {
dmScope: "per-channel-peer",
},
tools: {
profile: "messaging",
deny: ["group:automation", "group:runtime", "group:fs", "sessions_spawn", "sessions_send"],
fs: { workspaceOnly: true },
exec: { security: "deny", ask: "always" },
elevated: { enabled: false },
},
channels: {
whatsapp: { dmPolicy: "pairing", groups: { "*": { requireMention: true } } },
},
}
```
Keeps the Gateway local-only, isolates DMs, and disables control-plane/runtime tools by default. Re-enable tools selectively per trusted agent from there.
Built-in baseline for chat-driven agent turns: non-owner senders cannot use the `cron` or `gateway` tools regardless of config.
## Trust boundary matrix
Quick model for triaging risk reports:
| Boundary or control | What it means | Common misread |
| --------------------------------------------------------- | ------------------------------------------------- | ----------------------------------------------------------------------------- |
| `gateway.auth` (token/password/trusted-proxy/device auth) | Authenticates callers to gateway APIs | "Needs per-message signatures on every frame to be secure" |
| `sessionKey` | Routing key for context/session selection | "Session key is a user auth boundary" |
| Prompt/content guardrails | Reduce model abuse risk | "Prompt injection alone proves auth bypass" |
| `canvas.eval` / browser evaluate | Intentional operator capability when enabled | "Any JS eval primitive is automatically a vuln in this trust model" |
| Local TUI `!` shell | Explicit operator-triggered local execution | "Local shell convenience command is remote injection" |
| Node pairing and node commands | Operator-level remote execution on paired devices | "Remote device control should be treated as untrusted user access by default" |
| `gateway.nodes.pairing.autoApproveCidrs` | Opt-in trusted-network node enrollment policy | "A disabled-by-default allowlist is an automatic pairing vulnerability" |
## Not vulnerabilities by design
<Accordion title="Common findings closed as no-action">
- Prompt-injection-only chains without a policy, auth, or sandbox bypass.
- Claims that assume hostile multi-tenant operation on one shared host or config.
- Normal operator read-path access (for example `sessions.list` / `sessions.preview` / `chat.history`) classified as IDOR in a shared-gateway setup.
- Localhost-only deployment findings (for example missing HSTS on a loopback-only gateway).
- Discord inbound webhook signature findings for inbound paths that do not exist in this repo.
- Node pairing metadata treated as a hidden second per-command approval layer for `system.run`; the real execution boundary is the gateway's global node command policy plus the node's own exec approvals.
- `gateway.nodes.pairing.autoApproveCidrs` treated as a vulnerability by itself. It is disabled by default, requires explicit CIDR/IP entries, only applies to first-time `role: node` pairing with no requested scopes, and never auto-approves operator/browser/Control UI, WebChat, role/scope upgrades, metadata or public-key changes, or same-host loopback trusted-proxy header paths (even when loopback trusted-proxy auth is enabled).
- "Missing per-user authorization" findings that treat `sessionKey` as an auth token.
</Accordion>
## Gateway and node trust
Treat Gateway and node as one operator trust domain with different roles:
- **Gateway**: control plane and policy surface (`gateway.auth`, tool policy, routing).
- **Node**: remote execution surface paired to that Gateway (commands, device actions, host-local capabilities).
- A caller authenticated to the Gateway is trusted at Gateway scope; after pairing, node actions are trusted operator actions on that node. See [Operator scopes](/gateway/operator-scopes).
- Direct loopback backend clients authenticated with the shared gateway token/password can make internal control-plane RPCs without presenting a user device identity. This is not a remote or browser pairing bypass - network clients, node clients, device-token clients, and explicit device identities still go through pairing and scope-upgrade enforcement.
- Exec approvals (allowlist + ask) are guardrails for operator intent, not hostile multi-tenant isolation. They bind exact request context and best-effort direct local file operands; they do not semantically model every runtime/interpreter loader path. Use sandboxing and host isolation for strong boundaries.
- Trusted single-operator default: host exec on `gateway`/`node` is allowed without approval prompts (`security="full"`, `ask="off"`). That is intentional UX, not a vulnerability by itself.
For hostile-user isolation, split trust boundaries by OS user/host and run separate gateways.
## Threat model
Your AI assistant can execute arbitrary shell commands, read/write files, access network services, and send messages to anyone (if given channel access). People who message it can try to trick it into doing bad things, social-engineer access to your data, or probe for infrastructure details.
Most failures here are not exotic exploits - they are "someone messaged the bot and the bot did what they asked." OpenClaw's stance, in order:
1. **Identity first** - decide who can talk to the bot (DM pairing / allowlists / explicit "open").
2. **Scope next** - decide where the bot can act (group allowlists + mention gating, tools, sandboxing, device permissions).
3. **Model last** - assume the model can be manipulated; design so manipulation has limited blast radius.
## DM access: pairing, allowlist, open, disabled
Every DM-capable channel supports `dmPolicy` (or `*.dm.policy`), which gates inbound DMs before the message is processed:
| Policy | Behavior |
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `pairing` | Default. Unknown senders get a pairing code; bot ignores them until approved. Codes expire after 1 hour; repeated DMs do not resend a code until a new request is created. Pending requests capped at 3 per channel. |
| `allowlist` | Unknown senders blocked, no pairing handshake. |
| `open` | Anyone can DM (public). Requires the channel allowlist to include `"*"` (explicit opt-in). |
| `disabled` | Inbound DMs ignored entirely. |
```bash
openclaw pairing list <channel>
openclaw pairing approve <channel> <code>
```
Details + files on disk: [Pairing](/channels/pairing)
Treat `dmPolicy="open"` and `groupPolicy="open"` as last-resort settings; prefer pairing + allowlists unless you fully trust every member of the room.
### Allowlists (two layers)
- **DM allowlist** (`allowFrom` / `channels.discord.allowFrom` / `channels.slack.allowFrom`; legacy: `channels.discord.dm.allowFrom`, `channels.slack.dm.allowFrom`): who can DM the bot. When `dmPolicy="pairing"`, approvals write to `~/.openclaw/credentials/<channel>-allowFrom.json` (default account) or `<channel>-<accountId>-allowFrom.json` (non-default accounts), merged with config allowlists.
- **Group allowlist** (channel-specific): which groups/channels/guilds the bot accepts at all.
- `channels.whatsapp.groups`, `channels.telegram.groups`, `channels.imessage.groups`: per-group defaults like `requireMention`; when set, also acts as a group allowlist (include `"*"` to keep allow-all behavior). Customize mention triggers with `agents.list[].groupChat.mentionPatterns` (for example `["@openclaw", "@mybot"]`) so `requireMention` gates on your own bot names.
- `groupPolicy="allowlist"` + `groupAllowFrom`: restrict who can trigger the bot inside a group session (WhatsApp/Telegram/Signal/iMessage/Microsoft Teams).
- `channels.discord.guilds` / `channels.slack.channels`: per-surface allowlists + mention defaults.
- Check order: `groupPolicy`/group allowlists first, then mention/reply activation. Replying to a bot message (implicit mention) does **not** bypass `groupAllowFrom`.
Details: [Configuration](/gateway/configuration) and [Groups](/channels/groups)
### DM session isolation (multi-user mode)
By default, OpenClaw routes all DMs into the main session for cross-device continuity. If multiple people can DM the bot (open DMs or a multi-person allowlist), isolate DM sessions:
```json5
{ session: { dmScope: "per-channel-peer" } }
```
`session.dmScope` values:
| Value | Scope |
| -------------------------- | ---------------------------------------------------------------------- |
| `main` (config default) | All DMs share one session. |
| `per-channel-peer` | Each channel+sender pair gets an isolated DM context (secure DM mode). |
| `per-account-channel-peer` | Like above, split further by account (multi-account channels). |
| `per-peer` | Each sender gets one session across all channels of the same type. |
Local CLI onboarding writes `session.dmScope: "per-channel-peer"` when unset, and preserves any explicit existing value.
This is a messaging-context boundary, not a host-admin boundary. If users are mutually adversarial and share the same Gateway host/config, run separate gateways per trust boundary instead.
If the same person contacts you on multiple channels, use `session.identityLinks` to collapse those DM sessions into one canonical identity. See [Session Management](/concepts/session) and [Configuration](/gateway/configuration).
## Context visibility vs trigger authorization
Two separate concepts:
- **Trigger authorization**: who can trigger the agent (`dmPolicy`, `groupPolicy`, allowlists, mention gates).
- **Context visibility**: what supplemental context reaches the model (reply body, quoted text, thread history, forwarded metadata).
`contextVisibility` controls the second:
- `"all"` (default): supplemental context kept as received.
- `"allowlist"`: supplemental context filtered to senders allowed by active allowlist checks.
- `"allowlist_quote"`: like `allowlist`, but still keeps one explicit quoted reply.
Set per channel or per room/conversation - see [Groups](/channels/groups#context-visibility-and-allowlists). Reports that only show "model can see quoted/historical text from non-allowlisted senders" are hardening findings addressable with `contextVisibility`, not auth or sandbox bypasses by themselves; a security-impacting report still needs a demonstrated trust-boundary bypass.
## Prompt injection
An attacker crafts a message that manipulates the model into unsafe action ("ignore your instructions", "dump your filesystem", "follow this link and run commands"). Prompt injection is **not solved** by system prompt guardrails alone - those are soft guidance; hard enforcement comes from tool policy, exec approvals, sandboxing, and channel allowlists (which operators can still disable by design).
Prompt injection does not require public DMs: even if only you can message the bot, any **untrusted content** it reads (web search/fetch results, browser pages, emails, docs, attachments, pasted logs/code) can carry adversarial instructions. The content itself is a threat surface, not just the sender.
Red flags to treat as untrusted:
- "Read this file/URL and do exactly what it says."
- "Ignore your system prompt or safety rules."
- "Reveal your hidden instructions or tool outputs."
- "Paste the full contents of ~/.openclaw or your logs."
What helps in practice:
- Keep inbound DMs locked down (pairing/allowlists); prefer mention gating in groups; avoid always-on bots in public rooms.
- Treat links, attachments, and pasted instructions as hostile by default.
- Run sensitive tool execution in a sandbox; keep secrets out of the agent's reachable filesystem. Sandboxing is opt-in: if sandbox mode is off, implicit `host=auto` resolves to the gateway host, while explicit `host=sandbox` still fails closed (no sandbox runtime available). Set `host=gateway` to make that behavior explicit in config.
- Limit high-risk tools (`exec`, `browser`, `web_fetch`, `web_search`) to trusted agents or explicit allowlists.
- If you allowlist interpreters (`python`, `node`, `ruby`, `perl`, `php`, `lua`, `osascript`), enable `tools.exec.strictInlineEval` so inline eval forms (`-c`, `-e`, and similar) still need explicit approval. In allowlist mode, any heredoc segment (`<<`) always requires reviewer or explicit approval, regardless of quoting - an allowlisted command cannot use a heredoc body to bypass allowlist review.
- Reduce blast radius by using a read-only or tool-disabled **reader agent** to summarize untrusted content, then pass the summary to your main agent.
- Keep `web_search` / `web_fetch` / `browser` off for tool-enabled agents unless needed.
- For OpenResponses URL inputs (`input_file` / `input_image`), set a tight `gateway.http.endpoints.responses.files.urlAllowlist` / `images.urlAllowlist` and keep `maxUrlParts` low (empty allowlists count as unset). Use `files.allowUrl: false` / `images.allowUrl: false` to disable URL fetching entirely.
- Keep secrets out of prompts; pass them via env/config on the gateway host instead.
**Model choice matters.** Prompt-injection resistance is not uniform across model tiers - smaller/cheaper models are more susceptible to tool misuse and instruction hijacking under adversarial prompts.
<Warning>
For tool-enabled agents or agents that read untrusted content, prompt-injection risk with older/smaller models is often too high. Do not run those workloads on weak model tiers.
</Warning>
- Use the latest-generation, best-tier model for any bot that can run tools or touch files/networks.
- Do not use older/weaker/smaller tiers for tool-enabled agents or untrusted inboxes.
- If you must use a smaller model, reduce blast radius: read-only tools, strong sandboxing, minimal filesystem access, strict allowlists. Enable sandboxing for all sessions and disable `web_search`/`web_fetch`/`browser` unless inputs are tightly controlled.
- For chat-only personal assistants with trusted input and no tools, smaller models are usually fine.
### External content and untrusted-input wrapping
OpenResponses `input_file` text is still injected as untrusted external content even though the Gateway decodes it locally - the block carries `<<<EXTERNAL_UNTRUSTED_CONTENT ...>>>` boundary markers plus `Source: External` metadata (this path omits the longer `SECURITY NOTICE:` banner used elsewhere). The same marker-based wrapping applies when media-understanding extracts text from attached documents before appending it to the media prompt.
OpenClaw also strips common self-hosted LLM chat-template special-token literals (Qwen/ChatML, Llama, Gemma, Mistral, Phi, GPT-OSS role/turn tokens) from wrapped external content and metadata before they reach the model. Self-hosted OpenAI-compatible backends (vLLM, SGLang, TGI, LM Studio, custom Hugging Face tokenizer stacks) sometimes tokenize literal strings like `<|im_start|>` or `<|start_header_id|>` as structural chat-template tokens inside user content; without this sanitization, untrusted text in a fetched page, email body, or file-contents tool output could forge a synthetic `assistant`/`system` role boundary. Sanitization happens at the external-content wrapping layer, so it applies uniformly across fetch/read tools and inbound channel content. Hosted providers (OpenAI, Anthropic) already apply their own request-side sanitization; keep external-content wrapping enabled and prefer backend settings that split/escape special tokens when available.
Outbound model responses have a separate sanitizer that strips leaked `<tool_call>`, `<function_calls>`, `<system-reminder>`, `<previous_response>`, and similar internal scaffolding from user-visible replies at the final channel delivery boundary.
This does not replace `dmPolicy`, allowlists, exec approvals, sandboxing, or `contextVisibility` - it closes one specific tokenizer-layer bypass.
### Bypass flags (keep off in production)
- `hooks.mappings[].allowUnsafeExternalContent`
- `hooks.gmail.allowUnsafeExternalContent`
- Cron payload field `allowUnsafeExternalContent`
Only enable temporarily for tightly scoped debugging; if enabled, isolate that agent (sandbox + minimal tools + dedicated session namespace).
Hook payloads are untrusted content even when delivery comes from systems you control (mail/docs/web content can carry prompt injection). Weak model tiers increase this risk - for hook-driven automation, prefer strong modern model tiers and keep tool policy tight (`tools.profile: "messaging"` or stricter), plus sandboxing where possible.
### Reasoning and verbose output in groups
`/reasoning`, `/verbose`, and `/trace` can expose internal reasoning, tool output, or plugin diagnostics not meant for a public channel - they can include tool args, URLs, plugin diagnostics, and data the model saw. Keep them disabled in public rooms; enable only in trusted DMs or tightly controlled rooms.
## Command authorization
Slash commands and directives are honored only for authorized senders, derived from channel allowlists/pairing plus `commands.useAccessGroups` (see [Configuration](/gateway/configuration) and [Slash commands](/tools/slash-commands)). If a channel allowlist is empty or includes `"*"`, commands are effectively open for that channel.
`/exec` is a session-only convenience for authorized operators - it does not write config or change other sessions.
## Control plane tools
Two built-in tools can make persistent changes:
- `gateway` inspects config with `config.schema.lookup` / `config.get`, and mutates with `config.apply`, `config.patch`, and `update.run`.
- `cron` creates scheduled jobs that keep running after the original chat/task ends.
`gateway config.apply`/`config.patch` are fail-closed by default: only a narrow allowlist of low-risk agent runtime tuning (`agents.defaults.thinkingDefault`, per-agent model/thinking/reasoning/fast-mode fields), mention-gating (`channels.*.requireMention` at several nesting depths), and visible-reply settings (`messages.visibleReplies`, `messages.groupChat.visibleReplies`, `messages.groupChat.unmentionedInbound`) are agent-tunable. Any other changed config path is rejected. Global model defaults and prompt overlays stay operator-controlled, and new sensitive config trees are protected unless deliberately added to that allowlist. The tool still refuses to rewrite `tools.exec.ask` or `tools.exec.security`; legacy `tools.bash.*` aliases normalize to the equivalent `tools.exec.*` path before the write is checked.
For any agent/surface handling untrusted content, deny these by default:
```json5
{
tools: {
deny: ["gateway", "cron", "sessions_spawn", "sessions_send"],
},
}
```
`commands.restart=false` only blocks restart actions - it does not disable `gateway` config/update actions.
## Node execution (`system.run`)
If a macOS node is paired, the Gateway can invoke `system.run` on it - this is remote code execution on that Mac.
- Requires node pairing (approval + token). Pairing establishes node identity/trust and token issuance; it is not a per-command approval surface.
- The Gateway applies a coarse global node command policy via `gateway.nodes.allowCommands` / `denyCommands`. `denyCommands` matches exact node command names only (for example `system.run`), not shell text inside a command payload - a reconnecting node advertising a different command list is not, by itself, a vulnerability if the gateway global policy and the node's own exec approvals still enforce the boundary.
- The per-node `system.run` policy is the node's own exec approvals file (`exec.approvals.node.*`), controlled on the Mac via Settings -> Exec approvals (security + ask + allowlist); it can be stricter or looser than the gateway's global command-ID policy.
- A node running `security="full"` and `ask="off"` follows the default trusted-operator model - expected behavior, not a bug, unless your deployment needs a tighter stance.
- Approval mode binds exact request context and, when possible, one concrete local script/file operand. If OpenClaw cannot identify exactly one direct local file for an interpreter/runtime command, approval-backed execution is denied rather than promising full semantic coverage.
- For `host=node`, approval-backed runs also store a canonical prepared `systemRunPlan`; later approved forwards reuse that stored plan, and gateway validation rejects caller edits to command/cwd/session context after the approval request was created.
- To disable remote execution entirely: set security to `deny` and remove node pairing for that Mac.
## Dynamic skills (watcher / remote nodes)
OpenClaw can refresh the skills list mid-session: the skills watcher updates the snapshot on the next agent turn when `SKILL.md` changes, and connecting a macOS node can make macOS-only skills eligible (based on bin probing). Treat skill folders as trusted code and restrict who can modify them.
## Plugins
Plugins run in-process with the Gateway - treat them as trusted code.
- Only install from sources you trust; prefer explicit `plugins.allow` allowlists; review plugin config before enabling; restart the Gateway after plugin changes.
- Installing/updating (`openclaw plugins install <package>`, `openclaw plugins update <id>`) runs untrusted code:
- The install path is the per-plugin directory under the active plugin install root.
- OpenClaw does not run built-in local dangerous-code blocking during install/update. Use `security.installPolicy` for operator-owned local allow/block decisions and `openclaw security audit --deep` for diagnostic scanning.
- npm and git plugin installs run package-manager dependency convergence only during the explicit install/update flow. Local paths and archives are treated as self-contained packages; OpenClaw copies/references them without running `npm install`.
- Prefer pinned exact versions (`@scope/pkg@1.2.3`) and inspect the unpacked code before enabling.
- `--dangerously-force-unsafe-install` is deprecated and no longer changes install/update behavior.
- `security.installPolicy` lets operators run a trusted local command to make host-specific allow/block decisions for skill and plugin installs. It runs after source material is staged but before install continues, applies to ClawHub skills too, and is not bypassed by deprecated unsafe flags.
Details: [Plugins](/tools/plugin)
## Sandboxing
Dedicated doc: [Sandboxing](/gateway/sandboxing)
Two complementary approaches:
- **Full Gateway in Docker** (container boundary): [Docker](/install/docker)
- **Tool sandbox** (`agents.defaults.sandbox`; host gateway + sandbox-isolated tools; Docker is the default backend): [Sandboxing](/gateway/sandboxing)
<Note>
To prevent cross-agent access, keep `agents.defaults.sandbox.scope` at `"agent"` (default) or use `"session"` for stricter per-session isolation. `scope: "shared"` uses a single container or workspace.
</Note>
Agent workspace access inside the sandbox (`agents.defaults.sandbox.workspaceAccess`):
- `"none"` (default): tools see a sandbox workspace under `~/.openclaw/sandboxes`; agent workspace is off-limits.
- `"ro"`: mounts the agent workspace read-only at `/agent` (disables `write`/`edit`/`apply_patch`).
- `"rw"`: mounts the agent workspace read/write at `/workspace`.
Extra `sandbox.docker.binds` are validated against normalized, canonicalized source paths. A blocked-path denylist covers `/etc`, `/private/etc`, `/proc`, `/sys`, `/dev`, `/root`, `/boot`, and directories that commonly contain or alias the Docker socket (`/run`, `/var/run`, and `docker.sock` under them), plus HOME credential subpaths (`.aws`, `.cargo`, `.config`, `.docker`, `.gnupg`, `.netrc`, `.npm`, `.ssh`). Parent-symlink tricks and canonical home aliases are resolved through existing ancestors and re-checked, so they still fail closed if they resolve into a blocked root.
<Warning>
`tools.elevated` is the global baseline escape hatch that runs exec outside the sandbox. The effective host is `gateway` by default, or `node` when the exec target is configured to `node`. Keep `tools.elevated.allowFrom` tight and do not enable it for strangers. Further restrict per agent via `agents.list[].tools.elevated`. See [Elevated mode](/tools/elevated).
</Warning>
### Sub-agent delegation guardrail
If you allow session tools, treat delegated sub-agent runs as another boundary decision:
- Deny `sessions_spawn` unless the agent truly needs delegation.
- Keep `agents.defaults.subagents.allowAgents` and any per-agent `agents.list[].subagents.allowAgents` overrides restricted to known-safe target agents.
- For workflows that must remain sandboxed, call `sessions_spawn` with `sandbox: "require"` (default is `"inherit"`); `"require"` fails fast when the target child runtime is not sandboxed.
### Read-only mode
Build a read-only profile by combining `agents.defaults.sandbox.workspaceAccess: "ro"` (or `"none"` for no workspace access) with tool allow/deny lists that block `write`, `edit`, `apply_patch`, `exec`, `process`, etc.
- `tools.exec.applyPatch.workspaceOnly: true` (default): keeps `apply_patch` from writing/deleting outside the workspace directory even with sandboxing off. Set `false` only if you intentionally want `apply_patch` to touch files outside the workspace.
- `tools.fs.workspaceOnly: true` (optional): restricts `read`/`write`/`edit`/`apply_patch` paths and native prompt image auto-load paths to the workspace directory.
- Keep filesystem roots narrow - avoid broad roots like your home directory for agent/sandbox workspaces, which can expose sensitive local files (for example state/config under `~/.openclaw`) to filesystem tools.
## Per-agent access profiles (multi-agent)
Each agent can have its own sandbox + tool policy: full access, read-only, or no access. See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for precedence rules.
Common patterns: personal agent (full access, no sandbox), family/work agent (sandboxed + read-only tools), public agent (sandboxed + no filesystem/shell tools).
### Full access (no sandbox)
```json5
{
agents: {
list: [
{ id: "personal", workspace: "~/.openclaw/workspace-personal", sandbox: { mode: "off" } },
],
},
}
```
### Read-only tools + read-only workspace
```json5
{
agents: {
list: [
{
id: "family",
workspace: "~/.openclaw/workspace-family",
sandbox: { mode: "all", scope: "agent", workspaceAccess: "ro" },
tools: {
allow: ["read"],
deny: ["write", "edit", "apply_patch", "exec", "process", "browser"],
},
},
],
},
}
```
### No filesystem/shell access (provider messaging allowed)
```json5
{
agents: {
list: [
{
id: "public",
workspace: "~/.openclaw/workspace-public",
sandbox: { mode: "all", scope: "agent", workspaceAccess: "none" },
tools: {
// Session tools can reveal transcript data. Default scope is current session +
// spawned subagent sessions; clamp further with tools.sessions.visibility if needed.
sessions: { visibility: "tree" }, // self | tree | agent | all
allow: [
"sessions_list",
"sessions_history",
"sessions_send",
"sessions_spawn",
"session_status",
"discord",
"slack",
"telegram",
"whatsapp",
],
deny: [
"apply_patch",
"browser",
"canvas",
"cron",
"edit",
"exec",
"gateway",
"image",
"nodes",
"process",
"read",
"write",
],
},
},
],
},
}
```
## Browser control risks
Enabling browser control gives the model a real browser. If that profile already has logged-in sessions, the model can access those accounts and data - treat browser profiles as sensitive state.
- Prefer a dedicated profile for the agent (the default `openclaw` profile); avoid your personal daily-driver profile.
- Keep host browser control disabled for sandboxed agents unless you trust them.
- The standalone loopback browser control API only honors shared-secret auth (gateway token bearer auth or gateway password) - it does not consume trusted-proxy or Tailscale Serve identity headers.
- Treat browser downloads as untrusted input; prefer an isolated downloads directory.
- Disable browser sync/password managers in the agent profile if possible.
- For remote gateways, "browser control" is equivalent to "operator access" to whatever that profile can reach.
- Keep Gateway and node hosts tailnet-only; avoid exposing browser control ports to LAN or public internet.
- Disable browser proxy routing when not needed (`gateway.nodes.browser.mode="off"`).
- Chrome MCP existing-session mode is not "safer" - it can act as you in whatever that host Chrome profile can reach.
- Run a **node host** on the browser machine and let the Gateway proxy browser actions when the Gateway is remote from the browser (see [Browser tool](/tools/browser)); treat node pairing like admin access, keep Gateway and node host on the same tailnet, and avoid exposing relay/control ports over LAN, public internet, or Tailscale Funnel.
### Browser SSRF policy (strict by default)
Private/internal destinations stay blocked unless you explicitly opt in.
- Default: `browser.ssrfPolicy.dangerouslyAllowPrivateNetwork` unset, so private/internal/special-use destinations stay blocked. Legacy alias `allowPrivateNetwork` still accepted.
- Opt-in: set `dangerouslyAllowPrivateNetwork: true` to allow those destinations.
- In strict mode, use `hostnameAllowlist` (patterns like `*.example.com`) and `allowedHostnames` (exact host exceptions, including otherwise-blocked names like `localhost`) for explicit exceptions.
- Navigation is checked before the request and best-effort re-checked on the final `http(s)` URL after navigation, to reduce redirect-based pivots.
```json5
{
browser: {
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: false,
hostnameAllowlist: ["*.example.com", "example.com"],
allowedHostnames: ["localhost"],
},
},
}
```
## Network exposure
### Bind, port, firewall
The Gateway multiplexes WebSocket + HTTP on one port (default `18789`; config/flags/env: `gateway.port`, `--port`, `OPENCLAW_GATEWAY_PORT`). That HTTP surface includes the Control UI (SPA assets, default base path `/`) and the canvas host (`/__openclaw__/canvas` and `/__openclaw__/a2ui` - arbitrary HTML/JS; treat as untrusted content when loaded in a normal browser; do not expose it to untrusted networks/users or share an origin with privileged web surfaces).
`gateway.bind` controls where the Gateway listens:
- `"loopback"` (default): only local clients can connect.
- `"lan"`, `"tailnet"`, `"custom"`: expand the attack surface. Only use with gateway auth (shared token/password, or a correctly configured trusted proxy) and a real firewall.
Rules of thumb: prefer Tailscale Serve over LAN binds (Serve keeps the Gateway on loopback and Tailscale handles access); if you must bind to LAN, firewall the port to a tight source-IP allowlist rather than port-forwarding broadly; never expose the Gateway unauthenticated on `0.0.0.0`.
### Docker port publishing with UFW
Published container ports (`-p HOST:CONTAINER` or Compose `ports:`) route through Docker's forwarding chains, not only host `INPUT` rules. Enforce rules in `DOCKER-USER` (evaluated before Docker's own accept rules); most modern distros use the `iptables-nft` frontend, which still applies these rules to the nftables backend.
```bash
# /etc/ufw/after.rules (append as its own *filter section)
*filter
:DOCKER-USER - [0:0]
-A DOCKER-USER -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN
-A DOCKER-USER -s 127.0.0.0/8 -j RETURN
-A DOCKER-USER -s 10.0.0.0/8 -j RETURN
-A DOCKER-USER -s 172.16.0.0/12 -j RETURN
-A DOCKER-USER -s 192.168.0.0/16 -j RETURN
-A DOCKER-USER -s 100.64.0.0/10 -j RETURN
-A DOCKER-USER -p tcp --dport 80 -j RETURN
-A DOCKER-USER -p tcp --dport 443 -j RETURN
-A DOCKER-USER -m conntrack --ctstate NEW -j DROP
-A DOCKER-USER -j RETURN
COMMIT
```
IPv6 has separate tables - add a matching policy in `/etc/ufw/after6.rules` if Docker IPv6 is enabled. Avoid hardcoding interface names (`eth0`) since they vary across VPS images (`ens3`, `enp*`, etc.) and a mismatch can silently skip your deny rule.
```bash
ufw reload
iptables -S DOCKER-USER
ip6tables -S DOCKER-USER
nmap -sT -p 1-65535 <public-ip> --open
```
Expected external ports should be only what you intentionally expose (for most setups: SSH + reverse proxy ports).
### mDNS/Bonjour discovery
When the bundled `bonjour` plugin is enabled, the Gateway broadcasts presence via mDNS (`_openclaw-gw._tcp`, port 5353) for local device discovery. Full mode includes TXT records that expose operational details: `cliPath` (filesystem path revealing username and install location), `sshPort` (advertises SSH availability), `displayName`/`lanHost` (hostname info). Broadcasting infrastructure details makes LAN reconnaissance easier.
- Keep Bonjour disabled unless LAN discovery is needed - it auto-starts on macOS hosts and is opt-in elsewhere; direct Gateway URLs, Tailnet, SSH, or wide-area DNS-SD avoid local multicast.
- **Minimal mode** (default when Bonjour is enabled, recommended for exposed gateways) omits sensitive fields:
```json5
{ discovery: { mdns: { mode: "minimal" } } }
```
- **Off** suppresses local discovery while keeping the plugin enabled:
```json5
{ discovery: { mdns: { mode: "off" } } }
```
- **Full mode** (opt-in) includes `cliPath` + `sshPort`:
```json5
{ discovery: { mdns: { mode: "full" } } }
```
- Or set `OPENCLAW_DISABLE_BONJOUR=1` to disable mDNS without config changes.
In minimal mode the Gateway broadcasts `role`, `gatewayPort`, `transport` but omits `cliPath`/`sshPort`; apps that need the CLI path can fetch it over the authenticated WebSocket connection instead.
### Gateway WebSocket auth
Gateway auth is required by default - with no valid auth path configured, the Gateway refuses WebSocket connections (fail-closed). Onboarding generates a token by default (even for loopback) so local clients must authenticate.
```json5
{ gateway: { auth: { mode: "token", token: "your-token" } } }
```
`openclaw doctor --generate-gateway-token` can generate one for you.
<Note>
`gateway.remote.token` and `gateway.remote.password` are client credential sources - they do not protect local WS access by themselves. Local call paths use `gateway.remote.*` only as fallback when `gateway.auth.*` is unset. If `gateway.auth.token` or `gateway.auth.password` is explicitly configured via SecretRef and unresolved, resolution fails closed (no remote-fallback masking).
</Note>
Pin remote TLS with `gateway.remote.tlsFingerprint` when using `wss://`. Plaintext `ws://` is accepted for loopback, private IP literals, `.local`, and Tailnet `*.ts.net` gateway URLs; for other trusted private-DNS names, set `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` on the client process as break-glass (process environment only, not an `openclaw.json` key). Mobile pairing and Android manual/scanned gateway routes are stricter: cleartext only for loopback, while private-LAN, link-local, `.local`, and dotless hostnames must use TLS unless you explicitly opt into the trusted private-network cleartext path.
Device pairing is auto-approved for direct local loopback connects (plus a narrow backend/container-local self-connect path for trusted shared-secret helper flows); Tailnet and LAN connects, including same-host tailnet binds, are treated as remote and still need approval. Forwarded-header evidence on a loopback request disqualifies loopback locality; metadata-upgrade auto-approval is scoped narrowly. See [Gateway pairing](/gateway/pairing).
Auth modes:
- `"token"`: shared bearer token (recommended for most setups).
- `"password"`: prefer setting via `OPENCLAW_GATEWAY_PASSWORD`.
- `"trusted-proxy"`: trust an identity-aware reverse proxy to authenticate users and pass identity via headers. See [Trusted Proxy Auth](/gateway/trusted-proxy-auth).
Rotation checklist (token/password): generate/set a new secret (`gateway.auth.token` or `OPENCLAW_GATEWAY_PASSWORD`); restart the Gateway (or the macOS app if it supervises the Gateway); update remote clients (`gateway.remote.token`/`.password`); verify the old credentials no longer work.
### Tailscale Serve identity headers
When `gateway.auth.allowTailscale` is `true` (default for Serve), OpenClaw accepts the Tailscale Serve identity header `tailscale-user-login` for Control UI/WebSocket authentication. It verifies identity by resolving the `x-forwarded-for` address through the local Tailscale daemon (`tailscale whois`) and matching it to the header - this only triggers for loopback requests carrying `x-forwarded-for`, `x-forwarded-proto`, and `x-forwarded-host` as injected by Tailscale. For this async check, failed attempts for the same `{scope, ip}` are serialized before the limiter records the failure, so concurrent bad retries from one Serve client can lock out the second attempt immediately.
HTTP API endpoints (`/v1/*`, `/tools/invoke`, `/api/channels/*`) do not use Tailscale identity-header auth - they follow the gateway's configured HTTP auth mode.
Gateway HTTP bearer auth is effectively all-or-nothing operator access. Credentials that can call `/v1/chat/completions`, `/v1/responses`, plugin routes such as `/api/v1/admin/rpc`, or `/api/channels/*` are full-access operator secrets for that gateway: shared-secret bearer auth restores the full default operator scopes (`operator.admin`, `operator.approvals`, `operator.pairing`, `operator.read`, `operator.talk.secrets`, `operator.write`) and owner semantics for agent turns, and narrower `x-openclaw-scopes` values do not reduce that shared-secret path. Per-request scope semantics only apply when the request comes from an identity-bearing mode (trusted proxy auth) or an explicitly no-auth private ingress; in those modes, omitting `x-openclaw-scopes` falls back to the normal operator default scope set, and owner-level headers like `x-openclaw-model` require `operator.admin` when scopes are narrowed. `/tools/invoke` and HTTP session history endpoints follow the same shared-secret rule. Do not share these credentials with untrusted callers; prefer separate gateways per trust boundary.
Tokenless Serve auth assumes the gateway host itself is trusted - it is not protection against hostile same-host processes. If untrusted local code may run on the gateway host, disable `allowTailscale` and require explicit shared-secret auth (`token` or `password`).
Do not forward these headers from your own reverse proxy. If you terminate TLS or proxy in front of the gateway, disable `allowTailscale` and use shared-secret auth or [Trusted Proxy Auth](/gateway/trusted-proxy-auth) instead.
See [Tailscale](/gateway/tailscale) and [Web overview](/web).
### Reverse proxy configuration
Set `gateway.trustedProxies` for proper forwarded-client IP handling behind nginx/Caddy/Traefik/etc. When the Gateway detects proxy headers from an address **not** in `trustedProxies`, it will not treat the connection as local; if gateway auth is disabled, that connection is rejected. This prevents proxied connections from appearing to come from localhost and receiving automatic trust.
`trustedProxies` also feeds `gateway.auth.mode: "trusted-proxy"`, which is stricter: it fails closed on loopback-source proxies by default. Same-host loopback reverse proxies can use `trustedProxies` for local-client detection and forwarded-IP handling, but can only satisfy `trusted-proxy` auth mode when `gateway.auth.trustedProxy.allowLoopback = true`; otherwise use token/password auth.
```yaml
gateway:
trustedProxies:
- "10.0.0.1" # reverse proxy IP
allowRealIpFallback: false # default false; only enable if your proxy cannot provide X-Forwarded-For
auth:
mode: password
password: ${OPENCLAW_GATEWAY_PASSWORD}
```
When `trustedProxies` is set, the Gateway uses `X-Forwarded-For` to determine client IP; `X-Real-IP` is ignored unless `gateway.allowRealIpFallback: true` is explicitly set. Ensure your proxy **overwrites** `X-Forwarded-For`/`X-Real-IP` rather than appending to them:
```nginx
# good
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
# bad: preserves/appends untrusted client-supplied values
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
```
Trusted proxy headers do not make node device pairing automatically trusted - `gateway.nodes.pairing.autoApproveCidrs` is a separate, disabled-by-default operator policy, and loopback-source trusted-proxy header paths stay excluded from node auto-approval even when loopback trusted-proxy auth is enabled (because local callers can forge those headers).
### HSTS and origin notes
- OpenClaw's gateway is local/loopback first. If you terminate TLS at a reverse proxy, set HSTS there.
- If the gateway itself terminates HTTPS, `gateway.http.securityHeaders.strictTransportSecurity` emits the HSTS header from OpenClaw responses.
- Non-loopback Control UI deployments require `gateway.controlUi.allowedOrigins` by default; `allowedOrigins: ["*"]` is an explicit allow-all policy, not a hardened default - avoid it outside tightly controlled local testing.
- Browser-origin auth failures on loopback are still rate-limited even with the general loopback exemption enabled, but the lockout key is scoped per normalized `Origin` value instead of one shared localhost bucket.
- `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true` enables Host-header origin fallback mode; treat it as a dangerous operator-selected policy.
- Treat DNS rebinding and proxy-host header behavior as deployment hardening concerns; keep `trustedProxies` tight and avoid exposing the gateway directly to the public internet.
- Detailed deployment guidance: [Trusted Proxy Auth](/gateway/trusted-proxy-auth#tls-termination-and-hsts).
### Control UI over HTTP
The Control UI needs a secure context (HTTPS or localhost) to generate device identity.
- `gateway.controlUi.allowInsecureAuth`: local compatibility toggle. On localhost, allows Control UI auth without device identity when the page loads over non-secure HTTP. Does not bypass pairing checks and does not relax remote (non-localhost) device identity requirements. Prefer HTTPS (Tailscale Serve) or open the UI on `127.0.0.1`.
- `gateway.controlUi.dangerouslyDisableDeviceAuth`: break-glass only, disables device identity checks entirely. Severe security downgrade; keep off unless actively debugging and able to revert quickly.
- Separate from those flags, a successful `gateway.auth.mode: "trusted-proxy"` can admit **operator** Control UI sessions without device identity - an intentional auth-mode behavior, not an `allowInsecureAuth` shortcut, and it does not extend to node-role Control UI sessions.
`openclaw security audit` warns when `allowInsecureAuth` is enabled.
### Insecure/dangerous flags
`openclaw security audit` raises `config.insecure_or_dangerous_flags` for each enabled known insecure/dangerous debug switch (one finding per flag). Keep these unset in production. If audit suppressions are configured, `security.audit.suppressions.active` stays in the active output even when matching findings move to `suppressedFindings`.
<AccordionGroup>
<Accordion title="Flags tracked by the audit today">
- `gateway.controlUi.allowInsecureAuth=true`
- `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true`
- `gateway.controlUi.dangerouslyDisableDeviceAuth=true`
- `security.audit.suppressions configured (<count>)`
- `hooks.gmail.allowUnsafeExternalContent=true`
- `hooks.mappings[<index>].allowUnsafeExternalContent=true`
- `tools.exec.applyPatch.workspaceOnly=false`
- `plugins.entries.acpx.config.permissionMode=approve-all`
</Accordion>
<Accordion title="All dangerous*/dangerously* keys in the config schema">
Control UI and browser:
- `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback`
- `gateway.controlUi.dangerouslyDisableDeviceAuth`
- `browser.ssrfPolicy.dangerouslyAllowPrivateNetwork`
Channel name-matching (bundled and plugin channels; also per `accounts.<accountId>` where applicable):
- `channels.discord.dangerouslyAllowNameMatching`
- `channels.googlechat.dangerouslyAllowNameMatching`
- `channels.msteams.dangerouslyAllowNameMatching`
- `channels.slack.dangerouslyAllowNameMatching`
- `channels.irc.dangerouslyAllowNameMatching` (plugin channel)
- `channels.mattermost.dangerouslyAllowNameMatching` (plugin channel)
- `channels.synology-chat.dangerouslyAllowNameMatching` (plugin channel)
- `channels.synology-chat.dangerouslyAllowInheritedWebhookPath` (plugin channel)
- `channels.zalouser.dangerouslyAllowNameMatching` (plugin channel)
Network exposure:
- `channels.telegram.network.dangerouslyAllowPrivateNetwork` (also per account)
Sandbox Docker (defaults + per-agent):
- `agents.defaults.sandbox.docker.dangerouslyAllowReservedContainerTargets`
- `agents.defaults.sandbox.docker.dangerouslyAllowExternalBindSources`
- `agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin`
</Accordion>
</AccordionGroup>
## Deployment and host trust
- Full-disk encryption on the gateway host; prefer a dedicated OS user account for the Gateway if the host is shared.
- Published package dependency lock: source checkouts use `pnpm-lock.yaml`; the published `openclaw` npm package and OpenClaw-owned npm plugin packages include `npm-shrinkwrap.json` so installs use the reviewed transitive dependency graph from the release instead of resolving a fresh graph at install time. This is a supply-chain hardening and release reproducibility boundary, not a sandbox - see [npm shrinkwrap](/gateway/security/shrinkwrap).
- Secure file operations: OpenClaw uses `@openclaw/fs-safe` for root-bounded file access, atomic writes, archive extraction, temp workspaces, and secret-file helpers. The optional POSIX Python helper defaults **off**; set `OPENCLAW_FS_SAFE_PYTHON_MODE=auto` or `require` only when you want the extra fd-relative mutation hardening and can support a Python runtime. Details: [Secure file operations](/gateway/security/secure-file-operations).
- Shared Slack workspace risk: if everyone in Slack can message the bot, the core risk is delegated tool authority - any allowed sender can induce tool calls (`exec`, browser, network/file tools) within the agent's policy, prompt/content injection from one sender can affect shared state/devices/outputs, and if the shared agent has sensitive credentials/files, any allowed sender can potentially drive exfiltration via tool usage. Use separate agents/gateways with minimal tools for team workflows; keep personal-data agents private.
- Company-shared agent (acceptable pattern): fine when everyone using the agent is in the same trust boundary (for example one company team) and the agent is strictly business-scoped. Run it on a dedicated machine/VM/container, use a dedicated OS user + dedicated browser/profile/accounts, and do not sign that runtime into personal Apple/Google accounts or personal password-manager/browser profiles. Mixing personal and company identities on the same runtime collapses the separation and increases personal-data exposure risk.
## Secrets on disk
Assume anything under `~/.openclaw/` (or `$OPENCLAW_STATE_DIR/`) may contain secrets or private data:
| Path | Contents |
| ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `openclaw.json` | Config may include tokens (gateway, remote gateway), provider settings, and allowlists. |
| `credentials/**` | Channel credentials (for example WhatsApp creds), pairing allowlists, legacy OAuth imports. |
| `agents/<agentId>/agent/auth-profiles.json` | API keys, token profiles, OAuth tokens, optional `keyRef`/`tokenRef`. |
| `agents/<agentId>/agent/codex-home/**` | Per-agent Codex app-server account, config, skills, plugins, native thread state, diagnostics (default). |
| `$CODEX_HOME/**` or `~/.codex/**` | Opt-in shared Codex runtime state, only when `plugins.entries.codex.config.appServer.homeScope` is `"user"`. Uses the native Codex account, config, plugins, and thread store; enable only for an owner-controlled local Gateway. See [Codex harness](/plugins/codex-harness#share-threads-with-codex-desktop-and-cli). |
| `secrets.json` (optional) | File-backed secret payload used by `file` SecretRef providers (`secrets.providers`). |
| `agents/<agentId>/agent/auth.json` | Legacy compatibility file; static `api_key` entries are scrubbed when discovered. |
| `agents/<agentId>/sessions/**` | Session transcripts (`*.jsonl`) + routing metadata (`sessions.json`) that can contain private messages and tool output. |
| bundled plugin packages | Installed plugins (plus their `node_modules/`). |
| `sandboxes/**` | Tool sandbox workspaces; can accumulate copies of files read/written inside the sandbox. |
### Credential storage map
Also useful for backup decisions:
- WhatsApp: `~/.openclaw/credentials/whatsapp/<accountId>/creds.json`
- Telegram bot token: config/env or `channels.telegram.tokenFile` (regular file only; symlinks rejected)
- Discord bot token: config/env or SecretRef (env/file/exec providers)
- Slack tokens: config/env (`channels.slack.*`)
- Pairing allowlists: `~/.openclaw/credentials/<channel>-allowFrom.json` (default account) / `<channel>-<accountId>-allowFrom.json` (non-default accounts)
- Model auth profiles: `~/.openclaw/agents/<agentId>/agent/auth-profiles.json`
- Legacy OAuth import: `~/.openclaw/credentials/oauth.json`
Hardening: keep permissions tight (`700` on dirs, `600` on files); use full-disk encryption on the gateway host; prefer a dedicated OS user account if the host is shared.
### File permissions
- `~/.openclaw/openclaw.json`: `600` (user read/write only)
- `~/.openclaw`: `700` (user only)
`openclaw doctor` can warn and offer to tighten these.
### Workspace `.env` files
OpenClaw loads workspace-local `.env` files for agents and tools, but never lets them silently override gateway runtime controls:
- Provider credential environment variables are blocked from untrusted workspace `.env` files - for example `GEMINI_API_KEY`, `GOOGLE_API_KEY`, `XAI_API_KEY`, `MISTRAL_API_KEY`, `GROQ_API_KEY`, `DEEPSEEK_API_KEY`, `PERPLEXITY_API_KEY`, `BRAVE_API_KEY`, `TAVILY_API_KEY`, `EXA_API_KEY`, `FIRECRAWL_API_KEY`, and provider auth keys declared by installed trusted plugins. Put provider credentials in the Gateway process environment, `~/.openclaw/.env` (`$OPENCLAW_STATE_DIR/.env`), the config `env` block, or an optional login-shell import instead.
- Any key starting with `OPENCLAW_` is blocked from untrusted workspace `.env` files, reserving the whole runtime namespace so a future `OPENCLAW_*` control is fail-closed by default rather than silently inheritable from checked-in or attacker-supplied `.env` content.
- Channel endpoint settings for Matrix, Mattermost, IRC, and Synology Chat are also blocked from workspace `.env` overrides (for example `MATRIX_HOMESERVER`, `MATTERMOST_URL`, `IRC_HOST`, `SYNOLOGY_CHAT_INCOMING_URL`), so a cloned workspace cannot redirect bundled connector traffic through local endpoint config. These must come from the gateway process environment or `env.shellEnv`.
- Trusted process/OS environment variables, global runtime dotenv, config `env`, and enabled login-shell import still apply - this only constrains workspace `.env` file loading.
Workspace `.env` files frequently live next to agent code, get committed by accident, or get written by tools; blocking provider credentials prevents a cloned workspace from substituting attacker-controlled provider accounts.
### Logs and transcripts
OpenClaw stores session transcripts on disk under `~/.openclaw/agents/<agentId>/sessions/*.jsonl` for session continuity and optional memory indexing - any process/user with filesystem access can read them. Treat disk access as the trust boundary and lock down `~/.openclaw` permissions; run agents under separate OS users or hosts for stronger isolation.
Gateway logs may include tool summaries, errors, and URLs; session transcripts can include pasted secrets, file contents, command output, and links.
- Keep log/transcript redaction on (`logging.redactSensitive: "tools"`, default).
- Add custom patterns for your environment via `logging.redactPatterns` (tokens, hostnames, internal URLs).
- When sharing diagnostics, prefer `openclaw status --all` (pasteable, secrets redacted) over raw logs.
- Prune old session transcripts and log files if you do not need long retention.
Details: [Logging](/gateway/logging)
## Secure baseline (copy/paste)
```json5
{
gateway: {
mode: "local",
bind: "loopback",
port: 18789,
auth: { mode: "token", token: "your-long-random-token" },
},
channels: {
whatsapp: {
dmPolicy: "pairing",
groups: { "*": { requireMention: true } },
},
},
}
```
Keeps the Gateway private, requires DM pairing, and avoids always-on group bots. For safer tool execution too, add a sandbox + deny dangerous tools for any non-owner agent (see "Per-agent access profiles" above).
### Separate numbers (WhatsApp, Signal, Telegram)
For phone-number-based channels, consider running the assistant on a separate number from your personal one, so personal conversations stay private and the bot number handles automation with its own boundaries.
## Incident response
### Contain
1. Stop it: stop the macOS app (if it supervises the Gateway) or terminate your `openclaw gateway` process.
2. Close exposure: set `gateway.bind: "loopback"` (or disable Tailscale Funnel/Serve) until you understand what happened.
3. Freeze access: switch risky DMs/groups to `dmPolicy: "disabled"` / require mentions, and remove any `"*"` allow-all entries.
### Rotate (assume compromise if secrets leaked)
1. Rotate Gateway auth (`gateway.auth.token` / `OPENCLAW_GATEWAY_PASSWORD`) and restart.
2. Rotate remote client secrets (`gateway.remote.token` / `.password`) on any machine that can call the Gateway.
3. Rotate provider/API credentials (WhatsApp creds, Slack/Discord tokens, model/API keys in `auth-profiles.json`, and encrypted secrets payload values when used).
### Audit
1. Check Gateway logs: `/tmp/openclaw/openclaw-YYYY-MM-DD.log` (or `logging.file`).
2. Review the relevant transcript(s): `~/.openclaw/agents/<agentId>/sessions/*.jsonl`.
3. Review recent config changes that could have widened access: `gateway.bind`, `gateway.auth`, DM/group policies, `tools.elevated`, plugin changes.
4. Re-run `openclaw security audit --deep` and confirm critical findings are resolved.
### Collect for a report
- Timestamp, gateway host OS + OpenClaw version.
- The session transcript(s) + a short log tail (after redacting).
- What the attacker sent and what the agent did.
- Whether the Gateway was exposed beyond loopback (LAN/Tailscale Funnel/Serve).
## Secret scanning
CI runs the pre-commit `detect-private-key` hook over the repository. If it fails, remove or rotate the committed key material, then reproduce locally:
```bash
pre-commit run --all-files detect-private-key
```
## Reporting security issues
Found a vulnerability in OpenClaw? Report responsibly:
1. Email: [security@openclaw.ai](mailto:security@openclaw.ai)
2. Do not post publicly until fixed.
3. We will credit you (unless you prefer anonymity).

View File

@@ -0,0 +1,74 @@
---
summary: "How OpenClaw handles local file access safely, and why the optional fs-safe Python helper is off by default"
read_when:
- Changing file access, archive extraction, workspace storage, or plugin filesystem helpers
title: "Secure file operations"
---
OpenClaw uses [`@openclaw/fs-safe`](https://github.com/openclaw/fs-safe) for security-sensitive local file operations: root-bounded reads/writes, atomic replacement, archive extraction, temp workspaces, JSON state, and secret-file handling.
It is a **library guardrail** for trusted OpenClaw code that receives untrusted path names, not a sandbox. Host filesystem permissions, OS users, containers, and the agent/tool policy still define the real blast radius.
## Default: no Python helper
OpenClaw sets the fs-safe POSIX Python helper to **off** by default:
- the gateway should not spawn a persistent Python sidecar unless an operator opts in;
- most installs do not need the extra parent-directory mutation hardening;
- disabling Python keeps runtime behavior predictable across desktop, Docker, CI, and bundled-app environments.
OpenClaw only changes the _default_. An explicit setting always wins:
```bash
# Default OpenClaw behavior: Node-only fs-safe fallbacks.
OPENCLAW_FS_SAFE_PYTHON_MODE=off
# Opt into the helper when available, falling back if unavailable.
OPENCLAW_FS_SAFE_PYTHON_MODE=auto
# Fail closed if the helper cannot start.
OPENCLAW_FS_SAFE_PYTHON_MODE=require
# Optional explicit interpreter path.
OPENCLAW_FS_SAFE_PYTHON=/usr/bin/python3
```
The generic fs-safe env names also work: `FS_SAFE_PYTHON_MODE` and `FS_SAFE_PYTHON`.
Use `require` (not `auto`) when the helper is part of your security posture; `auto` silently falls back to Node-only behavior if the helper cannot start.
## What stays protected without Python
With the helper off, OpenClaw still gets fs-safe's Node-only guardrails:
- rejects relative-path escapes (`..`), absolute paths, and path separators where only bare names are allowed;
- resolves operations through a trusted root handle instead of ad-hoc `path.resolve(...).startsWith(...)` checks;
- refuses symlink and hardlink patterns on APIs that require that policy;
- opens files with identity checks where the API returns or consumes file contents;
- writes state/config files via atomic sibling-temp + rename;
- enforces byte limits for reads and archive extraction;
- applies private file modes for secrets and state files where the API requires them.
This covers OpenClaw's normal threat model: trusted gateway code handling untrusted model/plugin/channel path input inside a single trusted operator boundary.
## What Python adds
On POSIX, the optional helper keeps one persistent Python process and uses fd-relative filesystem operations for parent-directory mutations: rename, remove, mkdir, stat/list, and some write paths.
That narrows same-UID race windows where another process swaps a parent directory between validation and mutation — defense in depth on hosts where untrusted local processes can modify the same directories OpenClaw operates in.
If your deployment has that risk and Python is guaranteed to exist, set:
```bash
OPENCLAW_FS_SAFE_PYTHON_MODE=require
```
## Plugin and core guidance
- Plugin-facing file access should go through `openclaw/plugin-sdk/*` helpers, not raw `fs`, when a path comes from a message, model output, config, or plugin input.
- Core code should use the fs-safe wrappers under `src/infra/*` so OpenClaw's process policy applies consistently.
- Archive extraction should use the fs-safe archive helpers with explicit size, entry-count, link, and destination limits.
- Secrets should use OpenClaw secret helpers or fs-safe secret/private-state helpers; do not hand-roll mode checks around `fs.writeFile`.
- For hostile local-user isolation, do not rely on fs-safe alone. Run separate gateways under separate OS users/hosts, or use sandboxing.
Related: [Security](/gateway/security), [Sandboxing](/gateway/sandboxing), [Exec approvals](/tools/exec-approvals), [Secrets](/gateway/secrets).

View File

@@ -0,0 +1,79 @@
---
summary: "Plain-English and technical explanation of npm shrinkwrap in OpenClaw releases"
read_when:
- You want to know what npm shrinkwrap means in an OpenClaw release
- You are reviewing package lockfiles, dependency changes, or supply-chain risk
- You are validating root or plugin npm packages before publishing
title: "npm shrinkwrap"
---
OpenClaw source checkouts use `pnpm-lock.yaml`. Published OpenClaw npm packages use `npm-shrinkwrap.json`, npm's publishable dependency lockfile, so package installs use the dependency graph reviewed during release.
## Why it matters
Shrinkwrap is a receipt for the dependency tree that ships with an npm package: it tells npm which exact transitive versions to install.
| File | Where it matters | What it means |
| --------------------- | ------------------------ | --------------------------------- |
| `pnpm-lock.yaml` | OpenClaw source checkout | Maintainer dependency graph |
| `npm-shrinkwrap.json` | Published npm package | npm install graph for users |
| `package-lock.json` | Local npm apps | Not the OpenClaw publish contract |
For OpenClaw releases this means:
- the published package does not ask npm to invent a fresh dependency graph at install time;
- dependency changes are reviewable because they land in a lockfile diff;
- release validation tests the same graph users will install;
- package-size or native-dependency surprises surface before publishing.
Shrinkwrap is not a sandbox. It does not make a dependency safe by itself, and it does not replace host isolation, `openclaw security audit`, package provenance, or install smoke tests.
OpenClaw is a gateway, plugin host, model router, and agent runtime, so a default install affects startup time, disk use, native package downloads, and supply-chain exposure. Shrinkwrap gives release review a stable boundary: reviewers see transitive dependency movement, validators reject unexpected lockfile drift, and plugin packages carry their own locked dependency graph instead of relying on the root package.
## Generating and checking
The root `openclaw` npm package, OpenClaw-owned npm plugin packages (for example `@openclaw/discord`), and publishable workspace packages such as [`@openclaw/ai`](/reference/openclaw-ai) include `npm-shrinkwrap.json` when they publish. Workspace dependencies are omitted from the root shrinkwrap because they publish beside the root package; each publishable workspace package pins its own transitive tree instead. Suitable plugin packages can also publish with explicit `bundledDependencies`, carrying their runtime dependency files in the plugin tarball instead of relying only on install-time resolution.
```bash
# All shrinkwrap-managed packages (root + publishable plugins)
pnpm deps:shrinkwrap:generate
pnpm deps:shrinkwrap:check
# Root package only
pnpm deps:shrinkwrap:root:generate
pnpm deps:shrinkwrap:root:check
# Only packages affected by the current changeset
pnpm deps:shrinkwrap:changed:generate
pnpm deps:shrinkwrap:changed:check
```
The generator resolves npm's publishable lock format but rejects generated package versions that are not already present in `pnpm-lock.yaml`. That keeps the pnpm dependency age, override, and patch-review boundary intact.
Review these as security-sensitive:
- `pnpm-lock.yaml`
- `npm-shrinkwrap.json`
- bundled plugin dependency payloads
- any `package-lock.json` diff
OpenClaw package validators require shrinkwrap in new root package tarballs and reject `package-lock.json` for published packages. The plugin npm publish path checks plugin-local shrinkwrap, installs package-local bundled dependencies, then packs or publishes.
## Inspecting a published package
Root package:
```bash
npm pack openclaw@<version> --json --pack-destination /tmp/openclaw-pack
tar -tf /tmp/openclaw-pack/openclaw-<version>.tgz | grep '^package/npm-shrinkwrap.json$'
```
Plugin package:
```bash
npm pack @openclaw/discord@<version> --json --pack-destination /tmp/openclaw-plugin-pack
tar -tf /tmp/openclaw-plugin-pack/openclaw-discord-<version>.tgz | grep '^package/npm-shrinkwrap.json$'
tar -tf /tmp/openclaw-plugin-pack/openclaw-discord-<version>.tgz | grep '^package/node_modules/'
```
Background: [npm-shrinkwrap.json](https://docs.npmjs.com/cli/v11/configuring-npm/npm-shrinkwrap-json).

153
docs/gateway/tailscale.md Normal file
View File

@@ -0,0 +1,153 @@
---
summary: "Integrated Tailscale Serve/Funnel for the Gateway dashboard"
read_when:
- Exposing the Gateway Control UI outside localhost
- Automating tailnet or public dashboard access
title: "Tailscale"
---
OpenClaw can auto-configure Tailscale **Serve** (tailnet) or **Funnel** (public) for the Gateway dashboard and WebSocket port. This keeps the gateway bound to loopback while Tailscale provides HTTPS, routing, and (for Serve) identity headers.
## Modes
`gateway.tailscale.mode`:
| Mode | Behavior |
| --------------- | --------------------------------------------------------------------------- |
| `serve` | Tailnet-only Serve via `tailscale serve`. The gateway stays on `127.0.0.1`. |
| `funnel` | Public HTTPS via `tailscale funnel`. Requires a shared password. |
| `off` (default) | No Tailscale automation. |
Status and audit output use **Tailscale exposure** for this OpenClaw Serve/Funnel mode. `off` means OpenClaw is not managing Serve or Funnel; it does not mean the local Tailscale daemon is stopped or logged out.
## Config examples
### Tailnet-only (Serve)
```json5
{
gateway: {
bind: "loopback",
tailscale: { mode: "serve" },
},
}
```
Open: `https://<magicdns>/` (or your configured `gateway.controlUi.basePath`)
To expose the Control UI through a named Tailscale Service instead of the device hostname, set `gateway.tailscale.serviceName` to the Service name:
```json5
{
gateway: {
bind: "loopback",
tailscale: { mode: "serve", serviceName: "svc:openclaw" },
},
}
```
Startup then reports the Service URL as `https://openclaw.<tailnet-name>.ts.net/` instead of the device hostname. Tailscale Services require the host to be an approved tagged node in your tailnet — configure the tag and approve the Service in Tailscale before enabling this, otherwise `tailscale serve --service=...` fails during gateway startup.
### Tailnet-only (bind to Tailnet IP)
Use this to have the gateway listen directly on the Tailnet IP, with no Serve/Funnel:
```json5
{
gateway: {
bind: "tailnet",
auth: { mode: "token", token: "your-token" },
},
}
```
Connect from another Tailnet device:
- Control UI: `http://<tailscale-ip>:18789/`
- WebSocket: `ws://<tailscale-ip>:18789`
<Note>
Loopback (`http://127.0.0.1:18789`) will **not** work in this mode.
</Note>
### Public internet (Funnel + shared password)
```json5
{
gateway: {
bind: "loopback",
tailscale: { mode: "funnel" },
auth: { mode: "password", password: "replace-me" },
},
}
```
Prefer `OPENCLAW_GATEWAY_PASSWORD` over committing a password to disk.
## CLI examples
```bash
openclaw gateway --tailscale serve
openclaw gateway --tailscale funnel --auth password
```
## Auth
`gateway.auth.mode` controls the handshake:
| Mode | Use case |
| ------------------------------------------------------ | ----------------------------------------------------------------------------------- |
| `none` | Private ingress only |
| `token` (default when `OPENCLAW_GATEWAY_TOKEN` is set) | Shared token |
| `password` | Shared secret via `OPENCLAW_GATEWAY_PASSWORD` or config |
| `trusted-proxy` | Identity-aware reverse proxy; see [Trusted Proxy Auth](/gateway/trusted-proxy-auth) |
### Tailscale identity headers (Serve only)
When `tailscale.mode: "serve"` and `gateway.auth.allowTailscale` is `true`, Control UI/WebSocket auth can use Tailscale identity headers (`tailscale-user-login`) instead of a token/password. OpenClaw verifies the header by resolving the request's `x-forwarded-for` address via the local Tailscale daemon (`tailscale whois`) and matching it to the header login before accepting it. A request only qualifies for this path when it arrives from loopback carrying Tailscale's `x-forwarded-for`, `x-forwarded-proto`, and `x-forwarded-host` headers.
This tokenless flow assumes the gateway host is trusted. If untrusted local code may run on the same host, set `gateway.auth.allowTailscale: false` and require token/password auth instead.
Scope of the bypass:
- Applies only to the Control UI WebSocket auth surface. HTTP API endpoints (`/v1/*`, `/tools/invoke`, `/api/channels/*`, etc.) never use Tailscale identity-header auth; they always follow the gateway's normal HTTP auth mode.
- For Control UI operator sessions that already carry browser device identity, a verified Tailscale identity skips the bootstrap-token/QR pairing round trip.
- It does not bypass device identity itself: device-less clients are still rejected, and node-role connections still go through normal pairing and auth checks.
## Notes
- Tailscale Serve/Funnel requires the `tailscale` CLI installed and logged in.
- `tailscale.mode: "funnel"` refuses to start unless auth mode is `password`, to avoid public exposure.
- `gateway.tailscale.serviceName` applies only to Serve mode and is passed to `tailscale serve --service=<name>`. The value must use Tailscale's `svc:<dns-label>` format, for example `svc:openclaw`. Tailscale requires Service hosts to be tagged nodes, and the Service may need admin-console approval before Serve can publish it.
- `gateway.tailscale.resetOnExit` undoes `tailscale serve`/`tailscale funnel` configuration on shutdown.
- `gateway.tailscale.preserveFunnel: true` keeps an externally configured `tailscale funnel` route alive across gateway restarts. With `mode: "serve"`, OpenClaw checks `tailscale funnel status` before re-applying Serve and skips it when a Funnel route already covers the gateway port. The OpenClaw-managed Funnel password-only policy is unchanged.
- `gateway.bind: "tailnet"` is a direct Tailnet bind (no HTTPS, no Serve/Funnel).
- `gateway.bind: "auto"` prefers loopback; use `tailnet` for Tailnet-only binding.
- Serve/Funnel only expose the **Gateway control UI + WS**. Nodes connect over the same Gateway WS endpoint, so Serve works for node access too.
### Tailscale prerequisites and limits
- Serve requires HTTPS enabled for your tailnet; the CLI prompts if it is missing.
- Serve injects Tailscale identity headers; Funnel does not.
- Funnel requires Tailscale v1.38.3+, MagicDNS, HTTPS enabled, and a funnel node attribute.
- Funnel only supports ports `443`, `8443`, and `10000` over TLS.
- Funnel on macOS requires the open-source Tailscale app variant.
## Browser control (remote Gateway + local browser)
To run the Gateway on one machine but drive a browser on another, run a **node host** on the browser machine and keep both on the same tailnet. The Gateway proxies browser actions to the node; no separate control server or Serve URL is needed.
Avoid Funnel for browser control; treat node pairing like operator access.
## Learn more
- Tailscale Serve overview: [https://tailscale.com/kb/1312/serve](https://tailscale.com/kb/1312/serve)
- `tailscale serve` command: [https://tailscale.com/kb/1242/tailscale-serve](https://tailscale.com/kb/1242/tailscale-serve)
- Tailscale Funnel overview: [https://tailscale.com/kb/1223/tailscale-funnel](https://tailscale.com/kb/1223/tailscale-funnel)
- `tailscale funnel` command: [https://tailscale.com/kb/1311/tailscale-funnel](https://tailscale.com/kb/1311/tailscale-funnel)
## Related
- [Remote access](/gateway/remote)
- [Discovery](/gateway/discovery)
- [Authentication](/gateway/authentication)

View File

@@ -0,0 +1,165 @@
---
summary: "Invoke a single tool directly via the Gateway HTTP endpoint"
read_when:
- Calling tools without running a full agent turn
- Building automations that need tool policy enforcement
title: "Tools invoke API"
---
OpenClaw's Gateway exposes an HTTP endpoint for invoking a single tool directly. It is always enabled and uses Gateway auth plus tool policy. Like the OpenAI-compatible `/v1/*` surface, shared-secret bearer auth is treated as trusted operator access for the whole gateway.
- `POST /tools/invoke`
- Same port as the Gateway (WS + HTTP multiplex): `http://<gateway-host>:<port>/tools/invoke`
- Default max request body size: 2 MB
## Authentication
Uses the Gateway auth configuration.
Common HTTP auth paths:
- shared-secret auth (`gateway.auth.mode="token"` or `"password"`): `Authorization: Bearer <token-or-password>`
- trusted identity-bearing HTTP auth (`gateway.auth.mode="trusted-proxy"`): route through the configured identity-aware proxy and let it inject the required identity headers
- private-ingress open auth (`gateway.auth.mode="none"`): no auth header required
Notes:
- `mode="token"` uses `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`).
- `mode="password"` uses `gateway.auth.password` (or `OPENCLAW_GATEWAY_PASSWORD`).
- `mode="trusted-proxy"` requires the HTTP request to come from a configured trusted proxy source; same-host loopback proxies require explicit `gateway.auth.trustedProxy.allowLoopback = true`.
- Internal same-host callers that bypass the proxy can use `gateway.auth.password` / `OPENCLAW_GATEWAY_PASSWORD` as a local direct fallback. Any `Forwarded`, `X-Forwarded-*`, or `X-Real-IP` header evidence keeps the request on the trusted-proxy path instead.
- If `gateway.auth.rateLimit` is configured and too many auth failures occur, the endpoint returns `429` with `Retry-After`.
## Security boundary (important)
Treat this endpoint as a **full operator-access** surface for the gateway instance.
- HTTP bearer auth here is not a narrow per-user scope model.
- A valid Gateway token/password for this endpoint should be treated like an owner/operator credential.
- For shared-secret auth modes (`token` and `password`), the endpoint restores the normal full operator defaults even if the caller sends a narrower `x-openclaw-scopes` header.
- Shared-secret auth also treats direct tool invokes on this endpoint as owner-sender turns.
- Trusted identity-bearing HTTP modes (trusted proxy auth, or `gateway.auth.mode="none"` on a private ingress) honor `x-openclaw-scopes` when present and otherwise fall back to the normal operator default scope set.
- Keep this endpoint on loopback/tailnet/private ingress only; do not expose it directly to the public internet.
Auth matrix:
| Auth mode | Behavior |
| --------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `token` or `password` + `Authorization: Bearer ...` | Proves possession of the shared gateway operator secret. Ignores narrower `x-openclaw-scopes`. Restores the full default operator scope set: `operator.admin`, `operator.approvals`, `operator.pairing`, `operator.read`, `operator.talk.secrets`, `operator.write`. Treats direct tool invokes as owner-sender turns. |
| Trusted identity-bearing HTTP (trusted proxy auth, or `mode="none"` on private ingress) | Authenticates an outer trusted identity or deployment boundary. Honors `x-openclaw-scopes` when present. Falls back to the normal operator default scope set when the header is absent. Only loses owner semantics when the caller explicitly narrows scopes and omits `operator.admin`. |
## Request body
```json
{
"tool": "sessions_list",
"action": "json",
"args": {},
"sessionKey": "main",
"dryRun": false
}
```
Fields:
- `tool` / `name` (string, required): tool name to invoke. `name` takes precedence if both are sent.
- `action` (string, optional): merged into `args.action` if the tool schema supports an `action` property and `args` did not already set one.
- `args` (object, optional): tool-specific arguments.
- `sessionKey` (string, optional): target session key. If omitted or `"main"`, the Gateway uses the configured main session key (honors `session.mainKey` and the default agent, or `global` in global session scope).
- `agentId` (string, optional): resolves the session key for that agent. Errors with `400` if it conflicts with an explicit `sessionKey` that already maps to a different agent.
- `idempotencyKey` (string, optional): used to derive a stable tool-call id for the invocation.
- `dryRun` (boolean, optional): reserved for future use; currently ignored.
## Policy + routing behavior
Tool availability is filtered through the same policy chain used by Gateway agents:
- `tools.profile` / `tools.byProvider.profile`
- `tools.allow` / `tools.byProvider.allow`
- `agents.<id>.tools.allow` / `agents.<id>.tools.byProvider.allow`
- group policies (if the session key maps to a group or channel)
- subagent policy (when invoking with a subagent session key)
If a tool is not allowed by policy, the endpoint returns **404**.
Important boundary notes:
- Exec approvals are operator guardrails, not a separate authorization boundary for this HTTP endpoint. If a tool is reachable here via Gateway auth + tool policy, `/tools/invoke` does not add an extra per-call approval prompt.
- If `exec` is reachable here, treat it as a mutating shell surface. Denying `write`, `edit`, `apply_patch`, or HTTP filesystem-write tools does not make shell execution read-only.
- Do not share Gateway bearer credentials with untrusted callers. If you need separation across trust boundaries, run separate gateways (ideally on separate OS users/hosts).
Gateway HTTP also applies a hard deny list by default (even if session policy allows the tool):
| Tool | Reason |
| ---------------- | --------------------------------------------------------- |
| `exec` | Direct command execution (RCE surface) |
| `spawn` | Arbitrary child process creation (RCE surface) |
| `shell` | Shell command execution (RCE surface) |
| `fs_write` | Arbitrary file mutation on the host |
| `fs_delete` | Arbitrary file deletion on the host |
| `fs_move` | Arbitrary file move/rename on the host |
| `apply_patch` | Patch application can rewrite arbitrary files |
| `sessions_spawn` | Session orchestration; spawning agents remotely is RCE |
| `sessions_send` | Cross-session message injection |
| `cron` | Persistent automation control plane |
| `gateway` | Gateway control plane; prevents reconfiguration via HTTP |
| `nodes` | Node command relay can reach `system.run` on paired hosts |
`cron`, `gateway`, and `nodes` are also owner-only: even outside this default deny list, non-owner callers cannot invoke them on this surface.
Customize the general deny list via `gateway.tools`:
```json5
{
gateway: {
tools: {
// Additional tools to block over HTTP /tools/invoke
deny: ["browser"],
// Remove tools from the default deny list for owner/admin callers
allow: ["gateway"],
},
},
}
```
`gateway.tools.allow` is an exposure override, not a scope upgrade. In identity-bearing HTTP modes, `cron`, `gateway`, and `nodes` remain unavailable to callers without owner/admin identity (`operator.admin`) even when listed in `gateway.tools.allow`. Shared-secret bearer auth still follows the full trusted-operator rule above.
To help group policies resolve context, you can optionally set:
- `x-openclaw-message-channel: <channel>` (example: `slack`, `telegram`)
- `x-openclaw-account-id: <accountId>` (when multiple accounts exist)
- `x-openclaw-message-to: <target>` (delivery target for message-tool policy)
- `x-openclaw-thread-id: <threadId>` (thread context for message-tool policy)
## Responses
| Status | Meaning |
| ------ | ---------------------------------------------------------------------------------------------- |
| `200` | `{ ok: true, result }` |
| `400` | `{ ok: false, error: { type, message } }` (invalid request or tool input error) |
| `401` | Unauthorized |
| `403` | `{ ok: false, error: { type, message, requiresApproval? } }` (tool call blocked by policy) |
| `404` | Tool not available (not found or not allowlisted) |
| `405` | Method not allowed |
| `408` | Request body read timed out |
| `413` | Request body exceeded the max payload size |
| `429` | Auth rate-limited (`Retry-After` set) |
| `500` | `{ ok: false, error: { type, message } }` (unexpected tool execution error; sanitized message) |
## Example
```bash
curl -sS http://127.0.0.1:18789/tools/invoke \
-H 'Authorization: Bearer secret' \
-H 'Content-Type: application/json' \
-d '{
"tool": "sessions_list",
"action": "json",
"args": {}
}'
```
## Related
- [Gateway protocol](/gateway/protocol)
- [Tools and plugins](/tools)

View File

@@ -0,0 +1,853 @@
---
summary: "Deep troubleshooting runbook for gateway, channels, automation, nodes, and browser"
read_when:
- The troubleshooting hub pointed you here for deeper diagnosis
- You need stable symptom based runbook sections with exact commands
title: "Troubleshooting"
sidebarTitle: "Troubleshooting"
---
This is the deep runbook. Start at [/help/troubleshooting](/help/troubleshooting) for the fast triage flow first.
## Command ladder
Run in this order:
```bash
openclaw status
openclaw gateway status
openclaw logs --follow
openclaw doctor
openclaw channels status --probe
```
Healthy signals:
- `openclaw gateway status` shows `Runtime: running`, `Connectivity probe: ok`, and a `Capability: ...` line.
- `openclaw doctor` reports no blocking config/service issues.
- `openclaw channels status --probe` shows live per-account transport status and, where supported, `works` or `audit ok`.
## After an update
Use when an update finishes but the Gateway is down, channels are empty, or model calls fail with 401s.
```bash
openclaw status --all
openclaw update status --json
openclaw gateway status --deep
openclaw doctor --fix
openclaw gateway restart
```
Look for:
- `Update restart` in `openclaw status` / `openclaw status --all`. Pending or failed handoffs include the next command to run.
- `plugin load failed: dependency tree corrupted; run openclaw doctor --fix` under Channels: the channel config still exists, but plugin registration failed before the channel could load.
- Provider 401s after re-auth: `openclaw doctor --fix` checks for stale per-agent OAuth auth shadows and removes old copies so all agents resolve the current shared profile.
## Split brain installs and newer config guard
Use when a gateway service unexpectedly stops after an update, or logs show one `openclaw` binary is older than the version that last wrote `openclaw.json`.
OpenClaw stamps config writes with `meta.lastTouchedVersion`. Read-only commands can inspect a config written by a newer OpenClaw, but process and service mutations refuse to run from an older binary. Blocked actions: gateway service start/stop/restart/uninstall, forced service reinstall, service-mode gateway startup, and `gateway --force` port cleanup.
```bash
which openclaw
openclaw --version
openclaw gateway status --deep
openclaw config get meta.lastTouchedVersion
```
<Steps>
<Step title="Fix PATH">
Fix `PATH` so `openclaw` resolves to the newer install, then rerun the action.
</Step>
<Step title="Reinstall the gateway service">
Reinstall the intended gateway service from the newer install:
```bash
openclaw gateway install --force
openclaw gateway restart
```
</Step>
<Step title="Remove stale wrappers">
Remove stale system package or old wrapper entries that still point at an old `openclaw` binary.
</Step>
</Steps>
<Warning>
For intentional downgrade or emergency recovery only, set `OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS=1` for the single command. Leave it unset for normal operation.
</Warning>
## Protocol mismatch after rollback
Use when logs keep printing `protocol mismatch` after a downgrade or rollback. An older Gateway is running, but a newer local client process is still reconnecting with a protocol range the older Gateway cannot speak.
```bash
openclaw --version
which -a openclaw
openclaw gateway status --deep
openclaw doctor --deep
openclaw logs --follow
```
Look for:
- `protocol mismatch ... client=... v<version> min=<n> max=<n> expected=<n>` in Gateway logs.
- `Established clients:` in `openclaw gateway status --deep` or `Gateway clients` in `openclaw doctor --deep`: active TCP clients connected to the Gateway port, with PIDs and command lines when the OS allows it.
- A client process whose command line points at the newer OpenClaw install or wrapper you rolled back from.
Fix:
1. Stop or restart the stale OpenClaw client process shown by `gateway status --deep`.
2. Restart apps or wrappers that embed OpenClaw: local dashboards, editors, app-server helpers, or long-running `openclaw logs --follow` shells.
3. Re-run `openclaw gateway status --deep` or `openclaw doctor --deep` and confirm the stale client PID is gone.
Do not make an older Gateway accept a newer incompatible protocol. Protocol bumps protect the wire contract; rollback recovery is a process/version cleanup problem.
## Skill symlink skipped as path escape
Use when logs include:
```text
Skipping escaped skill path outside its configured root: ... reason=symlink-escape
```
Every skill root is a containment boundary. A symlink under `~/.agents/skills`, `<workspace>/.agents/skills`, `<workspace>/skills`, or `~/.openclaw/skills` is skipped when its real target resolves outside that root, unless the target is explicitly trusted.
Inspect the link:
```bash
ls -l ~/.agents/skills/<name>
realpath ~/.agents/skills/<name>
openclaw config get skills.load
```
If the target is intentional, configure both the direct skill root and the allowed symlink target:
```json5
{
skills: {
load: {
extraDirs: ["~/Projects/manager/skills"],
allowSymlinkTargets: ["~/Projects/manager/skills"],
},
},
}
```
Then start a new session or wait for the skills watcher to refresh. Restart the gateway if the running process predates the config change.
Do not use broad targets such as `~`, `/`, or a whole synced project folder. Keep `allowSymlinkTargets` scoped to the real skill root that contains trusted `SKILL.md` directories.
If Skill Workshop apply should also write through those trusted symlinked workspace skill paths, enable `skills.workshop.allowSymlinkTargetWrites`. Keep it disabled for read-only shared skill roots.
Related:
- [Skills config](/tools/skills-config#symlinked-skill-roots)
- [Configuration examples](/gateway/configuration-examples#symlinked-sibling-skill-repo)
## Anthropic 429 extra usage required for long context
Use when logs/errors include: `HTTP 429: rate_limit_error: Extra usage is required for long context requests`.
```bash
openclaw logs --follow
openclaw models status
openclaw config get agents.defaults.models
```
Look for:
- Selected Anthropic model is a GA-capable 1M Claude 4.x model (Opus 4.6/4.7/4.8, Sonnet 4.6), or the model config still carries legacy `params.context1m: true`.
- Current Anthropic credential is not eligible for long-context usage.
- Requests fail only on long sessions/model runs that need the 1M context path.
Fix options:
<Steps>
<Step title="Use a standard context window">
Switch to a standard-window model, or remove legacy `context1m` from older
model config that is not GA-capable for 1M context.
</Step>
<Step title="Use an eligible credential">
Use an Anthropic credential that is eligible for long-context requests, or switch to an Anthropic API key.
</Step>
<Step title="Configure fallback models">
Configure fallback models so runs continue when Anthropic long-context requests are rejected.
</Step>
</Steps>
Related:
- [Anthropic](/providers/anthropic)
- [Token use and costs](/reference/token-use)
- [Why am I seeing HTTP 429 from Anthropic?](/help/faq-first-run#why-am-i-seeing-http-429-ratelimiterror-from-anthropic)
## Upstream 403 blocked responses
Use when an upstream LLM provider returns a generic `403` such as `Your request was blocked`.
Do not assume this is always an OpenClaw configuration issue. The response can come from an upstream security layer such as a CDN, WAF, bot-management rule, or reverse proxy in front of an OpenAI-compatible endpoint.
```bash
openclaw status
openclaw gateway status
openclaw logs --follow
```
Look for:
- Multiple models under the same provider failing the same way.
- HTML or generic security text instead of a normal provider API error.
- Provider-side security events for the same request time.
- A tiny direct `curl` probe succeeding while normal SDK-shaped requests fail.
Fix the provider-side filtering first when evidence points to a WAF/CDN block. Prefer a narrowly scoped allow or skip rule for the API path OpenClaw uses, and avoid disabling protection for the whole site.
<Warning>
A successful minimal `curl` does not guarantee that real SDK-style requests will pass through the same upstream security layer.
</Warning>
Related:
- [OpenAI-compatible endpoints](/gateway/configuration-reference#openai-compatible-endpoints)
- [Provider configuration](/providers)
- [Logs](/logging)
## Local OpenAI-compatible backend passes direct probes but agent runs fail
Use when:
- `curl ... /v1/models` works.
- Tiny direct `/v1/chat/completions` calls work.
- OpenClaw model runs fail only on normal agent turns.
```bash
curl http://127.0.0.1:1234/v1/models
curl http://127.0.0.1:1234/v1/chat/completions \
-H 'content-type: application/json' \
-d '{"model":"<id>","messages":[{"role":"user","content":"hi"}],"stream":false}'
openclaw infer model run --model <provider/model> --prompt "hi" --json
openclaw logs --follow
```
Look for:
- Direct tiny calls succeed, but OpenClaw runs fail only on larger prompts.
- `model_not_found` or 404 errors even though direct `/v1/chat/completions` works with the same bare model id.
- Backend errors about `messages[].content` expecting a string.
- Intermittent `incomplete turn detected ... stopReason=stop payloads=0` warnings with an OpenAI-compatible local backend.
- Backend crashes that appear only with larger prompt-token counts or full agent runtime prompts.
<AccordionGroup>
<Accordion title="Common signatures">
- `model_not_found` with a local MLX/vLLM-style server: verify `baseUrl` includes `/v1`, `api` is `"openai-completions"` for `/v1/chat/completions` backends, and `models.providers.<provider>.models[].id` is the bare provider-local id. Select it with the provider prefix once, for example `mlx/mlx-community/Qwen3-30B-A3B-6bit`; keep the catalog entry as `mlx-community/Qwen3-30B-A3B-6bit`.
- `messages[...].content: invalid type: sequence, expected a string`: backend rejects structured Chat Completions content parts. Fix: set `models.providers.<provider>.models[].compat.requiresStringContent: true`.
- `validation.keys` or allowed message keys like `["role","content"]`: backend rejects OpenAI-style replay metadata on Chat Completions messages. Fix: set `models.providers.<provider>.models[].compat.strictMessageKeys: true`.
- `incomplete turn detected ... stopReason=stop payloads=0`: the backend completed the Chat Completions request but returned no user-visible assistant text for that turn. OpenClaw retries replay-safe empty OpenAI-compatible turns once; persistent failures usually mean the backend is emitting empty/non-text content or suppressing final-answer text.
- Direct tiny requests succeed, but OpenClaw agent runs fail with backend/model crashes (for example Gemma on some `inferrs` builds): OpenClaw transport is likely already correct; the backend is failing on the larger agent-runtime prompt shape.
- Failures shrink after disabling tools but do not disappear: tool schemas were part of the pressure, but the remaining issue is still upstream model/server capacity or a backend bug.
</Accordion>
<Accordion title="Fix options">
1. Set `compat.requiresStringContent: true` for string-only Chat Completions backends.
2. Set `compat.strictMessageKeys: true` for strict Chat Completions backends that only accept `role` and `content` on each message.
3. Set `compat.supportsTools: false` for models/backends that cannot handle OpenClaw's tool schema surface reliably.
4. Lower prompt pressure where possible: smaller workspace bootstrap, shorter session history, lighter local model, or a backend with stronger long-context support.
5. If tiny direct requests keep passing while OpenClaw agent turns still crash inside the backend, treat it as an upstream server/model limitation and file a repro there with the accepted payload shape.
</Accordion>
</AccordionGroup>
Related:
- [Configuration](/gateway/configuration)
- [Local models](/gateway/local-models)
- [OpenAI-compatible endpoints](/gateway/configuration-reference#openai-compatible-endpoints)
## No replies
If channels are up but nothing answers, check routing and policy before reconnecting anything.
```bash
openclaw status
openclaw channels status --probe
openclaw pairing list --channel <channel> [--account <id>]
openclaw config get channels
openclaw logs --follow
```
Look for:
- Pairing pending for DM senders.
- Group mention gating (`requireMention`, `mentionPatterns`).
- Channel/group allowlist mismatches.
Common signatures:
- `drop guild message (mention required` → group message ignored until mention.
- `pairing request` → sender needs approval.
- `blocked` / `allowlist` → sender/channel was filtered by policy.
Related:
- [Channel troubleshooting](/channels/troubleshooting)
- [Groups](/channels/groups)
- [Pairing](/channels/pairing)
## Dashboard control UI connectivity
When the dashboard/control UI will not connect, validate URL, auth mode, and secure context assumptions.
```bash
openclaw gateway status
openclaw status
openclaw logs --follow
openclaw doctor
openclaw gateway status --json
```
Look for:
- Correct probe URL and dashboard URL.
- Auth mode/token mismatch between client and gateway.
- HTTP usage where device identity is required.
If a local browser cannot connect to `127.0.0.1:18789` after an update, first recover the local Gateway service and confirm it is serving the dashboard:
```bash
openclaw gateway restart
lsof -i :18789
curl http://127.0.0.1:18789
```
If `curl` returns OpenClaw HTML, the Gateway is working and the remaining issue is likely browser cache, an old deep link, or stale tab state. Open `http://127.0.0.1:18789` directly and navigate from the dashboard. If restart does not leave the service running, run `openclaw gateway start` and recheck `openclaw gateway status`.
<AccordionGroup>
<Accordion title="Connect / auth signatures">
- `device identity required` → non-secure context or missing device auth.
- `origin not allowed` → browser `Origin` is not in `gateway.controlUi.allowedOrigins` (or you are connecting from a non-loopback browser origin without an explicit allowlist).
- `device nonce required` / `device nonce mismatch` → client is not completing the challenge-based device auth flow (`connect.challenge` + `device.nonce`).
- `device signature invalid` / `device signature expired` → client signed the wrong payload (or stale timestamp) for the current handshake.
- `AUTH_TOKEN_MISMATCH` with `canRetryWithDeviceToken=true` → client can do one trusted retry with cached device token.
- That cached-token retry reuses the cached scope set stored with the paired device token. Explicit `deviceToken` / explicit `scopes` callers keep their requested scope set instead.
- `AUTH_SCOPE_MISMATCH` → the device token was recognized, but its approved scopes do not cover this connect request; re-pair or approve the requested scope contract instead of rotating a shared gateway token.
- Outside that retry path, connect auth precedence is explicit shared token/password first, then explicit `deviceToken`, then stored device token, then bootstrap token.
- On the async Tailscale Serve Control UI path, failed attempts for the same `{scope, ip}` are serialized before the limiter records the failure. Two bad concurrent retries from the same client can therefore surface `retry later` on the second attempt instead of two plain mismatches.
- `too many failed authentication attempts (retry later)` from a browser-origin loopback client → repeated failures from that same normalized `Origin` are locked out temporarily; another localhost origin uses a separate bucket.
- Repeated `unauthorized` after that retry → shared token/device token drift; refresh token config and re-approve/rotate device token if needed.
- `gateway connect failed:` → wrong host/port/url target.
</Accordion>
</AccordionGroup>
### Auth detail codes quick map
Use `error.details.code` from the failed `connect` response to pick the next action:
| Detail code | Meaning | Recommended action |
| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `AUTH_TOKEN_MISSING` | Client did not send a required shared token. | Paste/set token in the client and retry. For dashboard paths: `openclaw config get gateway.auth.token` then paste into Control UI settings. |
| `AUTH_TOKEN_MISMATCH` | Shared token did not match gateway auth token. | If `canRetryWithDeviceToken=true`, allow one trusted retry. Cached-token retries reuse stored approved scopes; explicit `deviceToken` / `scopes` callers keep requested scopes. If still failing, run the [token drift recovery checklist](/cli/devices#token-drift-recovery-checklist). |
| `AUTH_DEVICE_TOKEN_MISMATCH` | Cached per-device token is stale or revoked. | Rotate/re-approve device token using [devices CLI](/cli/devices), then reconnect. |
| `AUTH_SCOPE_MISMATCH` | Device token is valid, but its approved role/scopes do not cover this connect request. | Re-pair the device or approve the requested scope contract; do not treat this as shared-token drift. |
| `PAIRING_REQUIRED` | Device identity needs approval. Check `error.details.reason` for `not-paired`, `scope-upgrade`, `role-upgrade`, or `metadata-upgrade`, and use `requestId` / `remediationHint` when present. | Approve pending request: `openclaw devices list` then `openclaw devices approve <requestId>`. Scope/role upgrades use the same flow after you review the requested access. |
<Note>
Direct loopback backend RPCs authenticated with the shared gateway token/password should not depend on the CLI's paired-device scope baseline. If subagents or other internal calls still fail with `scope-upgrade`, verify the caller is using `client.id: "gateway-client"` and `client.mode: "backend"` and is not forcing an explicit `deviceIdentity` or device token.
</Note>
Device auth v2 migration check:
```bash
openclaw --version
openclaw doctor
openclaw gateway status
```
If logs show nonce/signature errors, update the connecting client and verify it:
<Steps>
<Step title="Wait for connect.challenge">
Client waits for the gateway-issued `connect.challenge`.
</Step>
<Step title="Sign the payload">
Client signs the challenge-bound payload.
</Step>
<Step title="Send the device nonce">
Client sends `connect.params.device.nonce` with the same challenge nonce.
</Step>
</Steps>
If `openclaw devices rotate` / `revoke` / `remove` is denied unexpectedly:
- Paired-device token sessions can manage only **their own** device unless the caller also has `operator.admin`.
- `openclaw devices rotate --scope ...` can only request operator scopes that the caller session already holds.
Related:
- [Configuration](/gateway/configuration) (gateway auth modes)
- [Control UI](/web/control-ui)
- [Devices](/cli/devices)
- [Remote access](/gateway/remote)
- [Trusted proxy auth](/gateway/trusted-proxy-auth)
## Gateway service not running
Use when the service is installed but the process does not stay up.
```bash
openclaw gateway status
openclaw status
openclaw logs --follow
openclaw doctor
openclaw gateway status --deep # also scan system-level services
```
Look for:
- `Runtime: stopped` with exit hints.
- Service config mismatch (`Config (cli)` vs `Config (service)`).
- Port/listener conflicts.
- Extra launchd/systemd/schtasks installs when `--deep` is used.
- `Other gateway-like services detected (best effort)` cleanup hints.
<AccordionGroup>
<Accordion title="Common signatures">
- `Gateway start blocked: set gateway.mode=local` or `existing config is missing gateway.mode` → local gateway mode is not enabled, or the config file was clobbered and lost `gateway.mode`. Fix: set `gateway.mode="local"` in your config, or re-run `openclaw onboard --mode local` / `openclaw setup` to restamp the expected local-mode config. If you are running OpenClaw via Podman, the default config path is `~/.openclaw/openclaw.json`.
- `refusing to bind gateway ... without auth` → non-loopback bind without a valid gateway auth path (token/password, or trusted-proxy where configured).
- `another gateway instance is already listening` / `EADDRINUSE` → port conflict.
- `Other gateway-like services detected (best effort)` → stale or parallel launchd/systemd/schtasks units exist. Most setups should keep one gateway per machine; if you do need more than one, isolate ports + config/state/workspace. See [/gateway#multiple-gateways-same-host](/gateway#multiple-gateways-same-host).
- `System-level OpenClaw gateway service detected` from doctor → a systemd system unit exists while the user-level service is missing. Remove or disable the duplicate before allowing doctor to install a user service, or set `OPENCLAW_SERVICE_REPAIR_POLICY=external` if the system unit is the intended supervisor.
- `Gateway service port does not match current gateway config` → the installed supervisor still pins the old `--port`. Run `openclaw doctor --fix` or `openclaw gateway install --force`, then restart the gateway service.
</Accordion>
</AccordionGroup>
Related:
- [Background exec and process tool](/gateway/background-process)
- [Configuration](/gateway/configuration)
- [Doctor](/gateway/doctor)
## macOS gateway silently stops responding, then resumes when you touch the dashboard
Use when channels (Telegram, WhatsApp, etc.) on a macOS host go quiet for minutes to hours at a time, and the gateway appears to come back the moment you open the Control UI, SSH in, or otherwise interact with the host. There is usually no obvious symptom in `openclaw status` because by the time you look the gateway is alive again.
```bash
ls ~/.openclaw/logs/stability/ | tail -5
openclaw gateway stability --bundle latest
pmset -g log | grep -iE "sleep|wake|maintenance" | tail -50
launchctl print gui/$UID/ai.openclaw.gateway | grep -E "state|last exit|runs"
```
Look for:
- One or more `*-uncaught_exception.json` bundles in `~/.openclaw/logs/stability/` with `error.code` set to a transient network code such as `ENETDOWN`, `ENETUNREACH`, `EHOSTUNREACH`, or `ECONNREFUSED`.
- `pmset -g log` lines like `Entering Sleep state due to 'Maintenance Sleep'` or `en0 driver is slow (msg: WillChangeState to 0)` aligned with the crash timestamps. Power Nap / Maintenance Sleep briefly puts the Wi-Fi driver into state 0; any outbound `connect()` that lands in that window can fail with `ENETDOWN` even on a host that otherwise has full network connectivity.
- `launchctl print` output showing `state = not running` with multiple recent `runs` and an exit code, especially when the gap between crash and the next launch is on the order of an hour rather than seconds. macOS launchd applies an undocumented respawn-protection gate after a crash burst that can stop honoring `KeepAlive=true` until an external trigger such as interactive login, dashboard connection, or `launchctl kickstart` re-arms it.
Common signatures:
- A stability bundle whose `error.code` is `ENETDOWN` or a sibling code, with the call stack pointing into Node `net` `lookupAndConnect` / `Socket.connect`. OpenClaw `2026.5.26` and newer classify these as benign transient network errors so they no longer propagate to the top-level uncaught handler; if you are on an older release, upgrade first.
- Long quiet periods that end the instant you connect to the Control UI or SSH into the host: the user-visible activity is what re-arms launchd's respawn gate, not anything the dashboard does to the gateway.
- `runs` count incrementing across the day with no corresponding `received SIG*; shutting down` line in `~/Library/Logs/openclaw/gateway.log`: clean shutdowns log a signal; transient crashes do not.
What to do:
1. **Upgrade the gateway** if you are running a release before `2026.5.26`. After upgrading, future `ENETDOWN` errors are logged as warnings instead of terminating the process.
2. **Reduce maintenance sleep activity** on Mac mini / desktop hosts meant to run as always-on servers:
```bash
sudo pmset -a sleep 0 disksleep 0 standby 0 powernap 0
```
This significantly reduces, but does not entirely eliminate, the underlying driver flap. The system can still perform some maintenance sleeps for TCP keepalive and mDNS upkeep regardless of these flags.
3. **Add a liveness watchdog** so a future crash burst that gets parked by launchd is caught quickly:
```bash
# Example launchd-aware liveness check, suitable for a 5-minute cron or LaunchAgent
state=$(launchctl print gui/$UID/ai.openclaw.gateway 2>/dev/null | awk -F'= ' '/state =/ {print $2; exit}')
if [ "$state" != "running" ]; then
launchctl kickstart -k gui/$UID/ai.openclaw.gateway
fi
```
The point is to externally re-arm the respawn gate; `KeepAlive=true` alone is not sufficient on macOS after a crash burst.
Related:
- [macOS platform notes](/platforms/macos)
- [Logging](/logging)
- [Doctor](/gateway/doctor)
## Gateway exits during high memory use
Use when the Gateway disappears under load, the supervisor reports an OOM-style restart, or logs mention `critical memory pressure bundle written`.
```bash
openclaw gateway status --deep
openclaw logs --follow
openclaw gateway stability --bundle latest
openclaw gateway diagnostics export
```
Look for:
- `Reason: diagnostic.memory.pressure.critical` in the latest stability bundle.
- `Memory pressure:` with `critical/rss_threshold`, `critical/heap_threshold`, or `critical/rss_growth`.
- `V8 heap:` values near the heap limit.
- `Largest session files:` entries such as `agents/<agent>/sessions/<session>.jsonl` or `sessions/<session>.jsonl`.
- Linux cgroup memory counters when the gateway runs inside a container or memory-limited service.
Common signatures:
- `critical memory pressure bundle written` appears shortly before restart → OpenClaw captured a pre-OOM stability bundle. Inspect it with `openclaw gateway stability --bundle latest`.
- `memory pressure: level=critical ... memoryPressureSnapshot=disabled` appears in gateway logs → OpenClaw detected critical memory pressure, but the pre-OOM stability snapshot is off.
- `Largest session files:` points at a very large redacted transcript path → reduce retained session history, inspect session growth, or move old transcripts out of the active store before restarting.
- `V8 heap:` used bytes are close to the heap limit → lower prompt/session pressure, reduce concurrent work, or raise the Node heap limit only after confirming the workload is expected.
- `Memory pressure: critical/rss_growth` → memory grew quickly inside one sampling window. Check the latest logs for a large import, runaway tool output, repeated retries, or a batch of queued agent work.
- Critical memory pressure appears in logs but no bundle exists → this is the default. Set `diagnostics.memoryPressureSnapshot: true` to capture the pre-OOM stability bundle on future critical memory pressure events.
The stability bundle is payload-free. It includes operational memory evidence and redacted relative file paths, not message text, webhook bodies, credentials, tokens, cookies, or raw session ids. Attach the diagnostics export to bug reports instead of copying raw logs.
Related:
- [Gateway health](/gateway/health)
- [Diagnostics export](/gateway/diagnostics)
- [Sessions](/cli/sessions)
## Gateway rejected invalid config
Use when Gateway startup fails with `Invalid config` or hot reload logs say it skipped an invalid edit.
```bash
openclaw logs --follow
openclaw config file
openclaw config validate
openclaw doctor
```
Look for:
- `Invalid config at ...`
- `config reload skipped (invalid config): ...`
- `Config write rejected: ...`
- A timestamped `openclaw.json.rejected.*` file beside the active config.
- A timestamped `openclaw.json.clobbered.*` file if `doctor --fix` repaired a broken direct edit.
- OpenClaw keeps the latest 32 `.clobbered.*` files for each config path and rotates older ones.
<AccordionGroup>
<Accordion title="What happened">
- The config did not validate during startup, hot reload, or an OpenClaw-owned write.
- Gateway startup fails closed instead of rewriting `openclaw.json`.
- Hot reload skips invalid external edits and keeps the current runtime config active.
- OpenClaw-owned writes reject invalid/destructive payloads before commit and save `.rejected.*`.
- `openclaw doctor --fix` owns repair. It can remove non-JSON prefixes or restore the last-known-good copy while preserving the rejected payload as `.clobbered.*`.
- When many repairs happen for one config path, OpenClaw rotates older `.clobbered.*` files so the newest repaired payload is still available.
</Accordion>
<Accordion title="Inspect and repair">
```bash
CONFIG="$(openclaw config file)"
ls -lt "$CONFIG".clobbered.* "$CONFIG".rejected.* 2>/dev/null | head
diff -u "$CONFIG" "$(ls -t "$CONFIG".clobbered.* 2>/dev/null | head -n 1)"
openclaw config validate
openclaw doctor
```
</Accordion>
<Accordion title="Common signatures">
- `.clobbered.*` exists → doctor preserved a broken external edit while repairing the active config.
- `.rejected.*` exists → an OpenClaw-owned config write failed schema or clobber checks before commit.
- `Config write rejected:` → the write tried to drop required shape, shrink the file sharply, or persist invalid config.
- `config reload skipped (invalid config):` → a direct edit failed validation and was ignored by the running Gateway.
- `Invalid config at ...` → startup failed before Gateway services booted.
- `missing-meta-vs-last-good`, `gateway-mode-missing-vs-last-good`, or `size-drop-vs-last-good:*` → an OpenClaw-owned write was rejected because it lost fields or size compared with the last-known-good backup.
- `Config last-known-good promotion skipped` → the candidate contained redacted secret placeholders such as `***`.
</Accordion>
<Accordion title="Fix options">
1. Run `openclaw doctor --fix` to let doctor repair prefixed/clobbered config or restore last-known-good.
2. Copy only the intended keys from `.clobbered.*` or `.rejected.*`, then apply them with `openclaw config set` or `config.patch`.
3. Run `openclaw config validate` before restarting.
4. If you edit by hand, keep the full JSON5 config, not just the partial object you wanted to change.
</Accordion>
</AccordionGroup>
Related:
- [Config](/cli/config)
- [Configuration: hot reload](/gateway/configuration#config-hot-reload)
- [Configuration: strict validation](/gateway/configuration#strict-validation)
- [Doctor](/gateway/doctor)
## Gateway probe warnings
Use when `openclaw gateway probe` reaches something, but still prints a warning block.
```bash
openclaw gateway probe
openclaw gateway probe --json
openclaw gateway probe --ssh user@gateway-host
```
Look for:
- `warnings[].code` and `primaryTargetId` in JSON output.
- Whether the warning is about SSH fallback, multiple gateways, missing scopes, or unresolved auth refs.
Common signatures:
- `SSH tunnel failed to start; falling back to direct probes.` → SSH setup failed, but the command still tried direct configured/loopback targets.
- `multiple reachable gateway identities detected` → distinct gateways answered, or OpenClaw could not prove reachable targets are the same gateway. An SSH tunnel, proxy URL, or configured remote URL to the same gateway is treated as one gateway with multiple transports, even when transport ports differ.
- `Read-probe diagnostics are limited by gateway scopes (missing operator.read)` → connect worked, but detail RPC is scope-limited; pair device identity or use credentials with `operator.read`.
- `Gateway accepted the WebSocket connection, but follow-up read diagnostics failed` → connect worked, but the full diagnostic RPC set timed out or failed. Treat this as a reachable Gateway with degraded diagnostics; compare `connect.ok` and `connect.rpcOk` in `--json` output.
- `Capability: pairing-pending` or `gateway closed (1008): pairing required` → the gateway answered, but this client still needs pairing/approval before normal operator access.
- Unresolved `gateway.auth.*` / `gateway.remote.*` SecretRef warning text → auth material was unavailable in this command path for the failed target.
Related:
- [Gateway](/cli/gateway)
- [Multiple gateways on the same host](/gateway#multiple-gateways-same-host)
- [Remote access](/gateway/remote)
## Channel connected, messages not flowing
If channel state is connected but message flow is dead, focus on policy, permissions, and channel specific delivery rules.
```bash
openclaw channels status --probe
openclaw pairing list --channel <channel> [--account <id>]
openclaw status --deep
openclaw logs --follow
openclaw config get channels
```
Look for:
- DM policy (`pairing`, `allowlist`, `open`, `disabled`).
- Group allowlist and mention requirements.
- Missing channel API permissions/scopes.
Common signatures:
- `mention required` → message ignored by group mention policy.
- `pairing` / pending approval traces → sender is not approved.
- `missing_scope`, `not_in_channel`, `Forbidden`, `401/403` → channel auth/permissions issue.
Related:
- [Channel troubleshooting](/channels/troubleshooting)
- [Discord](/channels/discord)
- [Telegram](/channels/telegram)
- [WhatsApp](/channels/whatsapp)
## Cron and heartbeat delivery
If cron or heartbeat did not run or did not deliver, verify scheduler state first, then delivery target.
```bash
openclaw cron status
openclaw cron list
openclaw cron runs --id <jobId> --limit 20
openclaw system heartbeat last
openclaw logs --follow
```
Look for:
- Cron enabled and next wake present.
- Job run history status (`ok`, `skipped`, `error`).
- Heartbeat skip reasons (`quiet-hours`, `requests-in-flight`, `cron-in-progress`, `lanes-busy`, `alerts-disabled`, `empty-heartbeat-file`, `no-tasks-due`).
<AccordionGroup>
<Accordion title="Common signatures">
- `cron: scheduler disabled; jobs will not run automatically` → cron disabled.
- `cron: timer tick failed` → scheduler tick failed; check file/log/runtime errors.
- `heartbeat skipped` with `reason=quiet-hours` → outside active hours window.
- `heartbeat skipped` with `reason=empty-heartbeat-file` → `HEARTBEAT.md` exists but only contains blank, comment, header, fence, or empty-checklist scaffolding, so OpenClaw skips the model call.
- `heartbeat skipped` with `reason=no-tasks-due` → `HEARTBEAT.md` contains a `tasks:` block, but none of the tasks are due on this tick.
- `heartbeat: unknown accountId` → invalid account id for heartbeat delivery target.
- `heartbeat skipped` with `reason=dm-blocked` → heartbeat target resolved to a DM-style destination while `agents.defaults.heartbeat.directPolicy` (or per-agent override) is set to `block`.
</Accordion>
</AccordionGroup>
Related:
- [Heartbeat](/gateway/heartbeat)
- [Scheduled tasks](/automation/cron-jobs)
- [Scheduled tasks: troubleshooting](/automation/cron-jobs#troubleshooting)
## Node paired, tool fails
If a node is paired but tools fail, isolate foreground, permission, and approval state.
```bash
openclaw nodes status
openclaw nodes describe --node <idOrNameOrIp>
openclaw approvals get --node <idOrNameOrIp>
openclaw logs --follow
openclaw status
```
Look for:
- Node online with expected capabilities.
- OS permission grants for camera/mic/location/screen.
- Exec approvals and allowlist state.
Common signatures:
- `NODE_BACKGROUND_UNAVAILABLE` → node app must be in foreground.
- `*_PERMISSION_REQUIRED` / `LOCATION_PERMISSION_REQUIRED` → missing OS permission.
- `SYSTEM_RUN_DENIED: approval required` → exec approval pending.
- `SYSTEM_RUN_DENIED: allowlist miss` → command blocked by allowlist.
Related:
- [Exec approvals](/tools/exec-approvals)
- [Node troubleshooting](/nodes/troubleshooting)
- [Nodes](/nodes/index)
## Browser tool fails
Use when browser tool actions fail even though the gateway itself is healthy.
```bash
openclaw browser status
openclaw browser start --browser-profile openclaw
openclaw browser profiles
openclaw logs --follow
openclaw doctor
```
Look for:
- Whether `plugins.allow` is set and includes `browser`.
- Valid browser executable path.
- CDP profile reachability.
- Local Chrome availability for `existing-session` / `user` profiles.
<AccordionGroup>
<Accordion title="Plugin / executable signatures">
- `unknown command "browser"` or `unknown command 'browser'` → the bundled browser plugin is excluded by `plugins.allow`.
- Browser tool missing / unavailable while `browser.enabled=true` → `plugins.allow` excludes `browser`, so the plugin never loaded.
- `Failed to start Chrome CDP on port` → browser process failed to launch.
- `browser.executablePath not found` → configured path is invalid.
- `browser.cdpUrl must be http(s) or ws(s)` → the configured CDP URL uses an unsupported scheme such as `file:` or `ftp:`.
- `browser.cdpUrl has invalid port` → the configured CDP URL has a bad or out-of-range port.
- `Playwright is not available in this gateway build; '<feature>' is unsupported.` → the current gateway install lacks the core browser runtime dependency; reinstall or update OpenClaw, then restart the gateway. ARIA snapshots and basic page screenshots can still work, but navigation, AI snapshots, CSS-selector element screenshots, and PDF export stay unavailable.
</Accordion>
<Accordion title="Chrome MCP / existing-session signatures">
- `Could not find DevToolsActivePort for chrome` → Chrome MCP existing-session could not attach to the selected browser data dir yet. Open the browser inspect page, enable remote debugging, keep the browser open, approve the first attach prompt, then retry. If signed-in state is not required, prefer the managed `openclaw` profile.
- `No browser tabs found for profile="user"` → the Chrome MCP attach profile has no open local Chrome tabs.
- `Remote CDP for profile "<name>" is not reachable` → the configured remote CDP endpoint is not reachable from the gateway host.
- `Browser attachOnly is enabled ... not reachable` or `Browser attachOnly is enabled and CDP websocket ... is not reachable` → attach-only profile has no reachable target, or the HTTP endpoint answered but the CDP WebSocket still could not be opened.
</Accordion>
<Accordion title="Element / screenshot / upload signatures">
- `fullPage is not supported for element screenshots` → screenshot request mixed `--full-page` with `--ref` or `--element`.
- `element screenshots are not supported for existing-session profiles; use ref from snapshot.` → Chrome MCP / `existing-session` screenshot calls must use page capture or a snapshot `--ref`, not CSS `--element`.
- `existing-session file uploads do not support element selectors; use ref/inputRef.` → Chrome MCP upload hooks need snapshot refs, not CSS selectors.
- `existing-session file uploads currently support one file at a time.` → send one upload per call on Chrome MCP profiles.
- `existing-session dialog handling does not support timeoutMs.` → dialog hooks on Chrome MCP profiles do not support timeout overrides.
- `existing-session type does not support timeoutMs overrides.` → omit `timeoutMs` for `act:type` on `profile="user"` / Chrome MCP existing-session profiles, or use a managed/CDP browser profile when a custom timeout is required.
- `existing-session evaluate does not support timeoutMs overrides.` → omit `timeoutMs` for `act:evaluate` on `profile="user"` / Chrome MCP existing-session profiles, or use a managed/CDP browser profile when a custom timeout is required.
- `response body is not supported for existing-session profiles yet.` → `responsebody` still requires a managed browser or raw CDP profile.
- Stale viewport / dark-mode / locale / offline overrides on attach-only or remote CDP profiles → run `openclaw browser stop --browser-profile <name>` to close the active control session and release Playwright/CDP emulation state without restarting the whole gateway.
</Accordion>
</AccordionGroup>
Related:
- [Browser (OpenClaw-managed)](/tools/browser)
- [Browser troubleshooting](/tools/browser-linux-troubleshooting)
## If you upgraded and something suddenly broke
Most post-upgrade breakage is config drift or stricter defaults now being enforced.
<AccordionGroup>
<Accordion title="1. Auth and URL override behavior changed">
```bash
openclaw gateway status
openclaw config get gateway.mode
openclaw config get gateway.remote.url
openclaw config get gateway.auth.mode
```
What to check:
- If `gateway.mode=remote`, CLI calls may be targeting remote while your local service is fine.
- Explicit `--url` calls do not fall back to stored credentials.
Common signatures:
- `gateway connect failed:` → wrong URL target.
- `unauthorized` → endpoint reachable but wrong auth.
</Accordion>
<Accordion title="2. Bind and auth guardrails are stricter">
```bash
openclaw config get gateway.bind
openclaw config get gateway.auth.mode
openclaw config get gateway.auth.token
openclaw gateway status
openclaw logs --follow
```
What to check:
- Non-loopback binds (`lan`, `tailnet`, `custom`) need a valid gateway auth path: shared token/password auth, or a correctly configured non-loopback `trusted-proxy` deployment.
- Old keys like `gateway.token` do not replace `gateway.auth.token`.
Common signatures:
- `refusing to bind gateway ... without auth` → non-loopback bind without a valid gateway auth path.
- `Connectivity probe: failed` while runtime is running → gateway alive but inaccessible with current auth/url.
</Accordion>
<Accordion title="3. Pairing and device identity state changed">
```bash
openclaw devices list
openclaw pairing list --channel <channel> [--account <id>]
openclaw logs --follow
openclaw doctor
```
What to check:
- Pending device approvals for dashboard/nodes.
- Pending DM pairing approvals after policy or identity changes.
Common signatures:
- `device identity required` → device auth not satisfied.
- `pairing required` → sender/device must be approved.
</Accordion>
</AccordionGroup>
If the service config and runtime still disagree after checks, reinstall service metadata from the same profile/state directory:
```bash
openclaw gateway install --force
openclaw gateway restart
```
Related:
- [Authentication](/gateway/authentication)
- [Background exec and process tool](/gateway/background-process)
- [Gateway-owned pairing](/gateway/pairing)
## Related
- [Doctor](/gateway/doctor)
- [FAQ](/help/faq)
- [Gateway runbook](/gateway)

View File

@@ -0,0 +1,496 @@
---
summary: "Delegate gateway authentication to a trusted reverse proxy (Pomerium, Caddy, nginx + OAuth)"
title: "Trusted proxy auth"
sidebarTitle: "Trusted proxy auth"
read_when:
- Running OpenClaw behind an identity-aware proxy
- Setting up Pomerium, Caddy, or nginx with OAuth in front of OpenClaw
- Fixing WebSocket 1008 unauthorized errors with reverse proxy setups
- Deciding where to set HSTS and other HTTP hardening headers
---
<Warning>
**Security-sensitive feature.** This mode delegates authentication entirely to your reverse proxy. Misconfiguration can expose your Gateway to unauthorized access. Read this page carefully before enabling.
</Warning>
## When to use
- You run OpenClaw behind an **identity-aware proxy** (Pomerium, Caddy + OAuth, nginx + oauth2-proxy, Traefik + forward auth).
- Your proxy handles all authentication and passes user identity via headers.
- You're in a Kubernetes or container environment where the proxy is the only path to the Gateway.
- You're hitting WebSocket `1008 unauthorized` errors because browsers can't pass tokens in WS payloads.
## When NOT to use
- Your proxy doesn't authenticate users (just a TLS terminator or load balancer).
- There's any path to the Gateway that bypasses the proxy (firewall holes, internal network access).
- You're unsure whether your proxy correctly strips/overwrites forwarded headers.
- You only need personal single-user access (consider Tailscale Serve + loopback instead).
## How it works
<Steps>
<Step title="Proxy authenticates the user">
Your reverse proxy authenticates users (OAuth, OIDC, SAML, etc.).
</Step>
<Step title="Proxy adds an identity header">
Proxy adds a header with the authenticated user identity (e.g., `x-forwarded-user: nick@example.com`).
</Step>
<Step title="Gateway verifies trusted source">
OpenClaw checks that the request came from a **trusted proxy IP** (`gateway.trustedProxies`) and is not the Gateway's own loopback or local interface address.
</Step>
<Step title="Gateway extracts identity">
OpenClaw reads the required headers, then the user identity from the configured header.
</Step>
<Step title="Authorize">
If everything checks out, and the user passes `allowUsers` (when set), the request is authorized.
</Step>
</Steps>
## Configuration
```json5
{
gateway: {
// Trusted-proxy auth expects the proxy's source IP to be non-loopback by default
bind: "lan",
// CRITICAL: Only add your proxy's IP(s) here
trustedProxies: ["10.0.0.1", "172.17.0.1"],
auth: {
mode: "trusted-proxy",
trustedProxy: {
// Header containing authenticated user identity (required)
userHeader: "x-forwarded-user",
// Optional: headers that MUST be present (proxy verification)
requiredHeaders: ["x-forwarded-proto", "x-forwarded-host"],
// Optional: restrict to specific users (empty = allow all)
allowUsers: ["nick@example.com", "admin@company.org"],
// Optional: allow a same-host loopback proxy after explicit opt-in
allowLoopback: false,
},
},
},
}
```
<Warning>
**Runtime rules, in order of evaluation**
1. The request's source IP must match `gateway.trustedProxies` (CIDR-aware), or it is rejected (`trusted_proxy_untrusted_source`).
2. Loopback-source requests (`127.0.0.1`, `::1`) are rejected unless `gateway.auth.trustedProxy.allowLoopback = true` and the loopback address is also in `trustedProxies` (`trusted_proxy_loopback_source`). This check runs before header checks, so a loopback source fails this way even if required headers are also missing.
3. Non-loopback sources that match one of the Gateway host's own local network interface addresses are rejected as a spoofing guard (`trusted_proxy_local_interface_source`). If interface discovery itself fails, the request is rejected too (`trusted_proxy_local_interface_check_failed`).
4. `requiredHeaders` and `userHeader` must be present and non-blank.
5. `allowUsers`, if non-empty, must include the extracted user.
**Forwarded-header evidence overrides loopback locality for local-direct fallback.** If a request arrives on loopback but carries a `Forwarded`, any `X-Forwarded-*`, or `X-Real-IP` header, that evidence disqualifies it from local-direct password fallback and device-identity gating, even though it still fails trusted-proxy auth as loopback.
`allowLoopback` trusts local processes on the Gateway host to the same degree as the reverse proxy. Enable it only when the Gateway is still firewalled from direct remote access and the local proxy strips or overwrites client-supplied identity headers.
Internal Gateway clients that do not travel through the reverse proxy should use `gateway.auth.password` / `OPENCLAW_GATEWAY_PASSWORD`, not trusted-proxy identity headers. Non-loopback Control UI deployments still need explicit `gateway.controlUi.allowedOrigins`.
</Warning>
### Configuration reference
<ParamField path="gateway.trustedProxies" type="string[]" required>
Array of proxy IP addresses (or CIDRs) to trust. Requests from other IPs are rejected.
</ParamField>
<ParamField path="gateway.auth.mode" type="string" required>
Must be `"trusted-proxy"`.
</ParamField>
<ParamField path="gateway.auth.trustedProxy.userHeader" type="string" required>
Header name containing the authenticated user identity.
</ParamField>
<ParamField path="gateway.auth.trustedProxy.requiredHeaders" type="string[]">
Additional headers that must be present for the request to be trusted.
</ParamField>
<ParamField path="gateway.auth.trustedProxy.allowUsers" type="string[]">
Allowlist of user identities. Empty means allow all authenticated users.
</ParamField>
<ParamField path="gateway.auth.trustedProxy.allowLoopback" type="boolean" default="false">
Opt-in support for same-host loopback reverse proxies.
</ParamField>
<Warning>
Only enable `allowLoopback` when the local reverse proxy is the intended trust boundary. Any local process that can connect to the Gateway can try to send proxy identity headers, so keep direct Gateway access private to the host and require proxy-owned headers such as `x-forwarded-proto`, or a signed assertion header where your proxy supports one.
</Warning>
## Control UI pairing behavior
When `gateway.auth.mode = "trusted-proxy"` is active and the request passes trusted-proxy checks, Control UI WebSocket sessions can connect without device pairing identity.
Scope implications:
- Device-less Control UI WebSocket sessions connect but receive no operator scopes by default. OpenClaw clears the requested scope list to `[]` so a session not bound to an approved paired device/token cannot self-declare permissions.
- If methods fail with `missing scope` after a successful WebSocket connect, use HTTPS so the browser can generate device identity and complete pairing. See [Control UI insecure HTTP](/web/control-ui#insecure-http).
- Break-glass only: `gateway.controlUi.dangerouslyDisableDeviceAuth=true` preserves requested scopes even without device identity. This is a severe security downgrade; revert quickly. See [Control UI insecure HTTP](/web/control-ui#insecure-http).
Reverse-proxy scope capping: if your proxy sends `x-openclaw-scopes` on the Control UI WebSocket upgrade request, OpenClaw caps the session scopes to the intersection of the requested scopes and the declared scopes. This header does not grant scopes; it only narrows what the session can hold.
Implications:
- Pairing is no longer the primary gate for Control UI access in this mode.
- Your reverse proxy auth policy and `allowUsers` become the effective access control.
- Keep gateway ingress locked to trusted proxy IPs only (`gateway.trustedProxies` + firewall).
Custom WebSocket clients are not Control UI sessions. `gateway.controlUi.dangerouslyDisableDeviceAuth` does not grant scopes to arbitrary `client.mode: "backend"` or CLI-shaped clients. Custom automation should use device identity/pairing, the reserved direct-local `client.id: "gateway-client"` backend helper path, or the [admin HTTP RPC plugin](/plugins/admin-http-rpc) when an HTTP request/response surface is a better fit.
## Operator scopes header
Trusted-proxy auth is an **identity-bearing** HTTP mode, so callers may optionally declare operator scopes with `x-openclaw-scopes` on HTTP API requests.
Note: WebSocket scopes are determined by the Gateway protocol handshake and device identity binding. On Control UI WebSocket upgrade requests, `x-openclaw-scopes` is only a cap on the negotiated session scopes, not a grant. See [Control UI pairing behavior](#control-ui-pairing-behavior).
Examples:
- `x-openclaw-scopes: operator.read`
- `x-openclaw-scopes: operator.read,operator.write`
- `x-openclaw-scopes: operator.admin,operator.write`
Behavior:
- When the header is present, OpenClaw honors the declared scope set.
- When the header is present but empty, the request declares **no** operator scopes.
- When the header is absent, normal identity-bearing HTTP APIs fall back to the standard operator default scope set (`operator.admin`, `operator.read`, `operator.write`, `operator.approvals`, `operator.pairing`, `operator.talk.secrets`).
- Gateway-auth **plugin HTTP routes** are narrower by default: when `x-openclaw-scopes` is absent, their runtime scope falls back to `operator.write` only.
- Browser-origin HTTP requests still have to pass `gateway.controlUi.allowedOrigins` (or deliberate Host-header fallback mode) even after trusted-proxy auth succeeds.
Practical rule: send `x-openclaw-scopes` explicitly when you want a trusted-proxy request to be narrower than the defaults, or when a gateway-auth plugin route needs something stronger than write scope.
## TLS termination and HSTS
Use one TLS termination point and apply HSTS there.
<Tabs>
<Tab title="Proxy TLS termination (recommended)">
When your reverse proxy handles HTTPS for `https://control.example.com`, set `Strict-Transport-Security` at the proxy for that domain.
- Good fit for internet-facing deployments.
- Keeps certificate + HTTP hardening policy in one place.
- OpenClaw can stay on loopback HTTP behind the proxy.
Example header value:
```text
Strict-Transport-Security: max-age=31536000; includeSubDomains
```
</Tab>
<Tab title="Gateway TLS termination">
If OpenClaw itself serves HTTPS directly (no TLS-terminating proxy), set:
```json5
{
gateway: {
tls: { enabled: true },
http: {
securityHeaders: {
strictTransportSecurity: "max-age=31536000; includeSubDomains",
},
},
},
}
```
`strictTransportSecurity` accepts a string header value, or `false` to disable explicitly.
</Tab>
</Tabs>
### Rollout guidance
- Start with a short max age first (for example `max-age=300`) while validating traffic.
- Increase to long-lived values (for example `max-age=31536000`) only after confidence is high.
- Add `includeSubDomains` only if every subdomain is HTTPS-ready.
- Use preload only if you intentionally meet preload requirements for your full domain set.
- Loopback-only local development does not benefit from HSTS.
## Proxy setup examples
<AccordionGroup>
<Accordion title="Pomerium">
Pomerium passes identity in `x-pomerium-claim-email` (or other claim headers) and a JWT in `x-pomerium-jwt-assertion`.
```json5
{
gateway: {
bind: "lan",
trustedProxies: ["10.0.0.1"], // Pomerium's IP
auth: {
mode: "trusted-proxy",
trustedProxy: {
userHeader: "x-pomerium-claim-email",
requiredHeaders: ["x-pomerium-jwt-assertion"],
},
},
},
}
```
Pomerium config snippet:
```yaml
routes:
- from: https://openclaw.example.com
to: http://openclaw-gateway:18789
policy:
- allow:
or:
- email:
is: nick@example.com
pass_identity_headers: true
```
</Accordion>
<Accordion title="Caddy with OAuth">
Caddy with the `caddy-security` plugin can authenticate users and pass identity headers.
```json5
{
gateway: {
bind: "lan",
trustedProxies: ["10.0.0.1"], // Caddy/sidecar proxy IP
auth: {
mode: "trusted-proxy",
trustedProxy: {
userHeader: "x-forwarded-user",
},
},
},
}
```
Caddyfile snippet:
```caddy
openclaw.example.com {
authenticate with oauth2_provider
authorize with policy1
reverse_proxy openclaw:18789 {
header_up X-Forwarded-User {http.auth.user.email}
}
}
```
</Accordion>
<Accordion title="nginx + oauth2-proxy">
oauth2-proxy authenticates users and passes identity in `x-auth-request-email`.
```json5
{
gateway: {
bind: "lan",
trustedProxies: ["10.0.0.1"], // nginx/oauth2-proxy IP
auth: {
mode: "trusted-proxy",
trustedProxy: {
userHeader: "x-auth-request-email",
},
},
},
}
```
nginx config snippet:
```nginx
location / {
auth_request /oauth2/auth;
auth_request_set $user $upstream_http_x_auth_request_email;
proxy_pass http://openclaw:18789;
proxy_set_header X-Auth-Request-Email $user;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
```
</Accordion>
<Accordion title="Traefik with forward auth">
```json5
{
gateway: {
bind: "lan",
trustedProxies: ["172.17.0.1"], // Traefik container IP
auth: {
mode: "trusted-proxy",
trustedProxy: {
userHeader: "x-forwarded-user",
},
},
},
}
```
</Accordion>
</AccordionGroup>
## Mixed token configuration
Gateway startup rejects trusted-proxy auth if a shared token is also configured (`gateway.auth.token` or `OPENCLAW_GATEWAY_TOKEN`). The two are mutually exclusive because a shared token would let same-host callers authenticate on a completely different path than the proxy-verified identity this mode is meant to enforce.
If startup fails with an error like `gateway auth mode is trusted-proxy, but a shared token is also configured`:
- Remove the shared token when using trusted-proxy mode, or
- Switch `gateway.auth.mode` to `"token"` if you intend token-based auth.
Loopback trusted-proxy identity headers still fail closed: same-host callers are not silently authenticated as proxy users. Internal OpenClaw callers that bypass the proxy may authenticate with `gateway.auth.password` / `OPENCLAW_GATEWAY_PASSWORD` instead. Token fallback remains intentionally unsupported in trusted-proxy mode.
## Security checklist
Before enabling trusted-proxy auth, verify:
- [ ] **Proxy is the only path**: The Gateway port is firewalled from everything except your proxy.
- [ ] **trustedProxies is minimal**: Only your actual proxy IPs, not entire subnets.
- [ ] **Loopback proxy source is deliberate**: trusted-proxy auth fails closed for loopback-source requests unless `gateway.auth.trustedProxy.allowLoopback` is explicitly enabled for a same-host proxy.
- [ ] **Proxy strips headers**: Your proxy overwrites (not appends) `x-forwarded-*` headers from clients.
- [ ] **TLS termination**: Your proxy handles TLS; users connect via HTTPS.
- [ ] **allowedOrigins is explicit**: Non-loopback Control UI uses explicit `gateway.controlUi.allowedOrigins`.
- [ ] **allowUsers is set** (recommended): Restrict to known users rather than allowing anyone authenticated.
- [ ] **No mixed token config**: Do not set both `gateway.auth.token` and `gateway.auth.mode: "trusted-proxy"`.
- [ ] **Local password fallback is private**: If you configure `gateway.auth.password` for internal direct callers, keep the Gateway port firewalled so non-proxy remote clients cannot reach it directly.
## Security audit
`openclaw security audit` flags trusted-proxy auth with a **critical** severity finding. This is intentional; it's a reminder that you're delegating security to your proxy setup.
The audit checks for:
- Base `gateway.trusted_proxy_auth` warning/critical reminder.
- Missing `trustedProxies` configuration.
- Missing `userHeader` configuration.
- Empty `allowUsers` (allows any authenticated user).
- Enabled `allowLoopback` for same-host proxy sources.
Separate, non-trusted-proxy-specific findings also apply whenever Control UI is exposed: wildcard or missing `gateway.controlUi.allowedOrigins`, and Host-header origin fallback.
## Troubleshooting
<AccordionGroup>
<Accordion title="trusted_proxy_untrusted_source">
The request didn't come from an IP in `gateway.trustedProxies`. Check:
- Is the proxy IP correct? (Docker container IPs can change.)
- Is there a load balancer in front of your proxy?
- Use `docker inspect` or `kubectl get pods -o wide` to find actual IPs.
</Accordion>
<Accordion title="trusted_proxy_loopback_source">
OpenClaw rejected a loopback-source trusted-proxy request.
Check:
- Is the proxy connecting from `127.0.0.1` / `::1`?
- Are you trying to use trusted-proxy auth with a same-host loopback reverse proxy?
Fix:
- Prefer token/password auth for internal same-host clients that do not go through the proxy, or
- Route through a non-loopback trusted proxy address and keep that IP in `gateway.trustedProxies`, or
- For a deliberate same-host reverse proxy, set `gateway.auth.trustedProxy.allowLoopback = true`, keep the loopback address in `gateway.trustedProxies`, and make sure the proxy strips or overwrites identity headers.
</Accordion>
<Accordion title="trusted_proxy_local_interface_source / trusted_proxy_local_interface_check_failed">
The request's source IP matched one of the Gateway host's own non-loopback network interface addresses (not the proxy), a guard against spoofed same-host traffic on tailnets or Docker bridge networks. `..._check_failed` means interface discovery itself errored, so OpenClaw fails closed.
Check:
- Is a process on the Gateway host itself sending identity headers directly, bypassing the proxy?
- Does the proxy run in the same network namespace as the Gateway, with an IP that also shows up as a local interface?
Fix: route proxy traffic through an address that is not also bound locally by the Gateway host, or use `allowLoopback` only for a genuine same-host proxy setup.
</Accordion>
<Accordion title="trusted_proxy_user_missing">
The user header was empty or missing. Check:
- Is your proxy configured to pass identity headers?
- Is the header name correct? (case-insensitive, but spelling matters)
- Is the user actually authenticated at the proxy?
</Accordion>
<Accordion title="trusted_proxy_missing_header_*">
A required header wasn't present. Check:
- Your proxy configuration for those specific headers.
- Whether headers are being stripped somewhere in the chain.
</Accordion>
<Accordion title="trusted_proxy_user_not_allowed">
The user is authenticated but not in `allowUsers`. Either add them or remove the allowlist.
</Accordion>
<Accordion title="trusted_proxy_no_proxies_configured / trusted_proxy_config_missing">
`gateway.auth.mode` is `"trusted-proxy"` but `gateway.trustedProxies` is empty, or `gateway.auth.trustedProxy` itself is missing. Every request is rejected until both are set.
</Accordion>
<Accordion title="trusted_proxy_origin_not_allowed">
Trusted-proxy auth succeeded, but the browser `Origin` header did not pass Control UI origin checks.
Check:
- `gateway.controlUi.allowedOrigins` includes the exact browser origin.
- You are not relying on wildcard origins unless you intentionally want allow-all behavior.
- If you intentionally use Host-header fallback mode, `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true` is set deliberately.
</Accordion>
<Accordion title="Connection succeeds but methods report missing scope">
The WebSocket connects, but `chat.history`, `sessions.list`, or
`models.list` fails with `missing scope: operator.read`.
Common causes:
- Device-less Control UI session: trusted-proxy auth can admit the WebSocket connection without device identity, but OpenClaw clears scopes on device-less sessions by design.
- Custom backend client: `gateway.controlUi.dangerouslyDisableDeviceAuth` is Control UI scoped and does not grant scopes to arbitrary backend or CLI-shaped WebSocket clients.
- Overly narrow `x-openclaw-scopes`: if your proxy injects this header on the Control UI WebSocket upgrade request, the session scopes are capped to that set. An empty header value yields no scopes.
Fix:
- For Control UI, use HTTPS so the browser can generate device identity and complete pairing.
- For custom automation, use device identity/pairing, the reserved direct-local `gateway-client` backend helper path, or [admin HTTP RPC](/plugins/admin-http-rpc).
- Use `gateway.controlUi.dangerouslyDisableDeviceAuth: true` only as a temporary Control UI break-glass path.
</Accordion>
<Accordion title="WebSocket still failing">
Make sure your proxy:
- Supports WebSocket upgrades (`Upgrade: websocket`, `Connection: upgrade`).
- Passes the identity headers on WebSocket upgrade requests (not just HTTP).
- Doesn't have a separate auth path for WebSocket connections.
</Accordion>
</AccordionGroup>
## Migration from token auth
<Steps>
<Step title="Configure the proxy">
Configure your proxy to authenticate users and pass headers.
</Step>
<Step title="Test the proxy independently">
Test the proxy setup independently (curl with headers).
</Step>
<Step title="Update OpenClaw config">
Update OpenClaw config with trusted-proxy auth.
</Step>
<Step title="Restart the Gateway">
Restart the Gateway.
</Step>
<Step title="Test WebSocket">
Test WebSocket connections from the Control UI.
</Step>
<Step title="Audit">
Run `openclaw security audit` and review findings.
</Step>
</Steps>
## Related
- [Configuration](/gateway/configuration) — config reference
- [Operator scopes](/gateway/operator-scopes) — roles, scopes, and approval checks
- [Remote access](/gateway/remote) — other remote access patterns
- [Security](/gateway/security) — full security guide
- [Tailscale](/gateway/tailscale) — simpler alternative for tailnet-only access