Compare commits

..

39 Commits

Author SHA1 Message Date
Vincent Koc
3d845e2519 test(qa): cover subagent completion fallback 2026-04-26 01:54:10 -07:00
Vincent Koc
8d50a3a9c4 fix(agents): fallback subagent completion delivery 2026-04-26 00:32:20 -07:00
Peter Steinberger
4c7a94aac4 fix: quote Windows UI runner paths 2026-04-26 08:31:00 +01:00
Vincent Koc
434c8a1c91 docs(heartbeat): rewrite with Steps for quick start, ParamField for field notes, AccordionGroup for delivery and tasks behavior 2026-04-26 00:30:47 -07:00
Shakker
04575333d3 chore: ignore local agent skills 2026-04-26 08:26:28 +01:00
Shakker
50558e0d56 docs: note channel runtime laziness fixes 2026-04-26 08:26:28 +01:00
Shakker
8fe449c883 fix: avoid channel runtime in format summaries 2026-04-26 08:26:27 +01:00
Shakker
8b32c31252 fix: keep thread placement metadata cold 2026-04-26 08:26:27 +01:00
Shakker
2e101e8413 fix: keep channel security checks cold 2026-04-26 08:26:27 +01:00
Vincent Koc
a77996dc56 fix(diagnostics): propagate trusted traceparent headers 2026-04-26 00:24:47 -07:00
Vincent Koc
5e8fda4c64 docs(memory-config): rewrite with CardGroup overview links, Steps for auto-detect, AccordionGroup for provider configs and QMD subsections 2026-04-26 00:21:28 -07:00
Peter Steinberger
76cf013df5 test: remove slow reply bypass from docker smoke 2026-04-26 08:19:23 +01:00
Vincent Koc
450dc3a206 docs(control-ui): rewrite with Steps for pairing, AccordionGroup for capabilities and chat behavior, Tabs for tailnet access 2026-04-26 00:18:47 -07:00
Peter Steinberger
7b438965bd test: stabilize docker mcp readiness smokes 2026-04-26 08:17:28 +01:00
Peter Steinberger
c5bbf83904 fix: skip unresolved delivery targets for no-deliver cron 2026-04-26 08:17:28 +01:00
Peter Steinberger
f4f74a2391 docs(changelog): organize unreleased notes 2026-04-26 08:15:34 +01:00
Vincent Koc
0c8f0aacf5 docs(plugin-architecture): rewrite with AccordionGroup for shapes and ownership, Steps for the architecture pipeline, Tabs for layering 2026-04-26 00:15:25 -07:00
Peter Steinberger
1de4aff06d fix: cover Windows pnpm and Lobster install regressions 2026-04-26 08:14:28 +01:00
Peter Steinberger
5b9be2cdb1 fix: migrate agent runtime config 2026-04-26 08:12:44 +01:00
Vincent Koc
9d6e79019f docs(secrets): rewrite with Tabs for SecretRef sources, AccordionGroup for providers and exec examples, Steps for the audit flow 2026-04-26 00:12:05 -07:00
Shakker
b5e4e2f257 Revert "fix(plugins): persist registry contribution metadata"
This reverts commit 1ee5654220.
2026-04-26 08:11:09 +01:00
Shakker
59d1fa65df docs: note plugin uninstall file cleanup 2026-04-26 08:11:09 +01:00
Shakker
6428440086 fix: remove plugins from recorded install roots 2026-04-26 08:11:09 +01:00
Peter Steinberger
d419fb561d feat(tts): resolve channel account config generically 2026-04-26 08:10:36 +01:00
Vincent Koc
6c60cd2b72 docs(mcp): rewrite with Steps for lifecycle, Tabs for client modes, ParamField for serve options, AccordionGroup for tools 2026-04-26 00:08:16 -07:00
Vincent Koc
1ee5654220 fix(plugins): persist registry contribution metadata 2026-04-26 00:03:21 -07:00
Peter Steinberger
54f8e4145e test: speed up provider and security tests 2026-04-26 07:59:32 +01:00
Peter Steinberger
d1e5f4bd3c fix(update): bound Windows scheduled task stop 2026-04-26 07:56:46 +01:00
Shakker
3ad29972d0 docs: note read-only channel command discovery 2026-04-26 07:55:00 +01:00
Shakker
43557b16a6 fix: keep channel command discovery read-only 2026-04-26 07:55:00 +01:00
Shakker
fd97f530e3 docs: note cold session metadata fix 2026-04-26 07:55:00 +01:00
Shakker
bbed91bf71 fix: avoid session metadata channel runtime fallback 2026-04-26 07:55:00 +01:00
Shakker
49b106d357 docs: note cold native command defaults 2026-04-26 07:55:00 +01:00
Shakker
7a7728db13 fix: keep native command auto defaults cold 2026-04-26 07:55:00 +01:00
Shakker
aee4c92344 docs: note provider index disablement fix 2026-04-26 07:55:00 +01:00
Shakker
78fb0ade09 fix: honor plugin disablement in provider index rows 2026-04-26 07:55:00 +01:00
Vincent Koc
f48dc96d43 docs(opentelemetry): document harness lifecycle metric, span, and diagnostic events from 82ddcf24f5 2026-04-25 23:54:30 -07:00
Vincent Koc
ff7f0df871 docs(config-tools): rewrite with AccordionGroup for provider examples and field details, ParamField for loop detectors 2026-04-25 23:51:26 -07:00
Peter Steinberger
4ee537a04a fix(node-runtime): keep node-host recovering after gateway restarts 2026-04-26 07:49:45 +01:00
170 changed files with 5072 additions and 2568 deletions

30
.gitignore vendored
View File

@@ -97,6 +97,36 @@ USER.md
# local tooling
.serena/
# Local project-agent skill installs. Only repo-owned skills are visible by
# default; promoting a new repo skill should require an intentional `git add -f`.
.agents/skills/*
!.agents/skills/blacksmith-testbox/
!.agents/skills/blacksmith-testbox/**
!.agents/skills/openclaw-ghsa-maintainer/
!.agents/skills/openclaw-ghsa-maintainer/**
!.agents/skills/openclaw-parallels-smoke/
!.agents/skills/openclaw-parallels-smoke/**
!.agents/skills/openclaw-pr-maintainer/
!.agents/skills/openclaw-pr-maintainer/**
!.agents/skills/openclaw-qa-testing/
!.agents/skills/openclaw-qa-testing/**
!.agents/skills/openclaw-release-maintainer/
!.agents/skills/openclaw-release-maintainer/**
!.agents/skills/openclaw-secret-scanning-maintainer/
!.agents/skills/openclaw-secret-scanning-maintainer/**
!.agents/skills/openclaw-test-heap-leaks/
!.agents/skills/openclaw-test-heap-leaks/**
!.agents/skills/openclaw-test-performance/
!.agents/skills/openclaw-test-performance/**
!.agents/skills/optimizetests/
!.agents/skills/optimizetests/**
!.agents/skills/parallels-discord-roundtrip/
!.agents/skills/parallels-discord-roundtrip/**
!.agents/skills/security-triage/
!.agents/skills/security-triage/**
!.agents/skills/tag-duplicate-prs-issues/
!.agents/skills/tag-duplicate-prs-issues/**
# Agent credentials and memory (NEVER COMMIT)
/memory/
.agent/*.json

View File

@@ -10,23 +10,26 @@ Docs: https://docs.openclaw.ai
- Codex/agent: translate `--thinking minimal` to `low` for modern Codex models (gpt-5.5, gpt-5.4, gpt-5.4-mini, gpt-5.2) at request build time so the first turn is accepted instead of paying a wasted call + retry-with-low fallback. Older Codex models still receive `minimal` directly. Fixes #71946. Thanks @hclsys.
- TTS/WhatsApp: add `/tts latest` read-aloud support with duplicate suppression and `/tts chat on|off|default` session-scoped auto-TTS overrides, completing the on-demand voice-note UX for current-chat replies. Fixes #66032.
- Plugins/tokenjuice: bump the bundled tokenjuice runtime to 0.6.3. Thanks @vincentkoc.
- TTS/agents: allow `agents.list[].tts` to override global `messages.tts` for per-agent voices while keeping shared provider credentials and preferences in the existing TTS config surface.
- TTS/agents: make `/tts audio`, `/tts status`, and the `tts` agent tool honor the active `agents.list[].tts` voice/provider override.
- TTS/channels: resolve channel and account TTS overrides generically, enabling Feishu and QQBot accounts to deep-merge `channels.<channel>.accounts.<id>.tts` over global and per-agent TTS config. Thanks @sahilsatralkar.
- TTS/agents: allow `agents.list[].tts` to override global `messages.tts` for per-agent voices, and make `/tts audio`, `/tts status`, and the `tts` agent tool honor the active voice/provider override while keeping shared provider credentials and preferences in the existing TTS config surface.
- Providers/Azure Speech: add Azure Speech as a bundled TTS provider with Speech-resource auth, voice listing, SSML escaping, native Ogg/Opus voice-note output, and telephony output. (#51776) Thanks @leonchui.
- Browser automation: add a CDP-native role snapshot fallback with iframe-aware refs, cursor-clickable detection, target attach preparation, and `openclaw browser doctor --deep` live snapshot probing.
- Google Meet: add calendar-backed attendance export workflows, export manifests, dry-run previews, and tool parity for meeting records.
- Control UI: add PWA install support and Web Push notifications for Gateway chat. (#44590) Thanks @eduardocruz.
- Browser automation: add safe tab URLs in agent responses plus a CDP-native role snapshot fallback with iframe-aware refs, cursor-clickable detection, target attach preparation, and `openclaw browser doctor --deep` live snapshot probing.
- CLI/image generation: expose generic `--background` on `openclaw infer image generate` and `openclaw infer image edit`, keep `--openai-background` as an OpenAI alias, and let fal image generation honor `--output-format png|jpeg`. Thanks @steipete.
- Browser/config: allow local managed Chrome launch discovery and post-launch CDP readiness timeouts to be raised for slower hosts such as Raspberry Pi. Fixes #66803. Thanks @beat843796.
- Discord: allow `channels.discord.voice.model` to override the LLM used for voice channel responses while keeping STT and TTS on their existing media settings. (#64368) Thanks @mrdavey.
- Browser/CLI: add `openclaw browser start --headless` as a one-shot local managed browser launch override without rewriting persisted browser config. Thanks @BenediktSchackenberg.
- CLI/Crestodian: open interactive Crestodian in the full OpenClaw TUI shell instead of a basic readline prompt.
- CLI/Crestodian: shorten the startup greeting to the active planner/model, config state, Gateway probe result, and next debug action instead of dumping every discovered backend.
- CLI/Crestodian/TUI: add the first-run setup helper, local planner fallback, full-TUI interactive Crestodian, startup progress indicators, context mode selector, and a shorter startup greeting. (#71720, #71760) Thanks @SebTardif and @kevinlin-openai.
- Plugins: migrate the local plugin registry automatically during package install/update, keeping install metadata in the plugin index while indexing existing plugin manifests for the new cold registry path. Thanks @vincentkoc and @shakkernerd.
- Plugins/doctor: make `openclaw doctor --fix` refresh the plugin index and cold registry index when needed without treating plugin install records as authored config. Thanks @vincentkoc and @shakkernerd.
- Plugins/hooks: add before-agent-finalize hooks, cron `jobId` hook context, bounded native permission fingerprints, and Codex MCP hook relay support. (#71765, #71758, #71707) Thanks @vincentkoc and @pashpashpash.
- Plugins/tokenjuice: bump the bundled tokenjuice runtime to 0.6.3. Thanks @vincentkoc.
- Diagnostics/OTEL: align model-call GenAI span attributes with OpenTelemetry stability opt-in semantics, keeping legacy `gen_ai.system` by default while emitting `gen_ai.provider.name` under `OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental`. Thanks @vincentkoc.
- Diagnostics/OTEL: support signal-specific OTLP endpoint overrides for traces, metrics, and logs via config or standard OTEL environment variables. Thanks @vincentkoc.
- Diagnostics/OTEL: emit bounded telemetry exporter health diagnostics for startup and log-export failures without exporting raw error text. Thanks @vincentkoc.
- Diagnostics/OTEL: export agent harness lifecycle telemetry as bounded `openclaw.harness.run` spans and `openclaw.harness.duration_ms` metrics so QA-lab, Codex, and future harnesses share one trace shape. Thanks @vincentkoc.
- Diagnostics/trace: propagate W3C `traceparent` headers from trusted model-call trace context to provider transports while replacing caller-supplied traceparent values. Thanks @vincentkoc.
- Plugins/CLI: add `openclaw plugins registry` for explicit persisted-registry inspection and `--refresh` repair without making normal startup rescan plugin locations. Thanks @vincentkoc.
- Plugins/CLI: make `openclaw plugins list` read the cold persisted registry snapshot by default, leaving module-aware diagnostics to `plugins doctor` and `plugins inspect`. Thanks @vincentkoc.
- Plugins/startup: move gateway startup plugin planning onto the versioned cold registry index, with postinstall repair for older registry files that predate startup metadata. Thanks @vincentkoc.
@@ -56,6 +59,7 @@ Docs: https://docs.openclaw.ai
- Diagnostics/OTEL: keep model-usage span GenAI provider attributes aligned with the existing semantic-convention opt-in policy, using legacy `gen_ai.system` unless latest experimental GenAI conventions are enabled. Thanks @vincentkoc.
- Diagnostics/OTEL: keep `gen_ai.request.model` present on GenAI token usage metrics with a bounded `unknown` fallback when model usage events do not include a model. Thanks @vincentkoc.
- Docs/OTEL: document the GenAI token and model-call duration metrics, model-usage span attributes, and `OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental` provider-attribute behavior. Thanks @vincentkoc.
- Docs: refresh the MCP, model provider, doctor, troubleshooting, BlueBubbles, media generation, TTS, subagents, skills, cron/tasks, exec approvals, and voice-call guides with structured Steps, Tabs, and Accordion content.
- Diagnostics/trace: add an internal traceparent propagation helper that only formats trusted dispatcher metadata, keeping plugin-emitted diagnostic traces out of outbound propagation by default. Thanks @vincentkoc.
- Diagnostics/OTEL: add bounded outbound message delivery lifecycle diagnostics and export them as low-cardinality delivery spans/metrics without message body, recipient, room, or media-path data. (#71471) Thanks @vincentkoc and @jlapenna.
- Diagnostics/OTEL: emit bounded exec-process diagnostics and export them as `openclaw.exec` spans without exposing command text, working directories, or container identifiers. (#71451) Thanks @vincentkoc and @jlapenna.
@@ -76,10 +80,24 @@ Docs: https://docs.openclaw.ai
### Fixes
- UI/Windows: quote resolved pnpm `.cmd` launcher paths before spawning UI install/build/test commands so Node installs under `C:\Program Files` no longer fail as `C:\Program`. Fixes #45275. Thanks @Kobevictor, @stoppieboy, and @iubns.
- Plugins/uninstall: remove tracked plugin files from their recorded managed extensions root even when the current state directory points somewhere else, so `openclaw plugins uninstall --force` does not leave the plugin discoverable. Thanks @shakkernerd.
- Agents/runtime: add `agentRuntime.id` as the canonical config key, migrate
legacy runtime-policy configs with `openclaw doctor --fix`, and route
canonical Anthropic models through `claude-cli` without passing CLI backend
aliases to embedded harness selection. Fixes #71957. Thanks @WolvenRA.
- CLI/update: guard Windows scheduled-task stops by state and timeout so auto-update restart cannot hang indefinitely on `schtasks /End` before stale-listener cleanup. Fixes #69970. Thanks @yangswld and @sherlock-huang.
- Windows install/Lobster: execute `pnpm.exe` directly when `npm_execpath` points at the native pnpm binary, add an installed-package fallback for the Lobster embedded runtime, and include the Lobster runner regression test in Windows CI. Fixes #69456. Thanks @igormf.
- Gateway/install: refresh loaded gateway service installs when the current service embeds stale gateway auth instead of returning already-installed, avoiding LaunchAgent token-mismatch loops after token rotation. Fixes #70752. Thanks @hyspacex.
- Update: ignore bundled plugin `.openclaw-install-stage` directories during global install verification and packaged dist pruning so leftover runtime-dep staging files do not turn successful updates into `unexpected packaged dist file` failures. Fixes #71752. Thanks @waynegault.
- Node runtime: keep node-host retry timers alive across Gateway restarts and exit on terminal credential pauses so supervised nodes do not become silent zombies. Fixes #69800. Thanks @meroli28.
- Gateway/plugins: stop persisted WhatsApp auth state from activating bundled channel runtime-dependency repair during startup when `channels.whatsapp` is absent, avoiding npm/git stalls on packaged Linux installs. Fixes #71994. Thanks @xiao398008.
- Gateway/device tokens: enforce caller-scope containment inside token rotation and revocation so pairing-only sessions cannot mutate higher-scope operator tokens. Fixes #71990. Thanks @coygeek.
- Plugins/channels: keep security checks, thread-binding placement, provider summaries, health formatting, and message action labels on read-only or already-loaded channel metadata instead of importing full channel runtime. Thanks @shakkernerd.
- Sessions/channels: stop group-session metadata from loading bundled channel runtime just to classify `#channel` subjects, using only already-loaded channel capabilities on that path. Thanks @shakkernerd.
- Plugins/channels: keep native command and native skill `auto` defaults on static channel metadata so config, audit, and command-list checks do not load channel runtime just to read those defaults. Thanks @shakkernerd.
- CLI/channels: keep channel remove selection and all-channel capabilities summaries on read-only plugin metadata, loading channel runtime only for the selected mutation path. Thanks @shakkernerd.
- CLI/models: keep Provider Index preview rows out of `models list --all --provider <id>` when the owning provider plugin is disabled, preserving config authority for cold catalog fallbacks. Thanks @shakkernerd.
- CLI/model runs: keep `openclaw infer model run` on explicit OpenRouter models from loading the full provider catalog or inheriting chat-agent silent-reply policy, restoring non-empty one-shot probe output. Fixes #68791. Thanks @limpredator.
- Installer/macOS: rerun Homebrew install steps without the gum spinner when raw-mode ioctl failures occur, and avoid claiming `node@24` was installed when the Homebrew keg binary is missing. Fixes #70411. Thanks @1fanwang and @dad-io.
- Installer: load nvm before Node.js detection so `curl | bash` installs respect nvm-managed Node instead of stale system Node. Fixes #49556. Thanks @heavenlxj.

View File

@@ -1,4 +1,4 @@
f1eefb91a486188915373b09199959f0f1a7cd01dc75ef923832741f72a12543 config-baseline.json
9f0e386d5118cbca785a2e8e9c8b170d844faf1b7ef5e82e6b15d9e1c39f3796 config-baseline.core.json
d8e7866e0c3f633222f75a35defed3c3a03d849f4aa4f70871e3436e80074e76 config-baseline.json
5f5fb87fd46f9cbb84d8af17e00ae3c4b74062e8ad517bc2260ba83da2e9014f config-baseline.core.json
7cd9c908f066c143eab2a201efbc9640f483ab28bba92ddeca1d18cc2b528bc3 config-baseline.channel.json
a5479c182ec987bb21e814b8a4e7b3bda7190ae5c2b35fd5ca403dfa48afa115 config-baseline.plugin.json

View File

@@ -213,6 +213,11 @@ openclaw pairing list feishu
appId: "cli_xxx",
appSecret: "xxx",
name: "Primary bot",
tts: {
providers: {
openai: { voice: "shimmer" },
},
},
},
backup: {
appId: "cli_yyy",
@@ -227,6 +232,10 @@ openclaw pairing list feishu
```
`defaultAccount` controls which account is used when outbound APIs do not specify an `accountId`.
`accounts.<id>.tts` uses the same shape as `messages.tts` and deep-merges over
global TTS config, so multi-bot Feishu setups can keep shared provider
credentials globally while overriding only voice, model, persona, or auto mode
per account.
### Message limits
@@ -386,6 +395,7 @@ Full configuration: [Gateway configuration](/gateway/configuration)
| `channels.feishu.accounts.<id>.appId` | App ID | — |
| `channels.feishu.accounts.<id>.appSecret` | App Secret | — |
| `channels.feishu.accounts.<id>.domain` | Per-account domain override | `feishu` |
| `channels.feishu.accounts.<id>.tts` | Per-account TTS override | `messages.tts` |
| `channels.feishu.dmPolicy` | DM policy | `allowlist` |
| `channels.feishu.allowFrom` | DM allowlist (open_id list) | [BotOwnerId] |
| `channels.feishu.groupPolicy` | Group policy | `allowlist` |

View File

@@ -122,10 +122,10 @@ openclaw channels add --channel qqbot --account bot2 --token "222222222:secret-o
STT and TTS support two-level configuration with priority fallback:
| Setting | Plugin-specific | Framework fallback |
| ------- | -------------------- | ----------------------------- |
| STT | `channels.qqbot.stt` | `tools.media.audio.models[0]` |
| TTS | `channels.qqbot.tts` | `messages.tts` |
| Setting | Plugin-specific | Framework fallback |
| ------- | -------------------------------------------------------- | ----------------------------- |
| STT | `channels.qqbot.stt` | `tools.media.audio.models[0]` |
| TTS | `channels.qqbot.tts`, `channels.qqbot.accounts.<id>.tts` | `messages.tts` |
```json5
{
@@ -140,12 +140,23 @@ STT and TTS support two-level configuration with priority fallback:
model: "your-tts-model",
voice: "your-voice",
},
accounts: {
qq-main: {
tts: {
providers: {
openai: { voice: "shimmer" },
},
},
},
},
},
},
}
```
Set `enabled: false` on either to disable.
Account-level TTS overrides use the same shape as `messages.tts` and deep-merge
over the channel/global TTS config.
Inbound QQ voice attachments are exposed to agents as audio media metadata while
keeping raw voice files out of generic `MediaPaths`. `[[audio_as_voice]]` plain

View File

@@ -162,7 +162,7 @@ configured OpenClaw model. If no configured model is usable yet, it can fall
back to local runtimes already present on the machine:
- Claude Code CLI: `claude-cli/claude-opus-4-7`
- Codex app-server harness: `openai/gpt-5.5` with `embeddedHarness.runtime: "codex"`
- Codex app-server harness: `openai/gpt-5.5` with `agentRuntime.id: "codex"`
- Codex CLI: `codex-cli/gpt-5.5`
The model-assisted planner cannot mutate config directly. It must translate the

View File

@@ -5,91 +5,89 @@ read_when:
- Running `openclaw mcp serve`
- Managing OpenClaw-saved MCP server definitions
title: "MCP"
sidebarTitle: "MCP"
---
`openclaw mcp` has two jobs:
- run OpenClaw as an MCP server with `openclaw mcp serve`
- manage OpenClaw-owned outbound MCP server definitions with `list`, `show`,
`set`, and `unset`
- manage OpenClaw-owned outbound MCP server definitions with `list`, `show`, `set`, and `unset`
In other words:
- `serve` is OpenClaw acting as an MCP server
- `list` / `show` / `set` / `unset` is OpenClaw acting as an MCP client-side
registry for other MCP servers its runtimes may consume later
- `list` / `show` / `set` / `unset` is OpenClaw acting as an MCP client-side registry for other MCP servers its runtimes may consume later
Use [`openclaw acp`](/cli/acp) when OpenClaw should host a coding harness
session itself and route that runtime through ACP.
Use [`openclaw acp`](/cli/acp) when OpenClaw should host a coding harness session itself and route that runtime through ACP.
## OpenClaw as an MCP server
This is the `openclaw mcp serve` path.
## When to use `serve`
### When to use `serve`
Use `openclaw mcp serve` when:
- Codex, Claude Code, or another MCP client should talk directly to
OpenClaw-backed channel conversations
- Codex, Claude Code, or another MCP client should talk directly to OpenClaw-backed channel conversations
- you already have a local or remote OpenClaw Gateway with routed sessions
- you want one MCP server that works across OpenClaw's channel backends instead
of running separate per-channel bridges
- you want one MCP server that works across OpenClaw's channel backends instead of running separate per-channel bridges
Use [`openclaw acp`](/cli/acp) instead when OpenClaw should host the coding
runtime itself and keep the agent session inside OpenClaw.
Use [`openclaw acp`](/cli/acp) instead when OpenClaw should host the coding runtime itself and keep the agent session inside OpenClaw.
## How it works
### How it works
`openclaw mcp serve` starts a stdio MCP server. The MCP client owns that
process. While the client keeps the stdio session open, the bridge connects to a
local or remote OpenClaw Gateway over WebSocket and exposes routed channel
conversations over MCP.
`openclaw mcp serve` starts a stdio MCP server. The MCP client owns that process. While the client keeps the stdio session open, the bridge connects to a local or remote OpenClaw Gateway over WebSocket and exposes routed channel conversations over MCP.
Lifecycle:
<Steps>
<Step title="Client spawns the bridge">
The MCP client spawns `openclaw mcp serve`.
</Step>
<Step title="Bridge connects to Gateway">
The bridge connects to the OpenClaw Gateway over WebSocket.
</Step>
<Step title="Sessions become MCP conversations">
Routed sessions become MCP conversations and transcript/history tools.
</Step>
<Step title="Live events queue">
Live events are queued in memory while the bridge is connected.
</Step>
<Step title="Optional Claude push">
If Claude channel mode is enabled, the same session can also receive Claude-specific push notifications.
</Step>
</Steps>
1. the MCP client spawns `openclaw mcp serve`
2. the bridge connects to Gateway
3. routed sessions become MCP conversations and transcript/history tools
4. live events are queued in memory while the bridge is connected
5. if Claude channel mode is enabled, the same session can also receive
Claude-specific push notifications
<AccordionGroup>
<Accordion title="Important behavior">
- live queue state starts when the bridge connects
- older transcript history is read with `messages_read`
- Claude push notifications only exist while the MCP session is alive
- when the client disconnects, the bridge exits and the live queue is gone
- one-shot agent entry points such as `openclaw agent` and `openclaw infer model run` retire any bundled MCP runtimes they open when the reply completes, so repeated scripted runs do not accumulate stdio MCP child processes
- stdio MCP servers launched by OpenClaw (bundled or user-configured) are torn down as a process tree on shutdown, so child subprocesses started by the server do not survive after the parent stdio client exits
- deleting or resetting a session disposes that session's MCP clients through the shared runtime cleanup path, so there are no lingering stdio connections tied to a removed session
</Accordion>
</AccordionGroup>
Important behavior:
- live queue state starts when the bridge connects
- older transcript history is read with `messages_read`
- Claude push notifications only exist while the MCP session is alive
- when the client disconnects, the bridge exits and the live queue is gone
- one-shot agent entry points such as `openclaw agent` and
`openclaw infer model run` retire any bundled MCP runtimes they open when the
reply completes, so repeated scripted runs do not accumulate stdio MCP child
processes
- stdio MCP servers launched by OpenClaw (bundled or user-configured) are torn
down as a process tree on shutdown, so child subprocesses started by the
server do not survive after the parent stdio client exits
- deleting or resetting a session disposes that session's MCP clients through
the shared runtime cleanup path, so there are no lingering stdio connections
tied to a removed session
## Choose a client mode
### Choose a client mode
Use the same bridge in two different ways:
- Generic MCP clients: standard MCP tools only. Use `conversations_list`,
`messages_read`, `events_poll`, `events_wait`, `messages_send`, and the
approval tools.
- Claude Code: standard MCP tools plus the Claude-specific channel adapter.
Enable `--claude-channel-mode on` or leave the default `auto`.
<Tabs>
<Tab title="Generic MCP clients">
Standard MCP tools only. Use `conversations_list`, `messages_read`, `events_poll`, `events_wait`, `messages_send`, and the approval tools.
</Tab>
<Tab title="Claude Code">
Standard MCP tools plus the Claude-specific channel adapter. Enable `--claude-channel-mode on` or leave the default `auto`.
</Tab>
</Tabs>
Today, `auto` behaves the same as `on`. There is no client capability detection
yet.
<Note>
Today, `auto` behaves the same as `on`. There is no client capability detection yet.
</Note>
## What `serve` exposes
### What `serve` exposes
The bridge uses existing Gateway session route metadata to expose channel-backed
conversations. A conversation appears when OpenClaw already has session state
with a known route such as:
The bridge uses existing Gateway session route metadata to expose channel-backed conversations. A conversation appears when OpenClaw already has session state with a known route such as:
- `channel`
- recipient or destination metadata
@@ -104,101 +102,91 @@ This gives MCP clients one place to:
- send a reply back through the same route
- see approval requests that arrive while the bridge is connected
## Usage
### Usage
```bash
# Local Gateway
openclaw mcp serve
<Tabs>
<Tab title="Local Gateway">
```bash
openclaw mcp serve
```
</Tab>
<Tab title="Remote Gateway (token)">
```bash
openclaw mcp serve --url wss://gateway-host:18789 --token-file ~/.openclaw/gateway.token
```
</Tab>
<Tab title="Remote Gateway (password)">
```bash
openclaw mcp serve --url wss://gateway-host:18789 --password-file ~/.openclaw/gateway.password
```
</Tab>
<Tab title="Verbose / Claude off">
```bash
openclaw mcp serve --verbose
openclaw mcp serve --claude-channel-mode off
```
</Tab>
</Tabs>
# Remote Gateway
openclaw mcp serve --url wss://gateway-host:18789 --token-file ~/.openclaw/gateway.token
# Remote Gateway with password auth
openclaw mcp serve --url wss://gateway-host:18789 --password-file ~/.openclaw/gateway.password
# Enable verbose bridge logs
openclaw mcp serve --verbose
# Disable Claude-specific push notifications
openclaw mcp serve --claude-channel-mode off
```
## Bridge tools
### Bridge tools
The current bridge exposes these MCP tools:
- `conversations_list`
- `conversation_get`
- `messages_read`
- `attachments_fetch`
- `events_poll`
- `events_wait`
- `messages_send`
- `permissions_list_open`
- `permissions_respond`
<AccordionGroup>
<Accordion title="conversations_list">
Lists recent session-backed conversations that already have route metadata in Gateway session state.
### `conversations_list`
Useful filters:
Lists recent session-backed conversations that already have route metadata in
Gateway session state.
- `limit`
- `search`
- `channel`
- `includeDerivedTitles`
- `includeLastMessage`
Useful filters:
</Accordion>
<Accordion title="conversation_get">
Returns one conversation by `session_key`.
</Accordion>
<Accordion title="messages_read">
Reads recent transcript messages for one session-backed conversation.
</Accordion>
<Accordion title="attachments_fetch">
Extracts non-text message content blocks from one transcript message. This is a metadata view over transcript content, not a standalone durable attachment blob store.
</Accordion>
<Accordion title="events_poll">
Reads queued live events since a numeric cursor.
</Accordion>
<Accordion title="events_wait">
Long-polls until the next matching queued event arrives or a timeout expires.
- `limit`
- `search`
- `channel`
- `includeDerivedTitles`
- `includeLastMessage`
Use this when a generic MCP client needs near-real-time delivery without a Claude-specific push protocol.
### `conversation_get`
</Accordion>
<Accordion title="messages_send">
Sends text back through the same route already recorded on the session.
Returns one conversation by `session_key`.
Current behavior:
### `messages_read`
- requires an existing conversation route
- uses the session's channel, recipient, account id, and thread id
- sends text only
Reads recent transcript messages for one session-backed conversation.
</Accordion>
<Accordion title="permissions_list_open">
Lists pending exec/plugin approval requests the bridge has observed since it connected to the Gateway.
</Accordion>
<Accordion title="permissions_respond">
Resolves one pending exec/plugin approval request with:
### `attachments_fetch`
- `allow-once`
- `allow-always`
- `deny`
Extracts non-text message content blocks from one transcript message. This is a
metadata view over transcript content, not a standalone durable attachment blob
store.
</Accordion>
</AccordionGroup>
### `events_poll`
Reads queued live events since a numeric cursor.
### `events_wait`
Long-polls until the next matching queued event arrives or a timeout expires.
Use this when a generic MCP client needs near-real-time delivery without a
Claude-specific push protocol.
### `messages_send`
Sends text back through the same route already recorded on the session.
Current behavior:
- requires an existing conversation route
- uses the session's channel, recipient, account id, and thread id
- sends text only
### `permissions_list_open`
Lists pending exec/plugin approval requests the bridge has observed since it
connected to the Gateway.
### `permissions_respond`
Resolves one pending exec/plugin approval request with:
- `allow-once`
- `allow-always`
- `deny`
## Event model
### Event model
The bridge keeps an in-memory event queue while it is connected.
@@ -211,46 +199,43 @@ Current event types:
- `plugin_approval_resolved`
- `claude_permission_request`
Important limits:
<Warning>
- the queue is live-only; it starts when the MCP bridge starts
- `events_poll` and `events_wait` do not replay older Gateway history by
themselves
- `events_poll` and `events_wait` do not replay older Gateway history by themselves
- durable backlog should be read with `messages_read`
</Warning>
## Claude channel notifications
### Claude channel notifications
The bridge can also expose Claude-specific channel notifications. This is the
OpenClaw equivalent of a Claude Code channel adapter: standard MCP tools remain
available, but live inbound messages can also arrive as Claude-specific MCP
notifications.
The bridge can also expose Claude-specific channel notifications. This is the OpenClaw equivalent of a Claude Code channel adapter: standard MCP tools remain available, but live inbound messages can also arrive as Claude-specific MCP notifications.
Flags:
<Tabs>
<Tab title="off">
`--claude-channel-mode off`: standard MCP tools only.
</Tab>
<Tab title="on">
`--claude-channel-mode on`: enable Claude channel notifications.
</Tab>
<Tab title="auto (default)">
`--claude-channel-mode auto`: current default; same bridge behavior as `on`.
</Tab>
</Tabs>
- `--claude-channel-mode off`: standard MCP tools only
- `--claude-channel-mode on`: enable Claude channel notifications
- `--claude-channel-mode auto`: current default; same bridge behavior as `on`
When Claude channel mode is enabled, the server advertises Claude experimental
capabilities and can emit:
When Claude channel mode is enabled, the server advertises Claude experimental capabilities and can emit:
- `notifications/claude/channel`
- `notifications/claude/channel/permission`
Current bridge behavior:
- inbound `user` transcript messages are forwarded as
`notifications/claude/channel`
- inbound `user` transcript messages are forwarded as `notifications/claude/channel`
- Claude permission requests received over MCP are tracked in-memory
- if the linked conversation later sends `yes abcde` or `no abcde`, the bridge
converts that to `notifications/claude/channel/permission`
- these notifications are live-session only; if the MCP client disconnects,
there is no push target
- if the linked conversation later sends `yes abcde` or `no abcde`, the bridge converts that to `notifications/claude/channel/permission`
- these notifications are live-session only; if the MCP client disconnects, there is no push target
This is intentionally client-specific. Generic MCP clients should rely on the
standard polling tools.
This is intentionally client-specific. Generic MCP clients should rely on the standard polling tools.
## MCP client config
### MCP client config
Example stdio client config:
@@ -272,43 +257,52 @@ Example stdio client config:
}
```
For most generic MCP clients, start with the standard tool surface and ignore
Claude mode. Turn Claude mode on only for clients that actually understand the
Claude-specific notification methods.
For most generic MCP clients, start with the standard tool surface and ignore Claude mode. Turn Claude mode on only for clients that actually understand the Claude-specific notification methods.
## Options
### Options
`openclaw mcp serve` supports:
- `--url <url>`: Gateway WebSocket URL
- `--token <token>`: Gateway token
- `--token-file <path>`: read token from file
- `--password <password>`: Gateway password
- `--password-file <path>`: read password from file
- `--claude-channel-mode <auto|on|off>`: Claude notification mode
- `-v`, `--verbose`: verbose logs on stderr
<ParamField path="--url" type="string">
Gateway WebSocket URL.
</ParamField>
<ParamField path="--token" type="string">
Gateway token.
</ParamField>
<ParamField path="--token-file" type="string">
Read token from file.
</ParamField>
<ParamField path="--password" type="string">
Gateway password.
</ParamField>
<ParamField path="--password-file" type="string">
Read password from file.
</ParamField>
<ParamField path="--claude-channel-mode" type='"auto" | "on" | "off"'>
Claude notification mode.
</ParamField>
<ParamField path="-v, --verbose" type="boolean">
Verbose logs on stderr.
</ParamField>
<Tip>
Prefer `--token-file` or `--password-file` over inline secrets when possible.
</Tip>
## Security and trust boundary
### Security and trust boundary
The bridge does not invent routing. It only exposes conversations that Gateway
already knows how to route.
The bridge does not invent routing. It only exposes conversations that Gateway already knows how to route.
That means:
- sender allowlists, pairing, and channel-level trust still belong to the
underlying OpenClaw channel configuration
- sender allowlists, pairing, and channel-level trust still belong to the underlying OpenClaw channel configuration
- `messages_send` can only reply through an existing stored route
- approval state is live/in-memory only for the current bridge session
- bridge auth should use the same Gateway token or password controls you would
trust for any other remote Gateway client
- bridge auth should use the same Gateway token or password controls you would trust for any other remote Gateway client
If a conversation is missing from `conversations_list`, the usual cause is not
MCP configuration. It is missing or incomplete route metadata in the underlying
Gateway session.
If a conversation is missing from `conversations_list`, the usual cause is not MCP configuration. It is missing or incomplete route metadata in the underlying Gateway session.
## Testing
### Testing
OpenClaw ships a deterministic Docker smoke for this bridge:
@@ -320,79 +314,60 @@ That smoke:
- starts a seeded Gateway container
- starts a second container that spawns `openclaw mcp serve`
- verifies conversation discovery, transcript reads, attachment metadata reads,
live event queue behavior, and outbound send routing
- validates Claude-style channel and permission notifications over the real
stdio MCP bridge
- verifies conversation discovery, transcript reads, attachment metadata reads, live event queue behavior, and outbound send routing
- validates Claude-style channel and permission notifications over the real stdio MCP bridge
This is the fastest way to prove the bridge works without wiring a real
Telegram, Discord, or iMessage account into the test run.
This is the fastest way to prove the bridge works without wiring a real Telegram, Discord, or iMessage account into the test run.
For broader testing context, see [Testing](/help/testing).
## Troubleshooting
### Troubleshooting
### No conversations returned
<AccordionGroup>
<Accordion title="No conversations returned">
Usually means the Gateway session is not already routable. Confirm that the underlying session has stored channel/provider, recipient, and optional account/thread route metadata.
</Accordion>
<Accordion title="events_poll or events_wait misses older messages">
Expected. The live queue starts when the bridge connects. Read older transcript history with `messages_read`.
</Accordion>
<Accordion title="Claude notifications do not show up">
Check all of these:
Usually means the Gateway session is not already routable. Confirm that the
underlying session has stored channel/provider, recipient, and optional
account/thread route metadata.
- the client kept the stdio MCP session open
- `--claude-channel-mode` is `on` or `auto`
- the client actually understands the Claude-specific notification methods
- the inbound message happened after the bridge connected
### `events_poll` or `events_wait` misses older messages
Expected. The live queue starts when the bridge connects. Read older transcript
history with `messages_read`.
### Claude notifications do not show up
Check all of these:
- the client kept the stdio MCP session open
- `--claude-channel-mode` is `on` or `auto`
- the client actually understands the Claude-specific notification methods
- the inbound message happened after the bridge connected
### Approvals are missing
`permissions_list_open` only shows approval requests observed while the bridge
was connected. It is not a durable approval history API.
</Accordion>
<Accordion title="Approvals are missing">
`permissions_list_open` only shows approval requests observed while the bridge was connected. It is not a durable approval history API.
</Accordion>
</AccordionGroup>
## OpenClaw as an MCP client registry
This is the `openclaw mcp list`, `show`, `set`, and `unset` path.
These commands do not expose OpenClaw over MCP. They manage OpenClaw-owned MCP
server definitions under `mcp.servers` in OpenClaw config.
These commands do not expose OpenClaw over MCP. They manage OpenClaw-owned MCP server definitions under `mcp.servers` in OpenClaw config.
Those saved definitions are for runtimes that OpenClaw launches or configures
later, such as embedded Pi and other runtime adapters. OpenClaw stores the
definitions centrally so those runtimes do not need to keep their own duplicate
MCP server lists.
Those saved definitions are for runtimes that OpenClaw launches or configures later, such as embedded Pi and other runtime adapters. OpenClaw stores the definitions centrally so those runtimes do not need to keep their own duplicate MCP server lists.
Important behavior:
<AccordionGroup>
<Accordion title="Important behavior">
- these commands only read or write OpenClaw config
- they do not connect to the target MCP server
- they do not validate whether the command, URL, or remote transport is reachable right now
- runtime adapters decide which transport shapes they actually support at execution time
- embedded Pi exposes configured MCP tools in normal `coding` and `messaging` tool profiles; `minimal` still hides them, and `tools.deny: ["bundle-mcp"]` disables them explicitly
- session-scoped bundled MCP runtimes are reaped after `mcp.sessionIdleTtlMs` milliseconds of idle time (default 10 minutes; set `0` to disable) and one-shot embedded runs clean them up at run end
</Accordion>
</AccordionGroup>
- these commands only read or write OpenClaw config
- they do not connect to the target MCP server
- they do not validate whether the command, URL, or remote transport is
reachable right now
- runtime adapters decide which transport shapes they actually support at
execution time
- embedded Pi exposes configured MCP tools in normal `coding` and `messaging`
tool profiles; `minimal` still hides them, and `tools.deny: ["bundle-mcp"]`
disables them explicitly
- session-scoped bundled MCP runtimes are reaped after `mcp.sessionIdleTtlMs`
milliseconds of idle time (default 10 minutes; set `0` to disable) and
one-shot embedded runs clean them up at run end
Runtime adapters may normalize this shared registry into the shape their downstream client expects. For example, embedded Pi consumes OpenClaw `transport` values directly, while Claude Code and Gemini receive CLI-native `type` values such as `http`, `sse`, or `stdio`.
Runtime adapters may normalize this shared registry into the shape their
downstream client expects. For example, embedded Pi consumes OpenClaw
`transport` values directly, while Claude Code and Gemini receive CLI-native
`type` values such as `http`, `sse`, or `stdio`.
### Saved MCP server definitions
## Saved MCP server definitions
OpenClaw also stores a lightweight MCP server registry in config for surfaces
that want OpenClaw-managed MCP definitions.
OpenClaw also stores a lightweight MCP server registry in config for surfaces that want OpenClaw-managed MCP definitions.
Commands:
@@ -447,11 +422,13 @@ Launches a local child process and communicates over stdin/stdout.
| `env` | Extra environment variables |
| `cwd` / `workingDirectory` | Working directory for the process |
#### Stdio env safety filter
<Warning>
**Stdio env safety filter**
OpenClaw rejects interpreter-startup env keys that can alter how a stdio MCP server starts up before the first RPC, even if they appear in a server's `env` block. Blocked keys include `NODE_OPTIONS`, `PYTHONSTARTUP`, `PYTHONPATH`, `PERL5OPT`, `RUBYOPT`, `SHELLOPTS`, `PS4`, and similar runtime-control variables. Startup rejects these with a configuration error so they cannot inject an implicit prelude, swap the interpreter, or enable a debugger against the stdio process. Ordinary credential, proxy, and server-specific env vars (`GITHUB_TOKEN`, `HTTP_PROXY`, custom `*_API_KEY`, etc.) are unaffected.
If your MCP server genuinely needs one of the blocked variables, set it on the gateway host process instead of under the stdio server's `env`.
</Warning>
### SSE / HTTP transport
@@ -480,8 +457,7 @@ Example:
}
```
Sensitive values in `url` (userinfo) and `headers` are redacted in logs and
status output.
Sensitive values in `url` (userinfo) and `headers` are redacted in logs and status output.
### Streamable HTTP transport
@@ -513,8 +489,9 @@ Example:
}
```
These commands manage saved config only. They do not start the channel bridge,
open a live MCP client session, or prove the target server is reachable.
<Note>
These commands manage saved config only. They do not start the channel bridge, open a live MCP client session, or prove the target server is reachable.
</Note>
## Current limits
@@ -526,8 +503,7 @@ Current limits:
- no generic push protocol beyond the Claude-specific adapter
- no message edit or react tools yet
- HTTP/SSE/streamable-http transport connects to a single remote server; no multiplexed upstream yet
- `permissions_list_open` only includes approvals observed while the bridge is
connected
- `permissions_list_open` only includes approvals observed while the bridge is connected
## Related

View File

@@ -114,6 +114,12 @@ Use `openclaw node run` for a foreground node host (no service).
Service commands accept `--json` for machine-readable output.
The node host retries Gateway restart and network closes in-process. If the
Gateway reports a terminal token/password/bootstrap auth pause, the node host
logs the close detail and exits non-zero so launchd/systemd can restart it with
fresh config and credentials. Pairing-required pauses stay in the foreground
flow so the pending request can be approved.
## Pairing
The first connection creates a pending device pairing request (`role: node`) on the Gateway.

View File

@@ -18,14 +18,24 @@ configuration. They are different layers:
| ------------- | ------------------------------------- | ------------------------------------------------------------------- |
| Provider | `openai`, `anthropic`, `openai-codex` | How OpenClaw authenticates, discovers models, and names model refs. |
| Model | `gpt-5.5`, `claude-opus-4-6` | The model selected for the agent turn. |
| Agent runtime | `pi`, `codex`, ACP-backed runtimes | The low level loop that executes the prepared turn. |
| Agent runtime | `pi`, `codex`, `claude-cli` | The low level loop or backend that executes the prepared turn. |
| Channel | Telegram, Discord, Slack, WhatsApp | Where messages enter and leave OpenClaw. |
You will also see the word **harness** in code and config. A harness is the
implementation that provides an agent runtime. For example, the bundled Codex
harness implements the `codex` runtime. The config key is still named
`embeddedHarness` for compatibility, but user-facing docs and status output
should generally say runtime.
You will also see the word **harness** in code. A harness is the implementation
that provides an agent runtime. For example, the bundled Codex harness
implements the `codex` runtime. Public config uses `agentRuntime.id`; `openclaw
doctor --fix` rewrites older runtime-policy keys to that shape.
There are two runtime families:
- **Embedded harnesses** run inside OpenClaw's prepared agent loop. Today this
is the built-in `pi` runtime plus registered plugin harnesses such as
`codex`.
- **CLI backends** run a local CLI process while keeping the model ref
canonical. For example, `anthropic/claude-opus-4-7` with
`agentRuntime.id: "claude-cli"` means "select the Anthropic model, execute
through Claude CLI." `claude-cli` is not an embedded harness id and must not
be passed to AgentHarness selection.
## Three things named Codex
@@ -34,7 +44,7 @@ Most confusion comes from three different surfaces sharing the Codex name:
| Surface | OpenClaw name/config | What it does |
| ---------------------------------------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------- |
| Codex OAuth provider route | `openai-codex/*` model refs | Uses ChatGPT/Codex subscription OAuth through the normal OpenClaw PI runner. |
| Native Codex app-server runtime | `embeddedHarness.runtime: "codex"` | Runs the embedded agent turn through the bundled Codex app-server harness. |
| Native Codex app-server runtime | `agentRuntime.id: "codex"` | Runs the embedded agent turn through the bundled Codex app-server harness. |
| Codex ACP adapter | `runtime: "acp"`, `agentId: "codex"` | Runs Codex through the external ACP/acpx control plane. Use only when ACP/acpx is explicitly asked. |
| Native Codex chat-control command set | `/codex ...` | Binds, resumes, steers, stops, and inspects Codex app-server threads from chat. |
| OpenAI Platform API route for GPT/Codex-style models | `openai/*` model refs | Uses OpenAI API-key auth unless a runtime override, such as `runtime: "codex"`, runs the turn. |
@@ -52,8 +62,8 @@ The common Codex setup uses the `openai` provider with the `codex` runtime:
agents: {
defaults: {
model: "openai/gpt-5.5",
embeddedHarness: {
runtime: "codex",
agentRuntime: {
id: "codex",
},
},
},
@@ -76,7 +86,7 @@ This is the agent-facing decision tree:
1. If the user asks for **Codex bind/control/thread/resume/steer/stop**, use the
native `/codex` command surface when the bundled `codex` plugin is enabled.
2. If the user asks for **Codex as the embedded runtime**, use
`openai/<model>` with `embeddedHarness.runtime: "codex"`.
`openai/<model>` with `agentRuntime.id: "codex"`.
3. If the user asks for **Codex OAuth/subscription auth on the normal OpenClaw
runner**, use `openai-codex/<model>` and leave the runtime as PI.
4. If the user explicitly says **ACP**, **acpx**, or **Codex ACP adapter**, use
@@ -87,7 +97,7 @@ This is the agent-facing decision tree:
| You mean... | Use... |
| --------------------------------------- | -------------------------------------------- |
| Codex app-server chat/thread control | `/codex ...` from the bundled `codex` plugin |
| Codex app-server embedded agent runtime | `embeddedHarness.runtime: "codex"` |
| Codex app-server embedded agent runtime | `agentRuntime.id: "codex"` |
| OpenAI Codex OAuth on the PI runner | `openai-codex/*` model refs |
| Claude Code or other external harness | ACP/acpx |
@@ -122,9 +132,9 @@ OpenClaw chooses an embedded runtime after provider and model resolution:
1. A session's recorded runtime wins. Config changes do not hot-switch an
existing transcript to a different native thread system.
2. `OPENCLAW_AGENT_RUNTIME=<id>` forces that runtime for new or reset sessions.
3. `agents.defaults.embeddedHarness.runtime` or
`agents.list[].embeddedHarness.runtime` can set `auto`, `pi`, or a registered
runtime id such as `codex`.
3. `agents.defaults.agentRuntime.id` or `agents.list[].agentRuntime.id` can set
`auto`, `pi`, a registered embedded harness id such as `codex`, or a
supported CLI backend alias such as `claude-cli`.
4. In `auto` mode, registered plugin runtimes can claim supported provider/model
pairs.
5. If no runtime claims a turn in `auto` mode and `fallback: "pi"` is set
@@ -137,6 +147,24 @@ Explicit plugin runtimes fail closed by default. For example,
a broader fallback setting, so an agent-level `runtime: "codex"` is not silently
routed back to PI just because defaults used `fallback: "pi"`.
CLI backend aliases are different from embedded harness ids. The preferred
Claude CLI form is:
```json5
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-7",
agentRuntime: { id: "claude-cli" },
},
},
}
```
Legacy refs such as `claude-cli/claude-opus-4-7` remain supported for
compatibility, but new config should keep the provider/model canonical and put
the execution backend in `agentRuntime.id`.
`auto` mode is intentionally conservative. Plugin runtimes can claim
provider/model pairs they understand, but the Codex plugin does not claim the
`openai-codex` provider in `auto` mode. That keeps
@@ -146,7 +174,7 @@ moving subscription-auth configs onto the native app-server harness.
If `openclaw doctor` warns that the `codex` plugin is enabled while
`openai-codex/*` still routes through PI, treat that as a diagnosis, not a
migration. Keep the config unchanged when PI Codex OAuth is what you want.
Switch to `openai/<model>` plus `runtime: "codex"` only when you want native
Switch to `openai/<model>` plus `agentRuntime.id: "codex"` only when you want native
Codex app-server execution.
## Compatibility contract

View File

@@ -24,17 +24,17 @@ Reference for **LLM/model providers** (not chat channels like WhatsApp/Telegram)
- `openai/<model>` uses the direct OpenAI API-key provider in PI.
- `openai-codex/<model>` uses Codex OAuth in PI.
- `openai/<model>` plus `agents.defaults.embeddedHarness.runtime: "codex"` uses the native Codex app-server harness.
- `openai/<model>` plus `agents.defaults.agentRuntime.id: "codex"` uses the native Codex app-server harness.
See [OpenAI](/providers/openai) and [Codex harness](/plugins/codex-harness). If the provider/runtime split is confusing, read [Agent runtimes](/concepts/agent-runtimes) first.
Plugin auto-enable follows the same boundary: `openai-codex/<model>` belongs to the OpenAI plugin, while the Codex plugin is enabled by `embeddedHarness.runtime: "codex"` or legacy `codex/<model>` refs.
Plugin auto-enable follows the same boundary: `openai-codex/<model>` belongs to the OpenAI plugin, while the Codex plugin is enabled by `agentRuntime.id: "codex"` or legacy `codex/<model>` refs.
GPT-5.5 is available through `openai/gpt-5.5` for direct API-key traffic, `openai-codex/gpt-5.5` in PI for Codex OAuth, and the native Codex app-server harness when `embeddedHarness.runtime: "codex"` is set.
GPT-5.5 is available through `openai/gpt-5.5` for direct API-key traffic, `openai-codex/gpt-5.5` in PI for Codex OAuth, and the native Codex app-server harness when `agentRuntime.id: "codex"` is set.
</Accordion>
<Accordion title="CLI runtimes">
CLI runtimes use the same split: choose canonical model refs such as `anthropic/claude-*`, `google/gemini-*`, or `openai/gpt-*`, then set `agents.defaults.embeddedHarness.runtime` to `claude-cli`, `google-gemini-cli`, or `codex-cli` when you want a local CLI backend.
CLI runtimes use the same split: choose canonical model refs such as `anthropic/claude-*`, `google/gemini-*`, or `openai/gpt-*`, then set `agents.defaults.agentRuntime.id` to `claude-cli`, `google-gemini-cli`, or `codex-cli` when you want a local CLI backend.
Legacy `claude-cli/*`, `google-gemini-cli/*`, and `codex-cli/*` refs migrate back to canonical provider refs with the runtime recorded separately.
@@ -108,6 +108,10 @@ OpenClaw ships with the piai catalog. These providers require **no** `models.
- Example model: `anthropic/claude-opus-4-6`
- CLI: `openclaw onboard --auth-choice apiKey`
- Direct public Anthropic requests support the shared `/fast` toggle and `params.fastMode`, including API-key and OAuth-authenticated traffic sent to `api.anthropic.com`; OpenClaw maps that to Anthropic `service_tier` (`auto` vs `standard_only`)
- Preferred Claude CLI config keeps the model ref canonical and selects the CLI
backend separately: `anthropic/claude-opus-4-7` with
`agents.defaults.agentRuntime.id: "claude-cli"`. Legacy
`claude-cli/claude-opus-4-7` refs still work for compatibility.
<Note>
Anthropic staff told us OpenClaw-style Claude CLI usage is allowed again, so OpenClaw treats Claude CLI reuse and `claude -p` usage as sanctioned for this integration unless Anthropic publishes a new policy. Anthropic setup-token remains available as a supported OpenClaw token path, but OpenClaw now prefers Claude CLI reuse and `claude -p` when available.
@@ -124,7 +128,7 @@ Anthropic staff told us OpenClaw-style Claude CLI usage is allowed again, so Ope
- Provider: `openai-codex`
- Auth: OAuth (ChatGPT)
- PI model ref: `openai-codex/gpt-5.5`
- Native Codex app-server harness ref: `openai/gpt-5.5` with `agents.defaults.embeddedHarness.runtime: "codex"`
- Native Codex app-server harness ref: `openai/gpt-5.5` with `agents.defaults.agentRuntime.id: "codex"`
- Native Codex app-server harness docs: [Codex harness](/plugins/codex-harness)
- Legacy model refs: `codex/gpt-*`
- Plugin boundary: `openai-codex/*` loads the OpenAI plugin; the native Codex app-server plugin is selected only by the Codex harness runtime or legacy `codex/*` refs.

View File

@@ -13,7 +13,7 @@ Quick provider overview + examples: [/concepts/model-providers](/concepts/model-
Model refs choose a provider and model. They do not usually choose the
low-level agent runtime. For example, `openai/gpt-5.5` can run through the
normal OpenAI provider path or through the Codex app-server runtime, depending
on `agents.defaults.embeddedHarness.runtime`. See
on `agents.defaults.agentRuntime.id`. See
[/concepts/agent-runtimes](/concepts/agent-runtimes).
## How model selection works

View File

@@ -316,8 +316,8 @@ Time format in system prompt. Default: `auto` (OS preference).
fallbacks: ["openai/gpt-5.4-mini"],
},
params: { cacheRetention: "long" }, // global default provider params
embeddedHarness: {
runtime: "pi", // pi | auto | registered harness id, e.g. codex
agentRuntime: {
id: "pi", // pi | auto | registered harness id, e.g. codex
fallback: "pi", // pi | none
},
pdfMaxBytesMb: 10,
@@ -373,25 +373,25 @@ Time format in system prompt. Default: `auto` (OS preference).
- `params.extra_body`/`params.extraBody`: advanced pass-through JSON merged into `api: "openai-completions"` request bodies for OpenAI-compatible proxies. If it collides with generated request keys, the extra body wins; non-native completions routes still strip OpenAI-only `store` afterward.
- `params.chat_template_kwargs`: vLLM/OpenAI-compatible chat-template arguments merged into top-level `api: "openai-completions"` request bodies. For `vllm/nemotron-3-*` with thinking off, OpenClaw automatically sends `enable_thinking: false` and `force_nonempty_content: true`; explicit `chat_template_kwargs` override those defaults, and `extra_body.chat_template_kwargs` still has final precedence.
- `params.preserveThinking`: Z.AI-only opt-in for preserved thinking. When enabled and thinking is on, OpenClaw sends `thinking.clear_thinking: false` and replays prior `reasoning_content`; see [Z.AI thinking and preserved thinking](/providers/zai#thinking-and-preserved-thinking).
- `embeddedHarness`: default low-level embedded agent runtime policy. Omitted runtime defaults to OpenClaw Pi. Use `runtime: "pi"` to force the built-in PI harness, `runtime: "auto"` to let registered plugin harnesses claim supported models, or a registered harness id such as `runtime: "codex"`. Set `fallback: "none"` to disable automatic PI fallback. Explicit plugin runtimes such as `codex` fail closed by default unless you set `fallback: "pi"` in the same override scope. Keep model refs canonical as `provider/model`; select Codex, Claude CLI, Gemini CLI, and other execution backends through runtime config instead of legacy runtime provider prefixes. See [Agent runtimes](/concepts/agent-runtimes) for how this differs from provider/model selection.
- `agentRuntime`: default low-level agent runtime policy. Omitted id defaults to OpenClaw Pi. Use `id: "pi"` to force the built-in PI harness, `id: "auto"` to let registered plugin harnesses claim supported models, a registered harness id such as `id: "codex"`, or a supported CLI backend alias such as `id: "claude-cli"`. Set `fallback: "none"` to disable automatic PI fallback. Explicit plugin runtimes such as `codex` fail closed by default unless you set `fallback: "pi"` in the same override scope. Keep model refs canonical as `provider/model`; select Codex, Claude CLI, Gemini CLI, and other execution backends through runtime config instead of legacy runtime provider prefixes. See [Agent runtimes](/concepts/agent-runtimes) for how this differs from provider/model selection.
- Config writers that mutate these fields (for example `/models set`, `/models set-image`, and fallback add/remove commands) save canonical object form and preserve existing fallback lists when possible.
- `maxConcurrent`: max parallel agent runs across sessions (each session still serialized). Default: 4.
### `agents.defaults.embeddedHarness`
### `agents.defaults.agentRuntime`
`embeddedHarness` controls which low-level executor runs embedded agent turns.
Most deployments should keep the default OpenClaw Pi runtime.
Use it when a trusted plugin provides a native harness, such as the bundled
Codex app-server harness. For the mental model, see
[Agent runtimes](/concepts/agent-runtimes).
`agentRuntime` controls which low-level executor runs agent turns. Most
deployments should keep the default OpenClaw Pi runtime. Use it when a trusted
plugin provides a native harness, such as the bundled Codex app-server harness,
or when you want a supported CLI backend such as Claude CLI. For the mental
model, see [Agent runtimes](/concepts/agent-runtimes).
```json5
{
agents: {
defaults: {
model: "openai/gpt-5.5",
embeddedHarness: {
runtime: "codex",
agentRuntime: {
id: "codex",
fallback: "none",
},
},
@@ -399,12 +399,14 @@ Codex app-server harness. For the mental model, see
}
```
- `runtime`: `"auto"`, `"pi"`, or a registered plugin harness id. The bundled Codex plugin registers `codex`.
- `fallback`: `"pi"` or `"none"`. In `runtime: "auto"`, omitted fallback defaults to `"pi"` so old configs can keep using PI when no plugin harness claims a run. In explicit plugin runtime mode, such as `runtime: "codex"`, omitted fallback defaults to `"none"` so a missing harness fails instead of silently using PI. Runtime overrides do not inherit fallback from a broader scope; set `fallback: "pi"` alongside the explicit runtime when you intentionally want that compatibility fallback. Selected plugin harness failures always surface directly.
- Environment overrides: `OPENCLAW_AGENT_RUNTIME=<id|auto|pi>` overrides `runtime`; `OPENCLAW_AGENT_HARNESS_FALLBACK=pi|none` overrides fallback for that process.
- For Codex-only deployments, set `model: "openai/gpt-5.5"` and `embeddedHarness.runtime: "codex"`. You may also set `embeddedHarness.fallback: "none"` explicitly for readability; it is the default for explicit plugin runtimes.
- `id`: `"auto"`, `"pi"`, a registered plugin harness id, or a supported CLI backend alias. The bundled Codex plugin registers `codex`; the bundled Anthropic plugin provides the `claude-cli` CLI backend.
- `fallback`: `"pi"` or `"none"`. In `id: "auto"`, omitted fallback defaults to `"pi"` so old configs can keep using PI when no plugin harness claims a run. In explicit plugin runtime mode, such as `id: "codex"`, omitted fallback defaults to `"none"` so a missing harness fails instead of silently using PI. Runtime overrides do not inherit fallback from a broader scope; set `fallback: "pi"` alongside the explicit runtime when you intentionally want that compatibility fallback. Selected plugin harness failures always surface directly.
- Environment overrides: `OPENCLAW_AGENT_RUNTIME=<id|auto|pi>` overrides `id`; `OPENCLAW_AGENT_HARNESS_FALLBACK=pi|none` overrides fallback for that process.
- For Codex-only deployments, set `model: "openai/gpt-5.5"` and `agentRuntime.id: "codex"`. You may also set `agentRuntime.fallback: "none"` explicitly for readability; it is the default for explicit plugin runtimes.
- For Claude CLI deployments, prefer `model: "anthropic/claude-opus-4-7"` plus `agentRuntime.id: "claude-cli"`. Legacy `claude-cli/claude-opus-4-7` model refs still work for compatibility, but new config should keep provider/model selection canonical and put the execution backend in `agentRuntime.id`.
- Older runtime-policy keys are rewritten to `agentRuntime` by `openclaw doctor --fix`.
- Harness choice is pinned per session id after the first embedded run. Config/env changes affect new or reset sessions, not an existing transcript. Legacy sessions with transcript history but no recorded pin are treated as PI-pinned. `/status` reports the effective runtime, for example `Runtime: OpenClaw Pi Default` or `Runtime: OpenAI Codex`.
- This only controls the embedded chat harness. Media generation, vision, PDF, music, video, and TTS still use their provider/model settings.
- This only controls text agent-turn execution. Media generation, vision, PDF, music, video, and TTS still use their provider/model settings.
**Built-in alias shorthands** (only apply when the model is in `agents.defaults.models`):
@@ -923,7 +925,7 @@ for provider examples and precedence.
thinkingDefault: "high", // per-agent thinking level override
reasoningDefault: "on", // per-agent reasoning visibility override
fastModeDefault: false, // per-agent fast mode override
embeddedHarness: { runtime: "auto", fallback: "pi" },
agentRuntime: { id: "auto", fallback: "pi" },
params: { cacheRetention: "none" }, // overrides matching defaults.models params by key
tts: {
providers: {
@@ -970,7 +972,7 @@ for provider examples and precedence.
- `thinkingDefault`: optional per-agent default thinking level (`off | minimal | low | medium | high | xhigh | adaptive | max`). Overrides `agents.defaults.thinkingDefault` for this agent when no per-message or session override is set. The selected provider/model profile controls which values are valid; for Google Gemini, `adaptive` keeps provider-owned dynamic thinking (`thinkingLevel` omitted on Gemini 3/3.1, `thinkingBudget: -1` on Gemini 2.5).
- `reasoningDefault`: optional per-agent default reasoning visibility (`on | off | stream`). Applies when no per-message or session reasoning override is set.
- `fastModeDefault`: optional per-agent default for fast mode (`true | false`). Applies when no per-message or session fast-mode override is set.
- `embeddedHarness`: optional per-agent low-level harness policy override. Use `{ runtime: "codex" }` to make one agent Codex-only while other agents keep the default PI fallback in `auto` mode.
- `agentRuntime`: optional per-agent low-level runtime policy override. Use `{ id: "codex" }` to make one agent Codex-only while other agents keep the default PI fallback in `auto` mode.
- `runtime`: optional per-agent runtime descriptor. Use `type: "acp"` with `runtime.acp` defaults (`agent`, `backend`, `mode`, `cwd`) when the agent should default to ACP harness sessions.
- `identity.avatar`: workspace-relative path, `http(s)` URL, or `data:` URI.
- `identity` derives defaults: `ackReaction` from `emoji`, `mentionPatterns` from `name`/`emoji`.

View File

@@ -5,11 +5,10 @@ read_when:
- 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.*` config keys and custom provider / base-URL setup. For agents, channels, and other top-level config keys, see [Configuration reference](/gateway/configuration-reference).
## Tools
@@ -17,7 +16,9 @@ channels, and other top-level config keys, see
`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 |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------- |
@@ -113,8 +114,7 @@ Controls elevated exec access outside the sandbox:
### `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`.
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
{
@@ -135,14 +135,31 @@ Settings can be defined globally in `tools.loopDetection` and overridden per-age
}
```
- `historySize`: max tool-call history retained for loop analysis.
- `warningThreshold`: repeating no-progress pattern threshold for warnings.
- `criticalThreshold`: higher repeating threshold for blocking critical loops.
- `globalCircuitBreakerThreshold`: hard stop threshold for any no-progress run.
- `detectors.genericRepeat`: warn on repeated same-tool/same-args calls.
- `detectors.knownPollNoProgress`: warn/block on known poll tools (`process.poll`, `command_status`, etc.).
- `detectors.pingPong`: warn/block on alternating no-progress pair patterns.
- If `warningThreshold >= criticalThreshold` or `criticalThreshold >= globalCircuitBreakerThreshold`, validation fails.
<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="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>
<Warning>
If `warningThreshold >= criticalThreshold` or `criticalThreshold >= globalCircuitBreakerThreshold`, validation fails.
</Warning>
### `tools.web`
@@ -208,34 +225,33 @@ Configures inbound media understanding (image/audio/video):
}
```
<Accordion title="Media model entry fields">
<AccordionGroup>
<Accordion title="Media model entry fields">
**Provider entry** (`type: "provider"` or omitted):
**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
- `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"`):
**CLI entry** (`type: "cli"`):
- `command`: executable to run
- `args`: templated args (supports `{{MediaPath}}`, `{{Prompt}}`, `{{MaxChars}}`, etc.)
- `command`: executable to run
- `args`: templated args (supports `{{MediaPath}}`, `{{Prompt}}`, `{{MaxChars}}`, etc.)
**Common fields:**
**Common fields:**
- `capabilities`: optional list (`image`, `audio`, `video`). Defaults: `openai`/`anthropic`/`minimax` → image, `google` → image+audio+video, `groq` → audio.
- `prompt`, `maxChars`, `maxBytes`, `timeoutSeconds`, `language`: per-entry overrides.
- Failures fall back to the next entry.
- `capabilities`: optional list (`image`, `audio`, `video`). Defaults: `openai`/`anthropic`/`minimax` → image, `google` → image+audio+video, `groq` → audio.
- `prompt`, `maxChars`, `maxBytes`, `timeoutSeconds`, `language`: per-entry overrides.
- Failures fall back to the next entry.
Provider auth follows standard order: `auth-profiles.json` → env vars → `models.providers.*.apiKey`.
Provider auth follows standard order: `auth-profiles.json` → env vars → `models.providers.*.apiKey`.
**Async completion fields:**
**Async completion fields:**
- `asyncCompletion.directSend`: when `true`, completed async `music_generate` and `video_generate` tasks try direct channel delivery first. Default: `false` (legacy requester-session wake/model-delivery path).
- `asyncCompletion.directSend`: when `true`, completed async `music_generate`
and `video_generate` tasks try direct channel delivery first. Default: `false`
(legacy requester-session wake/model-delivery path).
</Accordion>
</Accordion>
</AccordionGroup>
### `tools.agentToAgent`
@@ -267,13 +283,15 @@ Default: `tree` (current session + sessions spawned by it, such as subagents).
}
```
Notes:
- `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"`, visibility is forced to `tree` even if `tools.sessions.visibility="all"`.
<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"`, visibility is forced to `tree` even if `tools.sessions.visibility="all"`.
</Accordion>
</AccordionGroup>
### `tools.sessions_spawn`
@@ -295,14 +313,16 @@ Controls inline attachment support for `sessions_spawn`.
}
```
Notes:
- Attachments are only supported for `runtime: "subagent"`. ACP runtime rejects them.
- Files are materialized into the child workspace at `.openclaw/attachments/<uuid>/` with a `.manifest.json`.
- Attachment content is automatically redacted from transcript persistence.
- Base64 inputs are validated with strict alphabet/padding checks and a pre-decode size guard.
- File permissions are `0700` for directories and `0600` for files.
- Cleanup follows the `cleanup` policy: `delete` always removes attachments; `keep` retains them only when `retainOnSessionKeep: true`.
<AccordionGroup>
<Accordion title="Attachment notes">
- Attachments are only supported for `runtime: "subagent"`. ACP runtime rejects them.
- Files are materialized into the child workspace at `.openclaw/attachments/<uuid>/` with a `.manifest.json`.
- Attachment content is automatically redacted from transcript persistence.
- Base64 inputs are validated with strict alphabet/padding checks and a pre-decode size guard.
- File permissions are `0700` for directories and `0600` for files.
- Cleanup follows the `cleanup` policy: `delete` always removes attachments; `keep` retains them only when `retainOnSessionKeep: true`.
</Accordion>
</AccordionGroup>
<a id="toolsexperimental"></a>
@@ -320,8 +340,6 @@ Experimental built-in tool flags. Default off unless a strict-agentic GPT-5 auto
}
```
Notes:
- `planTool`: enables the structured `update_plan` tool for non-trivial multi-step work tracking.
- Default: `false` unless `agents.defaults.embeddedPi.executionContract` (or a per-agent override) is set to `"strict-agentic"` for an OpenAI or OpenAI Codex GPT-5-family run. 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`.
@@ -382,286 +400,281 @@ OpenClaw uses the built-in model catalog. Add custom providers via `models.provi
}
```
- Use `authHeader: true` + `headers` for custom auth needs.
- Override agent config root with `OPENCLAW_AGENT_DIR` (or `PI_CODING_AGENT_DIR`, a legacy environment variable alias).
- 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` use the higher value between explicit config and implicit catalog values.
- Matching model `contextTokens` preserves an explicit runtime cap when present; use it to limit effective context without changing native model metadata.
- Use `models.mode: "replace"` when you want config to fully rewrite `models.json`.
- Marker persistence is source-authoritative: markers are written from the active source config snapshot (pre-resolution), not from resolved runtime secret values.
<AccordionGroup>
<Accordion title="Auth and merge precedence">
- Use `authHeader: true` + `headers` for custom auth needs.
- Override agent config root with `OPENCLAW_AGENT_DIR` (or `PI_CODING_AGENT_DIR`, a legacy environment variable alias).
- 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` use the higher value between explicit config and implicit catalog values.
- Matching model `contextTokens` preserves an explicit runtime cap when present; use it to limit effective context without changing native model metadata.
- Use `models.mode: "replace"` when you want config to fully rewrite `models.json`.
- 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
- `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`.
- `models.providers.*.api`: request adapter (`openai-completions`, `openai-responses`, `anthropic-messages`, `google-generative-ai`, etc).
- `models.providers.*.apiKey`: provider credential (prefer SecretRef/env substitution).
- `models.providers.*.auth`: auth strategy (`api-key`, `token`, `oauth`, `aws-sdk`).
- `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.
- `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 HTTPS to `baseUrl` when DNS resolves to private, CGNAT, or similar ranges, via the provider HTTP fetch guard (operator opt-in for trusted self-hosted OpenAI-compatible endpoints). WebSocket uses the same `request` for headers/TLS but not that fetch SSRF gate. Default `false`.
- `models.providers.*.models`: explicit provider model catalog entries.
- `models.providers.*.models.*.contextWindow`: native model context window metadata.
- `models.providers.*.models.*.contextTokens`: optional runtime context cap. Use this 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.
- `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.
<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`, `anthropic-messages`, `google-generative-ai`, etc).
- `models.providers.*.apiKey`: provider credential (prefer SecretRef/env substitution).
- `models.providers.*.auth`: auth strategy (`api-key`, `token`, `oauth`, `aws-sdk`).
- `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 HTTPS to `baseUrl` when DNS resolves to private, CGNAT, or similar ranges, via the provider HTTP fetch guard (operator opt-in for trusted self-hosted OpenAI-compatible endpoints). 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.*.contextWindow`: native model context window metadata.
- `models.providers.*.models.*.contextTokens`: optional runtime context cap. Use this 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.
</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>
### Provider examples
<Accordion title="Cerebras (GLM 4.6 / 4.7)">
```json5
{
env: { CEREBRAS_API_KEY: "sk-..." },
agents: {
defaults: {
model: {
primary: "cerebras/zai-glm-4.7",
fallbacks: ["cerebras/zai-glm-4.6"],
<AccordionGroup>
<Accordion title="Cerebras (GLM 4.6 / 4.7)">
```json5
{
env: { CEREBRAS_API_KEY: "sk-..." },
agents: {
defaults: {
model: {
primary: "cerebras/zai-glm-4.7",
fallbacks: ["cerebras/zai-glm-4.6"],
},
models: {
"cerebras/zai-glm-4.7": { alias: "GLM 4.7 (Cerebras)" },
"cerebras/zai-glm-4.6": { alias: "GLM 4.6 (Cerebras)" },
},
},
},
models: {
"cerebras/zai-glm-4.7": { alias: "GLM 4.7 (Cerebras)" },
"cerebras/zai-glm-4.6": { alias: "GLM 4.6 (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: "zai-glm-4.6", name: "GLM 4.6 (Cerebras)" },
],
},
},
},
}
```
Use `cerebras/zai-glm-4.7` for Cerebras; `zai/glm-4.7` for Z.AI direct.
</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="Z.AI (GLM-4.7)">
```json5
{
agents: {
defaults: {
model: { primary: "zai/glm-4.7" },
models: { "zai/glm-4.7": {} },
},
},
}
```
Set `ZAI_API_KEY`. `z.ai/*` and `z-ai/*` are accepted aliases. Shortcut: `openclaw onboard --auth-choice zai-api-key`.
- General endpoint: `https://api.z.ai/api/paas/v4`
- Coding endpoint (default): `https://api.z.ai/api/coding/paas/v4`
- For the general endpoint, define a custom provider with the base URL override.
</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,
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: "zai-glm-4.6", name: "GLM 4.6 (Cerebras)" },
],
},
],
},
},
},
},
}
```
}
```
For the China endpoint: `baseUrl: "https://api.moonshot.cn/v1"` or `openclaw onboard --auth-choice moonshot-api-key-cn`.
Use `cerebras/zai-glm-4.7` for Cerebras; `zai/glm-4.7` for Z.AI direct.
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="Kimi Coding">
```json5
{
env: { KIMI_API_KEY: "sk-..." },
agents: {
defaults: {
model: { primary: "kimi/kimi-code" },
models: { "kimi/kimi-code": { alias: "Kimi Code" } },
},
},
}
```
</Accordion>
Anthropic-compatible, built-in provider. Shortcut: `openclaw onboard --auth-choice kimi-code-api-key`.
<Accordion title="Kimi Coding">
```json5
{
env: { KIMI_API_KEY: "sk-..." },
agents: {
defaults: {
model: { primary: "kimi/kimi-code" },
models: { "kimi/kimi-code": { alias: "Kimi Code" } },
},
},
}
```
Anthropic-compatible, built-in provider. Shortcut: `openclaw onboard --auth-choice kimi-code-api-key`.
</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,
</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 M2.7 (direct)">
```json5
{
agents: {
defaults: {
model: { primary: "minimax/MiniMax-M2.7" },
models: {
"minimax/MiniMax-M2.7": { alias: "Minimax" },
},
],
},
},
},
},
}
```
Base URL should omit `/v1` (Anthropic client appends it). Shortcut: `openclaw onboard --auth-choice synthetic-api-key`.
</Accordion>
<Accordion title="MiniMax M2.7 (direct)">
```json5
{
agents: {
defaults: {
model: { primary: "minimax/MiniMax-M2.7" },
models: {
"minimax/MiniMax-M2.7": { alias: "Minimax" },
},
},
},
models: {
mode: "merge",
providers: {
minimax: {
baseUrl: "https://api.minimax.io/anthropic",
apiKey: "${MINIMAX_API_KEY}",
api: "anthropic-messages",
models: [
{
id: "MiniMax-M2.7",
name: "MiniMax M2.7",
reasoning: true,
input: ["text"],
cost: { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0.375 },
contextWindow: 204800,
maxTokens: 131072,
mode: "merge",
providers: {
minimax: {
baseUrl: "https://api.minimax.io/anthropic",
apiKey: "${MINIMAX_API_KEY}",
api: "anthropic-messages",
models: [
{
id: "MiniMax-M2.7",
name: "MiniMax M2.7",
reasoning: true,
input: ["text"],
cost: { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0.375 },
contextWindow: 204800,
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 M2.7 only.
On the Anthropic-compatible streaming path, OpenClaw disables MiniMax thinking
by default unless you explicitly set `thinking` yourself. `/fast on` or
`params.fastMode: true` rewrites `MiniMax-M2.7` to
`MiniMax-M2.7-highspeed`.
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 M2.7 only. On the Anthropic-compatible streaming path, OpenClaw disables MiniMax thinking by default unless you explicitly set `thinking` yourself. `/fast on` or `params.fastMode: true` rewrites `MiniMax-M2.7` to `MiniMax-M2.7-highspeed`.
</Accordion>
</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,
},
],
},
},
},
}
```
<Accordion title="Local models (LM Studio)">
For the China endpoint: `baseUrl: "https://api.moonshot.cn/v1"` or `openclaw onboard --auth-choice moonshot-api-key-cn`.
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.
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>
<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`. `z.ai/*` and `z-ai/*` are accepted aliases. Shortcut: `openclaw onboard --auth-choice zai-api-key`.
- General endpoint: `https://api.z.ai/api/paas/v4`
- Coding endpoint (default): `https://api.z.ai/api/coding/paas/v4`
- For the general endpoint, define a custom provider with the base URL override.
</Accordion>
</AccordionGroup>
---
## Related
- [Configuration reference](/gateway/configuration-reference) — other top-level keys
- [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

@@ -85,6 +85,7 @@ cat ~/.openclaw/openclaw.json
- 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`, simple `notify: true` webhook fallback jobs).
- Legacy agent runtime-policy migration to `agents.defaults.agentRuntime` and `agents.list[].agentRuntime`.
</Accordion>
<Accordion title="State and integrity">
- Session lock file inspection and stale lock cleanup.
@@ -237,7 +238,7 @@ That stages grounded durable candidates into the short-term dreaming store while
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 plugin route warnings">
When the bundled Codex plugin is enabled, doctor also checks whether `openai-codex/*` primary model refs still resolve through the default PI runner. That combination is valid when you want Codex OAuth/subscription auth through PI, but it is easy to confuse with the native Codex app-server harness. Doctor warns and points to the explicit app-server shape: `openai/*` plus `embeddedHarness.runtime: "codex"` or `OPENCLAW_AGENT_RUNTIME=codex`.
When the bundled Codex plugin is enabled, doctor also checks whether `openai-codex/*` primary model refs still resolve through the default PI runner. That combination is valid when you want Codex OAuth/subscription auth through PI, but it is easy to confuse with the native Codex app-server harness. Doctor warns and points to the explicit app-server shape: `openai/*` plus `agentRuntime.id: "codex"` or `OPENCLAW_AGENT_RUNTIME=codex`.
Doctor does not repair this automatically because both routes are valid:

View File

@@ -4,27 +4,38 @@ read_when:
- Adjusting heartbeat cadence or messaging
- Deciding between heartbeat and cron for scheduled tasks
title: "Heartbeat"
sidebarTitle: "Heartbeat"
---
> **Heartbeat vs Cron?** See [Automation & Tasks](/automation) for guidance on when to use each.
<Note>
**Heartbeat vs cron?** See [Automation & Tasks](/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 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).
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)
1. Leave heartbeats enabled (default is `30m`, or `1h` for Anthropic OAuth/token auth, including Claude CLI reuse) or set your own cadence.
2. Create a tiny `HEARTBEAT.md` checklist or `tasks:` block in the agent workspace (optional but recommended).
3. Decide where heartbeat messages should go (`target: "none"` is the default; set `target: "last"` to route to the last contact).
4. Optional: enable heartbeat reasoning delivery for transparency.
5. Optional: use lightweight bootstrap context if heartbeat runs only need `HEARTBEAT.md`.
6. Optional: enable isolated sessions to avoid sending full conversation history each heartbeat.
7. Optional: restrict heartbeats to active hours (local time).
<Steps>
<Step title="Pick a cadence">
Leave heartbeats enabled (default is `30m`, or `1h` for Anthropic OAuth/token auth, 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:
@@ -49,44 +60,30 @@ Example config:
## Defaults
- Interval: `30m` (or `1h` when Anthropic OAuth/token auth is the detected auth mode, including Claude CLI reuse). 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.`
- The heartbeat prompt is sent **verbatim** as the user message. The system
prompt includes a “Heartbeat” section only when heartbeats are enabled for the
default agent, 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.
- 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.`
- The heartbeat prompt is sent **verbatim** as the user message. The system prompt includes a "Heartbeat" section only when heartbeats are enabled for the default agent, 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.
## 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 [/concepts/timezone](/concepts/timezone)).
- **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).
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`**.
- 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.
- 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.
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
@@ -121,9 +118,7 @@ and logged; a message that is only `HEARTBEAT_OK` is dropped.
### 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).
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.
@@ -184,10 +179,11 @@ 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" }`.
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>
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
### Multi-account example
Use `accountId` to target a specific account on multi-account channels like Telegram:
@@ -218,63 +214,87 @@ Use `accountId` to target a specific account on multi-account channels like Tele
### Field notes
- `every`: heartbeat interval (duration string; default unit = minutes).
- `model`: optional model override for heartbeat runs (`provider/model`).
- `includeReasoning`: when enabled, also deliver the separate `Reasoning:` message when available (same shape as `/reasoning on`).
- `lightContext`: when true, heartbeat runs use lightweight bootstrap context and keep only `HEARTBEAT.md` from workspace bootstrap files.
- `isolatedSession`: 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.
- `session`: 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).
- `target`:
- `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.
- `directPolicy`: controls direct/DM delivery behavior:
- `allow` (default): allow direct/DM heartbeat delivery.
- `block`: suppress direct/DM delivery (`reason=dm-blocked`).
- `to`: 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>`.
- `accountId`: 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.
- `prompt`: overrides the default prompt body (not merged).
- `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery.
- `suppressToolErrorWarnings`: when true, suppresses tool error warning payloads during heartbeat runs.
- `activeHours`: 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 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 `Reasoning:` 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="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="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="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
- Heartbeats run in the agents 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 is busy, the heartbeat is skipped and retried later.
- If `target` resolves to no external destination, the run still happens but no
outbound message is sent.
- 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"`.
- 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.
<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 is busy, the heartbeat is skipped and retried later.
- 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:
By default, `HEARTBEAT_OK` acknowledgments are suppressed while alert content is delivered. You can adjust this per channel or per account:
```yaml
channels:
@@ -335,19 +355,11 @@ channels:
## 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 include every 30 minutes.
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 include 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 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.
If `HEARTBEAT.md` exists but is effectively empty (only blank lines and markdown
headers like `# Heading`), 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.
If `HEARTBEAT.md` exists but is effectively empty (only blank lines and markdown headers like `# Heading`), 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.
@@ -357,14 +369,13 @@ Example `HEARTBEAT.md`:
# Heartbeat checklist
- Quick scan: anything urgent in inboxes?
- If its daytime, do a lightweight check-in if nothing else is pending.
- 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.
`HEARTBEAT.md` also supports a small structured `tasks:` block for interval-based checks inside heartbeat itself.
Example:
@@ -384,14 +395,16 @@ tasks:
- If nothing needs attention after all due tasks, reply HEARTBEAT_OK.
```
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.
<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.
@@ -399,18 +412,16 @@ Task mode is useful when you want one heartbeat file to hold several periodic ch
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:
`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 its shorter and focused on inbox follow-ups.
- "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.”
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."
Safety note: dont put secrets (API keys, phone numbers, private tokens) into
`HEARTBEAT.md` — it becomes part of the prompt context.
<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)
@@ -420,24 +431,19 @@ You can enqueue a system event and trigger an immediate heartbeat with:
openclaw system event --text "Check for urgent follow-ups" --mode now
```
If multiple agents have `heartbeat` configured, a manual wake runs each of those
agent heartbeats immediately.
If multiple agents have `heartbeat` configured, a manual wake runs each of those agent heartbeats immediately.
Use `--mode next-heartbeat` to wait for the next scheduled tick.
## Reasoning delivery (optional)
By default, heartbeats deliver only the final answer payload.
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
`Reasoning:` (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.
When enabled, heartbeats will also deliver a separate message prefixed `Reasoning:` (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

View File

@@ -19,6 +19,9 @@ works without code changes. For local file logs and how to read them, see
and exec.
- **`diagnostics-otel` plugin** subscribes to those events and exports them as
OpenTelemetry **metrics**, **traces**, and **logs** over OTLP/HTTP.
- **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 only attach when both the diagnostics surface and the plugin are
enabled, so the in-process cost stays near zero by default.
@@ -61,11 +64,11 @@ openclaw plugins enable diagnostics-otel
## Signals exported
| Signal | What goes in it |
| ----------- | --------------------------------------------------------------------------------------------------------------------------------- |
| **Metrics** | Counters and histograms for token usage, cost, run duration, message flow, queue lanes, session state, exec, and memory pressure. |
| **Traces** | Spans for model usage, model calls, tool execution, exec, webhook/message processing, context assembly, and tool loops. |
| **Logs** | Structured `logging.file` records exported over OTLP when `diagnostics.otel.logs` is enabled. |
| Signal | What goes in it |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| **Metrics** | Counters and histograms for token usage, cost, run duration, message flow, queue lanes, session state, exec, and memory pressure. |
| **Traces** | Spans for model usage, model calls, harness lifecycle, tool execution, exec, webhook/message processing, context assembly, and tool loops. |
| **Logs** | Structured `logging.file` records exported over OTLP when `diagnostics.otel.logs` is enabled. |
Toggle `traces`, `metrics`, and `logs` independently. All three default to on
when `diagnostics.otel.enabled` is true.
@@ -121,6 +124,11 @@ identifiers (channel, provider, model, error category, hash-only request ids)
and never include prompt text, response text, tool inputs, tool outputs, or
session keys.
Outbound model requests may include a W3C `traceparent` header. That header is
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 opt-in independently:
@@ -176,6 +184,10 @@ When any subkey is enabled, model and tool spans get bounded, redacted
- `openclaw.session.stuck_age_ms` (histogram, attrs: `openclaw.state`)
- `openclaw.run.attempt` (counter, attrs: `openclaw.attempt`)
### Harness lifecycle
- `openclaw.harness.duration_ms` (histogram, attrs: `openclaw.harness.id`, `openclaw.harness.plugin`, `openclaw.outcome`, `openclaw.harness.phase` on errors)
### Exec
- `openclaw.exec.duration_ms` (histogram, attrs: `openclaw.exec.target`, `openclaw.exec.mode`, `openclaw.outcome`, `openclaw.failureKind`)
@@ -201,6 +213,10 @@ When any subkey is enabled, model and tool spans get bounded, redacted
- `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.provider.request_id_hash` (bounded SHA-based hash of the upstream provider request id; raw ids are not exported)
- `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.errorCategory`, `openclaw.tool.params.*`
- `openclaw.exec`
@@ -251,6 +267,16 @@ to them directly without OTLP export.
- `run.attempt`
- `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

View File

@@ -5,11 +5,14 @@ read_when:
- 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 be stored as plaintext in configuration.
<Note>
Plaintext still works. SecretRefs are opt-in per credential.
</Note>
## Goals and runtime model
@@ -33,38 +36,32 @@ SecretRefs are validated only on effectively active surfaces.
- Inactive surfaces: unresolved refs do not block startup/reload.
- Inactive refs emit non-fatal diagnostics with code `SECRETS_REF_IGNORED_INACTIVE_SURFACE`.
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 that are not selected by `tools.web.search.provider`.
In auto mode (provider unset), keys are consulted by precedence for provider auto-detection until one resolves.
After selection, non-selected provider keys are treated as inactive until selected.
- 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` for the default agent or an enabled agent.
- `gateway.remote.token` / `gateway.remote.password` SecretRefs are active if one of these is true:
- `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.
<AccordionGroup>
<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 that are not selected by `tools.web.search.provider`. In auto mode (provider unset), keys are consulted by precedence for provider auto-detection until one resolves. After selection, non-selected provider keys are treated as inactive until selected.
- 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` for the default agent or an enabled agent.
- `gateway.remote.token` / `gateway.remote.password` SecretRefs are active if one of these is true:
- `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>
</AccordionGroup>
## Gateway auth surface diagnostics
When a SecretRef is configured on `gateway.auth.token`, `gateway.auth.password`,
`gateway.remote.token`, or `gateway.remote.password`, gateway startup/reload logs the
surface state explicitly:
When a SecretRef is configured on `gateway.auth.token`, `gateway.auth.password`, `gateway.remote.token`, or `gateway.remote.password`, gateway startup/reload logs the surface state explicitly:
- `active`: the SecretRef is part of the effective auth surface and must resolve.
- `inactive`: the SecretRef is ignored for this runtime because another auth surface wins, or
because remote auth is disabled/not active.
- `inactive`: the SecretRef is ignored for this runtime because another auth surface wins, or because remote auth is disabled/not active.
These entries are logged with `SECRETS_GATEWAY_AUTH_SURFACE` and include the reason used by the
active-surface policy, so you can see why a credential was treated as active or inactive.
These entries are logged with `SECRETS_GATEWAY_AUTH_SURFACE` and include the reason used by the active-surface policy, so you can see why a credential was treated as active or inactive.
## Onboarding reference preflight
@@ -84,40 +81,43 @@ Use one object shape everywhere:
{ source: "env" | "file" | "exec", provider: "default", id: "..." }
```
### `source: "env"`
<Tabs>
<Tab title="env">
```json5
{ source: "env", provider: "default", id: "OPENAI_API_KEY" }
```
```json5
{ source: "env", provider: "default", id: "OPENAI_API_KEY" }
```
Validation:
Validation:
- `provider` must match `^[a-z][a-z0-9_-]{0,63}$`
- `id` must match `^[A-Z][A-Z0-9_]{0,127}$`
- `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" }
```
### `source: "file"`
Validation:
```json5
{ source: "file", provider: "filemain", id: "/providers/openai/apiKey" }
```
- `provider` must match `^[a-z][a-z0-9_-]{0,63}$`
- `id` must be an absolute JSON pointer (`/...`)
- RFC6901 escaping in segments: `~` => `~0`, `/` => `~1`
Validation:
</Tab>
<Tab title="exec">
```json5
{ source: "exec", provider: "vault", id: "providers/openai/apiKey" }
```
- `provider` must match `^[a-z][a-z0-9_-]{0,63}$`
- `id` must be an absolute JSON pointer (`/...`)
- RFC6901 escaping in segments: `~` => `~0`, `/` => `~1`
Validation:
### `source: "exec"`
- `provider` must match `^[a-z][a-z0-9_-]{0,63}$`
- `id` must match `^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$`
- `id` must not contain `.` or `..` as slash-delimited path segments (for example `a/../b` is rejected)
```json5
{ source: "exec", provider: "vault", id: "providers/openai/apiKey" }
```
Validation:
- `provider` must match `^[a-z][a-z0-9_-]{0,63}$`
- `id` must match `^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$`
- `id` must not contain `.` or `..` as slash-delimited path segments (for example `a/../b` is rejected)
</Tab>
</Tabs>
## Provider config
@@ -155,138 +155,139 @@ Define providers under `secrets.providers`:
}
```
### Env provider
<AccordionGroup>
<Accordion title="Env provider">
- Optional allowlist via `allowlist`.
- Missing/empty env values fail resolution.
</Accordion>
<Accordion title="File provider">
- Reads local file from `path`.
- `mode: "json"` expects JSON object payload and resolves `id` as pointer.
- `mode: "singleValue"` expects ref id `"value"` and returns file contents.
- Path must pass ownership/permission checks.
- Windows fail-closed note: if ACL verification is unavailable for a path, resolution fails. For trusted paths only, set `allowInsecurePath: true` on that provider to bypass path security checks.
</Accordion>
<Accordion title="Exec provider">
- Runs configured absolute binary path, no shell.
- By default, `command` must point to a regular file (not a symlink).
- Set `allowSymlinkCommand: true` to allow symlink command paths (for example Homebrew shims). OpenClaw validates the resolved target path.
- Pair `allowSymlinkCommand` with `trustedDirs` for package-manager paths (for example `["/opt/homebrew"]`).
- Supports timeout, no-output timeout, output byte limits, env allowlist, and trusted dirs.
- Windows fail-closed note: if ACL verification is unavailable for the command path, resolution fails. For trusted paths only, set `allowInsecurePath: true` on that provider to bypass path security checks.
- Optional allowlist via `allowlist`.
- Missing/empty env values fail resolution.
Request payload (stdin):
### File provider
```json
{ "protocolVersion": 1, "provider": "vault", "ids": ["providers/openai/apiKey"] }
```
- Reads local file from `path`.
- `mode: "json"` expects JSON object payload and resolves `id` as pointer.
- `mode: "singleValue"` expects ref id `"value"` and returns file contents.
- Path must pass ownership/permission checks.
- Windows fail-closed note: if ACL verification is unavailable for a path, resolution fails. For trusted paths only, set `allowInsecurePath: true` on that provider to bypass path security checks.
Response payload (stdout):
### Exec provider
```jsonc
{ "protocolVersion": 1, "values": { "providers/openai/apiKey": "<openai-api-key>" } } // pragma: allowlist secret
```
- Runs configured absolute binary path, no shell.
- By default, `command` must point to a regular file (not a symlink).
- Set `allowSymlinkCommand: true` to allow symlink command paths (for example Homebrew shims). OpenClaw validates the resolved target path.
- Pair `allowSymlinkCommand` with `trustedDirs` for package-manager paths (for example `["/opt/homebrew"]`).
- Supports timeout, no-output timeout, output byte limits, env allowlist, and trusted dirs.
- Windows fail-closed note: if ACL verification is unavailable for the command path, resolution fails. For trusted paths only, set `allowInsecurePath: true` on that provider to bypass path security checks.
Optional per-id errors:
Request payload (stdin):
```json
{
"protocolVersion": 1,
"values": {},
"errors": { "providers/openai/apiKey": { "message": "not found" } }
}
```
```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>
</AccordionGroup>
## Exec integration examples
### 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,
<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" },
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
models: [{ id: "gpt-5", name: "gpt-5" }],
apiKey: { source: "exec", provider: "onepassword_openai", id: "value" },
},
},
},
},
},
}
```
### 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,
}
```
</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" },
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
models: [{ id: "gpt-5", name: "gpt-5" }],
apiKey: { source: "exec", provider: "vault_openai", id: "value" },
},
},
},
},
},
}
```
### `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,
}
```
</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" },
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
@@ -356,7 +357,9 @@ 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
@@ -415,15 +418,22 @@ Command paths can opt into supported SecretRef resolution via gateway snapshot R
There are two broad behaviors:
- Strict command paths (for example `openclaw memory` remote-memory paths and `openclaw qr --remote` when it needs remote shared-secret refs) read from the active snapshot and fail fast when a required SecretRef is unavailable.
- 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) also prefer the active snapshot, but degrade instead of aborting when a targeted SecretRef is unavailable in that command path.
<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 in that command path.
Read-only behavior:
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 targeted local fallback for the specific command surface.
- If a targeted SecretRef is still unavailable, the command continues with degraded read-only output and explicit diagnostics such as 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.
- 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 targeted local fallback for the specific command surface.
- If a targeted SecretRef is still unavailable, the command continues with degraded read-only output and explicit diagnostics such as "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:
@@ -434,82 +444,97 @@ Other notes:
Default operator flow:
```bash
openclaw secrets audit --check
openclaw secrets configure
openclaw secrets audit --check
```
<Steps>
<Step title="Audit current state">
```bash
openclaw secrets audit --check
```
</Step>
<Step title="Configure SecretRefs">
```bash
openclaw secrets configure
```
</Step>
<Step title="Re-audit">
```bash
openclaw secrets audit --check
```
</Step>
</Steps>
### `secrets audit`
<AccordionGroup>
<Accordion title="secrets audit">
Findings include:
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)
- 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:
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.
- 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:
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`).
- 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:
### `secrets configure`
- 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
- can apply immediately
Interactive helper that:
Exec note:
- 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
- can apply immediately
- 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.
Exec note:
Helpful modes:
- 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.
- `openclaw secrets configure --providers-only`
- `openclaw secrets configure --skip-provider-setup`
- `openclaw secrets configure --agent <id>`
Helpful modes:
`configure` apply defaults:
- `openclaw secrets configure --providers-only`
- `openclaw secrets configure --skip-provider-setup`
- `openclaw secrets configure --agent <id>`
- 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`
`configure` apply defaults:
</Accordion>
<Accordion title="secrets apply">
Apply a saved plan:
- 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`
```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
```
### `secrets apply`
Exec note:
Apply a saved plan:
- dry-run skips exec checks unless `--allow-exec` is set.
- write mode rejects plans containing exec SecretRefs/providers unless `--allow-exec` is set.
```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
```
For strict target/path contract details and exact rejection rules, see [Secrets Apply Plan Contract](/gateway/secrets-plan-contract).
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:
@@ -529,11 +554,11 @@ For static credentials, runtime no longer depends on plaintext legacy auth stora
Some SecretInput unions are easier to configure in raw editor mode than in form mode.
## Related docs
## Related
- CLI commands: [secrets](/cli/secrets)
- Plan contract details: [Secrets Apply Plan Contract](/gateway/secrets-plan-contract)
- Credential surface: [SecretRef Credential Surface](/reference/secretref-credential-surface)
- Auth setup: [Authentication](/gateway/authentication)
- Security posture: [Security](/gateway/security)
- Environment precedence: [Environment Variables](/help/environment)
- [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

@@ -597,7 +597,7 @@ and troubleshooting see the main [FAQ](/help/faq).
`openai-codex/gpt-5.5` for Codex OAuth through the default PI runner. Use
`openai/gpt-5.5` for direct OpenAI API-key access. GPT-5.5 can also use
subscription/OAuth via `openai-codex/gpt-5.5` or native Codex app-server
runs with `openai/gpt-5.5` and `embeddedHarness.runtime: "codex"`.
runs with `openai/gpt-5.5` and `agentRuntime.id: "codex"`.
See [Model providers](/concepts/model-providers) and [Onboarding (CLI)](/start/wizard).
</Accordion>
@@ -607,7 +607,7 @@ and troubleshooting see the main [FAQ](/help/faq).
- `openai/gpt-5.5` = current direct OpenAI API-key route in PI
- `openai-codex/gpt-5.5` = Codex OAuth route in PI
- `openai/gpt-5.5` + `embeddedHarness.runtime: "codex"` = native Codex app-server route
- `openai/gpt-5.5` + `agentRuntime.id: "codex"` = native Codex app-server route
- `openai-codex:...` = auth profile id, not a model ref
If you want the direct OpenAI Platform billing/limit path, set

View File

@@ -9,8 +9,7 @@ title: "Plugin internals"
sidebarTitle: "Internals"
---
This is the **deep architecture reference** for the OpenClaw plugin system. For
practical guides, start with one of the focused pages below.
This is the **deep architecture reference** for the OpenClaw plugin system. For practical guides, start with one of the focused pages below.
<CardGroup cols={2}>
<Card title="Install and use plugins" icon="plug" href="/tools/plugin">
@@ -32,8 +31,7 @@ practical guides, start with one of the focused pages below.
## Public capability model
Capabilities are the public **native plugin** model inside OpenClaw. Every
native OpenClaw plugin registers against one or more capability types:
Capabilities are the public **native plugin** model inside OpenClaw. Every native OpenClaw plugin registers against one or more capability types:
| Capability | Registration method | Example plugins |
| ---------------------- | ------------------------------------------------ | ------------------------------------ |
@@ -51,15 +49,13 @@ native OpenClaw plugin registers against one or more capability types:
| Channel / messaging | `api.registerChannel(...)` | `msteams`, `matrix` |
| Gateway discovery | `api.registerGatewayDiscoveryService(...)` | `bonjour` |
A plugin that registers zero capabilities but provides hooks, tools, discovery
services, or background services is a **legacy hook-only** plugin. That pattern
is still fully supported.
<Note>
A plugin that registers zero capabilities but provides hooks, tools, discovery services, or background services is a **legacy hook-only** plugin. That pattern is still fully supported.
</Note>
### External compatibility stance
The capability model is landed in core and used by bundled/native plugins
today, but external plugin compatibility still needs a tighter bar than "it is
exported, therefore it is frozen."
The capability model is landed in core and used by bundled/native plugins today, but external plugin compatibility still needs a tighter bar than "it is exported, therefore it is frozen."
| Plugin situation | Guidance |
| ------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
@@ -67,33 +63,32 @@ exported, therefore it is frozen."
| New bundled/native plugins | Prefer explicit capability registration over vendor-specific reach-ins or new hook-only designs. |
| External plugins adopting capability registration | Allowed, but treat capability-specific helper surfaces as evolving unless docs mark them stable. |
Capability registration is the intended direction. Legacy hooks remain the
safest no-breakage path for external plugins during the transition. Exported
helper subpaths are not all equal — prefer narrow documented contracts over
incidental helper exports.
Capability registration is the intended direction. Legacy hooks remain the safest no-breakage path for external plugins during the transition. Exported helper subpaths are not all equal — prefer narrow documented contracts over incidental helper exports.
### Plugin shapes
OpenClaw classifies every loaded plugin into a shape based on its actual
registration behavior (not just static metadata):
OpenClaw classifies every loaded plugin into a shape based on its actual registration behavior (not just static metadata):
- **plain-capability**: registers exactly one capability type (for example a
provider-only plugin like `mistral`).
- **hybrid-capability**: registers multiple capability types (for example
`openai` owns text inference, speech, media understanding, and image
generation).
- **hook-only**: registers only hooks (typed or custom), no capabilities,
tools, commands, or services.
- **non-capability**: registers tools, commands, services, or routes but no
capabilities.
<AccordionGroup>
<Accordion title="plain-capability">
Registers exactly one capability type (for example a provider-only plugin like `mistral`).
</Accordion>
<Accordion title="hybrid-capability">
Registers multiple capability types (for example `openai` owns text inference, speech, media understanding, and image generation).
</Accordion>
<Accordion title="hook-only">
Registers only hooks (typed or custom), no capabilities, tools, commands, or services.
</Accordion>
<Accordion title="non-capability">
Registers tools, commands, services, or routes but no capabilities.
</Accordion>
</AccordionGroup>
Use `openclaw plugins inspect <id>` to see a plugin's shape and capability
breakdown. See [CLI reference](/cli/plugins#inspect) for details.
Use `openclaw plugins inspect <id>` to see a plugin's shape and capability breakdown. See [CLI reference](/cli/plugins#inspect) for details.
### Legacy hooks
The `before_agent_start` hook remains supported as a compatibility path for
hook-only plugins. Legacy real-world plugins still depend on it.
The `before_agent_start` hook remains supported as a compatibility path for hook-only plugins. Legacy real-world plugins still depend on it.
Direction:
@@ -105,8 +100,7 @@ Direction:
### Compatibility signals
When you run `openclaw doctor` or `openclaw plugins inspect <id>`, you may see
one of these labels:
When you run `openclaw doctor` or `openclaw plugins inspect <id>`, you may see one of these labels:
| Signal | Meaning |
| -------------------------- | ------------------------------------------------------------ |
@@ -115,98 +109,71 @@ one of these labels:
| **legacy warning** | Plugin uses `before_agent_start`, which is deprecated |
| **hard error** | Config is invalid or plugin failed to load |
Neither `hook-only` nor `before_agent_start` will break your plugin today:
`hook-only` is advisory, and `before_agent_start` only triggers a warning. These
signals also appear in `openclaw status --all` and `openclaw plugins doctor`.
Neither `hook-only` nor `before_agent_start` will break your plugin today: `hook-only` is advisory, and `before_agent_start` only triggers a warning. These signals also appear in `openclaw status --all` and `openclaw plugins doctor`.
## Architecture overview
OpenClaw's plugin system has four layers:
1. **Manifest + discovery**
OpenClaw finds candidate plugins from configured paths, workspace roots,
global plugin roots, and bundled plugins. Discovery reads native
`openclaw.plugin.json` manifests plus supported bundle manifests first.
2. **Enablement + validation**
Core decides whether a discovered plugin is enabled, disabled, blocked, or
selected for an exclusive slot such as memory.
3. **Runtime loading**
Native OpenClaw plugins are loaded in-process via jiti and register
capabilities into a central registry. Compatible bundles are normalized into
registry records without importing runtime code.
4. **Surface consumption**
The rest of OpenClaw reads the registry to expose tools, channels, provider
setup, hooks, HTTP routes, CLI commands, and services.
<Steps>
<Step title="Manifest + discovery">
OpenClaw finds candidate plugins from configured paths, workspace roots, global plugin roots, and bundled plugins. Discovery reads native `openclaw.plugin.json` manifests plus supported bundle manifests first.
</Step>
<Step title="Enablement + validation">
Core decides whether a discovered plugin is enabled, disabled, blocked, or selected for an exclusive slot such as memory.
</Step>
<Step title="Runtime loading">
Native OpenClaw plugins are loaded in-process via jiti and register capabilities into a central registry. Compatible bundles are normalized into registry records without importing runtime code.
</Step>
<Step title="Surface consumption">
The rest of OpenClaw reads the registry to expose tools, channels, provider setup, hooks, HTTP routes, CLI commands, and services.
</Step>
</Steps>
For plugin CLI specifically, root command discovery is split in two phases:
- parse-time metadata comes from `registerCli(..., { descriptors: [...] })`
- the real plugin CLI module can stay lazy and register on first invocation
That keeps plugin-owned CLI code inside the plugin while still letting OpenClaw
reserve root command names before parsing.
That keeps plugin-owned CLI code inside the plugin while still letting OpenClaw reserve root command names before parsing.
The important design boundary:
- manifest/config validation should work from **manifest/schema metadata**
without executing plugin code
- native capability discovery may load trusted plugin entry code to build a
non-activating registry snapshot
- native runtime behavior comes from the plugin module's `register(api)` path
with `api.registrationMode === "full"`
- manifest/config validation should work from **manifest/schema metadata** without executing plugin code
- native capability discovery may load trusted plugin entry code to build a non-activating registry snapshot
- native runtime behavior comes from the plugin module's `register(api)` path with `api.registrationMode === "full"`
That split lets OpenClaw validate config, explain missing/disabled plugins, and
build UI/schema hints before the full runtime is active.
That split lets OpenClaw validate config, explain missing/disabled plugins, and build UI/schema hints before the full runtime is active.
### Activation planning
Activation planning is part of the control plane. Callers can ask which plugins
are relevant to a concrete command, provider, channel, route, agent harness, or
capability before loading broader runtime registries.
Activation planning is part of the control plane. Callers can ask which plugins are relevant to a concrete command, provider, channel, route, agent harness, or capability before loading broader runtime registries.
The planner keeps current manifest behavior compatible:
- `activation.*` fields are explicit planner hints
- `providers`, `channels`, `commandAliases`, `setup.providers`,
`contracts.tools`, and hooks remain manifest ownership fallback
- `providers`, `channels`, `commandAliases`, `setup.providers`, `contracts.tools`, and hooks remain manifest ownership fallback
- the ids-only planner API stays available for existing callers
- the plan API reports reason labels so diagnostics can distinguish explicit
hints from ownership fallback
- the plan API reports reason labels so diagnostics can distinguish explicit hints from ownership fallback
Do not treat `activation` as a lifecycle hook or a replacement for
`register(...)`. It is metadata used to narrow loading. Prefer ownership fields
when they already describe the relationship; use `activation` only for extra
planner hints.
<Warning>
Do not treat `activation` as a lifecycle hook or a replacement for `register(...)`. It is metadata used to narrow loading. Prefer ownership fields when they already describe the relationship; use `activation` only for extra planner hints.
</Warning>
### Channel plugins and the shared message tool
Channel plugins do not need to register a separate send/edit/react tool for
normal chat actions. OpenClaw keeps one shared `message` tool in core, and
channel plugins own the channel-specific discovery and execution behind it.
Channel plugins do not need to register a separate send/edit/react tool for normal chat actions. OpenClaw keeps one shared `message` tool in core, and channel plugins own the channel-specific discovery and execution behind it.
The current boundary is:
- core owns the shared `message` tool host, prompt wiring, session/thread
bookkeeping, and execution dispatch
- channel plugins own scoped action discovery, capability discovery, and any
channel-specific schema fragments
- channel plugins own provider-specific session conversation grammar, such as
how conversation ids encode thread ids or inherit from parent conversations
- core owns the shared `message` tool host, prompt wiring, session/thread bookkeeping, and execution dispatch
- channel plugins own scoped action discovery, capability discovery, and any channel-specific schema fragments
- channel plugins own provider-specific session conversation grammar, such as how conversation ids encode thread ids or inherit from parent conversations
- channel plugins execute the final action through their action adapter
For channel plugins, the SDK surface is
`ChannelMessageActionAdapter.describeMessageTool(...)`. That unified discovery
call lets a plugin return its visible actions, capabilities, and schema
contributions together so those pieces do not drift apart.
For channel plugins, the SDK surface is `ChannelMessageActionAdapter.describeMessageTool(...)`. That unified discovery call lets a plugin return its visible actions, capabilities, and schema contributions together so those pieces do not drift apart.
When a channel-specific message-tool param carries a media source such as a
local path or remote media URL, the plugin should also return
`mediaSourceParams` from `describeMessageTool(...)`. Core uses that explicit
list to apply sandbox path normalization and outbound media-access hints
without hardcoding plugin-owned param names.
Prefer action-scoped maps there, not one channel-wide flat list, so a
profile-only media param does not get normalized on unrelated actions like
`send`.
When a channel-specific message-tool param carries a media source such as a local path or remote media URL, the plugin should also return `mediaSourceParams` from `describeMessageTool(...)`. Core uses that explicit list to apply sandbox path normalization and outbound media-access hints without hardcoding plugin-owned param names. Prefer action-scoped maps there, not one channel-wide flat list, so a profile-only media param does not get normalized on unrelated actions like `send`.
Core passes runtime scope into that discovery step. Important fields include:
@@ -219,107 +186,92 @@ Core passes runtime scope into that discovery step. Important fields include:
- `agentId`
- trusted inbound `requesterSenderId`
That matters for context-sensitive plugins. A channel can hide or expose
message actions based on the active account, current room/thread/message, or
trusted requester identity without hardcoding channel-specific branches in the
core `message` tool.
That matters for context-sensitive plugins. A channel can hide or expose message actions based on the active account, current room/thread/message, or trusted requester identity without hardcoding channel-specific branches in the core `message` tool.
This is why embedded-runner routing changes are still plugin work: the runner is
responsible for forwarding the current chat/session identity into the plugin
discovery boundary so the shared `message` tool exposes the right channel-owned
surface for the current turn.
This is why embedded-runner routing changes are still plugin work: the runner is responsible for forwarding the current chat/session identity into the plugin discovery boundary so the shared `message` tool exposes the right channel-owned surface for the current turn.
For channel-owned execution helpers, bundled plugins should keep the execution
runtime inside their own extension modules. Core no longer owns the Discord,
Slack, Telegram, or WhatsApp message-action runtimes under `src/agents/tools`.
We do not publish separate `plugin-sdk/*-action-runtime` subpaths, and bundled
plugins should import their own local runtime code directly from their
extension-owned modules.
For channel-owned execution helpers, bundled plugins should keep the execution runtime inside their own extension modules. Core no longer owns the Discord, Slack, Telegram, or WhatsApp message-action runtimes under `src/agents/tools`. We do not publish separate `plugin-sdk/*-action-runtime` subpaths, and bundled plugins should import their own local runtime code directly from their extension-owned modules.
The same boundary applies to provider-named SDK seams in general: core should
not import channel-specific convenience barrels for Slack, Discord, Signal,
WhatsApp, or similar extensions. If core needs a behavior, either consume the
bundled plugin's own `api.ts` / `runtime-api.ts` barrel or promote the need
into a narrow generic capability in the shared SDK.
The same boundary applies to provider-named SDK seams in general: core should not import channel-specific convenience barrels for Slack, Discord, Signal, WhatsApp, or similar extensions. If core needs a behavior, either consume the bundled plugin's own `api.ts` / `runtime-api.ts` barrel or promote the need into a narrow generic capability in the shared SDK.
For polls specifically, there are two execution paths:
- `outbound.sendPoll` is the shared baseline for channels that fit the common
poll model
- `actions.handleAction("poll")` is the preferred path for channel-specific
poll semantics or extra poll parameters
- `outbound.sendPoll` is the shared baseline for channels that fit the common poll model
- `actions.handleAction("poll")` is the preferred path for channel-specific poll semantics or extra poll parameters
Core now defers shared poll parsing until after plugin poll dispatch declines
the action, so plugin-owned poll handlers can accept channel-specific poll
fields without being blocked by the generic poll parser first.
Core now defers shared poll parsing until after plugin poll dispatch declines the action, so plugin-owned poll handlers can accept channel-specific poll fields without being blocked by the generic poll parser first.
See [Plugin architecture internals](/plugins/architecture-internals) for the full startup sequence.
## Capability ownership model
OpenClaw treats a native plugin as the ownership boundary for a **company** or a
**feature**, not as a grab bag of unrelated integrations.
OpenClaw treats a native plugin as the ownership boundary for a **company** or a **feature**, not as a grab bag of unrelated integrations.
That means:
- a company plugin should usually own all of that company's OpenClaw-facing
surfaces
- a company plugin should usually own all of that company's OpenClaw-facing surfaces
- a feature plugin should usually own the full feature surface it introduces
- channels should consume shared core capabilities instead of re-implementing
provider behavior ad hoc
- channels should consume shared core capabilities instead of re-implementing provider behavior ad hoc
<Accordion title="Example ownership patterns across bundled plugins">
- **Vendor multi-capability**: `openai` owns text inference, speech, realtime
voice, media understanding, and image generation. `google` owns text
inference plus media understanding, image generation, and web search.
`qwen` owns text inference plus media understanding and video generation.
- **Vendor single-capability**: `elevenlabs` and `microsoft` own speech;
`firecrawl` owns web-fetch; `minimax` / `mistral` / `moonshot` / `zai` own
media-understanding backends.
- **Feature plugin**: `voice-call` owns call transport, tools, CLI, routes,
and Twilio media-stream bridging, but consumes shared speech, realtime
transcription, and realtime voice capabilities instead of importing vendor
plugins directly.
</Accordion>
<AccordionGroup>
<Accordion title="Vendor multi-capability">
`openai` owns text inference, speech, realtime voice, media understanding, and image generation. `google` owns text inference plus media understanding, image generation, and web search. `qwen` owns text inference plus media understanding and video generation.
</Accordion>
<Accordion title="Vendor single-capability">
`elevenlabs` and `microsoft` own speech; `firecrawl` owns web-fetch; `minimax` / `mistral` / `moonshot` / `zai` own media-understanding backends.
</Accordion>
<Accordion title="Feature plugin">
`voice-call` owns call transport, tools, CLI, routes, and Twilio media-stream bridging, but consumes shared speech, realtime transcription, and realtime voice capabilities instead of importing vendor plugins directly.
</Accordion>
</AccordionGroup>
The intended end state is:
- OpenAI lives in one plugin even if it spans text models, speech, images, and
future video
- OpenAI lives in one plugin even if it spans text models, speech, images, and future video
- another vendor can do the same for its own surface area
- channels do not care which vendor plugin owns the provider; they consume the
shared capability contract exposed by core
- channels do not care which vendor plugin owns the provider; they consume the shared capability contract exposed by core
This is the key distinction:
- **plugin** = ownership boundary
- **capability** = core contract that multiple plugins can implement or consume
So if OpenClaw adds a new domain such as video, the first question is not
"which provider should hardcode video handling?" The first question is "what is
the core video capability contract?" Once that contract exists, vendor plugins
can register against it and channel/feature plugins can consume it.
So if OpenClaw adds a new domain such as video, the first question is not "which provider should hardcode video handling?" The first question is "what is the core video capability contract?" Once that contract exists, vendor plugins can register against it and channel/feature plugins can consume it.
If the capability does not exist yet, the right move is usually:
1. define the missing capability in core
2. expose it through the plugin API/runtime in a typed way
3. wire channels/features against that capability
4. let vendor plugins register implementations
<Steps>
<Step title="Define the capability">
Define the missing capability in core.
</Step>
<Step title="Expose through the SDK">
Expose it through the plugin API/runtime in a typed way.
</Step>
<Step title="Wire consumers">
Wire channels/features against that capability.
</Step>
<Step title="Vendor implementations">
Let vendor plugins register implementations.
</Step>
</Steps>
This keeps ownership explicit while avoiding core behavior that depends on a
single vendor or a one-off plugin-specific code path.
This keeps ownership explicit while avoiding core behavior that depends on a single vendor or a one-off plugin-specific code path.
### Capability layering
Use this mental model when deciding where code belongs:
- **core capability layer**: shared orchestration, policy, fallback, config
merge rules, delivery semantics, and typed contracts
- **vendor plugin layer**: vendor-specific APIs, auth, model catalogs, speech
synthesis, image generation, future video backends, usage endpoints
- **channel/feature plugin layer**: Slack/Discord/voice-call/etc. integration
that consumes core capabilities and presents them on a surface
<Tabs>
<Tab title="Core capability layer">
Shared orchestration, policy, fallback, config merge rules, delivery semantics, and typed contracts.
</Tab>
<Tab title="Vendor plugin layer">
Vendor-specific APIs, auth, model catalogs, speech synthesis, image generation, future video backends, usage endpoints.
</Tab>
<Tab title="Channel/feature plugin layer">
Slack/Discord/voice-call/etc. integration that consumes core capabilities and presents them on a surface.
</Tab>
</Tabs>
For example, TTS follows this shape:
@@ -331,10 +283,7 @@ That same pattern should be preferred for future capabilities.
### Multi-capability company plugin example
A company plugin should feel cohesive from the outside. If OpenClaw has shared
contracts for models, speech, realtime transcription, realtime voice, media
understanding, image generation, video generation, web fetch, and web search,
a vendor can own all of its surfaces in one place:
A company plugin should feel cohesive from the outside. If OpenClaw has shared contracts for models, speech, realtime transcription, realtime voice, media understanding, image generation, video generation, web fetch, and web search, a vendor can own all of its surfaces in one place:
```ts
import type { OpenClawPluginDefinition } from "openclaw/plugin-sdk/plugin-entry";
@@ -393,117 +342,101 @@ What matters is not the exact helper names. The shape matters:
- one plugin owns the vendor surface
- core still owns the capability contracts
- channels and feature plugins consume `api.runtime.*` helpers, not vendor code
- contract tests can assert that the plugin registered the capabilities it
claims to own
- contract tests can assert that the plugin registered the capabilities it claims to own
### Capability example: video understanding
OpenClaw already treats image/audio/video understanding as one shared
capability. The same ownership model applies there:
OpenClaw already treats image/audio/video understanding as one shared capability. The same ownership model applies there:
1. core defines the media-understanding contract
2. vendor plugins register `describeImage`, `transcribeAudio`, and
`describeVideo` as applicable
3. channels and feature plugins consume the shared core behavior instead of
wiring directly to vendor code
<Steps>
<Step title="Core defines the contract">
Core defines the media-understanding contract.
</Step>
<Step title="Vendor plugins register">
Vendor plugins register `describeImage`, `transcribeAudio`, and `describeVideo` as applicable.
</Step>
<Step title="Consumers use the shared behavior">
Channels and feature plugins consume the shared core behavior instead of wiring directly to vendor code.
</Step>
</Steps>
That avoids baking one provider's video assumptions into core. The plugin owns
the vendor surface; core owns the capability contract and fallback behavior.
That avoids baking one provider's video assumptions into core. The plugin owns the vendor surface; core owns the capability contract and fallback behavior.
Video generation already uses that same sequence: core owns the typed
capability contract and runtime helper, and vendor plugins register
`api.registerVideoGenerationProvider(...)` implementations against it.
Video generation already uses that same sequence: core owns the typed capability contract and runtime helper, and vendor plugins register `api.registerVideoGenerationProvider(...)` implementations against it.
Need a concrete rollout checklist? See
[Capability Cookbook](/tools/capability-cookbook).
Need a concrete rollout checklist? See [Capability Cookbook](/tools/capability-cookbook).
## Contracts and enforcement
The plugin API surface is intentionally typed and centralized in
`OpenClawPluginApi`. That contract defines the supported registration points and
the runtime helpers a plugin may rely on.
The plugin API surface is intentionally typed and centralized in `OpenClawPluginApi`. That contract defines the supported registration points and the runtime helpers a plugin may rely on.
Why this matters:
- plugin authors get one stable internal standard
- core can reject duplicate ownership such as two plugins registering the same
provider id
- core can reject duplicate ownership such as two plugins registering the same provider id
- startup can surface actionable diagnostics for malformed registration
- contract tests can enforce bundled-plugin ownership and prevent silent drift
There are two layers of enforcement:
1. **runtime registration enforcement**
The plugin registry validates registrations as plugins load. Examples:
duplicate provider ids, duplicate speech provider ids, and malformed
registrations produce plugin diagnostics instead of undefined behavior.
2. **contract tests**
Bundled plugins are captured in contract registries during test runs so
OpenClaw can assert ownership explicitly. Today this is used for model
providers, speech providers, web search providers, and bundled registration
ownership.
<AccordionGroup>
<Accordion title="Runtime registration enforcement">
The plugin registry validates registrations as plugins load. Examples: duplicate provider ids, duplicate speech provider ids, and malformed registrations produce plugin diagnostics instead of undefined behavior.
</Accordion>
<Accordion title="Contract tests">
Bundled plugins are captured in contract registries during test runs so OpenClaw can assert ownership explicitly. Today this is used for model providers, speech providers, web search providers, and bundled registration ownership.
</Accordion>
</AccordionGroup>
The practical effect is that OpenClaw knows, up front, which plugin owns which
surface. That lets core and channels compose seamlessly because ownership is
declared, typed, and testable rather than implicit.
The practical effect is that OpenClaw knows, up front, which plugin owns which surface. That lets core and channels compose seamlessly because ownership is declared, typed, and testable rather than implicit.
### What belongs in a contract
Good plugin contracts are:
<Tabs>
<Tab title="Good contracts">
- typed
- small
- capability-specific
- owned by core
- reusable by multiple plugins
- consumable by channels/features without vendor knowledge
</Tab>
<Tab title="Bad contracts">
- vendor-specific policy hidden in core
- one-off plugin escape hatches that bypass the registry
- channel code reaching straight into a vendor implementation
- ad hoc runtime objects that are not part of `OpenClawPluginApi` or `api.runtime`
</Tab>
</Tabs>
- typed
- small
- capability-specific
- owned by core
- reusable by multiple plugins
- consumable by channels/features without vendor knowledge
Bad plugin contracts are:
- vendor-specific policy hidden in core
- one-off plugin escape hatches that bypass the registry
- channel code reaching straight into a vendor implementation
- ad hoc runtime objects that are not part of `OpenClawPluginApi` or
`api.runtime`
When in doubt, raise the abstraction level: define the capability first, then
let plugins plug into it.
When in doubt, raise the abstraction level: define the capability first, then let plugins plug into it.
## Execution model
Native OpenClaw plugins run **in-process** with the Gateway. They are not
sandboxed. A loaded native plugin has the same process-level trust boundary as
core code.
Native OpenClaw plugins run **in-process** with the Gateway. They are not sandboxed. A loaded native plugin has the same process-level trust boundary as core code.
<Warning>
Implications:
- a native plugin can register tools, network handlers, hooks, and services
- a native plugin bug can crash or destabilize the gateway
- a malicious native plugin is equivalent to arbitrary code execution inside
the OpenClaw process
- a malicious native plugin is equivalent to arbitrary code execution inside the OpenClaw process
</Warning>
Compatible bundles are safer by default because OpenClaw currently treats them
as metadata/content packs. In current releases, that mostly means bundled
skills.
Compatible bundles are safer by default because OpenClaw currently treats them as metadata/content packs. In current releases, that mostly means bundled skills.
Use allowlists and explicit install/load paths for non-bundled plugins. Treat
workspace plugins as development-time code, not production defaults.
Use allowlists and explicit install/load paths for non-bundled plugins. Treat workspace plugins as development-time code, not production defaults.
For bundled workspace package names, keep the plugin id anchored in the npm
name: `@openclaw/<id>` by default, or an approved typed suffix such as
`-provider`, `-plugin`, `-speech`, `-sandbox`, or `-media-understanding` when
the package intentionally exposes a narrower plugin role.
For bundled workspace package names, keep the plugin id anchored in the npm name: `@openclaw/<id>` by default, or an approved typed suffix such as `-provider`, `-plugin`, `-speech`, `-sandbox`, or `-media-understanding` when the package intentionally exposes a narrower plugin role.
Important trust note:
<Note>
**Trust note:**
- `plugins.allow` trusts **plugin ids**, not source provenance.
- A workspace plugin with the same id as a bundled plugin intentionally shadows
the bundled copy when that workspace plugin is enabled/allowlisted.
- A workspace plugin with the same id as a bundled plugin intentionally shadows the bundled copy when that workspace plugin is enabled/allowlisted.
- This is normal and useful for local development, patch testing, and hotfixes.
- Bundled-plugin trust is resolved from the source snapshot — the manifest and
code on disk at load time — rather than from install metadata. A corrupted
or substituted install record cannot silently widen a bundled plugin's trust
surface beyond what the actual source claims.
- Bundled-plugin trust is resolved from the source snapshot — the manifest and code on disk at load time — rather than from install metadata. A corrupted or substituted install record cannot silently widen a bundled plugin's trust surface beyond what the actual source claims.
</Note>
## Export boundary
@@ -516,22 +449,14 @@ Keep capability registration public. Trim non-contract helper exports:
- vendor-specific convenience helpers
- setup/onboarding helpers that are implementation details
Some bundled-plugin helper subpaths still remain in the generated SDK export
map for compatibility and bundled-plugin maintenance. Current examples include
`plugin-sdk/feishu`, `plugin-sdk/feishu-setup`, `plugin-sdk/zalo`,
`plugin-sdk/zalo-setup`, and several `plugin-sdk/matrix*` seams. Treat those as
reserved implementation-detail exports, not as the recommended SDK pattern for
new third-party plugins.
Some bundled-plugin helper subpaths still remain in the generated SDK export map for compatibility and bundled-plugin maintenance. Current examples include `plugin-sdk/feishu`, `plugin-sdk/feishu-setup`, `plugin-sdk/zalo`, `plugin-sdk/zalo-setup`, and several `plugin-sdk/matrix*` seams. Treat those as reserved implementation-detail exports, not as the recommended SDK pattern for new third-party plugins.
## Internals and reference
For the load pipeline, registry model, provider runtime hooks, Gateway HTTP
routes, message tool schemas, channel target resolution, provider catalogs,
context engine plugins, and the guide to adding a new capability, see
[Plugin architecture internals](/plugins/architecture-internals).
For the load pipeline, registry model, provider runtime hooks, Gateway HTTP routes, message tool schemas, channel target resolution, provider catalogs, context engine plugins, and the guide to adding a new capability, see [Plugin architecture internals](/plugins/architecture-internals).
## Related
- [Building plugins](/plugins/building-plugins)
- [Plugin SDK setup](/plugins/sdk-setup)
- [Plugin manifest](/plugins/manifest)
- [Plugin SDK setup](/plugins/sdk-setup)

View File

@@ -26,7 +26,7 @@ The bundled `codex` plugin contributes several separate capabilities:
| Capability | How you use it | What it does |
| --------------------------------- | --------------------------------------------------- | ----------------------------------------------------------------------------- |
| Native embedded runtime | `embeddedHarness.runtime: "codex"` | Runs OpenClaw embedded agent turns through Codex app-server. |
| Native embedded runtime | `agentRuntime.id: "codex"` | Runs OpenClaw embedded agent turns through Codex app-server. |
| Native chat-control commands | `/codex bind`, `/codex resume`, `/codex steer`, ... | Binds and controls Codex app-server threads from a messaging conversation. |
| Codex app-server provider/catalog | `codex` internals, surfaced through the harness | Lets the runtime discover and validate app-server models. |
| Codex media-understanding path | `codex/*` image-model compatibility paths | Runs bounded Codex app-server turns for supported image understanding models. |
@@ -69,7 +69,7 @@ and [Plugin guard behavior](/tools/plugin).
The harness is off by default. New configs should keep OpenAI model refs
canonical as `openai/gpt-*` and explicitly force
`embeddedHarness.runtime: "codex"` or `OPENCLAW_AGENT_RUNTIME=codex` when they
`agentRuntime.id: "codex"` or `OPENCLAW_AGENT_RUNTIME=codex` when they
want native app-server execution. Legacy `codex/*` model refs still auto-select
the harness for compatibility, but runtime-backed legacy provider prefixes are
not shown as normal model/provider choices.
@@ -87,14 +87,14 @@ Use this table before changing config:
| ------------------------------------------- | -------------------------- | -------------------------------------- | --------------------------- | ------------------------------ |
| OpenAI API through normal OpenClaw runner | `openai/gpt-*` | omitted or `runtime: "pi"` | OpenAI provider | `Runtime: OpenClaw Pi Default` |
| Codex OAuth/subscription through PI | `openai-codex/gpt-*` | omitted or `runtime: "pi"` | OpenAI Codex OAuth provider | `Runtime: OpenClaw Pi Default` |
| Native Codex app-server embedded turns | `openai/gpt-*` | `embeddedHarness.runtime: "codex"` | `codex` plugin | `Runtime: OpenAI Codex` |
| Mixed providers with conservative auto mode | provider-specific refs | `runtime: "auto", fallback: "pi"` | Optional plugin runtimes | Depends on selected runtime |
| Native Codex app-server embedded turns | `openai/gpt-*` | `agentRuntime.id: "codex"` | `codex` plugin | `Runtime: OpenAI Codex` |
| Mixed providers with conservative auto mode | provider-specific refs | `agentRuntime.id: "auto"` | Optional plugin runtimes | Depends on selected runtime |
| Explicit Codex ACP adapter session | ACP prompt/model dependent | `sessions_spawn` with `runtime: "acp"` | healthy `acpx` backend | ACP task/session status |
The important split is provider versus runtime:
- `openai-codex/*` answers "which provider/auth route should PI use?"
- `embeddedHarness.runtime: "codex"` answers "which loop should execute this
- `agentRuntime.id: "codex"` answers "which loop should execute this
embedded turn?"
- `/codex ...` answers "which native Codex conversation should this chat bind
or control?"
@@ -106,11 +106,11 @@ OpenAI-family routes are prefix-specific. Use `openai-codex/*` when you want
Codex OAuth through PI; use `openai/*` when you want direct OpenAI API access or
when you are forcing the native Codex app-server harness:
| Model ref | Runtime path | Use when |
| ----------------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------- |
| `openai/gpt-5.4` | OpenAI provider through OpenClaw/PI plumbing | You want current direct OpenAI Platform API access with `OPENAI_API_KEY`. |
| `openai-codex/gpt-5.5` | OpenAI Codex OAuth through OpenClaw/PI | You want ChatGPT/Codex subscription auth with the default PI runner. |
| `openai/gpt-5.5` + `embeddedHarness.runtime: "codex"` | Codex app-server harness | You want native Codex app-server execution for the embedded agent turn. |
| Model ref | Runtime path | Use when |
| --------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------- |
| `openai/gpt-5.4` | OpenAI provider through OpenClaw/PI plumbing | You want current direct OpenAI Platform API access with `OPENAI_API_KEY`. |
| `openai-codex/gpt-5.5` | OpenAI Codex OAuth through OpenClaw/PI | You want ChatGPT/Codex subscription auth with the default PI runner. |
| `openai/gpt-5.5` + `agentRuntime.id: "codex"` | Codex app-server harness | You want native Codex app-server execution for the embedded agent turn. |
GPT-5.5 is currently subscription/OAuth-only in OpenClaw. Use
`openai-codex/gpt-5.5` for PI OAuth, or `openai/gpt-5.5` with the Codex
@@ -123,7 +123,7 @@ refs and records the runtime policy separately, while fallback-only legacy refs
are left unchanged because runtime is configured for the whole agent container.
New PI Codex OAuth configs should use `openai-codex/gpt-*`; new native
app-server harness configs should use `openai/gpt-*` plus
`embeddedHarness.runtime: "codex"`.
`agentRuntime.id: "codex"`.
`agents.defaults.imageModel` follows the same prefix split. Use
`openai-codex/gpt-*` when image understanding should run through the OpenAI
@@ -152,14 +152,14 @@ means:
- **No change is required** if you intended ChatGPT/Codex OAuth through PI.
- Change the model to `openai/<model>` and set
`embeddedHarness.runtime: "codex"` if you intended native app-server
`agentRuntime.id: "codex"` if you intended native app-server
execution.
- Existing sessions still need `/new` or `/reset` after a runtime change,
because session runtime pins are sticky.
Harness selection is not a live session control. When an embedded turn runs,
OpenClaw records the selected harness id on that session and keeps using it for
later turns in the same session id. Change `embeddedHarness` config or
later turns in the same session id. Change `agentRuntime` config or
`OPENCLAW_AGENT_RUNTIME` when you want future sessions to use another harness;
use `/new` or `/reset` to start a fresh session before switching an existing
conversation between PI and Codex. This avoids replaying one transcript through
@@ -205,8 +205,8 @@ Use `openai/gpt-5.5`, enable the bundled plugin, and force the `codex` harness:
agents: {
defaults: {
model: "openai/gpt-5.5",
embeddedHarness: {
runtime: "codex",
agentRuntime: {
id: "codex",
},
},
},
@@ -230,11 +230,11 @@ If your config uses `plugins.allow`, include `codex` there too:
Legacy configs that set `agents.defaults.model` or an agent model to
`codex/<model>` still auto-enable the bundled `codex` plugin. New configs should
prefer `openai/<model>` plus the explicit `embeddedHarness` entry above.
prefer `openai/<model>` plus the explicit `agentRuntime` entry above.
## Add Codex alongside other models
Do not set `runtime: "codex"` globally if the same agent should freely switch
Do not set `agentRuntime.id: "codex"` globally if the same agent should freely switch
between Codex and non-Codex provider models. A forced runtime applies to every
embedded turn for that agent or session. If you select an Anthropic model while
that runtime is forced, OpenClaw still tries the Codex harness and fails closed
@@ -242,8 +242,8 @@ instead of silently routing that turn through PI.
Use one of these shapes instead:
- Put Codex on a dedicated agent with `embeddedHarness.runtime: "codex"`.
- Keep the default agent on `runtime: "auto"` and PI fallback for normal mixed
- Put Codex on a dedicated agent with `agentRuntime.id: "codex"`.
- Keep the default agent on `agentRuntime.id: "auto"` and PI fallback for normal mixed
provider usage.
- Use legacy `codex/*` refs only for compatibility. New configs should prefer
`openai/*` plus an explicit Codex runtime policy.
@@ -262,8 +262,8 @@ adds a separate Codex agent:
},
agents: {
defaults: {
embeddedHarness: {
runtime: "auto",
agentRuntime: {
id: "auto",
fallback: "pi",
},
},
@@ -277,8 +277,8 @@ adds a separate Codex agent:
id: "codex",
name: "Codex",
model: "openai/gpt-5.5",
embeddedHarness: {
runtime: "codex",
agentRuntime: {
id: "codex",
},
},
],
@@ -302,7 +302,7 @@ Agents should route user requests by intent, not by the word "Codex" alone:
| "Bind this chat to Codex" | `/codex bind` |
| "Resume Codex thread `<id>` here" | `/codex resume <id>` |
| "Show Codex threads" | `/codex threads` |
| "Use Codex as the runtime for this agent" | config change to `embeddedHarness.runtime` |
| "Use Codex as the runtime for this agent" | config change to `agentRuntime.id` |
| "Use my ChatGPT/Codex subscription with normal OpenClaw" | `openai-codex/*` model refs |
| "Run Codex through ACP/acpx" | ACP `sessions_spawn({ runtime: "acp", ... })` |
| "Start Claude Code/Gemini/OpenCode/Cursor in a thread" | ACP/acpx, not `/codex` and not native sub-agents |
@@ -323,8 +323,8 @@ uses Codex. Explicit plugin runtimes default to no PI fallback, so
agents: {
defaults: {
model: "openai/gpt-5.5",
embeddedHarness: {
runtime: "codex",
agentRuntime: {
id: "codex",
fallback: "none",
},
},
@@ -352,8 +352,8 @@ auto-selection:
{
agents: {
defaults: {
embeddedHarness: {
runtime: "auto",
agentRuntime: {
id: "auto",
fallback: "pi",
},
},
@@ -367,8 +367,8 @@ auto-selection:
id: "codex",
name: "Codex",
model: "openai/gpt-5.5",
embeddedHarness: {
runtime: "codex",
agentRuntime: {
id: "codex",
fallback: "none",
},
},
@@ -565,8 +565,8 @@ Codex-only harness validation:
agents: {
defaults: {
model: "openai/gpt-5.5",
embeddedHarness: {
runtime: "codex",
agentRuntime: {
id: "codex",
},
},
},
@@ -772,15 +772,15 @@ understanding continue to use the matching provider/model settings such as
**Codex does not appear as a normal `/model` provider:** that is expected for
new configs. Select an `openai/gpt-*` model with
`embeddedHarness.runtime: "codex"` (or a legacy `codex/*` ref), enable
`agentRuntime.id: "codex"` (or a legacy `codex/*` ref), enable
`plugins.entries.codex.enabled`, and check whether `plugins.allow` excludes
`codex`.
**OpenClaw uses PI instead of Codex:** `runtime: "auto"` can still use PI as the
**OpenClaw uses PI instead of Codex:** `agentRuntime.id: "auto"` can still use PI as the
compatibility backend when no Codex harness claims the run. Set
`embeddedHarness.runtime: "codex"` to force Codex selection while testing. A
`agentRuntime.id: "codex"` to force Codex selection while testing. A
forced Codex runtime now fails instead of falling back to PI unless you
explicitly set `embeddedHarness.fallback: "pi"`. Once Codex app-server is
explicitly set `agentRuntime.fallback: "pi"`. Once Codex app-server is
selected, its failures surface directly without extra fallback config.
**The app-server is rejected:** upgrade Codex so the app-server handshake
@@ -795,9 +795,9 @@ or disable discovery.
and that the remote app-server speaks the same Codex app-server protocol version.
**A non-Codex model uses PI:** that is expected unless you forced
`embeddedHarness.runtime: "codex"` for that agent or selected a legacy
`agentRuntime.id: "codex"` for that agent or selected a legacy
`codex/*` ref. Plain `openai/gpt-*` and other provider refs stay on their normal
provider path in `auto` mode. If you force `runtime: "codex"`, every embedded
provider path in `auto` mode. If you force `agentRuntime.id: "codex"`, every embedded
turn for that agent must be a Codex-supported OpenAI model.
## Related

View File

@@ -82,8 +82,8 @@ Current compatibility records include:
- bundled plugin allowlist and enablement behavior
- legacy provider/channel env-var manifest metadata
- activation hints that are being replaced by manifest contribution ownership
- `embeddedHarness` and `agent-harness` naming aliases while public naming moves
toward `agentRuntime`
- legacy runtime-policy config keys while doctor migrates operators to
`agentRuntime`
- generated bundled channel config metadata fallback while registry-first
`channelConfigs` metadata lands
- the persisted plugin registry disable env while repair flows migrate operators

View File

@@ -524,6 +524,12 @@ Non-bundled plugins that declare `channels[]` should also declare matching
cold-path config schema, setup, and Control UI surfaces cannot know the
channel-owned option shape until plugin runtime executes.
`channelConfigs.<channel-id>.commands.nativeCommandsAutoEnabled` and
`nativeSkillsAutoEnabled` can declare static `auto` defaults for command config
checks that run before channel runtime loads. Bundled channels can also publish
the same defaults through `package.json#openclaw.channel.commands` alongside
their other package-owned channel catalog metadata.
```json
{
"channelConfigs": {
@@ -543,6 +549,10 @@ channel-owned option shape until plugin runtime executes.
},
"label": "Matrix",
"description": "Matrix homeserver connection",
"commands": {
"nativeCommandsAutoEnabled": true,
"nativeSkillsAutoEnabled": true
},
"preferOver": ["matrix-legacy"]
}
}
@@ -557,6 +567,7 @@ Each channel entry can include:
| `uiHints` | `Record<string, object>` | Optional UI labels/placeholders/sensitive hints for that channel config section. |
| `label` | `string` | Channel label merged into picker and inspect surfaces when runtime metadata is not ready. |
| `description` | `string` | Short channel description for inspect and catalog surfaces. |
| `commands` | `object` | Static native command and native skill auto-defaults for pre-runtime config checks. |
| `preferOver` | `string[]` | Legacy or lower-priority plugin ids this channel should outrank in selection surfaces. |
### Replacing another channel plugin
@@ -792,6 +803,7 @@ Important examples:
| `openclaw.setupEntry` | Lightweight setup-only entrypoint used during onboarding, deferred channel startup, and read-only channel status/SecretRef discovery. Must stay inside the plugin package directory. |
| `openclaw.runtimeSetupEntry` | Declares the built JavaScript setup entrypoint for installed packages. Must stay inside the plugin package directory. |
| `openclaw.channel` | Cheap channel catalog metadata like labels, docs paths, aliases, and selection copy. |
| `openclaw.channel.commands` | Static native command and native skill auto-default metadata used by config, audit, and command-list surfaces before channel runtime loads. |
| `openclaw.channel.configuredState` | Lightweight configured-state checker metadata that can answer "does env-only setup already exist?" without loading the full channel runtime. |
| `openclaw.channel.persistedAuthState` | Lightweight persisted-auth checker metadata that can answer "is anything already signed in?" without loading the full channel runtime. |
| `openclaw.install.npmSpec` / `openclaw.install.localPath` | Install/update hints for bundled and externally published plugins. |

View File

@@ -142,7 +142,7 @@ OpenClaw. The harness then claims that provider in `supports(...)`.
The bundled Codex plugin follows this pattern:
- preferred user model refs: `openai/gpt-5.5` plus
`embeddedHarness.runtime: "codex"`
`agentRuntime.id: "codex"`
- compatibility refs: legacy `codex/gpt-*` refs remain accepted, but new
configs should not use them as normal provider/model refs
- harness id: `codex`
@@ -153,7 +153,7 @@ The bundled Codex plugin follows this pattern:
The Codex plugin is additive. Plain `openai/gpt-*` refs continue to use the
normal OpenClaw provider path unless you force the Codex harness with
`embeddedHarness.runtime: "codex"`. Older `codex/gpt-*` refs still select the
`agentRuntime.id: "codex"`. Older `codex/gpt-*` refs still select the
Codex provider and harness for compatibility.
For operator setup, model prefix examples, and Codex-only configs, see
@@ -194,14 +194,14 @@ intentional silent replies such as `NO_REPLY` unclassified.
The bundled `codex` harness is the native Codex mode for embedded OpenClaw
agent turns. Enable the bundled `codex` plugin first, and include `codex` in
`plugins.allow` if your config uses a restrictive allowlist. Native app-server
configs should use `openai/gpt-*` with `embeddedHarness.runtime: "codex"`.
configs should use `openai/gpt-*` with `agentRuntime.id: "codex"`.
Use `openai-codex/*` for Codex OAuth through PI instead. Legacy `codex/*`
model refs remain compatibility aliases for the native harness.
When this mode runs, Codex owns the native thread id, resume behavior,
compaction, and app-server execution. OpenClaw still owns the chat channel,
visible transcript mirror, tool policy, approvals, media delivery, and session
selection. Use `embeddedHarness.runtime: "codex"` without a `fallback` override
selection. Use `agentRuntime.id: "codex"` without a `fallback` override
when you need to prove that only the Codex app-server path can claim the run.
Explicit plugin runtimes already fail closed by default. Set `fallback: "pi"`
only when you intentionally want PI to handle missing harness selection. Codex
@@ -209,8 +209,8 @@ app-server failures already fail directly instead of retrying through PI.
## Disable PI fallback
By default, OpenClaw runs embedded agents with `agents.defaults.embeddedHarness`
set to `{ runtime: "auto", fallback: "pi" }`. In `auto` mode, registered plugin
By default, OpenClaw runs embedded agents with `agents.defaults.agentRuntime`
set to `{ id: "auto", fallback: "pi" }`. In `auto` mode, registered plugin
harnesses can claim a provider/model pair. If none match, OpenClaw falls back
to PI.
@@ -228,8 +228,8 @@ For Codex-only embedded runs:
"agents": {
"defaults": {
"model": "openai/gpt-5.5",
"embeddedHarness": {
"runtime": "codex"
"agentRuntime": {
"id": "codex"
}
}
}
@@ -244,8 +244,8 @@ the fallback:
{
"agents": {
"defaults": {
"embeddedHarness": {
"runtime": "auto",
"agentRuntime": {
"id": "auto",
"fallback": "none"
}
}
@@ -259,8 +259,8 @@ Per-agent overrides use the same shape:
{
"agents": {
"defaults": {
"embeddedHarness": {
"runtime": "auto",
"agentRuntime": {
"id": "auto",
"fallback": "pi"
}
},
@@ -268,8 +268,8 @@ Per-agent overrides use the same shape:
{
"id": "codex-only",
"model": "openai/gpt-5.5",
"embeddedHarness": {
"runtime": "codex",
"agentRuntime": {
"id": "codex",
"fallback": "none"
}
}

View File

@@ -97,6 +97,25 @@ Anthropic's current public docs:
Setup and runtime details for the Claude CLI backend are in [CLI Backends](/gateway/cli-backends).
</Note>
### Config example
Prefer the canonical Anthropic model ref plus a CLI runtime override:
```json5
{
agents: {
defaults: {
model: { primary: "anthropic/claude-opus-4-7" },
agentRuntime: { id: "claude-cli" },
},
},
}
```
Legacy `claude-cli/claude-opus-4-7` model refs still work for
compatibility, but new config should keep provider/model selection as
`anthropic/*` and put the execution backend in `agentRuntime.id`.
<Tip>
If you want the clearest billing path, use an Anthropic API key instead. OpenClaw also supports subscription-style options from [OpenAI Codex](/providers/openai), [Qwen Cloud](/providers/qwen), [MiniMax](/providers/minimax), and [Z.AI / GLM](/providers/glm).
</Tip>

View File

@@ -13,7 +13,7 @@ Gemini Grounding.
- Provider: `google`
- Auth: `GEMINI_API_KEY` or `GOOGLE_API_KEY`
- API: Google Gemini API
- Runtime option: `agents.defaults.embeddedHarness.runtime: "google-gemini-cli"`
- Runtime option: `agents.defaults.agentRuntime.id: "google-gemini-cli"`
reuses Gemini CLI OAuth while keeping model refs canonical as `google/*`.
## Getting started

View File

@@ -17,7 +17,7 @@ embedded agent loop:
- **API key** — direct OpenAI Platform access with usage-based billing (`openai/*` models)
- **Codex subscription through PI** — ChatGPT/Codex sign-in with subscription access (`openai-codex/*` models)
- **Codex app-server harness** — native Codex app-server execution (`openai/*` models plus `agents.defaults.embeddedHarness.runtime: "codex"`)
- **Codex app-server harness** — native Codex app-server execution (`openai/*` models plus `agents.defaults.agentRuntime.id: "codex"`)
OpenAI explicitly supports subscription OAuth usage in external tools and workflows like OpenClaw.
@@ -27,13 +27,13 @@ changing config.
## Quick choice
| Goal | Use | Notes |
| --------------------------------------------- | -------------------------------------------------------- | ---------------------------------------------------------------------------- |
| Direct API-key billing | `openai/gpt-5.5` | Set `OPENAI_API_KEY` or run OpenAI API-key onboarding. |
| GPT-5.5 with ChatGPT/Codex subscription auth | `openai-codex/gpt-5.5` | Default PI route for Codex OAuth. Best first choice for subscription setups. |
| GPT-5.5 with native Codex app-server behavior | `openai/gpt-5.5` plus `embeddedHarness.runtime: "codex"` | Forces the Codex app-server harness for that model ref. |
| Image generation or editing | `openai/gpt-image-2` | Works with either `OPENAI_API_KEY` or OpenAI Codex OAuth. |
| Transparent-background images | `openai/gpt-image-1.5` | Use `outputFormat=png` or `webp` and `openai.background=transparent`. |
| Goal | Use | Notes |
| --------------------------------------------- | ------------------------------------------------ | ---------------------------------------------------------------------------- |
| Direct API-key billing | `openai/gpt-5.5` | Set `OPENAI_API_KEY` or run OpenAI API-key onboarding. |
| GPT-5.5 with ChatGPT/Codex subscription auth | `openai-codex/gpt-5.5` | Default PI route for Codex OAuth. Best first choice for subscription setups. |
| GPT-5.5 with native Codex app-server behavior | `openai/gpt-5.5` plus `agentRuntime.id: "codex"` | Forces the Codex app-server harness for that model ref. |
| Image generation or editing | `openai/gpt-image-2` | Works with either `OPENAI_API_KEY` or OpenAI Codex OAuth. |
| Transparent-background images | `openai/gpt-image-1.5` | Use `outputFormat=png` or `webp` and `openai.background=transparent`. |
## Naming map
@@ -44,7 +44,7 @@ The names are similar but not interchangeable:
| `openai` | Provider prefix | Direct OpenAI Platform API route. |
| `openai-codex` | Provider prefix | OpenAI Codex OAuth/subscription route through the normal OpenClaw PI runner. |
| `codex` plugin | Plugin | Bundled OpenClaw plugin that provides native Codex app-server runtime and `/codex` chat controls. |
| `embeddedHarness.runtime: codex` | Agent runtime | Force the native Codex app-server harness for embedded turns. |
| `agentRuntime.id: codex` | Agent runtime | Force the native Codex app-server harness for embedded turns. |
| `/codex ...` | Chat command set | Bind/control Codex app-server threads from a conversation. |
| `runtime: "acp", agentId: "codex"` | ACP session route | Explicit fallback path that runs Codex through ACP/acpx. |
@@ -57,7 +57,7 @@ combination so you can confirm it is intentional; it does not rewrite it.
GPT-5.5 is available through both direct OpenAI Platform API-key access and
subscription/OAuth routes. Use `openai/gpt-5.5` for direct `OPENAI_API_KEY`
traffic, `openai-codex/gpt-5.5` for Codex OAuth through PI, or
`openai/gpt-5.5` with `embeddedHarness.runtime: "codex"` for the native Codex
`openai/gpt-5.5` with `agentRuntime.id: "codex"` for the native Codex
app-server harness.
</Note>
@@ -65,7 +65,7 @@ app-server harness.
Enabling the OpenAI plugin, or selecting an `openai-codex/*` model, does not
enable the bundled Codex app-server plugin. OpenClaw enables that plugin only
when you explicitly select the native Codex harness with
`embeddedHarness.runtime: "codex"` or use a legacy `codex/*` model ref.
`agentRuntime.id: "codex"` or use a legacy `codex/*` model ref.
If the bundled `codex` plugin is enabled but `openai-codex/*` still resolves
through PI, `openclaw doctor` warns and leaves the route unchanged.
</Note>
@@ -76,7 +76,7 @@ through PI, `openclaw doctor` warns and leaves the route unchanged.
| ------------------------- | ---------------------------------------------------------- | ------------------------------------------------------ |
| Chat / Responses | `openai/<model>` model provider | Yes |
| Codex subscription models | `openai-codex/<model>` with `openai-codex` OAuth | Yes |
| Codex app-server harness | `openai/<model>` with `embeddedHarness.runtime: codex` | Yes |
| Codex app-server harness | `openai/<model>` with `agentRuntime.id: codex` | Yes |
| Server-side web search | Native OpenAI Responses tool | Yes, when web search is enabled and no provider pinned |
| Images | `image_generate` | Yes |
| Videos | `video_generate` | Yes |
@@ -120,15 +120,15 @@ Choose your preferred auth method and follow the setup steps.
| Model ref | Runtime config | Route | Auth |
| ---------------------- | -------------------------- | --------------------------- | ---------------- |
| `openai/gpt-5.5` | omitted / `runtime: "pi"` | Direct OpenAI Platform API | `OPENAI_API_KEY` |
| `openai/gpt-5.4-mini` | omitted / `runtime: "pi"` | Direct OpenAI Platform API | `OPENAI_API_KEY` |
| `openai/gpt-5.5` | `runtime: "codex"` | Codex app-server harness | Codex app-server |
| `openai/gpt-5.5` | omitted / `agentRuntime.id: "pi"` | Direct OpenAI Platform API | `OPENAI_API_KEY` |
| `openai/gpt-5.4-mini` | omitted / `agentRuntime.id: "pi"` | Direct OpenAI Platform API | `OPENAI_API_KEY` |
| `openai/gpt-5.5` | `agentRuntime.id: "codex"` | Codex app-server harness | Codex app-server |
<Note>
`openai/*` is the direct OpenAI API-key route unless you explicitly force
the Codex app-server harness. Use `openai-codex/*` for Codex OAuth through
the default PI runner, or use `openai/gpt-5.5` with
`embeddedHarness.runtime: "codex"` for native Codex app-server execution.
`agentRuntime.id: "codex"` for native Codex app-server execution.
</Note>
### Config example
@@ -185,7 +185,7 @@ Choose your preferred auth method and follow the setup steps.
|-----------|----------------|-------|------|
| `openai-codex/gpt-5.5` | omitted / `runtime: "pi"` | ChatGPT/Codex OAuth through PI | Codex sign-in |
| `openai-codex/gpt-5.5` | `runtime: "auto"` | Still PI unless a plugin explicitly claims `openai-codex` | Codex sign-in |
| `openai/gpt-5.5` | `embeddedHarness.runtime: "codex"` | Codex app-server harness | Codex app-server auth |
| `openai/gpt-5.5` | `agentRuntime.id: "codex"` | Codex app-server harness | Codex app-server auth |
<Note>
Keep using the `openai-codex` provider id for auth/profile commands. The
@@ -211,7 +211,7 @@ Choose your preferred auth method and follow the setup steps.
The default PI harness appears as `Runtime: OpenClaw Pi Default`. When the
bundled Codex app-server harness is selected, `/status` shows
`Runtime: OpenAI Codex`. Existing sessions keep their recorded harness id, so use
`/new` or `/reset` after changing `embeddedHarness` if you want `/status` to
`/new` or `/reset` after changing `agentRuntime` if you want `/status` to
reflect a new PI/Codex choice.
### Doctor warning
@@ -220,7 +220,7 @@ Choose your preferred auth method and follow the setup steps.
`openai-codex/*` route is selected, `openclaw doctor` warns that the model
still resolves through PI. Keep the config unchanged when that is the
intended subscription-auth route. Switch to `openai/<model>` plus
`embeddedHarness.runtime: "codex"` only when you want native Codex
`agentRuntime.id: "codex"` only when you want native Codex
app-server execution.
### Context window cap
@@ -380,7 +380,7 @@ See [Video Generation](/tools/video-generation) for shared tool parameters, prov
OpenClaw adds a shared GPT-5 prompt contribution for GPT-5-family runs across providers. It applies by model id, so `openai-codex/gpt-5.5`, `openai/gpt-5.5`, `openrouter/openai/gpt-5.5`, `opencode/gpt-5.5`, and other compatible GPT-5 refs receive the same overlay. Older GPT-4.x models do not.
The bundled native Codex harness uses the same GPT-5 behavior and heartbeat overlay through Codex app-server developer instructions, so `openai/gpt-5.x` sessions forced through `embeddedHarness.runtime: "codex"` keep the same follow-through and proactive heartbeat guidance even though Codex owns the rest of the harness prompt.
The bundled native Codex harness uses the same GPT-5 behavior and heartbeat overlay through Codex app-server developer instructions, so `openai/gpt-5.x` sessions forced through `agentRuntime.id: "codex"` keep the same follow-through and proactive heartbeat guidance even though Codex owns the rest of the harness prompt.
The GPT-5 contribution adds a tagged behavior contract for persona persistence, execution safety, tool discipline, output shape, completion checks, and verification. Channel-specific reply and silent-message behavior stays in the shared OpenClaw system prompt and outbound delivery policy. The GPT-5 guidance is always enabled for matching models. The friendly interaction-style layer is separate and configurable.
@@ -766,7 +766,7 @@ the Server-side compaction accordion below.
- Injects `context_management: [{ type: "compaction", compact_threshold: ... }]`
- Default `compact_threshold`: 70% of `contextWindow` (or `80000` when unavailable)
This applies to the built-in Pi harness path and to OpenAI provider hooks used by embedded runs. The native Codex app-server harness manages its own context through Codex and is configured separately with `agents.defaults.embeddedHarness.runtime`.
This applies to the built-in Pi harness path and to OpenAI provider hooks used by embedded runs. The native Codex app-server harness manages its own context through Codex and is configured separately with `agents.defaults.agentRuntime.id`.
<Tabs>
<Tab title="Enable explicitly">

View File

@@ -1,6 +1,7 @@
---
summary: "All configuration knobs for memory search, embedding providers, QMD, hybrid search, and multimodal indexing"
title: "Memory configuration reference"
sidebarTitle: "Memory config"
read_when:
- You want to configure memory search providers or embedding models
- You want to set up the QMD backend
@@ -8,28 +9,38 @@ read_when:
- You want to enable multimodal memory indexing
---
This page lists every configuration knob for OpenClaw memory search. For
conceptual overviews, see:
This page lists every configuration knob for OpenClaw memory search. For conceptual overviews, see:
- [Memory Overview](/concepts/memory) -- how memory works
- [Builtin Engine](/concepts/memory-builtin) -- default SQLite backend
- [QMD Engine](/concepts/memory-qmd) -- local-first sidecar
- [Memory Search](/concepts/memory-search) -- search pipeline and tuning
- [Active Memory](/concepts/active-memory) -- enabling the memory sub-agent for interactive sessions
<CardGroup cols={2}>
<Card title="Memory overview" href="/concepts/memory">
How memory works.
</Card>
<Card title="Builtin engine" href="/concepts/memory-builtin">
Default SQLite backend.
</Card>
<Card title="QMD engine" href="/concepts/memory-qmd">
Local-first sidecar.
</Card>
<Card title="Memory search" href="/concepts/memory-search">
Search pipeline and tuning.
</Card>
<Card title="Active memory" href="/concepts/active-memory">
Memory sub-agent for interactive sessions.
</Card>
</CardGroup>
All memory search settings live under `agents.defaults.memorySearch` in
`openclaw.json` unless noted otherwise.
All memory search settings live under `agents.defaults.memorySearch` in `openclaw.json` unless noted otherwise.
If you are looking for the **active memory** feature toggle and sub-agent config,
that lives under `plugins.entries.active-memory` instead of `memorySearch`.
<Note>
If you are looking for the **active memory** feature toggle and sub-agent config, that lives under `plugins.entries.active-memory` instead of `memorySearch`.
Active memory uses a two-gate model:
1. the plugin must be enabled and target the current agent id
2. the request must be an eligible interactive persistent chat session
See [Active Memory](/concepts/active-memory) for the activation model,
plugin-owned config, transcript persistence, and safe rollout pattern.
See [Active Memory](/concepts/active-memory) for the activation model, plugin-owned config, transcript persistence, and safe rollout pattern.
</Note>
---
@@ -46,20 +57,35 @@ plugin-owned config, transcript persistence, and safe rollout pattern.
When `provider` is not set, OpenClaw selects the first available:
1. `local` -- if `memorySearch.local.modelPath` is configured and the file exists.
2. `github-copilot` -- if a GitHub Copilot token can be resolved (env var or auth profile).
3. `openai` -- if an OpenAI key can be resolved.
4. `gemini` -- if a Gemini key can be resolved.
5. `voyage` -- if a Voyage key can be resolved.
6. `mistral` -- if a Mistral key can be resolved.
7. `bedrock` -- if the AWS SDK credential chain resolves (instance role, access keys, profile, SSO, web identity, or shared config).
<Steps>
<Step title="local">
Selected if `memorySearch.local.modelPath` is configured and the file exists.
</Step>
<Step title="github-copilot">
Selected if a GitHub Copilot token can be resolved (env var or auth profile).
</Step>
<Step title="openai">
Selected if an OpenAI key can be resolved.
</Step>
<Step title="gemini">
Selected if a Gemini key can be resolved.
</Step>
<Step title="voyage">
Selected if a Voyage key can be resolved.
</Step>
<Step title="mistral">
Selected if a Mistral key can be resolved.
</Step>
<Step title="bedrock">
Selected if the AWS SDK credential chain resolves (instance role, access keys, profile, SSO, web identity, or shared config).
</Step>
</Steps>
`ollama` is supported but not auto-detected (set it explicitly).
### API key resolution
Remote embeddings require an API key. Bedrock uses the AWS SDK default
credential chain instead (instance roles, SSO, access keys).
Remote embeddings require an API key. Bedrock uses the AWS SDK default credential chain instead (instance roles, SSO, access keys).
| Provider | Env var | Config key |
| -------------- | -------------------------------------------------- | --------------------------------- |
@@ -71,8 +97,9 @@ credential chain instead (instance roles, SSO, access keys).
| OpenAI | `OPENAI_API_KEY` | `models.providers.openai.apiKey` |
| Voyage | `VOYAGE_API_KEY` | `models.providers.voyage.apiKey` |
Codex OAuth covers chat/completions only and does not satisfy embedding
requests.
<Note>
Codex OAuth covers chat/completions only and does not satisfy embedding requests.
</Note>
---
@@ -80,11 +107,15 @@ requests.
For custom OpenAI-compatible endpoints or overriding provider defaults:
| Key | Type | Description |
| ---------------- | -------- | -------------------------------------------------- |
| `remote.baseUrl` | `string` | Custom API base URL |
| `remote.apiKey` | `string` | Override API key |
| `remote.headers` | `object` | Extra HTTP headers (merged with provider defaults) |
<ParamField path="remote.baseUrl" type="string">
Custom API base URL.
</ParamField>
<ParamField path="remote.apiKey" type="string">
Override API key.
</ParamField>
<ParamField path="remote.headers" type="object">
Extra HTTP headers (merged with provider defaults).
</ParamField>
```json5
{
@@ -105,130 +136,113 @@ For custom OpenAI-compatible endpoints or overriding provider defaults:
---
## Gemini-specific config
## Provider-specific config
| Key | Type | Default | Description |
| ---------------------- | -------- | ---------------------- | ------------------------------------------ |
| `model` | `string` | `gemini-embedding-001` | Also supports `gemini-embedding-2-preview` |
| `outputDimensionality` | `number` | `3072` | For Embedding 2: 768, 1536, or 3072 |
<AccordionGroup>
<Accordion title="Gemini">
| Key | Type | Default | Description |
| ---------------------- | -------- | ---------------------- | ------------------------------------------ |
| `model` | `string` | `gemini-embedding-001` | Also supports `gemini-embedding-2-preview` |
| `outputDimensionality` | `number` | `3072` | For Embedding 2: 768, 1536, or 3072 |
<Warning>
Changing model or `outputDimensionality` triggers an automatic full reindex.
</Warning>
<Warning>
Changing model or `outputDimensionality` triggers an automatic full reindex.
</Warning>
---
</Accordion>
<Accordion title="Bedrock">
Bedrock uses the AWS SDK default credential chain — no API keys needed. If OpenClaw runs on EC2 with a Bedrock-enabled instance role, just set the provider and model:
## Bedrock embedding config
Bedrock uses the AWS SDK default credential chain -- no API keys needed.
If OpenClaw runs on EC2 with a Bedrock-enabled instance role, just set the
provider and model:
```json5
{
agents: {
defaults: {
memorySearch: {
provider: "bedrock",
model: "amazon.titan-embed-text-v2:0",
```json5
{
agents: {
defaults: {
memorySearch: {
provider: "bedrock",
model: "amazon.titan-embed-text-v2:0",
},
},
},
},
},
}
```
}
```
| Key | Type | Default | Description |
| ---------------------- | -------- | ------------------------------ | ------------------------------- |
| `model` | `string` | `amazon.titan-embed-text-v2:0` | Any Bedrock embedding model ID |
| `outputDimensionality` | `number` | model default | For Titan V2: 256, 512, or 1024 |
| Key | Type | Default | Description |
| ---------------------- | -------- | ------------------------------ | ------------------------------- |
| `model` | `string` | `amazon.titan-embed-text-v2:0` | Any Bedrock embedding model ID |
| `outputDimensionality` | `number` | model default | For Titan V2: 256, 512, or 1024 |
### Supported models
**Supported models** (with family detection and dimension defaults):
The following models are supported (with family detection and dimension
defaults):
| Model ID | Provider | Default Dims | Configurable Dims |
| ------------------------------------------ | ---------- | ------------ | -------------------- |
| `amazon.titan-embed-text-v2:0` | Amazon | 1024 | 256, 512, 1024 |
| `amazon.titan-embed-text-v1` | Amazon | 1536 | -- |
| `amazon.titan-embed-g1-text-02` | Amazon | 1536 | -- |
| `amazon.titan-embed-image-v1` | Amazon | 1024 | -- |
| `amazon.nova-2-multimodal-embeddings-v1:0` | Amazon | 1024 | 256, 384, 1024, 3072 |
| `cohere.embed-english-v3` | Cohere | 1024 | -- |
| `cohere.embed-multilingual-v3` | Cohere | 1024 | -- |
| `cohere.embed-v4:0` | Cohere | 1536 | 256-1536 |
| `twelvelabs.marengo-embed-3-0-v1:0` | TwelveLabs | 512 | -- |
| `twelvelabs.marengo-embed-2-7-v1:0` | TwelveLabs | 1024 | -- |
| Model ID | Provider | Default Dims | Configurable Dims |
| ------------------------------------------ | ---------- | ------------ | -------------------- |
| `amazon.titan-embed-text-v2:0` | Amazon | 1024 | 256, 512, 1024 |
| `amazon.titan-embed-text-v1` | Amazon | 1536 | -- |
| `amazon.titan-embed-g1-text-02` | Amazon | 1536 | -- |
| `amazon.titan-embed-image-v1` | Amazon | 1024 | -- |
| `amazon.nova-2-multimodal-embeddings-v1:0` | Amazon | 1024 | 256, 384, 1024, 3072 |
| `cohere.embed-english-v3` | Cohere | 1024 | -- |
| `cohere.embed-multilingual-v3` | Cohere | 1024 | -- |
| `cohere.embed-v4:0` | Cohere | 1536 | 256-1536 |
| `twelvelabs.marengo-embed-3-0-v1:0` | TwelveLabs | 512 | -- |
| `twelvelabs.marengo-embed-2-7-v1:0` | TwelveLabs | 1024 | -- |
Throughput-suffixed variants (e.g., `amazon.titan-embed-text-v1:2:8k`) inherit the base model's configuration.
Throughput-suffixed variants (e.g., `amazon.titan-embed-text-v1:2:8k`) inherit
the base model's configuration.
**Authentication:** Bedrock auth uses the standard AWS SDK credential resolution order:
### Authentication
1. Environment variables (`AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY`)
2. SSO token cache
3. Web identity token credentials
4. Shared credentials and config files
5. ECS or EC2 metadata credentials
Bedrock auth uses the standard AWS SDK credential resolution order:
Region is resolved from `AWS_REGION`, `AWS_DEFAULT_REGION`, the `amazon-bedrock` provider `baseUrl`, or defaults to `us-east-1`.
1. Environment variables (`AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY`)
2. SSO token cache
3. Web identity token credentials
4. Shared credentials and config files
5. ECS or EC2 metadata credentials
**IAM permissions:** the IAM role or user needs:
Region is resolved from `AWS_REGION`, `AWS_DEFAULT_REGION`, the
`amazon-bedrock` provider `baseUrl`, or defaults to `us-east-1`.
```json
{
"Effect": "Allow",
"Action": "bedrock:InvokeModel",
"Resource": "*"
}
```
### IAM permissions
For least-privilege, scope `InvokeModel` to the specific model:
The IAM role or user needs:
```
arn:aws:bedrock:*::foundation-model/amazon.titan-embed-text-v2:0
```
```json
{
"Effect": "Allow",
"Action": "bedrock:InvokeModel",
"Resource": "*"
}
```
</Accordion>
<Accordion title="Local (GGUF + node-llama-cpp)">
| Key | Type | Default | Description |
| --------------------- | ------------------ | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `local.modelPath` | `string` | auto-downloaded | Path to GGUF model file |
| `local.modelCacheDir` | `string` | node-llama-cpp default | Cache dir for downloaded models |
| `local.contextSize` | `number \| "auto"` | `4096` | Context window size for the embedding context. 4096 covers typical chunks (128512 tokens) while bounding non-weight VRAM. Lower to 10242048 on constrained hosts. `"auto"` uses the model's trained maximum — not recommended for 8B+ models (Qwen3-Embedding-8B: 40 960 tokens → ~32 GB VRAM vs ~8.8 GB at 4096). |
For least-privilege, scope `InvokeModel` to the specific model:
Default model: `embeddinggemma-300m-qat-Q8_0.gguf` (~0.6 GB, auto-downloaded). Requires native build: `pnpm approve-builds` then `pnpm rebuild node-llama-cpp`.
```
arn:aws:bedrock:*::foundation-model/amazon.titan-embed-text-v2:0
```
Use the standalone CLI to verify the same provider path the Gateway uses:
---
```bash
openclaw memory status --deep --agent main
openclaw memory index --force --agent main
```
## Local embedding config
If `provider` is `auto`, `local` is selected only when `local.modelPath` points to an existing local file. `hf:` and HTTP(S) model references can still be used explicitly with `provider: "local"`, but they do not make `auto` select local before the model is available on disk.
| Key | Type | Default | Description |
| --------------------- | ------------------ | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `local.modelPath` | `string` | auto-downloaded | Path to GGUF model file |
| `local.modelCacheDir` | `string` | node-llama-cpp default | Cache dir for downloaded models |
| `local.contextSize` | `number \| "auto"` | `4096` | Context window size for the embedding context. 4096 covers typical chunks (128512 tokens) while bounding non-weight VRAM. Lower to 10242048 on constrained hosts. `"auto"` uses the model's trained maximum — not recommended for 8B+ models (Qwen3-Embedding-8B: 40 960 tokens → ~32 GB VRAM vs ~8.8 GB at 4096). |
Default model: `embeddinggemma-300m-qat-Q8_0.gguf` (~0.6 GB, auto-downloaded).
Requires native build: `pnpm approve-builds` then `pnpm rebuild node-llama-cpp`.
Use the standalone CLI to verify the same provider path the Gateway uses:
```bash
openclaw memory status --deep --agent main
openclaw memory index --force --agent main
```
If `provider` is `auto`, `local` is selected only when `local.modelPath` points
to an existing local file. `hf:` and HTTP(S) model references can still be used
explicitly with `provider: "local"`, but they do not make `auto` select local
before the model is available on disk.
</Accordion>
</AccordionGroup>
### Inline embedding timeout
| Key | Type | Default | Description |
| ----------------------------------- | -------- | ---------------- | ------------------------------------------------------------------------ |
| `sync.embeddingBatchTimeoutSeconds` | `number` | provider default | Override the timeout for inline embedding batches during memory indexing |
<ParamField path="sync.embeddingBatchTimeoutSeconds" type="number">
Override the timeout for inline embedding batches during memory indexing.
Unset uses the provider default: 600 seconds for local/self-hosted providers
such as `local`, `ollama`, and `lmstudio`, and 120 seconds for hosted providers.
Increase this when local CPU-bound embedding batches are healthy but slow.
Unset uses the provider default: 600 seconds for local/self-hosted providers such as `local`, `ollama`, and `lmstudio`, and 120 seconds for hosted providers. Increase this when local CPU-bound embedding batches are healthy but slow.
</ParamField>
---
@@ -243,21 +257,23 @@ All under `memorySearch.query.hybrid`:
| `textWeight` | `number` | `0.3` | Weight for BM25 scores (0-1) |
| `candidateMultiplier` | `number` | `4` | Candidate pool size multiplier |
### MMR (diversity)
<Tabs>
<Tab title="MMR (diversity)">
| Key | Type | Default | Description |
| ------------- | --------- | ------- | ------------------------------------ |
| `mmr.enabled` | `boolean` | `false` | Enable MMR re-ranking |
| `mmr.lambda` | `number` | `0.7` | 0 = max diversity, 1 = max relevance |
</Tab>
<Tab title="Temporal decay (recency)">
| Key | Type | Default | Description |
| ---------------------------- | --------- | ------- | ------------------------- |
| `temporalDecay.enabled` | `boolean` | `false` | Enable recency boost |
| `temporalDecay.halfLifeDays` | `number` | `30` | Score halves every N days |
| Key | Type | Default | Description |
| ------------- | --------- | ------- | ------------------------------------ |
| `mmr.enabled` | `boolean` | `false` | Enable MMR re-ranking |
| `mmr.lambda` | `number` | `0.7` | 0 = max diversity, 1 = max relevance |
Evergreen files (`MEMORY.md`, non-dated files in `memory/`) are never decayed.
### Temporal decay (recency)
| Key | Type | Default | Description |
| ---------------------------- | --------- | ------- | ------------------------- |
| `temporalDecay.enabled` | `boolean` | `false` | Enable recency boost |
| `temporalDecay.halfLifeDays` | `number` | `30` | Score halves every N days |
Evergreen files (`MEMORY.md`, non-dated files in `memory/`) are never decayed.
</Tab>
</Tabs>
### Full example
@@ -300,19 +316,9 @@ Evergreen files (`MEMORY.md`, non-dated files in `memory/`) are never decayed.
}
```
Paths can be absolute or workspace-relative. Directories are scanned
recursively for `.md` files. Symlink handling depends on the active backend:
the builtin engine ignores symlinks, while QMD follows the underlying QMD
scanner behavior.
Paths can be absolute or workspace-relative. Directories are scanned recursively for `.md` files. Symlink handling depends on the active backend: the builtin engine ignores symlinks, while QMD follows the underlying QMD scanner behavior.
For agent-scoped cross-agent transcript search, use
`agents.list[].memorySearch.qmd.extraCollections` instead of `memory.qmd.paths`.
Those extra collections follow the same `{ path, name, pattern? }` shape, but
they are merged per agent and can preserve explicit shared names when the path
points outside the current workspace.
If the same resolved path appears in both `memory.qmd.paths` and
`memorySearch.qmd.extraCollections`, QMD keeps the first entry and skips the
duplicate.
For agent-scoped cross-agent transcript search, use `agents.list[].memorySearch.qmd.extraCollections` instead of `memory.qmd.paths`. Those extra collections follow the same `{ path, name, pattern? }` shape, but they are merged per agent and can preserve explicit shared names when the path points outside the current workspace. If the same resolved path appears in both `memory.qmd.paths` and `memorySearch.qmd.extraCollections`, QMD keeps the first entry and skips the duplicate.
---
@@ -326,11 +332,11 @@ Index images and audio alongside Markdown using Gemini Embedding 2:
| `multimodal.modalities` | `string[]` | -- | `["image"]`, `["audio"]`, or `["all"]` |
| `multimodal.maxFileBytes` | `number` | `10000000` | Max file size for indexing |
Only applies to files in `extraPaths`. Default memory roots stay Markdown-only.
Requires `gemini-embedding-2-preview`. `fallback` must be `"none"`.
<Note>
Only applies to files in `extraPaths`. Default memory roots stay Markdown-only. Requires `gemini-embedding-2-preview`. `fallback` must be `"none"`.
</Note>
Supported formats: `.jpg`, `.jpeg`, `.png`, `.webp`, `.gif`, `.heic`, `.heif`
(images); `.mp3`, `.wav`, `.ogg`, `.opus`, `.m4a`, `.aac`, `.flac` (audio).
Supported formats: `.jpg`, `.jpeg`, `.png`, `.webp`, `.gif`, `.heic`, `.heif` (images); `.mp3`, `.wav`, `.ogg`, `.opus`, `.m4a`, `.aac`, `.flac` (audio).
---
@@ -355,12 +361,9 @@ Prevents re-embedding unchanged text during reindex or transcript updates.
| `remote.batch.pollIntervalMs` | `number` | -- | Poll interval |
| `remote.batch.timeoutMinutes` | `number` | -- | Batch timeout |
Available for `openai`, `gemini`, and `voyage`. OpenAI batch is typically
fastest and cheapest for large backfills.
Available for `openai`, `gemini`, and `voyage`. OpenAI batch is typically fastest and cheapest for large backfills.
This is separate from `sync.embeddingBatchTimeoutSeconds`, which controls inline
embedding calls used by local/self-hosted providers and hosted providers when
provider batch APIs are not active.
This is separate from `sync.embeddingBatchTimeoutSeconds`, which controls inline embedding calls used by local/self-hosted providers and hosted providers when provider batch APIs are not active.
---
@@ -375,9 +378,9 @@ Index session transcripts and surface them via `memory_search`:
| `sync.sessions.deltaBytes` | `number` | `100000` | Byte threshold for reindex |
| `sync.sessions.deltaMessages` | `number` | `50` | Message threshold for reindex |
Session indexing is opt-in and runs asynchronously. Results can be slightly
stale. Session logs live on disk, so treat filesystem access as the trust
boundary.
<Warning>
Session indexing is opt-in and runs asynchronously. Results can be slightly stale. Session logs live on disk, so treat filesystem access as the trust boundary.
</Warning>
---
@@ -388,8 +391,7 @@ boundary.
| `store.vector.enabled` | `boolean` | `true` | Use sqlite-vec for vector queries |
| `store.vector.extensionPath` | `string` | bundled | Override sqlite-vec path |
When sqlite-vec is unavailable, OpenClaw falls back to in-process cosine
similarity automatically.
When sqlite-vec is unavailable, OpenClaw falls back to in-process cosine similarity automatically.
---
@@ -404,8 +406,7 @@ similarity automatically.
## QMD backend config
Set `memory.backend = "qmd"` to enable. All QMD settings live under
`memory.qmd`:
Set `memory.backend = "qmd"` to enable. All QMD settings live under `memory.qmd`:
| Key | Type | Default | Description |
| ------------------------ | --------- | -------- | -------------------------------------------- |
@@ -417,70 +418,65 @@ Set `memory.backend = "qmd"` to enable. All QMD settings live under
| `sessions.retentionDays` | `number` | -- | Transcript retention |
| `sessions.exportDir` | `string` | -- | Export directory |
OpenClaw prefers the current QMD collection and MCP query shapes, but keeps
older QMD releases working by falling back to legacy `--mask` collection flags
and older MCP tool names when needed.
OpenClaw prefers the current QMD collection and MCP query shapes, but keeps older QMD releases working by falling back to legacy `--mask` collection flags and older MCP tool names when needed.
QMD model overrides stay on the QMD side, not OpenClaw config. If you need to
override QMD's models globally, set environment variables such as
`QMD_EMBED_MODEL`, `QMD_RERANK_MODEL`, and `QMD_GENERATE_MODEL` in the gateway
runtime environment.
<Note>
QMD model overrides stay on the QMD side, not OpenClaw config. If you need to override QMD's models globally, set environment variables such as `QMD_EMBED_MODEL`, `QMD_RERANK_MODEL`, and `QMD_GENERATE_MODEL` in the gateway runtime environment.
</Note>
### Update schedule
<AccordionGroup>
<Accordion title="Update schedule">
| Key | Type | Default | Description |
| ------------------------- | --------- | ------- | ------------------------------------- |
| `update.interval` | `string` | `5m` | Refresh interval |
| `update.debounceMs` | `number` | `15000` | Debounce file changes |
| `update.onBoot` | `boolean` | `true` | Refresh on startup |
| `update.waitForBootSync` | `boolean` | `false` | Block startup until refresh completes |
| `update.embedInterval` | `string` | -- | Separate embed cadence |
| `update.commandTimeoutMs` | `number` | -- | Timeout for QMD commands |
| `update.updateTimeoutMs` | `number` | -- | Timeout for QMD update operations |
| `update.embedTimeoutMs` | `number` | -- | Timeout for QMD embed operations |
</Accordion>
<Accordion title="Limits">
| Key | Type | Default | Description |
| ------------------------- | -------- | ------- | -------------------------- |
| `limits.maxResults` | `number` | `6` | Max search results |
| `limits.maxSnippetChars` | `number` | -- | Clamp snippet length |
| `limits.maxInjectedChars` | `number` | -- | Clamp total injected chars |
| `limits.timeoutMs` | `number` | `4000` | Search timeout |
</Accordion>
<Accordion title="Scope">
Controls which sessions can receive QMD search results. Same schema as [`session.sendPolicy`](/gateway/config-agents#session):
| Key | Type | Default | Description |
| ------------------------- | --------- | ------- | ------------------------------------- |
| `update.interval` | `string` | `5m` | Refresh interval |
| `update.debounceMs` | `number` | `15000` | Debounce file changes |
| `update.onBoot` | `boolean` | `true` | Refresh on startup |
| `update.waitForBootSync` | `boolean` | `false` | Block startup until refresh completes |
| `update.embedInterval` | `string` | -- | Separate embed cadence |
| `update.commandTimeoutMs` | `number` | -- | Timeout for QMD commands |
| `update.updateTimeoutMs` | `number` | -- | Timeout for QMD update operations |
| `update.embedTimeoutMs` | `number` | -- | Timeout for QMD embed operations |
### Limits
| Key | Type | Default | Description |
| ------------------------- | -------- | ------- | -------------------------- |
| `limits.maxResults` | `number` | `6` | Max search results |
| `limits.maxSnippetChars` | `number` | -- | Clamp snippet length |
| `limits.maxInjectedChars` | `number` | -- | Clamp total injected chars |
| `limits.timeoutMs` | `number` | `4000` | Search timeout |
### Scope
Controls which sessions can receive QMD search results. Same schema as
[`session.sendPolicy`](/gateway/config-agents#session):
```json5
{
memory: {
qmd: {
scope: {
default: "deny",
rules: [{ action: "allow", match: { chatType: "direct" } }],
```json5
{
memory: {
qmd: {
scope: {
default: "deny",
rules: [{ action: "allow", match: { chatType: "direct" } }],
},
},
},
},
},
}
```
}
```
The shipped default allows direct and channel sessions, while still denying
groups.
The shipped default allows direct and channel sessions, while still denying groups.
Default is DM-only. `match.keyPrefix` matches the normalized session key;
`match.rawKeyPrefix` matches the raw key including `agent:<id>:`.
Default is DM-only. `match.keyPrefix` matches the normalized session key; `match.rawKeyPrefix` matches the raw key including `agent:<id>:`.
### Citations
</Accordion>
<Accordion title="Citations">
`memory.citations` applies to all backends:
`memory.citations` applies to all backends:
| Value | Behavior |
| ---------------- | --------------------------------------------------- |
| `auto` (default) | Include `Source: <path#line>` footer in snippets |
| `on` | Always include footer |
| `off` | Omit footer (path still passed to agent internally) |
| Value | Behavior |
| ---------------- | --------------------------------------------------- |
| `auto` (default) | Include `Source: <path#line>` footer in snippets |
| `on` | Always include footer |
| `off` | Omit footer (path still passed to agent internally) |
</Accordion>
</AccordionGroup>
### Full QMD example
@@ -507,11 +503,9 @@ Default is DM-only. `match.keyPrefix` matches the normalized session key;
## Dreaming
Dreaming is configured under `plugins.entries.memory-core.config.dreaming`,
not under `agents.defaults.memorySearch`.
Dreaming is configured under `plugins.entries.memory-core.config.dreaming`, not under `agents.defaults.memorySearch`.
Dreaming runs as one scheduled sweep and uses internal light/deep/REM phases as
an implementation detail.
Dreaming runs as one scheduled sweep and uses internal light/deep/REM phases as an implementation detail.
For conceptual behavior and slash commands, see [Dreaming](/concepts/dreaming).
@@ -541,14 +535,14 @@ For conceptual behavior and slash commands, see [Dreaming](/concepts/dreaming).
}
```
Notes:
<Note>
- Dreaming writes machine state to `memory/.dreams/`.
- Dreaming writes human-readable narrative output to `DREAMS.md` (or existing `dreams.md`).
- The light/deep/REM phase policy and thresholds are internal behavior, not user-facing config.
</Note>
## Related
- [Configuration reference](/gateway/configuration-reference)
- [Memory overview](/concepts/memory)
- [Memory search](/concepts/memory-search)
- [Configuration reference](/gateway/configuration-reference)

View File

@@ -20,7 +20,7 @@ Codex has two OpenClaw routes:
| Route | Config/command | Setup page |
| -------------------------- | ------------------------------------------------------ | --------------------------------------- |
| Native Codex app-server | `/codex ...`, `embeddedHarness.runtime: "codex"` | [Codex harness](/plugins/codex-harness) |
| Native Codex app-server | `/codex ...`, `agentRuntime.id: "codex"` | [Codex harness](/plugins/codex-harness) |
| Explicit Codex ACP adapter | `/acp spawn codex`, `runtime: "acp", agentId: "codex"` | This page |
Prefer the native route unless you explicitly need ACP/acpx behavior.

View File

@@ -20,7 +20,7 @@ Each ACP session spawn is tracked as a [background task](/automation/tasks).
<Note>
**ACP is the external-harness path, not the default Codex path.** The
native Codex app-server plugin owns `/codex ...` controls and the
`embeddedHarness.runtime: "codex"` embedded runtime; ACP owns
`agentRuntime.id: "codex"` embedded runtime; ACP owns
`/acp ...` controls and `sessions_spawn({ runtime: "acp" })` sessions.
If you want Codex or Claude Code to connect as an external MCP client
@@ -172,7 +172,7 @@ Quick `/acp` flow from chat:
</Accordion>
<Accordion title="Model / provider / runtime selection cheat sheet">
- `openai-codex/*` — PI Codex OAuth/subscription route.
- `openai/*` plus `embeddedHarness.runtime: "codex"` — native Codex app-server embedded runtime.
- `openai/*` plus `agentRuntime.id: "codex"` — native Codex app-server embedded runtime.
- `/codex ...` — native Codex conversation control.
- `/acp ...` or `runtime: "acp"` — explicit ACP/acpx control.
</Accordion>

View File

@@ -212,7 +212,7 @@ OpenClaw scans for plugins in this order (first match wins):
runtime
- OpenAI-family Codex routes keep separate plugin boundaries:
`openai-codex/*` belongs to the OpenAI plugin, while the bundled Codex
app-server plugin is selected by `embeddedHarness.runtime: "codex"` or legacy
app-server plugin is selected by `agentRuntime.id: "codex"` or legacy
`codex/*` model refs
## Troubleshooting runtime hooks

View File

@@ -403,8 +403,41 @@ Precedence order for automatic replies, `/tts audio`, `/tts status`, and the
1. `messages.tts`
2. active `agents.list[].tts`
3. local `/tts` preferences for this host
4. inline `[[tts:...]]` directives when [model overrides](#model-driven-directives) are enabled
3. channel override, when the channel supports `channels.<channel>.tts`
4. account override, when the channel passes `channels.<channel>.accounts.<id>.tts`
5. local `/tts` preferences for this host
6. inline `[[tts:...]]` directives when [model overrides](#model-driven-directives) are enabled
Channel and account overrides use the same shape as `messages.tts` and
deep-merge over the earlier layers, so shared provider credentials can stay in
`messages.tts` while a channel or bot account changes only voice, model, persona,
or auto mode:
```json5
{
messages: {
tts: {
provider: "openai",
providers: {
openai: { apiKey: "${OPENAI_API_KEY}", model: "gpt-4o-mini-tts" },
},
},
},
channels: {
feishu: {
accounts: {
english: {
tts: {
providers: {
openai: { voice: "shimmer" },
},
},
},
},
},
},
}
```
## Personas

View File

@@ -4,6 +4,7 @@ read_when:
- You want to operate the Gateway from a browser
- You want Tailnet access without SSH tunnels
title: "Control UI"
sidebarTitle: "Control UI"
---
The Control UI is a small **Vite + Lit** single-page app served by the Gateway:
@@ -28,80 +29,52 @@ Auth is supplied during the WebSocket handshake via:
- Tailscale Serve identity headers when `gateway.auth.allowTailscale: true`
- trusted-proxy identity headers when `gateway.auth.mode: "trusted-proxy"`
The dashboard settings panel keeps a token for the current browser tab session
and selected gateway URL; passwords are not persisted. Onboarding usually
generates a gateway token for shared-secret auth on first connect, but password
auth works too when `gateway.auth.mode` is `"password"`.
The dashboard settings panel keeps a token for the current browser tab session and selected gateway URL; passwords are not persisted. Onboarding usually generates a gateway token for shared-secret auth on first connect, but password auth works too when `gateway.auth.mode` is `"password"`.
## Device pairing (first connection)
When you connect to the Control UI from a new browser or device, the Gateway
requires a **one-time pairing approval** — even if you're on the same Tailnet
with `gateway.auth.allowTailscale: true`. This is a security measure to prevent
unauthorized access.
When you connect to the Control UI from a new browser or device, the Gateway requires a **one-time pairing approval** — even if you're on the same Tailnet with `gateway.auth.allowTailscale: true`. This is a security measure to prevent unauthorized access.
**What you'll see:** "disconnected (1008): pairing required"
**To approve the device:**
<Steps>
<Step title="List pending requests">
```bash
openclaw devices list
```
</Step>
<Step title="Approve by request ID">
```bash
openclaw devices approve <requestId>
```
</Step>
</Steps>
```bash
# List pending requests
openclaw devices list
If the browser retries pairing with changed auth details (role/scopes/public key), the previous pending request is superseded and a new `requestId` is created. Re-run `openclaw devices list` before approval.
# Approve by request ID
openclaw devices approve <requestId>
```
If the browser is already paired and you change it from read access to write/admin access, this is treated as an approval upgrade, not a silent reconnect. OpenClaw keeps the old approval active, blocks the broader reconnect, and asks you to approve the new scope set explicitly.
If the browser retries pairing with changed auth details (role/scopes/public
key), the previous pending request is superseded and a new `requestId` is
created. Re-run `openclaw devices list` before approval.
Once approved, the device is remembered and won't require re-approval unless you revoke it with `openclaw devices revoke --device <id> --role <role>`. See [Devices CLI](/cli/devices) for token rotation and revocation.
If the browser is already paired and you change it from read access to
write/admin access, this is treated as an approval upgrade, not a silent
reconnect. OpenClaw keeps the old approval active, blocks the broader reconnect,
and asks you to approve the new scope set explicitly.
Once approved, the device is remembered and won't require re-approval unless
you revoke it with `openclaw devices revoke --device <id> --role <role>`. See
[Devices CLI](/cli/devices) for token rotation and revocation.
**Notes:**
- Direct local loopback browser connections (`127.0.0.1` / `localhost`) are
auto-approved.
- Tailnet and LAN browser connects still require explicit approval, even when
they originate from the same machine.
- Each browser profile generates a unique device ID, so switching browsers or
clearing browser data will require re-pairing.
<Note>
- Direct local loopback browser connections (`127.0.0.1` / `localhost`) are auto-approved.
- Tailnet and LAN browser connects still require explicit approval, even when they originate from the same machine.
- Each browser profile generates a unique device ID, so switching browsers or clearing browser data will require re-pairing.
</Note>
## Personal identity (browser-local)
The Control UI supports a per-browser personal identity (display name and
avatar) attached to outgoing messages for attribution in shared sessions. It
lives in browser storage, is scoped to the current browser profile, and is not
synced to other devices or persisted server-side beyond the normal transcript
authorship metadata on messages you actually send. Clearing site data or
switching browsers resets it to empty.
The Control UI supports a per-browser personal identity (display name and avatar) attached to outgoing messages for attribution in shared sessions. It lives in browser storage, is scoped to the current browser profile, and is not synced to other devices or persisted server-side beyond the normal transcript authorship metadata on messages you actually send. Clearing site data or switching browsers resets it to empty.
The same browser-local pattern applies to the assistant avatar override.
Uploaded assistant avatars overlay the gateway-resolved identity on the local
browser only and never round-trip through `config.patch`. The shared
`ui.assistant.avatar` config field is still available for non-UI clients
writing the field directly (such as scripted gateways or custom dashboards).
The same browser-local pattern applies to the assistant avatar override. Uploaded assistant avatars overlay the gateway-resolved identity on the local browser only and never round-trip through `config.patch`. The shared `ui.assistant.avatar` config field is still available for non-UI clients writing the field directly (such as scripted gateways or custom dashboards).
## Runtime config endpoint
The Control UI fetches its runtime settings from
`/__openclaw/control-ui-config.json`. That endpoint is gated by the same
gateway auth as the rest of the HTTP surface: unauthenticated browsers cannot
fetch it, and a successful fetch requires either an already valid gateway
token/password, Tailscale Serve identity, or a trusted-proxy identity.
The Control UI fetches its runtime settings from `/__openclaw/control-ui-config.json`. That endpoint is gated by the same gateway auth as the rest of the HTTP surface: unauthenticated browsers cannot fetch it, and a successful fetch requires either an already valid gateway token/password, Tailscale Serve identity, or a trusted-proxy identity.
## Language support
The Control UI can localize itself on first load based on your browser locale.
To override it later, open **Overview -> Gateway Access -> Language**. The
locale picker lives in the Gateway Access card, not under Appearance.
The Control UI can localize itself on first load based on your browser locale. To override it later, open **Overview -> Gateway Access -> Language**. The locale picker lives in the Gateway Access card, not under Appearance.
- Supported locales: `en`, `zh-CN`, `zh-TW`, `pt-BR`, `de`, `es`, `ja-JP`, `ko`, `fr`, `tr`, `uk`, `id`, `pl`, `th`
- Non-English translations are lazy-loaded in the browser.
@@ -110,95 +83,87 @@ locale picker lives in the Gateway Access card, not under Appearance.
## What it can do (today)
- Chat with the model via Gateway WS (`chat.history`, `chat.send`, `chat.abort`, `chat.inject`)
- Talk to OpenAI Realtime directly from the browser via WebRTC. The Gateway
mints a short-lived Realtime client secret with `talk.realtime.session`; the
browser sends microphone audio directly to OpenAI and relays
`openclaw_agent_consult` tool calls back through `chat.send` for the larger
configured OpenClaw model.
- Stream tool calls + live tool output cards in Chat (agent events)
- Channels: built-in plus bundled/external plugin channels status, QR login, and per-channel config (`channels.status`, `web.login.*`, `config.patch`)
- Instances: presence list + refresh (`system-presence`)
- Sessions: list + per-session model/thinking/fast/verbose/trace/reasoning overrides (`sessions.list`, `sessions.patch`)
- Dreams: dreaming status, enable/disable toggle, and Dream Diary reader (`doctor.memory.status`, `doctor.memory.dreamDiary`, `config.patch`)
- Cron jobs: list/add/edit/run/enable/disable + run history (`cron.*`)
- Skills: status, enable/disable, install, API key updates (`skills.*`)
- Nodes: list + caps (`node.list`)
- Exec approvals: edit gateway or node allowlists + ask policy for `exec host=gateway/node` (`exec.approvals.*`)
- Config: view/edit `~/.openclaw/openclaw.json` (`config.get`, `config.set`)
- Config: apply + restart with validation (`config.apply`) and wake the last active session
- Config writes include a base-hash guard to prevent clobbering concurrent edits
- Config writes (`config.set`/`config.apply`/`config.patch`) also preflight active SecretRef resolution for refs in the submitted config payload; unresolved active submitted refs are rejected before write
- Config schema + form rendering (`config.schema` / `config.schema.lookup`,
including field `title` / `description`, matched UI hints, immediate child
summaries, docs metadata on nested object/wildcard/array/composition nodes,
plus plugin + channel schemas when available); Raw JSON editor is
available only when the snapshot has a safe raw round-trip
- If a snapshot cannot safely round-trip raw text, Control UI forces Form mode and disables Raw mode for that snapshot
- Raw JSON editor "Reset to saved" preserves the raw-authored shape (formatting, comments, `$include` layout) instead of re-rendering a flattened snapshot, so external edits survive a reset when the snapshot can safely round-trip
- Structured SecretRef object values are rendered read-only in form text inputs to prevent accidental object-to-string corruption
- Debug: status/health/models snapshots + event log + manual RPC calls (`status`, `health`, `models.list`)
- Logs: live tail of gateway file logs with filter/export (`logs.tail`)
- Update: run a package/git update + restart (`update.run`) with a restart report
Cron jobs panel notes:
- For isolated jobs, delivery defaults to announce summary. You can switch to none if you want internal-only runs.
- Channel/target fields appear when announce is selected.
- Webhook mode uses `delivery.mode = "webhook"` with `delivery.to` set to a valid HTTP(S) webhook URL.
- For main-session jobs, webhook and none delivery modes are available.
- Advanced edit controls include delete-after-run, clear agent override, cron exact/stagger options,
agent model/thinking overrides, and best-effort delivery toggles.
- Form validation is inline with field-level errors; invalid values disable the save button until fixed.
- Set `cron.webhookToken` to send a dedicated bearer token, if omitted the webhook is sent without an auth header.
- Deprecated fallback: stored legacy jobs with `notify: true` can still use `cron.webhook` until migrated.
<AccordionGroup>
<Accordion title="Chat and Talk">
- Chat with the model via Gateway WS (`chat.history`, `chat.send`, `chat.abort`, `chat.inject`).
- Talk to OpenAI Realtime directly from the browser via WebRTC. The Gateway mints a short-lived Realtime client secret with `talk.realtime.session`; the browser sends microphone audio directly to OpenAI and relays `openclaw_agent_consult` tool calls back through `chat.send` for the larger configured OpenClaw model.
- Stream tool calls + live tool output cards in Chat (agent events).
</Accordion>
<Accordion title="Channels, instances, sessions, dreams">
- Channels: built-in plus bundled/external plugin channels status, QR login, and per-channel config (`channels.status`, `web.login.*`, `config.patch`).
- Instances: presence list + refresh (`system-presence`).
- Sessions: list + per-session model/thinking/fast/verbose/trace/reasoning overrides (`sessions.list`, `sessions.patch`).
- Dreams: dreaming status, enable/disable toggle, and Dream Diary reader (`doctor.memory.status`, `doctor.memory.dreamDiary`, `config.patch`).
</Accordion>
<Accordion title="Cron, skills, nodes, exec approvals">
- Cron jobs: list/add/edit/run/enable/disable + run history (`cron.*`).
- Skills: status, enable/disable, install, API key updates (`skills.*`).
- Nodes: list + caps (`node.list`).
- Exec approvals: edit gateway or node allowlists + ask policy for `exec host=gateway/node` (`exec.approvals.*`).
</Accordion>
<Accordion title="Config">
- View/edit `~/.openclaw/openclaw.json` (`config.get`, `config.set`).
- Apply + restart with validation (`config.apply`) and wake the last active session.
- Writes include a base-hash guard to prevent clobbering concurrent edits.
- Writes (`config.set`/`config.apply`/`config.patch`) preflight active SecretRef resolution for refs in the submitted config payload; unresolved active submitted refs are rejected before write.
- Schema + form rendering (`config.schema` / `config.schema.lookup`, including field `title` / `description`, matched UI hints, immediate child summaries, docs metadata on nested object/wildcard/array/composition nodes, plus plugin + channel schemas when available); Raw JSON editor is available only when the snapshot has a safe raw round-trip.
- If a snapshot cannot safely round-trip raw text, Control UI forces Form mode and disables Raw mode for that snapshot.
- Raw JSON editor "Reset to saved" preserves the raw-authored shape (formatting, comments, `$include` layout) instead of re-rendering a flattened snapshot, so external edits survive a reset when the snapshot can safely round-trip.
- Structured SecretRef object values are rendered read-only in form text inputs to prevent accidental object-to-string corruption.
</Accordion>
<Accordion title="Debug, logs, update">
- Debug: status/health/models snapshots + event log + manual RPC calls (`status`, `health`, `models.list`).
- Logs: live tail of gateway file logs with filter/export (`logs.tail`).
- Update: run a package/git update + restart (`update.run`) with a restart report.
</Accordion>
<Accordion title="Cron jobs panel notes">
- For isolated jobs, delivery defaults to announce summary. You can switch to none if you want internal-only runs.
- Channel/target fields appear when announce is selected.
- Webhook mode uses `delivery.mode = "webhook"` with `delivery.to` set to a valid HTTP(S) webhook URL.
- For main-session jobs, webhook and none delivery modes are available.
- Advanced edit controls include delete-after-run, clear agent override, cron exact/stagger options, agent model/thinking overrides, and best-effort delivery toggles.
- Form validation is inline with field-level errors; invalid values disable the save button until fixed.
- Set `cron.webhookToken` to send a dedicated bearer token, if omitted the webhook is sent without an auth header.
- Deprecated fallback: stored legacy jobs with `notify: true` can still use `cron.webhook` until migrated.
</Accordion>
</AccordionGroup>
## Chat behavior
- `chat.send` is **non-blocking**: it acks immediately with `{ runId, status: "started" }` and the response streams via `chat` events.
- Re-sending with the same `idempotencyKey` returns `{ status: "in_flight" }` while running, and `{ status: "ok" }` after completion.
- `chat.history` responses are size-bounded for UI safety. When transcript entries are too large, Gateway may truncate long text fields, omit heavy metadata blocks, and replace oversized messages with a placeholder (`[chat.history omitted: message too large]`).
- Assistant/generated images are persisted as managed media references and served back through authenticated Gateway media URLs, so reloads do not depend on raw base64 image payloads staying in the chat history response.
- `chat.history` also strips display-only inline directive tags from visible assistant text (for example `[[reply_to_*]]` and `[[audio_as_voice]]`), plain-text tool-call XML payloads (including `<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, and omits assistant entries whose whole visible text is only the exact silent token `NO_REPLY` / `no_reply`.
- During an active send and the final history refresh, the chat view keeps local
optimistic user/assistant messages visible if `chat.history` briefly returns
an older snapshot; the canonical transcript replaces those local messages once
the Gateway history catches up.
- `chat.inject` appends an assistant note to the session transcript and broadcasts a `chat` event for UI-only updates (no agent run, no channel delivery).
- The chat header model and thinking pickers patch the active session immediately through `sessions.patch`; they are persistent session overrides, not one-turn-only send options.
- When fresh Gateway session usage reports show high context pressure, the chat
composer area shows a context notice and, at recommended compaction levels, a
compact button that runs the normal session compaction path. Stale token
snapshots are hidden until the Gateway reports fresh usage again.
- Talk mode uses a registered realtime voice provider that supports browser
WebRTC sessions. Configure OpenAI with `talk.provider: "openai"` plus
`talk.providers.openai.apiKey`, or reuse the Voice Call realtime provider
config. The browser never receives the standard OpenAI API key; it receives
only the ephemeral Realtime client secret. Google Live realtime voice is
supported for backend Voice Call and Google Meet bridges, but not this browser
WebRTC path yet. The Realtime session prompt is assembled by the Gateway;
`talk.realtime.session` does not accept caller-provided instruction overrides.
- In the Chat composer, the Talk control is the waves button next to the
microphone dictation button. When Talk starts, the composer status row shows
`Connecting Talk...`, then `Talk live` while audio is connected, or
`Asking OpenClaw...` while a realtime tool call is consulting the configured
larger model through `chat.send`.
- Stop:
- Click **Stop** (calls `chat.abort`)
- While a run is active, normal follow-ups queue. Click **Steer** on a queued message to inject that follow-up into the running turn.
- Type `/stop` (or standalone abort phrases like `stop`, `stop action`, `stop run`, `stop openclaw`, `please stop`) to abort out-of-band
- `chat.abort` supports `{ sessionKey }` (no `runId`) to abort all active runs for that session
- Abort partial retention:
- When a run is aborted, partial assistant text can still be shown in the UI
- Gateway persists aborted partial assistant text into transcript history when buffered output exists
- Persisted entries include abort metadata so transcript consumers can tell abort partials from normal completion output
<AccordionGroup>
<Accordion title="Send and history semantics">
- `chat.send` is **non-blocking**: it acks immediately with `{ runId, status: "started" }` and the response streams via `chat` events.
- Re-sending with the same `idempotencyKey` returns `{ status: "in_flight" }` while running, and `{ status: "ok" }` after completion.
- `chat.history` responses are size-bounded for UI safety. When transcript entries are too large, Gateway may truncate long text fields, omit heavy metadata blocks, and replace oversized messages with a placeholder (`[chat.history omitted: message too large]`).
- Assistant/generated images are persisted as managed media references and served back through authenticated Gateway media URLs, so reloads do not depend on raw base64 image payloads staying in the chat history response.
- `chat.history` also strips display-only inline directive tags from visible assistant text (for example `[[reply_to_*]]` and `[[audio_as_voice]]`), plain-text tool-call XML payloads (including `<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, and omits assistant entries whose whole visible text is only the exact silent token `NO_REPLY` / `no_reply`.
- During an active send and the final history refresh, the chat view keeps local optimistic user/assistant messages visible if `chat.history` briefly returns an older snapshot; the canonical transcript replaces those local messages once the Gateway history catches up.
- `chat.inject` appends an assistant note to the session transcript and broadcasts a `chat` event for UI-only updates (no agent run, no channel delivery).
- The chat header model and thinking pickers patch the active session immediately through `sessions.patch`; they are persistent session overrides, not one-turn-only send options.
- When fresh Gateway session usage reports show high context pressure, the chat composer area shows a context notice and, at recommended compaction levels, a compact button that runs the normal session compaction path. Stale token snapshots are hidden until the Gateway reports fresh usage again.
</Accordion>
<Accordion title="Talk mode (browser WebRTC)">
Talk mode uses a registered realtime voice provider that supports browser WebRTC sessions. Configure OpenAI with `talk.provider: "openai"` plus `talk.providers.openai.apiKey`, or reuse the Voice Call realtime provider config. The browser never receives the standard OpenAI API key; it receives only the ephemeral Realtime client secret. Google Live realtime voice is supported for backend Voice Call and Google Meet bridges, but not this browser WebRTC path yet. The Realtime session prompt is assembled by the Gateway; `talk.realtime.session` does not accept caller-provided instruction overrides.
In the Chat composer, the Talk control is the waves button next to the microphone dictation button. When Talk starts, the composer status row shows `Connecting Talk...`, then `Talk live` while audio is connected, or `Asking OpenClaw...` while a realtime tool call is consulting the configured larger model through `chat.send`.
</Accordion>
<Accordion title="Stop and abort">
- Click **Stop** (calls `chat.abort`).
- While a run is active, normal follow-ups queue. Click **Steer** on a queued message to inject that follow-up into the running turn.
- Type `/stop` (or standalone abort phrases like `stop`, `stop action`, `stop run`, `stop openclaw`, `please stop`) to abort out-of-band.
- `chat.abort` supports `{ sessionKey }` (no `runId`) to abort all active runs for that session.
</Accordion>
<Accordion title="Abort partial retention">
- When a run is aborted, partial assistant text can still be shown in the UI.
- Gateway persists aborted partial assistant text into transcript history when buffered output exists.
- Persisted entries include abort metadata so transcript consumers can tell abort partials from normal completion output.
</Accordion>
</AccordionGroup>
## PWA install and web push
The Control UI ships a `manifest.webmanifest` and a service worker, so
modern browsers can install it as a standalone PWA. Web Push lets the
Gateway wake the installed PWA with notifications even when the tab or
browser window is not open.
The Control UI ships a `manifest.webmanifest` and a service worker, so modern browsers can install it as a standalone PWA. Web Push lets the Gateway wake the installed PWA with notifications even when the tab or browser window is not open.
| Surface | What it does |
| ----------------------------------------------------- | ------------------------------------------------------------------ |
@@ -207,37 +172,38 @@ browser window is not open.
| `push/vapid-keys.json` (under the OpenClaw state dir) | Auto-generated VAPID keypair used to sign Web Push payloads. |
| `push/web-push-subscriptions.json` | Persisted browser subscription endpoints. |
Override the VAPID keypair through env vars on the Gateway process when
you want to pin keys (for multi-host deployments, secrets rotation, or
tests):
Override the VAPID keypair through env vars on the Gateway process when you want to pin keys (for multi-host deployments, secrets rotation, or tests):
- `OPENCLAW_VAPID_PUBLIC_KEY`
- `OPENCLAW_VAPID_PRIVATE_KEY`
- `OPENCLAW_VAPID_SUBJECT` (defaults to `mailto:openclaw@localhost`)
The Control UI uses these scope-gated Gateway methods to register and
test browser subscriptions:
The Control UI uses these scope-gated Gateway methods to register and test browser subscriptions:
- `push.web.vapidPublicKey` — fetches the active VAPID public key.
- `push.web.subscribe` — registers an `endpoint` plus `keys.p256dh`/`keys.auth`.
- `push.web.unsubscribe` — removes a registered endpoint.
- `push.web.test` — sends a test notification to the caller's subscription.
Web Push is independent of the iOS APNS relay path
(see [Configuration](/gateway/configuration) for relay-backed push) and
the existing `push.test` method, which target native mobile pairing.
<Note>
Web Push is independent of the iOS APNS relay path (see [Configuration](/gateway/configuration) for relay-backed push) and the existing `push.test` method, which target native mobile pairing.
</Note>
## Hosted embeds
Assistant messages can render hosted web content inline with the `[embed ...]`
shortcode. The iframe sandbox policy is controlled by
`gateway.controlUi.embedSandbox`:
Assistant messages can render hosted web content inline with the `[embed ...]` shortcode. The iframe sandbox policy is controlled by `gateway.controlUi.embedSandbox`:
- `strict`: disables script execution inside hosted embeds
- `scripts`: allows interactive embeds while keeping origin isolation; this is
the default and is usually enough for self-contained browser games/widgets
- `trusted`: adds `allow-same-origin` on top of `allow-scripts` for same-site
documents that intentionally need stronger privileges
<Tabs>
<Tab title="strict">
Disables script execution inside hosted embeds.
</Tab>
<Tab title="scripts (default)">
Allows interactive embeds while keeping origin isolation; this is the default and is usually enough for self-contained browser games/widgets.
</Tab>
<Tab title="trusted">
Adds `allow-same-origin` on top of `allow-scripts` for same-site documents that intentionally need stronger privileges.
</Tab>
</Tabs>
Example:
@@ -251,61 +217,52 @@ Example:
}
```
Use `trusted` only when the embedded document genuinely needs same-origin
behavior. For most agent-generated games and interactive canvases, `scripts` is
the safer choice.
<Warning>
Use `trusted` only when the embedded document genuinely needs same-origin behavior. For most agent-generated games and interactive canvases, `scripts` is the safer choice.
</Warning>
Absolute external `http(s)` embed URLs stay blocked by default. If you
intentionally want `[embed url="https://..."]` to load third-party pages, set
`gateway.controlUi.allowExternalEmbedUrls: true`.
Absolute external `http(s)` embed URLs stay blocked by default. If you intentionally want `[embed url="https://..."]` to load third-party pages, set `gateway.controlUi.allowExternalEmbedUrls: true`.
## Tailnet access (recommended)
### Integrated Tailscale Serve (preferred)
<Tabs>
<Tab title="Integrated Tailscale Serve (preferred)">
Keep the Gateway on loopback and let Tailscale Serve proxy it with HTTPS:
Keep the Gateway on loopback and let Tailscale Serve proxy it with HTTPS:
```bash
openclaw gateway --tailscale serve
```
```bash
openclaw gateway --tailscale serve
```
Open:
Open:
- `https://<magicdns>/` (or your configured `gateway.controlUi.basePath`)
- `https://<magicdns>/` (or your configured `gateway.controlUi.basePath`)
By default, Control UI/WebSocket Serve requests can authenticate via Tailscale identity headers (`tailscale-user-login`) when `gateway.auth.allowTailscale` is `true`. OpenClaw verifies the identity by resolving the `x-forwarded-for` address with `tailscale whois` and matching it to the header, and only accepts these when the request hits loopback with Tailscale's `x-forwarded-*` headers. Set `gateway.auth.allowTailscale: false` if you want to require explicit shared-secret credentials even for Serve traffic. Then use `gateway.auth.mode: "token"` or `"password"`.
By default, Control UI/WebSocket Serve requests can authenticate via Tailscale identity headers
(`tailscale-user-login`) when `gateway.auth.allowTailscale` is `true`. OpenClaw
verifies the identity by resolving the `x-forwarded-for` address with
`tailscale whois` and matching it to the header, and only accepts these when the
request hits loopback with Tailscales `x-forwarded-*` headers. Set
`gateway.auth.allowTailscale: false` if you want to require explicit shared-secret
credentials even for Serve traffic. Then use `gateway.auth.mode: "token"` or
`"password"`.
For that async Serve identity path, failed auth attempts for the same client IP
and auth scope are serialized before rate-limit writes. Concurrent bad retries
from the same browser can therefore show `retry later` on the second request
instead of two plain mismatches racing in parallel.
Tokenless Serve auth assumes the gateway host is trusted. If untrusted local
code may run on that host, require token/password auth.
For that async Serve identity path, failed auth attempts for the same client IP and auth scope are serialized before rate-limit writes. Concurrent bad retries from the same browser can therefore show `retry later` on the second request instead of two plain mismatches racing in parallel.
### Bind to tailnet + token
<Warning>
Tokenless Serve auth assumes the gateway host is trusted. If untrusted local code may run on that host, require token/password auth.
</Warning>
```bash
openclaw gateway --bind tailnet --token "$(openssl rand -hex 32)"
```
</Tab>
<Tab title="Bind to tailnet + token">
```bash
openclaw gateway --bind tailnet --token "$(openssl rand -hex 32)"
```
Then open:
Then open:
- `http://<tailscale-ip>:18789/` (or your configured `gateway.controlUi.basePath`)
- `http://<tailscale-ip>:18789/` (or your configured `gateway.controlUi.basePath`)
Paste the matching shared secret into the UI settings (sent as
`connect.params.auth.token` or `connect.params.auth.password`).
Paste the matching shared secret into the UI settings (sent as `connect.params.auth.token` or `connect.params.auth.password`).
</Tab>
</Tabs>
## Insecure HTTP
If you open the dashboard over plain HTTP (`http://<lan-ip>` or `http://<tailscale-ip>`),
the browser runs in a **non-secure context** and blocks WebCrypto. By default,
OpenClaw **blocks** Control UI connections without device identity.
If you open the dashboard over plain HTTP (`http://<lan-ip>` or `http://<tailscale-ip>`), the browser runs in a **non-secure context** and blocks WebCrypto. By default, OpenClaw **blocks** Control UI connections without device identity.
Documented exceptions:
@@ -318,47 +275,47 @@ Documented exceptions:
- `https://<magicdns>/` (Serve)
- `http://127.0.0.1:18789/` (on the gateway host)
**Insecure-auth toggle behavior:**
<AccordionGroup>
<Accordion title="Insecure-auth toggle behavior">
```json5
{
gateway: {
controlUi: { allowInsecureAuth: true },
bind: "tailnet",
auth: { mode: "token", token: "replace-me" },
},
}
```
```json5
{
gateway: {
controlUi: { allowInsecureAuth: true },
bind: "tailnet",
auth: { mode: "token", token: "replace-me" },
},
}
```
`allowInsecureAuth` is a local compatibility toggle only:
`allowInsecureAuth` is a local compatibility toggle only:
- It allows localhost Control UI sessions to proceed without device identity in non-secure HTTP contexts.
- It does not bypass pairing checks.
- It does not relax remote (non-localhost) device identity requirements.
- It allows localhost Control UI sessions to proceed without device identity in
non-secure HTTP contexts.
- It does not bypass pairing checks.
- It does not relax remote (non-localhost) device identity requirements.
</Accordion>
<Accordion title="Break-glass only">
```json5
{
gateway: {
controlUi: { dangerouslyDisableDeviceAuth: true },
bind: "tailnet",
auth: { mode: "token", token: "replace-me" },
},
}
```
**Break-glass only:**
<Warning>
`dangerouslyDisableDeviceAuth` disables Control UI device identity checks and is a severe security downgrade. Revert quickly after emergency use.
</Warning>
```json5
{
gateway: {
controlUi: { dangerouslyDisableDeviceAuth: true },
bind: "tailnet",
auth: { mode: "token", token: "replace-me" },
},
}
```
`dangerouslyDisableDeviceAuth` disables Control UI device identity checks and is a
severe security downgrade. Revert quickly after emergency use.
Trusted-proxy note:
- successful trusted-proxy auth can admit **operator** Control UI sessions without
device identity
- this does **not** extend to node-role Control UI sessions
- same-host loopback reverse proxies still do not satisfy trusted-proxy auth; see
[Trusted proxy auth](/gateway/trusted-proxy-auth)
</Accordion>
<Accordion title="Trusted-proxy note">
- Successful trusted-proxy auth can admit **operator** Control UI sessions without device identity.
- This does **not** extend to node-role Control UI sessions.
- Same-host loopback reverse proxies still do not satisfy trusted-proxy auth; see [Trusted proxy auth](/gateway/trusted-proxy-auth).
</Accordion>
</AccordionGroup>
See [Tailscale](/gateway/tailscale) for HTTPS setup guidance.
@@ -409,42 +366,42 @@ Then point the UI at your Gateway WS URL (e.g. `ws://127.0.0.1:18789`).
## Debugging/testing: dev server + remote Gateway
The Control UI is static files; the WebSocket target is configurable and can be
different from the HTTP origin. This is handy when you want the Vite dev server
locally but the Gateway runs elsewhere.
The Control UI is static files; the WebSocket target is configurable and can be different from the HTTP origin. This is handy when you want the Vite dev server locally but the Gateway runs elsewhere.
1. Start the UI dev server: `pnpm ui:dev`
2. Open a URL like:
<Steps>
<Step title="Start the UI dev server">
```bash
pnpm ui:dev
```
</Step>
<Step title="Open with gatewayUrl">
```text
http://localhost:5173/?gatewayUrl=ws://<gateway-host>:18789
```
```text
http://localhost:5173/?gatewayUrl=ws://<gateway-host>:18789
```
Optional one-time auth (if needed):
Optional one-time auth (if needed):
```text
http://localhost:5173/?gatewayUrl=wss://<gateway-host>:18789#token=<gateway-token>
```
```text
http://localhost:5173/?gatewayUrl=wss://<gateway-host>:18789#token=<gateway-token>
```
</Step>
</Steps>
Notes:
- `gatewayUrl` is stored in localStorage after load and removed from the URL.
- `token` should be passed via the URL fragment (`#token=...`) whenever possible. Fragments are not sent to the server, which avoids request-log and Referer leakage. Legacy `?token=` query params are still imported once for compatibility, but only as a fallback, and are stripped immediately after bootstrap.
- `password` is kept in memory only.
- When `gatewayUrl` is set, the UI does not fall back to config or environment credentials.
Provide `token` (or `password`) explicitly. Missing explicit credentials is an error.
- Use `wss://` when the Gateway is behind TLS (Tailscale Serve, HTTPS proxy, etc.).
- `gatewayUrl` is only accepted in a top-level window (not embedded) to prevent clickjacking.
- Non-loopback Control UI deployments must set `gateway.controlUi.allowedOrigins`
explicitly (full origins). This includes remote dev setups.
- Gateway startup may seed local origins such as `http://localhost:<port>` and
`http://127.0.0.1:<port>` from the effective runtime bind and port, but remote
browser origins still need explicit entries.
- Do not use `gateway.controlUi.allowedOrigins: ["*"]` except for tightly controlled
local testing. It means allow any browser origin, not “match whatever host I am
using.”
- `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true` enables
Host-header origin fallback mode, but it is a dangerous security mode.
<AccordionGroup>
<Accordion title="Notes">
- `gatewayUrl` is stored in localStorage after load and removed from the URL.
- `token` should be passed via the URL fragment (`#token=...`) whenever possible. Fragments are not sent to the server, which avoids request-log and Referer leakage. Legacy `?token=` query params are still imported once for compatibility, but only as a fallback, and are stripped immediately after bootstrap.
- `password` is kept in memory only.
- When `gatewayUrl` is set, the UI does not fall back to config or environment credentials. Provide `token` (or `password`) explicitly. Missing explicit credentials is an error.
- Use `wss://` when the Gateway is behind TLS (Tailscale Serve, HTTPS proxy, etc.).
- `gatewayUrl` is only accepted in a top-level window (not embedded) to prevent clickjacking.
- Non-loopback Control UI deployments must set `gateway.controlUi.allowedOrigins` explicitly (full origins). This includes remote dev setups.
- Gateway startup may seed local origins such as `http://localhost:<port>` and `http://127.0.0.1:<port>` from the effective runtime bind and port, but remote browser origins still need explicit entries.
- Do not use `gateway.controlUi.allowedOrigins: ["*"]` except for tightly controlled local testing. It means allow any browser origin, not "match whatever host I am using."
- `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true` enables Host-header origin fallback mode, but it is a dangerous security mode.
</Accordion>
</AccordionGroup>
Example:
@@ -463,6 +420,6 @@ Remote access setup details: [Remote access](/gateway/remote).
## Related
- [Dashboard](/web/dashboard) — gateway dashboard
- [WebChat](/web/webchat) — browser-based chat interface
- [TUI](/web/tui) — terminal user interface
- [Health Checks](/gateway/health) — gateway health monitoring
- [TUI](/web/tui) — terminal user interface
- [WebChat](/web/webchat) — browser-based chat interface

View File

@@ -122,7 +122,7 @@ describe("anthropic cli migration", () => {
primary: "anthropic/claude-opus-4-7",
fallbacks: ["anthropic/claude-opus-4-6", "openai/gpt-5.2"],
},
embeddedHarness: { runtime: "claude-cli" },
agentRuntime: { id: "claude-cli" },
models: {
"anthropic/claude-opus-4-7": { alias: "Opus" },
"anthropic/claude-sonnet-4-6": {},
@@ -153,7 +153,7 @@ describe("anthropic cli migration", () => {
expect(result.configPatch).toEqual({
agents: {
defaults: {
embeddedHarness: { runtime: "claude-cli" },
agentRuntime: { id: "claude-cli" },
models: {
"openai/gpt-5.2": {},
"anthropic/claude-opus-4-7": {},
@@ -184,7 +184,7 @@ describe("anthropic cli migration", () => {
agents: {
defaults: {
model: { primary: "anthropic/claude-opus-4-7" },
embeddedHarness: { runtime: "claude-cli" },
agentRuntime: { id: "claude-cli" },
models: {
"anthropic/claude-opus-4-7": {},
"anthropic/claude-sonnet-4-6": {},
@@ -325,7 +325,7 @@ describe("anthropic cli migration", () => {
primary: "anthropic/claude-opus-4-7",
fallbacks: ["anthropic/claude-opus-4-6", "openai/gpt-5.2"],
},
embeddedHarness: { runtime: "claude-cli" },
agentRuntime: { id: "claude-cli" },
models: {
"anthropic/claude-opus-4-7": { alias: "Opus" },
"anthropic/claude-opus-4-6": { alias: "Opus" },

View File

@@ -12,9 +12,9 @@ import { CLAUDE_CLI_BACKEND_ID, CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS } from "./cli-
type AgentDefaultsModel = NonNullable<NonNullable<OpenClawConfig["agents"]>["defaults"]>["model"];
type AgentDefaultsModels = NonNullable<NonNullable<OpenClawConfig["agents"]>["defaults"]>["models"];
type AgentDefaultsEmbeddedHarness = NonNullable<
type AgentDefaultsRuntimePolicy = NonNullable<
NonNullable<OpenClawConfig["agents"]>["defaults"]
>["embeddedHarness"];
>["agentRuntime"];
type ClaudeCliCredential = NonNullable<ReturnType<typeof readClaudeCliCredentialsForSetup>>;
function toAnthropicModelRef(raw: string): string | null {
@@ -125,16 +125,14 @@ function seedClaudeCliAllowlist(
return next;
}
function selectClaudeCliRuntime(
embeddedHarness: AgentDefaultsEmbeddedHarness | undefined,
): AgentDefaultsEmbeddedHarness {
const currentRuntime = embeddedHarness?.runtime?.trim();
function selectClaudeCliRuntime(agentRuntime: AgentDefaultsRuntimePolicy | undefined) {
const currentRuntime = agentRuntime?.id?.trim();
if (currentRuntime && currentRuntime !== "auto") {
return embeddedHarness;
return agentRuntime;
}
return {
...embeddedHarness,
runtime: CLAUDE_CLI_BACKEND_ID,
...agentRuntime,
id: CLAUDE_CLI_BACKEND_ID,
};
}
@@ -198,7 +196,7 @@ export function buildAnthropicCliMigrationResult(
agents: {
defaults: {
...(rewrittenModel.changed ? { model: rewrittenModel.value } : {}),
embeddedHarness: selectClaudeCliRuntime(defaults?.embeddedHarness),
agentRuntime: selectClaudeCliRuntime(defaults?.agentRuntime),
models: nextModels,
},
},

View File

@@ -140,7 +140,7 @@ function isAnthropicCacheRetentionTarget(
}
function usesClaudeCliModelSelection(config: OpenClawConfig): boolean {
if (config.agents?.defaults?.embeddedHarness?.runtime === CLAUDE_CLI_BACKEND_ID) {
if (config.agents?.defaults?.agentRuntime?.id === CLAUDE_CLI_BACKEND_ID) {
return true;
}
const primary = resolveModelPrimaryValue(

View File

@@ -176,7 +176,7 @@ describe("anthropic provider replay hooks", () => {
},
agents: {
defaults: {
embeddedHarness: { runtime: "claude-cli" },
agentRuntime: { id: "claude-cli" },
model: { primary: "anthropic/claude-opus-4-7" },
models: {
"anthropic/claude-opus-4-7": {},

View File

@@ -40,6 +40,10 @@
"blurb": "very well supported right now.",
"systemImage": "bubble.left.and.bubble.right",
"markdownCapable": true,
"commands": {
"nativeCommandsAutoEnabled": true,
"nativeSkillsAutoEnabled": true
},
"configuredState": {
"specifier": "./configured-state",
"exportName": "hasDiscordConfiguredState"

View File

@@ -196,6 +196,12 @@ function buildDiscordModelPickerCurrentModel(
return `${defaultProvider}/${defaultModel}`;
}
function resolveConfiguredAgentRuntimeId(value: {
agentRuntime?: { id?: unknown };
}): string | undefined {
return normalizeOptionalString(value.agentRuntime?.id);
}
function buildDiscordModelPickerAllowedModelRefs(
data: Awaited<ReturnType<typeof loadDiscordModelPickerData>>,
): Set<string> {
@@ -386,15 +392,15 @@ function resolveDiscordModelPickerCurrentRuntime(params: {
// Fall through to configured defaults when the session store is unavailable.
}
const agentRuntime = normalizeOptionalString(
const agentRuntime = resolveConfiguredAgentRuntimeId(
params.cfg.agents?.list?.find(
(entry) => normalizeOptionalString(entry.id) === params.route.agentId,
)?.embeddedHarness?.runtime,
) ?? {},
);
if (agentRuntime) {
return agentRuntime;
}
return normalizeOptionalString(params.cfg.agents?.defaults?.embeddedHarness?.runtime) ?? "auto";
return resolveConfiguredAgentRuntimeId(params.cfg.agents?.defaults ?? {}) ?? "auto";
}
export async function replyWithDiscordModelPickerProviders(params: {

View File

@@ -0,0 +1 @@
export const defaultTopLevelPlacement = "child" as const;

View File

@@ -220,6 +220,45 @@ describe("FeishuConfigSchema optimization flags", () => {
});
});
describe("FeishuConfigSchema TTS overrides", () => {
it("accepts top-level and account-level TTS overrides", () => {
const result = FeishuConfigSchema.parse({
tts: {
auto: "always",
provider: "openai",
providers: {
openai: {
voice: "alloy",
},
},
},
accounts: {
english: {
tts: {
providers: {
openai: {
voice: "shimmer",
},
},
},
},
},
});
expect(result.tts).toMatchObject({
auto: "always",
provider: "openai",
});
expect(result.accounts?.english?.tts).toMatchObject({
providers: {
openai: {
voice: "shimmer",
},
},
});
});
});
describe("FeishuConfigSchema actions", () => {
it("accepts top-level reactions action gate", () => {
const result = FeishuConfigSchema.parse({

View File

@@ -20,6 +20,23 @@ const FeishuDomainSchema = z.union([
z.string().url().startsWith("https://"),
]);
const FeishuConnectionModeSchema = z.enum(["websocket", "webhook"]);
const TtsOverrideSchema = z
.object({
auto: z.enum(["off", "always", "inbound", "tagged"]).optional(),
enabled: z.boolean().optional(),
mode: z.enum(["final", "all"]).optional(),
provider: z.string().optional(),
persona: z.string().optional(),
personas: z.record(z.string(), z.record(z.string(), z.unknown())).optional(),
summaryModel: z.string().optional(),
modelOverrides: z.record(z.string(), z.unknown()).optional(),
providers: z.record(z.string(), z.record(z.string(), z.unknown())).optional(),
prefsPath: z.string().optional(),
maxTextLength: z.number().int().min(1).optional(),
timeoutMs: z.number().int().min(1000).max(120000).optional(),
})
.strict()
.optional();
const ToolPolicySchema = z
.object({
@@ -183,6 +200,7 @@ const FeishuSharedConfigShape = {
reactionNotifications: ReactionNotificationModeSchema,
typingIndicator: z.boolean().optional(),
resolveSenderNames: z.boolean().optional(),
tts: TtsOverrideSchema,
};
/**

View File

@@ -85,7 +85,7 @@ export function buildGoogleGeminiCliProvider(): ProviderPlugin {
configPatch: {
agents: {
defaults: {
embeddedHarness: { runtime: PROVIDER_ID },
agentRuntime: { id: PROVIDER_ID },
models: {
[DEFAULT_MODEL]: {},
},

View File

@@ -2,7 +2,11 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createEmbeddedLobsterRunner, resolveLobsterCwd } from "./lobster-runner.js";
import {
createEmbeddedLobsterRunner,
loadEmbeddedToolRuntimeFromPackage,
resolveLobsterCwd,
} from "./lobster-runner.js";
describe("resolveLobsterCwd", () => {
it("defaults to the current working directory", () => {
@@ -352,6 +356,60 @@ describe("createEmbeddedLobsterRunner", () => {
expect(loadRuntime).toHaveBeenCalledTimes(1);
});
it("falls back to the installed package core file when the core export is unavailable", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-package-"));
const packageRoot = path.join(tempDir, "node_modules", "@clawdbot", "lobster");
const packageEntryPath = path.join(packageRoot, "dist", "src", "sdk", "index.js");
const packageCorePath = path.join(packageRoot, "dist", "src", "core", "index.js");
try {
await fs.mkdir(path.dirname(packageEntryPath), { recursive: true });
await fs.mkdir(path.dirname(packageCorePath), { recursive: true });
await fs.writeFile(
path.join(packageRoot, "package.json"),
JSON.stringify({
name: "@clawdbot/lobster",
type: "module",
main: "./dist/src/sdk/index.js",
}),
"utf8",
);
await fs.writeFile(packageEntryPath, "export {};\n", "utf8");
await fs.writeFile(
packageCorePath,
[
"export async function runToolRequest() {",
" return { ok: true, status: 'ok', output: [{ source: 'fallback' }], requiresApproval: null };",
"}",
"export async function resumeToolRequest() {",
" return { ok: true, status: 'cancelled', output: [], requiresApproval: null };",
"}",
"",
].join("\n"),
"utf8",
);
const runtime = await loadEmbeddedToolRuntimeFromPackage({
importModule: async (specifier) => {
if (specifier === "@clawdbot/lobster/core") {
throw new Error("package export missing");
}
return (await import(`${specifier}?t=${Date.now()}`)) as object;
},
resolvePackageEntry: () => packageEntryPath,
});
await expect(runtime.runToolRequest({ pipeline: "commands.list" })).resolves.toEqual({
ok: true,
status: "ok",
output: [{ source: "fallback" }],
requiresApproval: null,
});
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("requires a pipeline for run", async () => {
const runner = createEmbeddedLobsterRunner({
loadRuntime: vi.fn().mockResolvedValue({

View File

@@ -1,10 +1,9 @@
import { readFileSync } from "node:fs";
import { stat } from "node:fs/promises";
import { createRequire } from "node:module";
import path from "node:path";
import { Readable, Writable } from "node:stream";
import {
resumeToolRequest as embeddedResumeToolRequest,
runToolRequest as embeddedRunToolRequest,
} from "@clawdbot/lobster/core";
import { pathToFileURL } from "node:url";
export type LobsterEnvelope =
| {
@@ -97,6 +96,45 @@ type EmbeddedToolRuntime = {
type LoadEmbeddedToolRuntime = () => Promise<EmbeddedToolRuntime>;
type LoadEmbeddedToolRuntimeFromPackageOptions = {
importModule?: (specifier: string) => Promise<Partial<EmbeddedToolRuntime>>;
resolvePackageEntry?: (specifier: string) => string;
};
const lobsterRequire = createRequire(import.meta.url);
function toEmbeddedToolRuntime(
moduleExports: Partial<EmbeddedToolRuntime>,
source: string,
): EmbeddedToolRuntime {
const { runToolRequest, resumeToolRequest } = moduleExports;
if (typeof runToolRequest === "function" && typeof resumeToolRequest === "function") {
return { runToolRequest, resumeToolRequest };
}
throw new Error(`${source} does not export Lobster embedded runtime functions`);
}
function findLobsterPackageRoot(resolvedEntryPath: string): string {
let dir = path.dirname(resolvedEntryPath);
while (true) {
const packageJsonPath = path.join(dir, "package.json");
try {
const parsed = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { name?: string };
if (parsed.name === "@clawdbot/lobster") {
return dir;
}
} catch {
// Keep walking until the installed package root is found.
}
const parent = path.dirname(dir);
if (parent === dir) {
throw new Error(`Could not locate @clawdbot/lobster package root from ${resolvedEntryPath}`);
}
dir = parent;
}
}
function normalizeForCwdSandbox(p: string): string {
const normalized = path.normalize(p);
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
@@ -255,11 +293,39 @@ async function withTimeout<T>(
});
}
async function loadEmbeddedToolRuntimeFromPackage(): Promise<EmbeddedToolRuntime> {
return {
runToolRequest: embeddedRunToolRequest,
resumeToolRequest: embeddedResumeToolRequest,
};
export async function loadEmbeddedToolRuntimeFromPackage(
options: LoadEmbeddedToolRuntimeFromPackageOptions = {},
): Promise<EmbeddedToolRuntime> {
const importModule =
options.importModule ??
(async (specifier: string) => (await import(specifier)) as Partial<EmbeddedToolRuntime>);
const resolvePackageEntry =
options.resolvePackageEntry ?? ((specifier: string) => lobsterRequire.resolve(specifier));
let coreLoadError: unknown;
try {
const coreSpecifier = ["@clawdbot", "lobster", "core"].join("/");
return toEmbeddedToolRuntime(await importModule(coreSpecifier), "@clawdbot/lobster/core");
} catch (error) {
coreLoadError = error;
}
let fallbackLoadError: unknown;
try {
const packageEntryPath = resolvePackageEntry("@clawdbot/lobster");
const packageRoot = findLobsterPackageRoot(packageEntryPath);
const coreRuntimeUrl = pathToFileURL(path.join(packageRoot, "dist/src/core/index.js")).href;
return toEmbeddedToolRuntime(await importModule(coreRuntimeUrl), coreRuntimeUrl);
} catch (error) {
fallbackLoadError = error;
}
throw new Error("Failed to load the Lobster embedded runtime", {
cause: new AggregateError(
[coreLoadError, fallbackLoadError],
"Both Lobster embedded runtime load paths failed",
),
});
}
export function createEmbeddedLobsterRunner(options?: {

View File

@@ -68,6 +68,7 @@ function makeUserInput(text: string) {
}
const SESSIONS_SPAWN_TOOL = { type: "function", name: "sessions_spawn" } as const;
const SESSIONS_YIELD_TOOL = { type: "function", name: "sessions_yield" } as const;
const THREAD_SUBAGENT_CHILD_ERROR_TOKEN = "QA_SUBAGENT_CHILD_ERROR";
const THREAD_SUBAGENT_TOOL_ERROR =
"thread=true requested but thread delivery is unavailable in this test harness.";
@@ -707,6 +708,75 @@ describe("qa mock openai server", () => {
});
});
it("drives yielded-parent subagent fallback QA through sessions_spawn and sessions_yield", async () => {
const server = await startMockServer();
const prompt =
"Subagent direct fallback QA check: spawn one worker and yield until QA-SUBAGENT-DIRECT-FALLBACK-OK is delivered.";
await expectResponsesText(server, {
stream: true,
tools: [SESSIONS_SPAWN_TOOL, SESSIONS_YIELD_TOOL],
input: [makeUserInput(prompt)],
});
await expect(
(await fetch(`${server.baseUrl}/debug/last-request`)).json(),
).resolves.toMatchObject({
plannedToolName: "sessions_spawn",
plannedToolArgs: {
label: "qa-direct-fallback-worker",
thread: false,
mode: "run",
},
});
const body = await expectResponsesText(server, {
stream: true,
tools: [SESSIONS_SPAWN_TOOL, SESSIONS_YIELD_TOOL],
input: [
makeUserInput(prompt),
{
type: "function_call_output",
call_id: "call_mock_sessions_spawn_1",
output: JSON.stringify({
status: "accepted",
childSessionKey: "agent:qa:subagent:child",
runId: "run-child-1",
}),
},
],
});
expect(body).toContain('"name":"sessions_yield"');
expect(body).toContain("QA-SUBAGENT-DIRECT-FALLBACK-OK");
await expect(
(await fetch(`${server.baseUrl}/debug/last-request`)).json(),
).resolves.toMatchObject({
plannedToolName: "sessions_yield",
});
});
it("returns no visible announce output for the direct fallback QA marker", async () => {
const server = await startMockServer();
const body = await expectResponsesJson<{
output?: Array<{ content?: Array<{ text?: string }> }>;
}>(server, {
stream: false,
input: [
makeUserInput(
[
"[Internal task completion event]",
"Task: qa-direct-fallback-worker",
"Result: QA-SUBAGENT-DIRECT-FALLBACK-OK",
].join("\n"),
),
],
});
expect(body.output?.[0]?.content?.[0]?.text).toBe("");
});
it("surfaces sessions_spawn tool errors instead of echoing child-task tokens", async () => {
const server = await startMockServer();

View File

@@ -147,6 +147,9 @@ const QA_EMPTY_RESPONSE_RECOVERY_PROMPT_RE = /empty response continuation qa che
const QA_EMPTY_RESPONSE_EXHAUSTION_PROMPT_RE = /empty response exhaustion qa check/i;
const QA_QUIET_STREAMING_PROMPT_RE = /quiet streaming qa check/i;
const QA_BLOCK_STREAMING_PROMPT_RE = /block streaming qa check/i;
const QA_SUBAGENT_DIRECT_FALLBACK_PROMPT_RE = /subagent direct fallback qa check/i;
const QA_SUBAGENT_DIRECT_FALLBACK_WORKER_RE = /subagent direct fallback worker/i;
const QA_SUBAGENT_DIRECT_FALLBACK_MARKER = "QA-SUBAGENT-DIRECT-FALLBACK-OK";
const QA_REASONING_ONLY_RETRY_NEEDLE =
"recorded reasoning but did not produce a user-visible answer";
const QA_EMPTY_RESPONSE_RETRY_NEEDLE =
@@ -784,6 +787,9 @@ function buildAssistantText(
if (/fanout worker beta/i.test(prompt)) {
return "BETA-OK";
}
if (QA_SUBAGENT_DIRECT_FALLBACK_WORKER_RE.test(prompt)) {
return QA_SUBAGENT_DIRECT_FALLBACK_MARKER;
}
if (/report the visible code/i.test(prompt) && /FORKED-CONTEXT-ALPHA/i.test(allInputText)) {
return "FORKED-CONTEXT-ALPHA";
}
@@ -1153,6 +1159,29 @@ async function buildResponsesPayload(
const hasReasoningOnlyRetryInstruction = allInputText.includes(QA_REASONING_ONLY_RETRY_NEEDLE);
const hasEmptyResponseRetryInstruction = allInputText.includes(QA_EMPTY_RESPONSE_RETRY_NEEDLE);
const canCallSessionsSpawn = hasDeclaredTool(body, "sessions_spawn");
const canCallSessionsYield = hasDeclaredTool(body, "sessions_yield");
if (
allInputText.includes(QA_SUBAGENT_DIRECT_FALLBACK_MARKER) &&
/Internal task completion event/i.test(allInputText)
) {
return buildAssistantEvents("");
}
if (QA_SUBAGENT_DIRECT_FALLBACK_PROMPT_RE.test(allInputText)) {
if (!toolOutput && canCallSessionsSpawn) {
return buildToolCallEventsWithArgs("sessions_spawn", {
task: `Subagent direct fallback worker: finish with exactly ${QA_SUBAGENT_DIRECT_FALLBACK_MARKER}.`,
label: "qa-direct-fallback-worker",
thread: false,
mode: "run",
runTimeoutSeconds: 30,
});
}
if (toolOutput && canCallSessionsYield && !/\byielded\b/i.test(toolOutput)) {
return buildToolCallEventsWithArgs("sessions_yield", {
message: `Waiting for ${QA_SUBAGENT_DIRECT_FALLBACK_MARKER}.`,
});
}
}
if (/remember this fact/i.test(prompt)) {
return buildAssistantEvents(buildAssistantText(input, body, scenarioState));
}

View File

@@ -185,6 +185,7 @@ describe("dispatchOutbound", () => {
text: "read this aloud",
cfg: {},
channel: "qqbot",
accountId: "qq-main",
});
expect(audioFileToSilkBase64Mock).toHaveBeenCalledWith("/tmp/openclaw-qqbot/tts.wav");
expect(sendVoiceMessageMock).toHaveBeenCalledWith(

View File

@@ -57,7 +57,12 @@ export interface GatewayPluginRuntime {
};
};
tts: {
textToSpeech: (params: { text: string; cfg: unknown; channel: string }) => Promise<{
textToSpeech: (params: {
text: string;
cfg: unknown;
channel: string;
accountId?: string;
}) => Promise<{
success: boolean;
audioPath?: string;
provider?: string;

View File

@@ -37,7 +37,12 @@ import {
/** TTS provider interface — injected from the outer layer. */
export interface TTSProvider {
/** Framework TTS: text → audio file path. */
textToSpeech(params: { text: string; cfg: unknown; channel: string }): Promise<{
textToSpeech(params: {
text: string;
cfg: unknown;
channel: string;
accountId?: string;
}): Promise<{
success: boolean;
audioPath?: string;
provider?: string;
@@ -406,6 +411,7 @@ export async function sendTextAsVoiceReply(
text: ttsText,
cfg,
channel: "qqbot",
accountId: account.accountId,
});
if (!ttsResult.success || !ttsResult.audioPath) {
log?.error(`TTS failed: ${ttsResult.error ?? "unknown"}`);

View File

@@ -28,6 +28,10 @@
"blurb": "supported (Socket Mode).",
"systemImage": "number",
"markdownCapable": true,
"commands": {
"nativeCommandsAutoEnabled": false,
"nativeSkillsAutoEnabled": false
},
"configuredState": {
"specifier": "./configured-state",
"exportName": "hasSlackConfiguredState"

View File

@@ -51,6 +51,7 @@ import {
type SpeechVoiceOption,
type TtsDirectiveOverrides,
type TtsDirectiveParseResult,
type TtsConfigResolutionContext,
} from "../api.js";
export type {
@@ -409,8 +410,11 @@ export function getResolvedSpeechProviderConfig(
return resolveLazyProviderConfig(config, canonical, cfg);
}
export function resolveTtsConfig(cfg: OpenClawConfig, agentId?: string): ResolvedTtsConfig {
const raw: TtsConfig = resolveEffectiveTtsConfig(cfg, agentId);
export function resolveTtsConfig(
cfg: OpenClawConfig,
contextOrAgentId?: string | TtsConfigResolutionContext,
): ResolvedTtsConfig {
const raw: TtsConfig = resolveEffectiveTtsConfig(cfg, contextOrAgentId);
const providerSource = raw.provider ? "config" : "default";
const timeoutMs = raw.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const auto = resolveConfiguredTtsAutoMode(raw);
@@ -470,11 +474,17 @@ function resolveEffectiveTtsAutoState(params: {
cfg: OpenClawConfig;
sessionAuto?: string;
agentId?: string;
channelId?: string;
accountId?: string;
}): {
autoMode: TtsAutoMode;
prefsPath: string;
} {
const raw: TtsConfig = resolveEffectiveTtsConfig(params.cfg, params.agentId);
const raw: TtsConfig = resolveEffectiveTtsConfig(params.cfg, {
agentId: params.agentId,
channelId: params.channelId,
accountId: params.accountId,
});
const prefsPath = resolveTtsPrefsPathValue(raw.prefsPath);
const sessionAuto = normalizeTtsAutoMode(params.sessionAuto);
if (sessionAuto) {
@@ -654,11 +664,17 @@ export function resolveExplicitTtsOverrides(params: {
modelId?: string;
voiceId?: string;
agentId?: string;
channelId?: string;
accountId?: string;
}): TtsDirectiveOverrides {
const providerInput = params.provider?.trim();
const modelId = params.modelId?.trim();
const voiceId = params.voiceId?.trim();
const config = resolveTtsConfig(params.cfg, params.agentId);
const config = resolveTtsConfig(params.cfg, {
agentId: params.agentId,
channelId: params.channelId,
accountId: params.accountId,
});
const prefsPath = params.prefsPath ?? resolveTtsPrefsPath(config);
const selectedProvider =
canonicalizeSpeechProviderId(providerInput, params.cfg) ??
@@ -991,6 +1007,8 @@ function resolveTtsRequestSetup(params: {
providerOverride?: TtsProvider;
disableFallback?: boolean;
agentId?: string;
channelId?: string;
accountId?: string;
}):
| {
config: ResolvedTtsConfig;
@@ -1000,7 +1018,11 @@ function resolveTtsRequestSetup(params: {
| {
error: string;
} {
const config = resolveTtsConfig(params.cfg, params.agentId);
const config = resolveTtsConfig(params.cfg, {
agentId: params.agentId,
channelId: params.channelId,
accountId: params.accountId,
});
const prefsPath = params.prefsPath ?? resolveTtsPrefsPath(config);
if (params.text.length > config.maxTextLength) {
return {
@@ -1027,6 +1049,7 @@ export async function textToSpeech(params: {
disableFallback?: boolean;
timeoutMs?: number;
agentId?: string;
accountId?: string;
}): Promise<TtsResult> {
const synthesis = await synthesizeSpeech(params);
if (!synthesis.success || !synthesis.audioBuffer || !synthesis.fileExtension) {
@@ -1077,6 +1100,7 @@ export async function synthesizeSpeech(params: {
disableFallback?: boolean;
timeoutMs?: number;
agentId?: string;
accountId?: string;
}): Promise<TtsSynthesisResult> {
const setup = resolveTtsRequestSetup({
text: params.text,
@@ -1085,6 +1109,8 @@ export async function synthesizeSpeech(params: {
providerOverride: params.overrides?.provider,
disableFallback: params.disableFallback,
agentId: params.agentId,
channelId: params.channel,
accountId: params.accountId,
});
if ("error" in setup) {
return { success: false, error: setup.error };
@@ -1365,6 +1391,7 @@ export async function maybeApplyTtsToPayload(params: {
inboundAudio?: boolean;
ttsAuto?: string;
agentId?: string;
accountId?: string;
}): Promise<ReplyPayload> {
if (params.payload.isCompactionNotice) {
return params.payload;
@@ -1373,11 +1400,17 @@ export async function maybeApplyTtsToPayload(params: {
cfg: params.cfg,
sessionAuto: params.ttsAuto,
agentId: params.agentId,
channelId: params.channel,
accountId: params.accountId,
});
if (autoMode === "off") {
return params.payload;
}
const config = resolveTtsConfig(params.cfg, params.agentId);
const config = resolveTtsConfig(params.cfg, {
agentId: params.agentId,
channelId: params.channel,
accountId: params.accountId,
});
const activeProvider = getTtsProvider(config, prefsPath);
const reply = resolveSendableOutboundReplyParts(params.payload);
@@ -1486,6 +1519,7 @@ export async function maybeApplyTtsToPayload(params: {
channel: params.channel,
overrides: directives.overrides,
agentId: params.agentId,
accountId: params.accountId,
});
if (result.success && result.audioPath) {

View File

@@ -38,6 +38,10 @@
"https://openclaw.ai"
],
"markdownCapable": true,
"commands": {
"nativeCommandsAutoEnabled": true,
"nativeSkillsAutoEnabled": true
},
"configuredState": {
"specifier": "./configured-state",
"exportName": "hasTelegramConfiguredState"

View File

@@ -1593,7 +1593,7 @@
"test:unit:fast:audit": "node scripts/test-unit-fast-audit.mjs",
"test:voicecall:closedloop": "node scripts/test-voicecall-closedloop.mjs",
"test:watch": "node scripts/test-projects.mjs --watch",
"test:windows:ci": "node scripts/test-projects.mjs src/process/exec.windows.test.ts src/process/windows-command.test.ts src/infra/windows-install-roots.test.ts test/scripts/npm-runner.test.ts test/scripts/pnpm-runner.test.ts test/scripts/ui.test.ts test/scripts/vitest-process-group.test.ts",
"test:windows:ci": "node scripts/test-projects.mjs src/process/exec.windows.test.ts src/process/windows-command.test.ts src/infra/windows-install-roots.test.ts extensions/lobster/src/lobster-runner.test.ts test/scripts/npm-runner.test.ts test/scripts/pnpm-runner.test.ts test/scripts/ui.test.ts test/scripts/vitest-process-group.test.ts",
"tool-display:check": "node --import tsx scripts/tool-display.ts --check",
"tool-display:write": "node --import tsx scripts/tool-display.ts --write",
"ts-topology": "node --import tsx scripts/ts-topology.ts",

View File

@@ -0,0 +1,99 @@
# Subagent completion direct fallback
```yaml qa-scenario
id: subagent-completion-direct-fallback
title: Subagent completion direct fallback
surface: subagents
coverage:
primary:
- agents.subagents
secondary:
- runtime.delivery
- channels.qa-channel
objective: Verify a yielded parent still receives a successful subagent result through direct fallback delivery when the dormant announce turn produces no visible reply.
successCriteria:
- Parent launches a native subagent.
- Parent yields instead of waiting in-turn.
- Subagent completion result is delivered to the original QA DM without a thread id.
- Durable task delivery is marked delivered, not failed.
docsRefs:
- docs/tools/subagents.md
- docs/help/testing.md
- docs/channels/qa-channel.md
codeRefs:
- src/agents/subagent-announce-delivery.ts
- src/agents/subagent-registry-lifecycle.ts
- src/agents/tools/sessions-yield-tool.ts
- extensions/qa-lab/src/providers/mock-openai/server.ts
execution:
kind: flow
summary: Reproduce yielded-parent subagent completion delivery and require frozen-result fallback to the QA DM.
config:
prompt: "Subagent direct fallback QA check: spawn one native subagent worker. The worker must finish with exactly QA-SUBAGENT-DIRECT-FALLBACK-OK. After spawning it, call sessions_yield and wait for the completion event. Do not use ACP."
expectedMarker: QA-SUBAGENT-DIRECT-FALLBACK-OK
expectedLabel: qa-direct-fallback-worker
```
```yaml qa-flow
steps:
- name: yielded parent receives child completion through direct fallback
actions:
- call: waitForGatewayHealthy
args:
- ref: env
- 120000
- call: waitForQaChannelReady
args:
- ref: env
- 120000
- call: reset
- set: sessionKey
value:
expr: "`agent:qa:subagent-direct-fallback:${randomUUID().slice(0, 8)}`"
- call: runAgentPrompt
args:
- ref: env
- sessionKey:
ref: sessionKey
message:
expr: config.prompt
timeoutMs:
expr: liveTurnTimeoutMs(env, 90000)
- call: waitForCondition
saveAs: outbound
args:
- lambda:
expr: "state.getSnapshot().messages.filter((message) => message.direction === 'outbound' && String(message.text ?? '').includes(config.expectedMarker)).at(-1)"
- expr: liveTurnTimeoutMs(env, 60000)
- expr: "env.providerMode === 'mock-openai' ? 100 : 250"
- assert:
expr: "String(outbound.text ?? '').trim().includes(config.expectedMarker)"
message:
expr: "`fallback completion marker missing from outbound QA DM: ${recentOutboundSummary(state)}`"
- if:
expr: "Boolean(env.mock)"
then:
- set: fallbackDebugRequests
value:
expr: "[...(await fetchJson(`${env.mock.baseUrl}/debug/requests`))]"
- assert:
expr: "fallbackDebugRequests.some((request) => !request.toolOutput && /subagent direct fallback qa check/i.test(String(request.allInputText ?? '')) && request.plannedToolName === 'sessions_spawn' && request.plannedToolArgs?.label === config.expectedLabel)"
message:
expr: "`expected sessions_spawn for yielded fallback scenario, saw ${JSON.stringify(fallbackDebugRequests.map((request) => ({ plannedToolName: request.plannedToolName ?? null, plannedToolArgs: request.plannedToolArgs ?? null })))}`"
- assert:
expr: "fallbackDebugRequests.some((request) => /subagent direct fallback qa check/i.test(String(request.allInputText ?? '')) && request.plannedToolName === 'sessions_yield')"
message:
expr: "`expected sessions_yield for yielded fallback scenario, saw ${JSON.stringify(fallbackDebugRequests.map((request) => request.plannedToolName ?? null))}`"
- call: waitForCondition
saveAs: deliveredTask
args:
- lambda:
expr: "(async () => { const payload = await runQaCli(env, ['tasks', 'list', '--json', '--runtime', 'subagent'], { timeoutMs: liveTurnTimeoutMs(env, 60000), json: true }); return (payload.tasks ?? []).find((task) => task.label === config.expectedLabel && task.deliveryStatus === 'delivered' && task.status === 'succeeded') ?? null; })()"
- expr: liveTurnTimeoutMs(env, 30000)
- 250
- assert:
expr: "deliveredTask.deliveryStatus === 'delivered'"
message:
expr: "`expected delivered task status for ${config.expectedLabel}, got ${JSON.stringify(deliveredTask)}`"
detailsExpr: "outbound.text"
```

View File

@@ -26,6 +26,7 @@ docker run --rm \
-e "OPENCLAW_SKIP_CHANNELS=1" \
-e "OPENCLAW_SKIP_GMAIL_WATCHER=1" \
-e "OPENCLAW_SKIP_CANVAS_HOST=1" \
-e "OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE=1" \
-e "OPENCLAW_STATE_DIR=/tmp/openclaw-state" \
-e "OPENCLAW_CONFIG_PATH=/tmp/openclaw-state/openclaw.json" \
-e "GW_URL=ws://127.0.0.1:$PORT" \

View File

@@ -80,6 +80,9 @@ async function main() {
},
agents: {
defaults: {
skipBootstrap: true,
contextInjection: "never",
skills: [],
subagents: {
runTimeoutSeconds: 8,
},

View File

@@ -26,6 +26,7 @@ docker run --rm \
-e "OPENCLAW_SKIP_GMAIL_WATCHER=1" \
-e "OPENCLAW_SKIP_CRON=1" \
-e "OPENCLAW_SKIP_CANVAS_HOST=1" \
-e "OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE=1" \
-e "OPENCLAW_STATE_DIR=/tmp/openclaw-state" \
-e "OPENCLAW_CONFIG_PATH=/tmp/openclaw-state/openclaw.json" \
-e "GW_URL=ws://127.0.0.1:$PORT" \

View File

@@ -3,8 +3,16 @@ import { closeSync, openSync, readSync } from "node:fs";
import path from "node:path";
import { buildCmdExeCommandLine } from "./windows-cmd-helpers.mjs";
function getPortableBasename(value) {
return value.split(/[/\\]/).at(-1) ?? value;
}
function getPortableExtension(value) {
return path.posix.extname(getPortableBasename(value)).toLowerCase();
}
function isPnpmExecPath(value) {
return /^pnpm(?:-cli)?(?:\.(?:[cm]?js|cmd|exe))?$/.test(path.basename(value).toLowerCase());
return /^pnpm(?:-cli)?(?:\.(?:[cm]?js|cmd|exe))?$/.test(getPortableBasename(value).toLowerCase());
}
function hasScriptShebang(value) {
@@ -30,7 +38,7 @@ function isNodeRunnablePnpmExecPath(value) {
if (!isPnpmExecPath(value)) {
return false;
}
const extension = path.extname(value).toLowerCase();
const extension = getPortableExtension(value);
if (extension === ".js" || extension === ".cjs" || extension === ".mjs") {
return true;
}
@@ -48,16 +56,31 @@ export function resolvePnpmRunner(params = {}) {
const platform = params.platform ?? process.platform;
const comSpec = params.comSpec ?? process.env.ComSpec ?? "cmd.exe";
if (
typeof npmExecPath === "string" &&
npmExecPath.length > 0 &&
isNodeRunnablePnpmExecPath(npmExecPath)
) {
return {
command: nodeExecPath,
args: [...nodeArgs, npmExecPath, ...pnpmArgs],
shell: false,
};
if (typeof npmExecPath === "string" && npmExecPath.length > 0 && isPnpmExecPath(npmExecPath)) {
if (isNodeRunnablePnpmExecPath(npmExecPath)) {
return {
command: nodeExecPath,
args: [...nodeArgs, npmExecPath, ...pnpmArgs],
shell: false,
};
}
const npmExecExtension = getPortableExtension(npmExecPath);
if (platform === "win32" && npmExecExtension === ".exe") {
return {
command: npmExecPath,
args: pnpmArgs,
shell: false,
};
}
if (platform === "win32" && npmExecExtension === ".cmd") {
return {
command: comSpec,
args: ["/d", "/s", "/c", buildCmdExeCommandLine(npmExecPath, pnpmArgs)],
shell: false,
windowsVerbatimArguments: true,
};
}
}
if (platform === "win32") {

View File

@@ -76,6 +76,10 @@ export function assertSafeWindowsShellArgs(args, platform = process.platform) {
);
}
export function prepareSpawnCommand(cmd, platform = process.platform) {
return shouldUseShellForCommand(cmd, platform) ? `"${cmd}"` : cmd;
}
function createSpawnOptions(cmd, args, envOverride) {
const useShell = shouldUseShellForCommand(cmd);
if (useShell) {
@@ -92,7 +96,7 @@ function createSpawnOptions(cmd, args, envOverride) {
function run(cmd, args) {
let child;
try {
child = spawn(cmd, args, createSpawnOptions(cmd, args));
child = spawn(prepareSpawnCommand(cmd), args, createSpawnOptions(cmd, args));
} catch (err) {
console.error(`Failed to launch ${cmd}:`, err);
process.exit(1);
@@ -113,7 +117,7 @@ function run(cmd, args) {
function runSync(cmd, args, envOverride) {
let result;
try {
result = spawnSync(cmd, args, createSpawnOptions(cmd, args, envOverride));
result = spawnSync(prepareSpawnCommand(cmd), args, createSpawnOptions(cmd, args, envOverride));
} catch (err) {
console.error(`Failed to launch ${cmd}:`, err);
process.exit(1);

View File

@@ -0,0 +1,19 @@
import type { AgentRuntimePolicyConfig } from "../config/types.agents-shared.js";
type AgentRuntimePolicyContainer = {
agentRuntime?: AgentRuntimePolicyConfig;
};
export function resolveAgentRuntimePolicy(
container: AgentRuntimePolicyContainer | undefined,
): AgentRuntimePolicyConfig | undefined {
const preferred = container?.agentRuntime;
if (hasAgentRuntimePolicy(preferred)) {
return preferred;
}
return undefined;
}
function hasAgentRuntimePolicy(value: AgentRuntimePolicyConfig | undefined): boolean {
return Boolean(value?.id?.trim() || value?.fallback);
}

View File

@@ -302,7 +302,7 @@ describe("Auth profile runtime contract - Pi and CLI adapter", () => {
cfg: {
agents: {
defaults: {
embeddedHarness: { runtime: "codex", fallback: "none" },
agentRuntime: { id: "codex", fallback: "none" },
},
},
} as OpenClawConfig,
@@ -385,7 +385,7 @@ describe("Auth profile runtime contract - Pi and CLI adapter", () => {
cfg: {
agents: {
defaults: {
embeddedHarness: { runtime: "codex", fallback: "none" },
agentRuntime: { id: "codex", fallback: "none" },
},
},
} as OpenClawConfig,
@@ -408,7 +408,7 @@ describe("Auth profile runtime contract - Pi and CLI adapter", () => {
cfg: {
agents: {
defaults: {
embeddedHarness: { runtime: "codex", fallback: "none" },
agentRuntime: { id: "codex", fallback: "none" },
},
},
} as OpenClawConfig,
@@ -434,7 +434,7 @@ describe("Auth profile runtime contract - Pi and CLI adapter", () => {
list: [
{
id: "main",
embeddedHarness: { runtime: "codex", fallback: "none" },
agentRuntime: { id: "codex", fallback: "none" },
},
],
},

View File

@@ -469,6 +469,60 @@ describe("CLI attempt execution", () => {
}),
);
});
it("routes canonical Anthropic models through the configured Claude CLI runtime", async () => {
const sessionKey = "agent:main:direct:canonical-claude-cli";
const sessionEntry: SessionEntry = {
sessionId: "openclaw-session-canonical-cli",
updatedAt: Date.now(),
};
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry };
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8");
runCliAgentMock.mockResolvedValueOnce(makeCliResult("canonical cli"));
await runAgentAttempt({
providerOverride: "anthropic",
modelOverride: "claude-opus-4-7",
cfg: {
agents: {
defaults: {
agentRuntime: { id: "claude-cli", fallback: "none" },
},
},
} as OpenClawConfig,
sessionEntry,
sessionId: sessionEntry.sessionId,
sessionKey,
sessionAgentId: "main",
sessionFile: path.join(tmpDir, "session.jsonl"),
workspaceDir: tmpDir,
body: "route this",
isFallbackRetry: false,
resolvedThinkLevel: "medium",
timeoutMs: 1_000,
runId: "run-canonical-claude-cli",
opts: { senderIsOwner: false } as Parameters<typeof runAgentAttempt>[0]["opts"],
runContext: {} as Parameters<typeof runAgentAttempt>[0]["runContext"],
spawnedBy: undefined,
messageChannel: "telegram",
skillsSnapshot: undefined,
resolvedVerboseLevel: undefined,
agentDir: tmpDir,
onAgentEvent: vi.fn(),
authProfileProvider: "anthropic",
sessionStore,
storePath,
sessionHasHistory: false,
});
expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled();
expect(runCliAgentMock).toHaveBeenCalledWith(
expect.objectContaining({
provider: "claude-cli",
model: "claude-opus-4-7",
}),
);
});
});
describe("embedded attempt harness pinning", () => {
@@ -476,6 +530,7 @@ describe("embedded attempt harness pinning", () => {
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-embedded-attempt-"));
runCliAgentMock.mockReset();
runEmbeddedPiAgentMock.mockReset();
});
@@ -541,7 +596,7 @@ describe("embedded attempt harness pinning", () => {
cfg: {
agents: {
defaults: {
embeddedHarness: { runtime: "codex", fallback: "none" },
agentRuntime: { id: "codex", fallback: "none" },
},
},
} as OpenClawConfig,
@@ -617,4 +672,54 @@ describe("embedded attempt harness pinning", () => {
}),
);
});
it("does not pass CLI runtime aliases as embedded harness ids for fallback providers", async () => {
const sessionEntry: SessionEntry = {
sessionId: "fallback-session",
updatedAt: Date.now(),
};
runEmbeddedPiAgentMock.mockResolvedValueOnce({
meta: { durationMs: 1 },
} satisfies EmbeddedPiRunResult);
await runAgentAttempt({
providerOverride: "openai",
modelOverride: "gpt-5.4",
cfg: {
agents: {
defaults: {
agentRuntime: { id: "claude-cli", fallback: "none" },
},
},
} as OpenClawConfig,
sessionEntry,
sessionId: sessionEntry.sessionId,
sessionKey: "agent:main:main",
sessionAgentId: "main",
sessionFile: path.join(tmpDir, "session.jsonl"),
workspaceDir: tmpDir,
body: "fallback",
isFallbackRetry: true,
resolvedThinkLevel: "medium",
timeoutMs: 1_000,
runId: "run-openai-fallback-with-cli-runtime",
opts: { senderIsOwner: false } as Parameters<typeof runAgentAttempt>[0]["opts"],
runContext: {} as Parameters<typeof runAgentAttempt>[0]["runContext"],
spawnedBy: undefined,
messageChannel: undefined,
skillsSnapshot: undefined,
resolvedVerboseLevel: undefined,
agentDir: tmpDir,
onAgentEvent: vi.fn(),
authProfileProvider: "openai",
sessionHasHistory: false,
});
expect(runCliAgentMock).not.toHaveBeenCalled();
expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce();
expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]).not.toHaveProperty(
"agentHarnessId",
"claude-cli",
);
});
});

View File

@@ -15,6 +15,7 @@ import { runCliAgent } from "../cli-runner.js";
import { getCliSessionBinding, setCliSessionBinding } from "../cli-session.js";
import { FailoverError } from "../failover-error.js";
import { resolveAgentHarnessPolicy } from "../harness/selection.js";
import { isCliRuntimeAlias, resolveCliRuntimeExecutionProvider } from "../model-runtime-aliases.js";
import { isCliProvider } from "../model-selection.js";
import { prepareSessionManagerForRun } from "../pi-embedded-runner/session-manager-init.js";
import { runEmbeddedPiAgent, type EmbeddedPiRunResult } from "../pi-embedded.js";
@@ -276,6 +277,14 @@ export function runAgentAttempt(params: {
sessionId: params.sessionId,
sessionKey: params.sessionKey ?? params.sessionId,
});
const agentRuntimeOverride = params.sessionEntry?.agentRuntimeOverride?.trim();
const cliExecutionProvider =
resolveCliRuntimeExecutionProvider({
provider: params.providerOverride,
cfg: params.cfg,
agentId: params.sessionAgentId,
runtimeOverride: agentRuntimeOverride,
}) ?? params.providerOverride;
const agentHarnessPolicy = resolveAgentHarnessPolicy({
provider: params.providerOverride,
modelId: params.modelOverride,
@@ -291,14 +300,14 @@ export function runAgentAttempt(params: {
workspaceDir: params.workspaceDir,
harnessId: sessionPinnedAgentHarnessId,
harnessRuntime: agentHarnessPolicy.runtime,
allowHarnessAuthProfileForwarding: !isCliProvider(params.providerOverride, params.cfg),
allowHarnessAuthProfileForwarding: !isCliProvider(cliExecutionProvider, params.cfg),
});
const authProfileId = runtimeAuthPlan.forwardedAuthProfileId;
if (isCliProvider(params.providerOverride, params.cfg)) {
const cliSessionBinding = getCliSessionBinding(params.sessionEntry, params.providerOverride);
if (isCliProvider(cliExecutionProvider, params.cfg)) {
const cliSessionBinding = getCliSessionBinding(params.sessionEntry, cliExecutionProvider);
const resolveReusableCliSessionBinding = async () => {
if (
!isClaudeCliProvider(params.providerOverride) ||
!isClaudeCliProvider(cliExecutionProvider) ||
!cliSessionBinding?.sessionId ||
(await claudeCliSessionTranscriptHasContent({ sessionId: cliSessionBinding.sessionId }))
) {
@@ -306,13 +315,13 @@ export function runAgentAttempt(params: {
}
log.warn(
`cli session reset: provider=${sanitizeForLog(params.providerOverride)} reason=transcript-missing sessionKey=${params.sessionKey ?? params.sessionId}`,
`cli session reset: provider=${sanitizeForLog(cliExecutionProvider)} reason=transcript-missing sessionKey=${params.sessionKey ?? params.sessionId}`,
);
if (params.sessionKey && params.sessionStore && params.storePath) {
params.sessionEntry =
(await clearCliSessionInStore({
provider: params.providerOverride,
provider: cliExecutionProvider,
sessionKey: params.sessionKey,
sessionStore: params.sessionStore,
storePath: params.storePath,
@@ -334,7 +343,7 @@ export function runAgentAttempt(params: {
workspaceDir: params.workspaceDir,
config: params.cfg,
prompt: effectivePrompt,
provider: params.providerOverride,
provider: cliExecutionProvider,
model: params.modelOverride,
thinkLevel: params.resolvedThinkLevel,
timeoutMs: params.timeoutMs,
@@ -370,12 +379,12 @@ export function runAgentAttempt(params: {
params.storePath
) {
log.warn(
`CLI session expired, clearing from session store: provider=${sanitizeForLog(params.providerOverride)} sessionKey=${params.sessionKey}`,
`CLI session expired, clearing from session store: provider=${sanitizeForLog(cliExecutionProvider)} sessionKey=${params.sessionKey}`,
);
params.sessionEntry =
(await clearCliSessionInStore({
provider: params.providerOverride,
provider: cliExecutionProvider,
sessionKey: params.sessionKey,
sessionStore: params.sessionStore,
storePath: params.storePath,
@@ -393,7 +402,7 @@ export function runAgentAttempt(params: {
const updatedEntry = { ...entry };
setCliSessionBinding(
updatedEntry,
params.providerOverride,
cliExecutionProvider,
result.meta.agentMeta.cliSessionBinding,
);
updatedEntry.updatedAt = Date.now();
@@ -503,7 +512,10 @@ function resolveConfiguredAgentHarnessId(params: {
agentId: params.sessionAgentId,
sessionKey: params.sessionKey,
});
return policy.runtime === "auto" ? undefined : policy.runtime;
if (policy.runtime === "auto" || isCliRuntimeAlias(policy.runtime)) {
return undefined;
}
return policy.runtime;
}
export function buildAcpResult(params: {

View File

@@ -1,6 +1,7 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
import { isRecord } from "../utils.js";
import { resolveAgentRuntimePolicy } from "./agent-runtime-policy.js";
export function collectConfiguredAgentHarnessRuntimes(
config: OpenClawConfig,
@@ -18,13 +19,13 @@ export function collectConfiguredAgentHarnessRuntimes(
runtimes.add(normalized);
};
pushRuntime(config.agents?.defaults?.embeddedHarness?.runtime);
pushRuntime(resolveAgentRuntimePolicy(config.agents?.defaults)?.id);
if (Array.isArray(config.agents?.list)) {
for (const agent of config.agents.list) {
if (!isRecord(agent)) {
continue;
}
pushRuntime((agent.embeddedHarness as Record<string, unknown> | undefined)?.runtime);
pushRuntime(resolveAgentRuntimePolicy(agent)?.id);
}
}
pushRuntime(env.OPENCLAW_AGENT_RUNTIME);

View File

@@ -124,7 +124,7 @@ describe("runAgentHarnessAttemptWithFallback", () => {
await expect(
runAgentHarnessAttemptWithFallback(
createAttemptParams({ agents: { defaults: { embeddedHarness: { fallback: "pi" } } } }),
createAttemptParams({ agents: { defaults: { agentRuntime: { fallback: "pi" } } } }),
),
).rejects.toThrow('Requested agent harness "codex" is not registered');
expect(piRunAttempt).not.toHaveBeenCalled();
@@ -132,7 +132,7 @@ describe("runAgentHarnessAttemptWithFallback", () => {
it("falls back to the PI harness in auto mode when no plugin harness matches", async () => {
const result = await runAgentHarnessAttemptWithFallback(
createAttemptParams({ agents: { defaults: { embeddedHarness: { runtime: "auto" } } } }),
createAttemptParams({ agents: { defaults: { agentRuntime: { id: "auto" } } } }),
);
expect(result.sessionIdUsed).toBe("pi");
@@ -144,7 +144,7 @@ describe("runAgentHarnessAttemptWithFallback", () => {
await expect(
runAgentHarnessAttemptWithFallback(
createAttemptParams({ agents: { defaults: { embeddedHarness: { runtime: "auto" } } } }),
createAttemptParams({ agents: { defaults: { agentRuntime: { id: "auto" } } } }),
),
).rejects.toThrow("codex startup failed");
expect(piRunAttempt).not.toHaveBeenCalled();
@@ -164,7 +164,7 @@ describe("runAgentHarnessAttemptWithFallback", () => {
await expect(
runAgentHarnessAttemptWithFallback(
createAttemptParams({ agents: { defaults: { embeddedHarness: { runtime: "codex" } } } }),
createAttemptParams({ agents: { defaults: { agentRuntime: { id: "codex" } } } }),
),
).rejects.toThrow("codex startup failed");
expect(piRunAttempt).not.toHaveBeenCalled();
@@ -185,7 +185,7 @@ describe("runAgentHarnessAttemptWithFallback", () => {
);
const params = createAttemptParams({
agents: { defaults: { embeddedHarness: { runtime: "auto" } } },
agents: { defaults: { agentRuntime: { id: "auto" } } },
});
const result = await runAgentHarnessAttemptWithFallback(params);
@@ -205,7 +205,7 @@ describe("runAgentHarnessAttemptWithFallback", () => {
await expect(
runAgentHarnessAttemptWithFallback(
createAttemptParams({
agents: { defaults: { embeddedHarness: { runtime: "auto", fallback: "pi" } } },
agents: { defaults: { agentRuntime: { id: "auto", fallback: "pi" } } },
}),
),
).rejects.toThrow("PI fallback is disabled");
@@ -215,7 +215,7 @@ describe("runAgentHarnessAttemptWithFallback", () => {
it("fails for config-forced plugin harnesses when fallback is omitted", async () => {
await expect(
runAgentHarnessAttemptWithFallback(
createAttemptParams({ agents: { defaults: { embeddedHarness: { runtime: "codex" } } } }),
createAttemptParams({ agents: { defaults: { agentRuntime: { id: "codex" } } } }),
),
).rejects.toThrow('Requested agent harness "codex" is not registered');
expect(piRunAttempt).not.toHaveBeenCalled();
@@ -224,7 +224,7 @@ describe("runAgentHarnessAttemptWithFallback", () => {
it("allows config-forced plugin harnesses to opt into PI fallback", async () => {
const result = await runAgentHarnessAttemptWithFallback(
createAttemptParams({
agents: { defaults: { embeddedHarness: { runtime: "codex", fallback: "pi" } } },
agents: { defaults: { agentRuntime: { id: "codex", fallback: "pi" } } },
}),
);
@@ -237,8 +237,8 @@ describe("runAgentHarnessAttemptWithFallback", () => {
runAgentHarnessAttemptWithFallback({
...createAttemptParams({
agents: {
defaults: { embeddedHarness: { fallback: "pi" } },
list: [{ id: "strict", embeddedHarness: { runtime: "codex" } }],
defaults: { agentRuntime: { fallback: "pi" } },
list: [{ id: "strict", agentRuntime: { id: "codex" } }],
},
}),
sessionKey: "agent:strict:session-1",
@@ -251,8 +251,8 @@ describe("runAgentHarnessAttemptWithFallback", () => {
const result = await runAgentHarnessAttemptWithFallback({
...createAttemptParams({
agents: {
defaults: { embeddedHarness: { fallback: "none" } },
list: [{ id: "strict", embeddedHarness: { runtime: "codex", fallback: "pi" } }],
defaults: { agentRuntime: { fallback: "none" } },
list: [{ id: "strict", agentRuntime: { id: "codex", fallback: "pi" } }],
},
}),
sessionKey: "agent:strict:session-1",
@@ -328,7 +328,7 @@ describe("selectAgentHarness", () => {
const harness = selectAgentHarness({
provider: "codex",
modelId: "gpt-5.4",
config: { agents: { defaults: { embeddedHarness: { runtime: "auto" } } } },
config: { agents: { defaults: { agentRuntime: { id: "auto" } } } },
});
expect(harness.id).toBe("codex-high");
@@ -362,20 +362,20 @@ describe("selectAgentHarness", () => {
provider: "anthropic",
modelId: "sonnet-4.6",
config: {
agents: { defaults: { embeddedHarness: { runtime: "auto", fallback: "none" } } },
agents: { defaults: { agentRuntime: { id: "auto", fallback: "none" } } },
},
}),
).toThrow("PI fallback is disabled");
expect(piRunAttempt).not.toHaveBeenCalled();
});
it("allows per-agent embedded harness policy overrides", () => {
it("allows per-agent runtime policy overrides", () => {
const config: OpenClawConfig = {
agents: {
defaults: { embeddedHarness: { fallback: "pi" } },
defaults: { agentRuntime: { fallback: "pi" } },
list: [
{ id: "main", default: true },
{ id: "strict", embeddedHarness: { runtime: "auto", fallback: "none" } },
{ id: "strict", agentRuntime: { id: "auto", fallback: "none" } },
],
},
};
@@ -393,6 +393,46 @@ describe("selectAgentHarness", () => {
);
});
it("uses agentRuntime as the runtime policy source", () => {
const config: OpenClawConfig = {
agents: {
defaults: {
agentRuntime: { id: "auto", fallback: "none" },
},
},
};
expect(() =>
selectAgentHarness({
provider: "anthropic",
modelId: "sonnet-4.6",
config,
}),
).toThrow("PI fallback is disabled");
});
it("does not treat CLI runtime aliases as embedded harness ids", async () => {
const config: OpenClawConfig = {
agents: {
defaults: {
agentRuntime: { id: "claude-cli", fallback: "none" },
},
},
};
expect(selectAgentHarness({ provider: "openai", modelId: "gpt-5.4", config }).id).toBe("pi");
await expect(
runAgentHarnessAttemptWithFallback({
...createAttemptParams(config),
provider: "openai",
modelId: "gpt-5.4",
}),
).resolves.toMatchObject({
sessionIdUsed: "pi",
});
});
it("keeps an existing session pinned to PI even when config now forces a plugin harness", () => {
registerFailingCodexHarness();
@@ -401,7 +441,7 @@ describe("selectAgentHarness", () => {
provider: "codex",
modelId: "gpt-5.4",
agentHarnessId: "pi",
config: { agents: { defaults: { embeddedHarness: { runtime: "codex" } } } },
config: { agents: { defaults: { agentRuntime: { id: "codex" } } } },
}).id,
).toBe("pi");
});

View File

@@ -1,9 +1,11 @@
import type { AgentEmbeddedHarnessConfig } from "../../config/types.agents-shared.js";
import type { AgentRuntimePolicyConfig } from "../../config/types.agents-shared.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { normalizeAgentId } from "../../routing/session-key.js";
import { resolveAgentRuntimePolicy } from "../agent-runtime-policy.js";
import { listAgentEntries, resolveSessionAgentIds } from "../agent-scope.js";
import { isCliRuntimeAlias } from "../model-runtime-aliases.js";
import type { CompactEmbeddedPiSessionParams } from "../pi-embedded-runner/compact.types.js";
import type {
EmbeddedRunAttemptParams,
@@ -314,10 +316,16 @@ export function resolveAgentHarnessPolicy(params: {
agentId: params.agentId,
sessionKey: params.sessionKey,
});
const defaultsPolicy = params.config?.agents?.defaults?.embeddedHarness;
const defaultsPolicy = resolveAgentRuntimePolicy(params.config?.agents?.defaults);
const runtime = env.OPENCLAW_AGENT_RUNTIME?.trim()
? resolveEmbeddedAgentRuntime(env)
: normalizeEmbeddedAgentRuntime(agentPolicy?.runtime ?? defaultsPolicy?.runtime);
: normalizeEmbeddedAgentRuntime(agentPolicy?.id ?? defaultsPolicy?.id);
if (isCliRuntimeAlias(runtime)) {
return {
runtime: "pi",
fallback: "pi",
};
}
return {
runtime,
fallback: resolveAgentHarnessFallbackPolicy({
@@ -332,8 +340,8 @@ export function resolveAgentHarnessPolicy(params: {
function resolveAgentHarnessFallbackPolicy(params: {
env: NodeJS.ProcessEnv;
runtime: EmbeddedAgentRuntime;
agentPolicy?: AgentEmbeddedHarnessConfig;
defaultsPolicy?: AgentEmbeddedHarnessConfig;
agentPolicy?: AgentRuntimePolicyConfig;
defaultsPolicy?: AgentRuntimePolicyConfig;
}): EmbeddedAgentHarnessFallback {
const envFallback = resolveEmbeddedAgentHarnessFallback(params.env);
if (envFallback) {
@@ -345,7 +353,7 @@ function resolveAgentHarnessFallbackPolicy(params: {
return normalizeAgentHarnessFallback(undefined, params.runtime);
}
if (params.agentPolicy?.runtime) {
if (params.agentPolicy?.id) {
return normalizeAgentHarnessFallback(params.agentPolicy.fallback, params.runtime);
}
@@ -362,7 +370,7 @@ function isPluginAgentRuntime(runtime: EmbeddedAgentRuntime): boolean {
function resolveAgentEmbeddedHarnessConfig(
config: OpenClawConfig | undefined,
params: { agentId?: string; sessionKey?: string },
): AgentEmbeddedHarnessConfig | undefined {
): AgentRuntimePolicyConfig | undefined {
if (!config) {
return undefined;
}
@@ -371,12 +379,13 @@ function resolveAgentEmbeddedHarnessConfig(
agentId: params.agentId,
sessionKey: params.sessionKey,
});
return listAgentEntries(config).find((entry) => normalizeAgentId(entry.id) === sessionAgentId)
?.embeddedHarness;
return resolveAgentRuntimePolicy(
listAgentEntries(config).find((entry) => normalizeAgentId(entry.id) === sessionAgentId),
);
}
function normalizeAgentHarnessFallback(
value: AgentEmbeddedHarnessConfig["fallback"] | undefined,
value: AgentRuntimePolicyConfig["fallback"] | undefined,
runtime: EmbeddedAgentRuntime,
): EmbeddedAgentHarnessFallback {
if (value) {

View File

@@ -1,5 +1,6 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { normalizeAgentId } from "../routing/session-key.js";
import { resolveAgentRuntimePolicy } from "./agent-runtime-policy.js";
import { normalizeProviderId } from "./provider-id.js";
export type LegacyRuntimeModelProviderAlias = {
@@ -39,6 +40,12 @@ const CLI_RUNTIME_BY_PROVIDER = new Map(
]),
);
const CLI_RUNTIME_ALIASES = new Set(
LEGACY_RUNTIME_MODEL_PROVIDER_ALIASES.filter((entry) => entry.cli).map((entry) =>
normalizeProviderId(entry.runtime),
),
);
export function listLegacyRuntimeModelProviderAliases(): readonly LegacyRuntimeModelProviderAlias[] {
return LEGACY_RUNTIME_MODEL_PROVIDER_ALIASES;
}
@@ -84,6 +91,11 @@ export function isLegacyRuntimeModelProvider(provider: string): boolean {
return Boolean(resolveLegacyRuntimeModelProviderAlias(provider));
}
export function isCliRuntimeAlias(runtime: string | undefined): boolean {
const normalized = runtime?.trim();
return normalized ? CLI_RUNTIME_ALIASES.has(normalizeProviderId(normalized)) : false;
}
function resolveConfiguredRuntime(params: {
cfg?: OpenClawConfig;
agentId?: string;
@@ -94,14 +106,15 @@ function resolveConfiguredRuntime(params: {
return normalizeProviderId(override);
}
if (params.agentId) {
const agentRuntime = params.cfg?.agents?.list
?.find((entry) => normalizeAgentId(entry.id) === normalizeAgentId(params.agentId ?? ""))
?.embeddedHarness?.runtime?.trim();
const agentEntry = params.cfg?.agents?.list?.find(
(entry) => normalizeAgentId(entry.id) === normalizeAgentId(params.agentId ?? ""),
);
const agentRuntime = resolveAgentRuntimePolicy(agentEntry)?.id?.trim();
if (agentRuntime) {
return normalizeProviderId(agentRuntime);
}
}
const defaults = params.cfg?.agents?.defaults?.embeddedHarness?.runtime?.trim();
const defaults = resolveAgentRuntimePolicy(params.cfg?.agents?.defaults)?.id?.trim();
if (defaults) {
return normalizeProviderId(defaults);
}

View File

@@ -254,6 +254,7 @@ export function createOpenClawTools(
agentChannel: options?.agentChannel,
config: resolvedConfig,
agentId: sessionAgentId,
agentAccountId: options?.agentAccountId,
}),
...collectPresentOpenClawTools([imageGenerateTool, musicGenerateTool, videoGenerateTool]),
...(embedded

View File

@@ -201,6 +201,51 @@ describe("createOpenClawTools TTS config wiring", () => {
__testing.setDepsForTest();
}
});
it("passes the active account id into the tts tool", async () => {
const injectedConfig = {
channels: {
feishu: {
accounts: {
"feishu-main": {
tts: {
provider: "microsoft",
},
},
},
},
},
} satisfies OpenClawConfig;
const { __testing, createOpenClawTools } = await import("./openclaw-tools.js");
__testing.setDepsForTest({ config: injectedConfig });
try {
const tool = createOpenClawTools({
agentChannel: "feishu",
agentAccountId: "feishu-main",
disableMessageTool: true,
disablePluginTools: true,
}).find((candidate) => candidate.name === "tts");
if (!tool) {
throw new Error("missing tts tool");
}
await tool.execute("call-1", { text: "hello from account" });
expect(mocks.textToSpeech).toHaveBeenCalledWith(
expect.objectContaining({
text: "hello from account",
cfg: injectedConfig,
channel: "feishu",
accountId: "feishu-main",
}),
);
} finally {
__testing.setDepsForTest();
}
});
});
describe("createOpenClawTools cron context wiring", () => {

View File

@@ -264,7 +264,7 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => {
config: {
agents: {
defaults: {
embeddedHarness: { runtime: "codex", fallback: "none" },
agentRuntime: { id: "codex", fallback: "none" },
},
},
},
@@ -336,7 +336,7 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => {
config: {
agents: {
defaults: {
embeddedHarness: { runtime: "codex", fallback: "none" },
agentRuntime: { id: "codex", fallback: "none" },
},
},
},

View File

@@ -105,6 +105,60 @@ describe("wrapStreamFnWithDiagnosticModelCallEvents", () => {
});
});
it("propagates the trusted model-call traceparent without mutating caller headers", async () => {
async function* stream() {
yield { type: "text", text: "ok" };
}
const capturedOptions: Array<Parameters<StreamFn>[2]> = [];
const callerOptions = {
headers: {
"X-Custom": "kept",
TraceParent: "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01",
},
sessionId: "provider-session",
};
const wrapped = wrapStreamFnWithDiagnosticModelCallEvents(
((
_model: Parameters<StreamFn>[0],
_context: Parameters<StreamFn>[1],
options: Parameters<StreamFn>[2],
) => {
capturedOptions.push(options);
return stream();
}) as unknown as StreamFn,
{
runId: "run-1",
provider: "openai",
model: "gpt-5.4",
trace: createDiagnosticTraceContext({
traceId: "4bf92f3577b34da6a3ce929d0e0e4736",
spanId: "00f067aa0ba902b7",
traceFlags: "01",
}),
nextCallId: () => "call-traceparent",
},
);
await drain(
wrapped({} as never, {} as never, callerOptions) as unknown as AsyncIterable<unknown>,
);
expect(capturedOptions).toHaveLength(1);
expect(capturedOptions[0]).not.toBe(callerOptions);
expect(capturedOptions[0]).toMatchObject({
sessionId: "provider-session",
headers: {
"X-Custom": "kept",
traceparent: expect.stringMatching(/^00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01$/),
},
});
expect(capturedOptions[0]?.headers).not.toHaveProperty("TraceParent");
expect(callerOptions.headers).toEqual({
"X-Custom": "kept",
TraceParent: "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01",
});
});
it("emits error events when stream iteration fails", async () => {
const requestId = "req_provider_123";
const stream = {

View File

@@ -11,6 +11,7 @@ import {
import {
createChildDiagnosticTraceContext,
freezeDiagnosticTraceContext,
formatDiagnosticTraceparent,
type DiagnosticTraceContext,
} from "../../../infra/diagnostic-trace-context.js";
import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js";
@@ -48,6 +49,8 @@ type ModelCallEndedHookFields = Pick<
>;
const MODEL_CALL_STREAM_RETURN_TIMEOUT_MS = 1000;
const TRACEPARENT_HEADER_NAME = "traceparent";
type ModelCallStreamOptions = Parameters<StreamFn>[2];
function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
if (value === null || (typeof value !== "object" && typeof value !== "function")) {
@@ -197,6 +200,29 @@ function emitModelCallError(
});
}
function withDiagnosticTraceparentHeader(
options: ModelCallStreamOptions,
trace: DiagnosticTraceContext,
): ModelCallStreamOptions {
const traceparent = formatDiagnosticTraceparent(trace);
if (!traceparent) {
return options;
}
const headers: Record<string, string> = {};
for (const [key, value] of Object.entries(options?.headers ?? {})) {
if (key.toLowerCase() === TRACEPARENT_HEADER_NAME) {
continue;
}
headers[key] = value;
}
headers[TRACEPARENT_HEADER_NAME] = traceparent;
return {
...(options ?? {}),
headers,
};
}
async function safeReturnIterator(iterator: AsyncIterator<unknown>): Promise<void> {
let returnResult: unknown;
try {
@@ -316,9 +342,10 @@ export function wrapStreamFnWithDiagnosticModelCallEvents(
const eventBase = baseModelCallEvent(ctx, callId, trace);
emitModelCallStarted(eventBase);
const startedAt = Date.now();
const propagatedOptions = withDiagnosticTraceparentHeader(options, trace);
try {
const result = streamFn(model, streamContext, options);
const result = streamFn(model, streamContext, propagatedOptions);
if (isPromiseLike(result)) {
return result.then(
(resolved) => observeModelCallResult(resolved, eventBase, startedAt),

View File

@@ -115,6 +115,48 @@ async function deliverDiscordDirectMessageCompletion(params: {
});
}
async function deliverTelegramDirectMessageCompletion(params: {
callGateway: typeof runtimeCallGateway;
sendMessage?: typeof runtimeSendMessage;
internalEvents?: AgentInternalEvent[];
isActive?: boolean;
queueEmbeddedPiMessage?: (sessionId: string, message: string) => boolean;
}) {
const origin = {
channel: "telegram",
to: "123456789",
accountId: "bot-1",
};
__testing.setDepsForTest({
callGateway: params.callGateway,
getRequesterSessionActivity: () => ({
sessionId: "requester-session-telegram",
isActive: params.isActive === true,
}),
loadConfig: () => ({}) as never,
...(params.queueEmbeddedPiMessage
? { queueEmbeddedPiMessage: params.queueEmbeddedPiMessage }
: {}),
...(params.sendMessage ? { sendMessage: params.sendMessage } : {}),
});
return deliverSubagentAnnouncement({
requesterSessionKey: "agent:main:telegram:123456789",
targetRequesterSessionKey: "agent:main:telegram:123456789",
triggerMessage: "child done",
steerMessage: "child done",
requesterOrigin: origin,
requesterSessionOrigin: origin,
completionDirectOrigin: origin,
directOrigin: origin,
requesterIsSubagent: false,
expectsCompletionMessage: true,
bestEffortDeliver: true,
directIdempotencyKey: "announce-telegram-dm-fallback",
internalEvents: params.internalEvents,
});
}
async function deliverSlackChannelAnnouncement(params: {
callGateway: typeof runtimeCallGateway;
isActive: boolean;
@@ -510,6 +552,92 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
);
});
it("uses direct fallback for Telegram DMs when announce-agent delivery fails", async () => {
const callGateway = vi.fn(async () => {
throw new Error("UNAVAILABLE: requester wake failed");
}) as unknown as typeof runtimeCallGateway;
const sendMessage = createSendMessageMock();
const result = await deliverTelegramDirectMessageCompletion({
callGateway,
sendMessage,
internalEvents: [
{
type: "task_completion",
source: "subagent",
childSessionKey: "agent:worker:subagent:child",
childSessionId: "child-session-id",
announceType: "subagent task",
taskLabel: "telegram completion smoke",
status: "ok",
statusLabel: "completed successfully",
result: "child completion output",
replyInstruction: "Summarize the result.",
},
],
});
expect(result).toEqual(
expect.objectContaining({
delivered: true,
path: "direct-fallback",
}),
);
expect(sendMessage).toHaveBeenCalledWith(
expect.objectContaining({
channel: "telegram",
accountId: "bot-1",
to: "123456789",
threadId: undefined,
content: "child completion output",
requesterSessionKey: "agent:main:telegram:123456789",
bestEffort: true,
idempotencyKey: "announce-telegram-dm-fallback",
}),
);
});
it("uses direct fallback when an active Telegram requester cannot be woken", async () => {
const callGateway = createGatewayMock();
const sendMessage = createSendMessageMock();
const queueEmbeddedPiMessage = vi.fn(() => false);
const result = await deliverTelegramDirectMessageCompletion({
callGateway,
sendMessage,
isActive: true,
queueEmbeddedPiMessage,
internalEvents: [
{
type: "task_completion",
source: "subagent",
childSessionKey: "agent:worker:subagent:child",
childSessionId: "child-session-id",
announceType: "subagent task",
taskLabel: "telegram wake smoke",
status: "ok",
statusLabel: "completed successfully",
result: "child completion output",
replyInstruction: "Summarize the result.",
},
],
});
expect(result).toEqual(
expect.objectContaining({
delivered: true,
path: "direct-fallback",
}),
);
expect(queueEmbeddedPiMessage).toHaveBeenCalledWith("requester-session-telegram", "child done");
expect(callGateway).not.toHaveBeenCalled();
expect(sendMessage).toHaveBeenCalledWith(
expect.objectContaining({
channel: "telegram",
to: "123456789",
content: "child completion output",
}),
);
});
it("uses a direct thread fallback when announce-agent returns no visible output", async () => {
const callGateway = createGatewayMock({
result: {

View File

@@ -681,6 +681,10 @@ async function sendSubagentAnnounceDirectly(params: {
isGatewayMessageChannel(normalizedSessionOnlyOriginChannel)
? normalizedSessionOnlyOriginChannel
: undefined;
const completionFallbackText =
params.expectsCompletionMessage && deliveryTarget.deliver
? extractThreadCompletionFallbackText(params.internalEvents)
: "";
const requesterActivity = resolveRequesterSessionActivity(canonicalRequesterSessionKey);
if (params.expectsCompletionMessage && requesterActivity.sessionId) {
const woke = requesterActivity.sessionId
@@ -696,6 +700,32 @@ async function sendSubagentAnnounceDirectly(params: {
};
}
if (requesterActivity.isActive) {
try {
const didFallback = await sendCompletionFallback({
cfg,
channel: deliveryTarget.channel,
to: deliveryTarget.to,
accountId: deliveryTarget.accountId,
threadId: deliveryTarget.threadId,
content: completionFallbackText,
requesterSessionKey: canonicalRequesterSessionKey,
bestEffortDeliver: params.bestEffortDeliver,
idempotencyKey: params.directIdempotencyKey,
signal: params.signal,
});
if (didFallback) {
return {
delivered: true,
path: resolveCompletionFallbackPath(deliveryTarget.threadId),
};
}
} catch (err) {
return {
delivered: false,
path: "direct",
error: `active requester session could not be woken; fallback send failed: ${summarizeDeliveryError(err)}`,
};
}
return {
delivered: false,
path: "direct",
@@ -709,10 +739,6 @@ async function sendSubagentAnnounceDirectly(params: {
path: "none",
};
}
const completionFallbackText =
params.expectsCompletionMessage && deliveryTarget.deliver
? extractThreadCompletionFallbackText(params.internalEvents)
: "";
let directAnnounceResponse: unknown;
try {
directAnnounceResponse = await runAnnounceDeliveryWithRetry({
@@ -758,22 +784,30 @@ async function sendSubagentAnnounceDirectly(params: {
}),
});
} catch (err) {
const didFallback = await sendCompletionFallback({
cfg,
channel: deliveryTarget.channel,
to: deliveryTarget.to,
accountId: deliveryTarget.accountId,
threadId: deliveryTarget.threadId,
content: deliveryTarget.threadId ? completionFallbackText : "",
requesterSessionKey: canonicalRequesterSessionKey,
bestEffortDeliver: params.bestEffortDeliver,
idempotencyKey: params.directIdempotencyKey,
signal: params.signal,
});
let didFallback = false;
try {
didFallback = await sendCompletionFallback({
cfg,
channel: deliveryTarget.channel,
to: deliveryTarget.to,
accountId: deliveryTarget.accountId,
threadId: deliveryTarget.threadId,
content: completionFallbackText,
requesterSessionKey: canonicalRequesterSessionKey,
bestEffortDeliver: params.bestEffortDeliver,
idempotencyKey: params.directIdempotencyKey,
signal: params.signal,
});
} catch (fallbackErr) {
throw new Error(
`${summarizeDeliveryError(err)}; fallback send failed: ${summarizeDeliveryError(fallbackErr)}`,
{ cause: fallbackErr },
);
}
if (didFallback) {
return {
delivered: true,
path: "direct-thread-fallback",
path: resolveCompletionFallbackPath(deliveryTarget.threadId),
};
}
throw err;

View File

@@ -23,6 +23,7 @@ import {
resolveSubagentAnnounceTimeoutMs,
resolveSubagentCompletionOrigin,
} from "./subagent-announce-delivery.js";
import type { SubagentAnnounceDeliveryResult } from "./subagent-announce-dispatch.js";
import { resolveAnnounceOrigin } from "./subagent-announce-origin.js";
import {
applySubagentWaitOutcome,
@@ -244,6 +245,7 @@ export async function runSubagentAnnounceFlow(params: {
wakeOnDescendantSettle?: boolean;
signal?: AbortSignal;
bestEffortDeliver?: boolean;
onDeliveryResult?: (delivery: SubagentAnnounceDeliveryResult) => void;
}): Promise<boolean> {
let didAnnounce = false;
const expectsCompletionMessage = params.expectsCompletionMessage === true;
@@ -562,6 +564,7 @@ export async function runSubagentAnnounceFlow(params: {
directIdempotencyKey,
signal: params.signal,
});
params.onDeliveryResult?.(delivery);
didAnnounce = delivery.delivered;
if (!delivery.delivered && delivery.path === "direct" && delivery.error) {
defaultRuntime.error?.(

View File

@@ -569,6 +569,82 @@ describe("subagent registry lifecycle hardening", () => {
expect(persist).toHaveBeenCalled();
});
it("persists the concrete announce delivery error when cleanup gives up", async () => {
const persist = vi.fn();
const entry = createRunEntry({
endedAt: 4_000,
expectsCompletionMessage: true,
retainAttachmentsOnKeep: true,
});
const runSubagentAnnounceFlow = vi.fn(
async (announceParams: {
onDeliveryResult?: (delivery: {
delivered: false;
path: "direct";
error: string;
phases: Array<{
phase: "direct-primary" | "queue-fallback";
delivered: boolean;
path: "direct" | "none";
error?: string;
}>;
}) => void;
}) => {
announceParams.onDeliveryResult?.({
delivered: false,
path: "direct",
error: "UNAVAILABLE: requester wake failed",
phases: [
{
phase: "direct-primary",
delivered: false,
path: "direct",
error: "UNAVAILABLE: requester wake failed",
},
{
phase: "queue-fallback",
delivered: false,
path: "none",
},
],
});
return false;
},
);
const controller = createLifecycleController({
entry,
persist,
runSubagentAnnounceFlow,
});
await expect(
controller.completeSubagentRun({
runId: entry.runId,
endedAt: 4_000,
outcome: { status: "ok" },
reason: SUBAGENT_ENDED_REASON_COMPLETE,
triggerCleanup: true,
}),
).resolves.toBeUndefined();
expect(taskExecutorMocks.setDetachedTaskDeliveryStatusByRunId).toHaveBeenCalledWith(
expect.objectContaining({
runId: entry.runId,
runtime: "subagent",
sessionKey: entry.childSessionKey,
deliveryStatus: "failed",
error:
"UNAVAILABLE: requester wake failed; direct-primary: UNAVAILABLE: requester wake failed",
}),
);
expect(entry.lastAnnounceDeliveryError).toBe(
"UNAVAILABLE: requester wake failed; direct-primary: UNAVAILABLE: requester wake failed",
);
expect(entry.cleanupCompletedAt).toBeTypeOf("number");
expect(persist).toHaveBeenCalled();
});
it("skips browser cleanup when steer restart suppresses cleanup flow", async () => {
const entry = createRunEntry({
expectsCompletionMessage: false,

View File

@@ -10,6 +10,7 @@ import {
} from "../tasks/detached-task-runtime.js";
import { normalizeDeliveryContext } from "../utils/delivery-context.shared.js";
import { retireSessionMcpRuntimeForSessionKey } from "./pi-bundle-mcp-tools.js";
import type { SubagentAnnounceDeliveryResult } from "./subagent-announce-dispatch.js";
import { type SubagentRunOutcome, withSubagentOutcomeTiming } from "./subagent-announce-output.js";
import {
SUBAGENT_ENDED_REASON_COMPLETE,
@@ -126,10 +127,25 @@ export function createSubagentRegistryLifecycleController(params: {
return name ? { name, message } : { message };
};
const formatAnnounceDeliveryError = (delivery: SubagentAnnounceDeliveryResult): string => {
const errors = [
delivery.error,
...(delivery.phases ?? []).map((phase) =>
phase.error ? `${phase.phase}: ${phase.error}` : undefined,
),
]
.map((value) => value?.trim())
.filter((value): value is string => Boolean(value));
return errors.length > 0
? [...new Set(errors)].join("; ")
: `delivery path ${delivery.path} did not complete`;
};
const safeSetSubagentTaskDeliveryStatus = (args: {
runId: string;
childSessionKey: string;
deliveryStatus: "delivered" | "failed";
deliveryError?: string;
}) => {
try {
setDetachedTaskDeliveryStatusByRunId({
@@ -137,6 +153,7 @@ export function createSubagentRegistryLifecycleController(params: {
runtime: "subagent",
sessionKey: args.childSessionKey,
deliveryStatus: args.deliveryStatus,
error: args.deliveryStatus === "failed" ? args.deliveryError : undefined,
});
} catch (err) {
params.warn("failed to update subagent background task delivery state", {
@@ -301,6 +318,7 @@ export function createSubagentRegistryLifecycleController(params: {
runId: giveUpParams.runId,
childSessionKey: giveUpParams.entry.childSessionKey,
deliveryStatus: "failed",
deliveryError: giveUpParams.entry.lastAnnounceDeliveryError,
});
giveUpParams.entry.wakeOnDescendantSettle = undefined;
giveUpParams.entry.fallbackFrozenResultText = undefined;
@@ -464,6 +482,7 @@ export function createSubagentRegistryLifecycleController(params: {
childSessionKey: entry.childSessionKey,
deliveryStatus: "delivered",
});
entry.lastAnnounceDeliveryError = undefined;
entry.wakeOnDescendantSettle = undefined;
entry.fallbackFrozenResultText = undefined;
entry.fallbackFrozenResultCapturedAt = undefined;
@@ -518,6 +537,7 @@ export function createSubagentRegistryLifecycleController(params: {
runId,
childSessionKey: entry.childSessionKey,
deliveryStatus: "failed",
deliveryError: entry.lastAnnounceDeliveryError,
});
entry.wakeOnDescendantSettle = undefined;
entry.fallbackFrozenResultText = undefined;
@@ -571,7 +591,11 @@ export function createSubagentRegistryLifecycleController(params: {
return false;
}
const requesterOrigin = normalizeDeliveryContext(entry.requesterOrigin);
let latestDeliveryError = entry.lastAnnounceDeliveryError;
const finalizeAnnounceCleanup = (didAnnounce: boolean) => {
if (!didAnnounce && latestDeliveryError) {
entry.lastAnnounceDeliveryError = latestDeliveryError;
}
void finalizeSubagentCleanup(runId, entry.cleanup, didAnnounce).catch((err) => {
defaultRuntime.log(`[warn] subagent cleanup finalize failed (${runId}): ${String(err)}`);
const current = params.runs.get(runId);
@@ -603,6 +627,21 @@ export function createSubagentRegistryLifecycleController(params: {
spawnMode: entry.spawnMode,
expectsCompletionMessage: entry.expectsCompletionMessage,
wakeOnDescendantSettle: entry.wakeOnDescendantSettle === true,
onDeliveryResult: (delivery) => {
if (delivery.delivered) {
if (entry.lastAnnounceDeliveryError !== undefined) {
entry.lastAnnounceDeliveryError = undefined;
params.persist();
}
latestDeliveryError = undefined;
return;
}
latestDeliveryError = formatAnnounceDeliveryError(delivery);
if (entry.lastAnnounceDeliveryError !== latestDeliveryError) {
entry.lastAnnounceDeliveryError = latestDeliveryError;
params.persist();
}
},
})
.then((didAnnounce) => {
finalizeAnnounceCleanup(didAnnounce);

View File

@@ -30,6 +30,7 @@ export type SubagentRunRecord = {
expectsCompletionMessage?: boolean;
announceRetryCount?: number;
lastAnnounceRetryAt?: number;
lastAnnounceDeliveryError?: string;
endedReason?: SubagentLifecycleEndedReason;
wakeOnDescendantSettle?: boolean;
frozenResultText?: string | null;

View File

@@ -18,12 +18,12 @@ describe("agents_list tool", () => {
loadConfigMock.mockReset();
});
it("returns model and embedded harness metadata for allowed agents", async () => {
it("returns model and agent runtime metadata for allowed agents", async () => {
loadConfigMock.mockReturnValue({
agents: {
defaults: {
model: "anthropic/claude-opus-4.5",
embeddedHarness: { runtime: "pi", fallback: "pi" },
agentRuntime: { id: "pi", fallback: "pi" },
subagents: { allowAgents: ["codex"] },
},
list: [
@@ -32,7 +32,7 @@ describe("agents_list tool", () => {
id: "codex",
name: "Codex",
model: "openai/gpt-5.5",
embeddedHarness: { runtime: "codex", fallback: "none" },
agentRuntime: { id: "codex", fallback: "none" },
},
],
},
@@ -51,14 +51,14 @@ describe("agents_list tool", () => {
id: "main",
configured: true,
model: "anthropic/claude-opus-4.5",
embeddedHarness: { runtime: "pi", source: "defaults" },
agentRuntime: { id: "pi", source: "defaults" },
},
{
id: "codex",
name: "Codex",
configured: true,
model: "openai/gpt-5.5",
embeddedHarness: { runtime: "codex", fallback: "none", source: "agent" },
agentRuntime: { id: "codex", fallback: "none", source: "agent" },
},
],
});
@@ -85,7 +85,7 @@ describe("agents_list tool", () => {
agents: [
{
id: "main",
embeddedHarness: { runtime: "codex", source: "env" },
agentRuntime: { id: "codex", source: "env" },
},
],
});

View File

@@ -5,6 +5,7 @@ import {
normalizeAgentId,
parseAgentSessionKey,
} from "../../routing/session-key.js";
import { resolveAgentRuntimePolicy } from "../agent-runtime-policy.js";
import {
listAgentEntries,
resolveAgentConfig,
@@ -21,8 +22,8 @@ type AgentListEntry = {
name?: string;
configured: boolean;
model?: string;
embeddedHarness?: {
runtime: string;
agentRuntime?: {
id: string;
fallback?: "pi" | "none";
source: "env" | "agent" | "defaults" | "implicit";
};
@@ -32,39 +33,41 @@ function normalizeRuntimeValue(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim().toLowerCase() : undefined;
}
function resolveAgentEmbeddedHarnessMetadata(
function resolveAgentRuntimeMetadata(
cfg: ReturnType<typeof loadConfig>,
agentId: string,
): AgentListEntry["embeddedHarness"] {
): NonNullable<AgentListEntry["agentRuntime"]> {
const envRuntime = normalizeRuntimeValue(process.env.OPENCLAW_AGENT_RUNTIME);
if (envRuntime) {
return {
runtime: envRuntime,
id: envRuntime,
source: "env",
};
}
const agentEntry = listAgentEntries(cfg).find((entry) => normalizeAgentId(entry.id) === agentId);
const agentRuntime = normalizeRuntimeValue(agentEntry?.embeddedHarness?.runtime);
const agentPolicy = resolveAgentRuntimePolicy(agentEntry);
const agentRuntime = normalizeRuntimeValue(agentPolicy?.id);
if (agentRuntime) {
return {
runtime: agentRuntime,
fallback: agentEntry?.embeddedHarness?.fallback,
id: agentRuntime,
fallback: agentPolicy?.fallback,
source: "agent",
};
}
const defaultsRuntime = normalizeRuntimeValue(cfg.agents?.defaults?.embeddedHarness?.runtime);
const defaultsPolicy = resolveAgentRuntimePolicy(cfg.agents?.defaults);
const defaultsRuntime = normalizeRuntimeValue(defaultsPolicy?.id);
if (defaultsRuntime) {
return {
runtime: defaultsRuntime,
fallback: cfg.agents?.defaults?.embeddedHarness?.fallback,
id: defaultsRuntime,
fallback: defaultsPolicy?.fallback,
source: "defaults",
};
}
return {
runtime: "pi",
id: "pi",
source: "implicit",
};
}
@@ -136,13 +139,16 @@ export function createAgentsListTool(opts?: {
.filter((id) => id !== requesterAgentId)
.toSorted((a, b) => a.localeCompare(b));
const ordered = [requesterAgentId, ...rest];
const agents: AgentListEntry[] = ordered.map((id) => ({
id,
name: configuredNameMap.get(id),
configured: configuredIds.includes(id),
model: resolveAgentEffectiveModelPrimary(cfg, id),
embeddedHarness: resolveAgentEmbeddedHarnessMetadata(cfg, id),
}));
const agents: AgentListEntry[] = ordered.map((id) => {
const agentRuntime = resolveAgentRuntimeMetadata(cfg, id);
return {
id,
name: configuredNameMap.get(id),
configured: configuredIds.includes(id),
model: resolveAgentEffectiveModelPrimary(cfg, id),
agentRuntime,
};
});
return jsonResult({
requester: requesterAgentId,

View File

@@ -104,6 +104,25 @@ describe("createTtsTool", () => {
);
});
it("passes the active account id to speech generation", async () => {
textToSpeechSpy.mockResolvedValue({
success: true,
audioPath: "/tmp/reply.opus",
provider: "test",
voiceCompatible: true,
});
const tool = createTtsTool({ agentAccountId: "feishu-main" });
await tool.execute("call-1", { text: "hello" });
expect(textToSpeechSpy).toHaveBeenCalledWith(
expect.objectContaining({
text: "hello",
accountId: "feishu-main",
}),
);
});
it("echoes longer utterances verbatim into the tool-result content", async () => {
textToSpeechSpy.mockResolvedValue({
success: true,

View File

@@ -58,6 +58,7 @@ export function createTtsTool(opts?: {
config?: OpenClawConfig;
agentChannel?: GatewayMessageChannel;
agentId?: string;
agentAccountId?: string;
}): AnyAgentTool {
return {
label: "TTS",
@@ -77,6 +78,7 @@ export function createTtsTool(opts?: {
channel: channel ?? opts?.agentChannel,
timeoutMs,
agentId: opts?.agentId,
accountId: opts?.agentAccountId,
});
if (result.success && result.audioPath) {

View File

@@ -417,6 +417,50 @@ describe("runAgentTurnWithFallback", () => {
);
});
it("does not pass CLI runtime overrides as embedded harness ids for fallback providers", async () => {
state.isCliProviderMock.mockImplementation((provider: unknown) => provider === "claude-cli");
state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
result: await params.run("openai", "gpt-5.4"),
provider: "openai",
model: "gpt-5.4",
attempts: [],
}));
state.runEmbeddedPiAgentMock.mockResolvedValueOnce({
payloads: [{ text: "fallback" }],
meta: {},
});
const runAgentTurnWithFallback = await getRunAgentTurnWithFallback();
const followupRun = createFollowupRun();
followupRun.run.provider = "anthropic";
followupRun.run.model = "claude-opus-4-7";
followupRun.run.config = {
agents: {
defaults: {
agentRuntime: { id: "claude-cli", fallback: "none" },
},
},
};
const result = await runAgentTurnWithFallback({
...createMinimalRunAgentTurnParams({ followupRun }),
getActiveSessionEntry: () =>
({
sessionId: "session",
updatedAt: Date.now(),
agentRuntimeOverride: "claude-cli",
}) as SessionEntry,
});
expect(result.kind).toBe("success");
expect(state.runCliAgentMock).not.toHaveBeenCalled();
expect(state.runEmbeddedPiAgentMock).toHaveBeenCalledOnce();
expect(state.runEmbeddedPiAgentMock.mock.calls[0]?.[0]).not.toHaveProperty(
"agentHarnessId",
"claude-cli",
);
});
it("forwards media-only tool results without typing text", async () => {
const onToolResult = vi.fn();
state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => {

View File

@@ -13,7 +13,10 @@ import { runCliAgent } from "../../agents/cli-runner.js";
import { getCliSessionBinding } from "../../agents/cli-session.js";
import { LiveSessionModelSwitchError } from "../../agents/live-model-switch-error.js";
import { runWithModelFallback, isFallbackSummaryError } from "../../agents/model-fallback.js";
import { resolveCliRuntimeExecutionProvider } from "../../agents/model-runtime-aliases.js";
import {
isCliRuntimeAlias,
resolveCliRuntimeExecutionProvider,
} from "../../agents/model-runtime-aliases.js";
import { isCliProvider } from "../../agents/model-selection.js";
import {
BILLING_ERROR_USER_MESSAGE,
@@ -1122,7 +1125,8 @@ export async function runAgentTurnWithFallback(params: {
...runBaseParams,
...(agentRuntimeOverride &&
agentRuntimeOverride !== "auto" &&
agentRuntimeOverride !== "default"
agentRuntimeOverride !== "default" &&
!isCliRuntimeAlias(agentRuntimeOverride)
? { agentHarnessId: agentRuntimeOverride }
: {}),
sandboxSessionKey: params.runtimePolicySessionKey,

View File

@@ -1667,7 +1667,7 @@ describe("runReplyAgent claude-cli routing", () => {
messageProvider: "webchat",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
config: { agents: { defaults: { embeddedHarness: { runtime: "claude-cli" } } } },
config: { agents: { defaults: { agentRuntime: { id: "claude-cli" } } } },
skillsSnapshot: {},
provider: "anthropic",
model: "claude-opus-4-7",

View File

@@ -489,7 +489,7 @@ describe("buildStatusReply subagent summary", () => {
...baseCfg,
agents: {
defaults: {
embeddedHarness: { runtime: "codex" },
agentRuntime: { id: "codex" },
},
},
},
@@ -529,7 +529,7 @@ describe("buildStatusReply subagent summary", () => {
...baseCfg,
agents: {
defaults: {
embeddedHarness: { runtime: "codex" },
agentRuntime: { id: "codex" },
},
},
},

View File

@@ -213,10 +213,13 @@ describe("handleTtsCommands status fallback reporting", () => {
const result = await handleTtsCommands(buildTtsParams("/tts status", cfg, "reader"), true);
expect(result?.shouldContinue).toBe(false);
expect(ttsMocks.resolveTtsConfig).toHaveBeenCalledWith(cfg, "reader");
expect(ttsMocks.resolveTtsConfig).toHaveBeenCalledWith(
cfg,
expect.objectContaining({ agentId: "reader", channelId: "forum" }),
);
});
it("passes the active agent id to /tts audio synthesis", async () => {
it("passes the active agent and account ids to /tts audio synthesis", async () => {
ttsMocks.textToSpeech.mockResolvedValue({
success: true,
audioPath: "/tmp/reader.ogg",
@@ -227,7 +230,12 @@ describe("handleTtsCommands status fallback reporting", () => {
agents: { list: [{ id: "reader", tts: { provider: PRIMARY_TTS_PROVIDER } }] },
} as OpenClawConfig;
const result = await handleTtsCommands(buildTtsParams("/tts audio hello", cfg, "reader"), true);
const result = await handleTtsCommands(
buildTtsParams("/tts audio hello", cfg, "reader", {
ctx: { AccountId: "feishu-main" },
}),
true,
);
expect(result?.shouldContinue).toBe(false);
expect(ttsMocks.textToSpeech).toHaveBeenCalledWith(
@@ -235,6 +243,7 @@ describe("handleTtsCommands status fallback reporting", () => {
text: "hello",
cfg,
agentId: "reader",
accountId: "feishu-main",
}),
);
});

View File

@@ -119,6 +119,7 @@ async function buildTtsAudioReply(params: {
text: string;
cfg: Parameters<typeof textToSpeech>[0]["cfg"];
channel: string;
accountId?: string;
prefsPath: string;
agentId?: string;
}): Promise<{ reply: ReplyPayload; provider?: string; hash?: string } | { error: string }> {
@@ -127,6 +128,7 @@ async function buildTtsAudioReply(params: {
text: params.text,
cfg: params.cfg,
channel: params.channel,
accountId: params.accountId,
prefsPath: params.prefsPath,
agentId: params.agentId,
});
@@ -185,7 +187,12 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
return { shouldContinue: false };
}
const config = resolveTtsConfig(params.cfg, params.agentId);
const accountId = params.ctx?.AccountId;
const config = resolveTtsConfig(params.cfg, {
agentId: params.agentId,
channelId: params.command.channel,
accountId,
});
const prefsPath = resolveTtsPrefsPath(config);
const action = parsed.action;
const args = parsed.args;
@@ -268,6 +275,7 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
text: latestText,
cfg: params.cfg,
channel: params.command.channel,
accountId,
prefsPath,
agentId: params.agentId,
});
@@ -301,6 +309,7 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
text: args,
cfg: params.cfg,
channel: params.command.channel,
accountId,
prefsPath,
agentId: params.agentId,
});

View File

@@ -91,6 +91,7 @@ async function maybeApplyAcpTts(params: {
cfg: OpenClawConfig;
agentId?: string;
channel?: string;
accountId?: string;
kind: ReplyDispatchKind;
inboundAudio: boolean;
ttsAuto?: TtsAutoMode;
@@ -103,6 +104,8 @@ async function maybeApplyAcpTts(params: {
cfg: params.cfg,
sessionAuto: params.ttsAuto,
agentId: params.agentId,
channelId: params.channel,
accountId: params.accountId,
});
if (!ttsStatus) {
return params.payload;
@@ -110,7 +113,14 @@ async function maybeApplyAcpTts(params: {
if (ttsStatus.autoMode === "inbound" && !params.inboundAudio) {
return params.payload;
}
if (params.kind !== "final" && resolveConfiguredTtsMode(params.cfg, params.agentId) === "final") {
if (
params.kind !== "final" &&
resolveConfiguredTtsMode(params.cfg, {
agentId: params.agentId,
channelId: params.channel,
accountId: params.accountId,
}) === "final"
) {
return params.payload;
}
const { maybeApplyTtsToPayload } = await loadDispatchAcpTtsRuntime();
@@ -122,6 +132,7 @@ async function maybeApplyAcpTts(params: {
inboundAudio: params.inboundAudio,
ttsAuto: params.ttsAuto,
agentId: params.agentId,
accountId: params.accountId,
});
}
@@ -175,6 +186,17 @@ export function createAcpDispatchDeliveryCoordinator(params: {
originatingTo?: string;
onReplyStart?: () => Promise<void> | void;
}): AcpDispatchDeliveryCoordinator {
const directChannel = normalizeOptionalLowercaseString(params.ctx.Provider ?? params.ctx.Surface);
const routedChannel = normalizeOptionalLowercaseString(params.originatingChannel);
const deliverySessionKey = normalizeOptionalString(params.sessionKey) ?? params.ctx.SessionKey;
const explicitAccountId = normalizeOptionalString(params.ctx.AccountId);
const resolvedAccountId =
explicitAccountId ??
normalizeOptionalString(
(
params.cfg.channels as Record<string, { defaultAccount?: unknown } | undefined> | undefined
)?.[routedChannel ?? directChannel ?? ""]?.defaultAccount,
);
const state: AcpDispatchDeliveryState = {
startedReplyLifecycle: false,
accumulatedBlockText: "",
@@ -184,6 +206,8 @@ export function createAcpDispatchDeliveryCoordinator(params: {
cfg: params.cfg,
ttsAuto: params.sessionTtsAuto,
agentId: params.agentId,
channelId: params.ttsChannel,
accountId: resolvedAccountId,
})
? createTtsDirectiveTextStreamCleaner()
: undefined,
@@ -200,18 +224,6 @@ export function createAcpDispatchDeliveryCoordinator(params: {
},
toolMessageByCallId: new Map(),
};
const directChannel = normalizeOptionalLowercaseString(params.ctx.Provider ?? params.ctx.Surface);
const routedChannel = normalizeOptionalLowercaseString(params.originatingChannel);
const deliverySessionKey = normalizeOptionalString(params.sessionKey) ?? params.ctx.SessionKey;
const explicitAccountId = normalizeOptionalString(params.ctx.AccountId);
const resolvedAccountId =
explicitAccountId ??
normalizeOptionalString(
(
params.cfg.channels as Record<string, { defaultAccount?: unknown } | undefined> | undefined
)?.[routedChannel ?? directChannel ?? ""]?.defaultAccount,
);
const settleDirectVisibleText = async () => {
if (state.settledDirectVisibleText || state.queuedDirectVisibleTextDeliveries === 0) {
return;
@@ -336,6 +348,7 @@ export function createAcpDispatchDeliveryCoordinator(params: {
cfg: params.cfg,
agentId: params.agentId,
channel: params.ttsChannel,
accountId: resolvedAccountId,
kind,
inboundAudio: params.inboundAudio,
ttsAuto: params.sessionTtsAuto,

View File

@@ -191,12 +191,17 @@ async function finalizeAcpTurnOutput(params: {
inboundAudio: boolean;
sessionTtsAuto?: TtsAutoMode;
ttsChannel?: string;
ttsAccountId?: string;
shouldEmitResolvedIdentityNotice: boolean;
}): Promise<boolean> {
await params.delivery.settleVisibleText();
let queuedFinal =
params.delivery.hasDeliveredVisibleText() && !params.delivery.hasFailedVisibleTextDelivery();
const ttsMode = resolveConfiguredTtsMode(params.cfg, params.agentId);
const ttsMode = resolveConfiguredTtsMode(params.cfg, {
agentId: params.agentId,
channelId: params.ttsChannel,
accountId: params.ttsAccountId,
});
const accumulatedVisibleBlockText = params.delivery.getAccumulatedVisibleBlockText();
const accumulatedBlockTtsText = params.delivery.getAccumulatedBlockTtsText();
const hasAccumulatedBlockText = accumulatedBlockTtsText.trim().length > 0;
@@ -204,6 +209,8 @@ async function finalizeAcpTurnOutput(params: {
cfg: params.cfg,
sessionAuto: params.sessionTtsAuto,
agentId: params.agentId,
channelId: params.ttsChannel,
accountId: params.ttsAccountId,
});
const canAttemptFinalTts =
ttsStatus != null && !(ttsStatus.autoMode === "inbound" && !params.inboundAudio);
@@ -220,6 +227,7 @@ async function finalizeAcpTurnOutput(params: {
inboundAudio: params.inboundAudio,
ttsAuto: params.sessionTtsAuto,
agentId: params.agentId,
accountId: params.ttsAccountId,
});
if (ttsSyntheticReply.mediaUrl) {
const delivered = await params.delivery.deliver("final", {
@@ -487,6 +495,7 @@ export async function tryDispatchAcpReply(params: {
inboundAudio: params.inboundAudio,
sessionTtsAuto: params.sessionTtsAuto,
ttsChannel: params.ttsChannel,
ttsAccountId: effectiveDispatchAccountId,
shouldEmitResolvedIdentityNotice,
})) || queuedFinal;

View File

@@ -122,7 +122,13 @@ async function maybeApplyTtsToReplyPayload(
params: Parameters<Awaited<ReturnType<typeof loadTtsRuntime>>["maybeApplyTtsToPayload"]>[0],
) {
if (
!shouldAttemptTtsPayload({ cfg: params.cfg, ttsAuto: params.ttsAuto, agentId: params.agentId })
!shouldAttemptTtsPayload({
cfg: params.cfg,
ttsAuto: params.ttsAuto,
agentId: params.agentId,
channelId: params.channel,
accountId: params.accountId,
})
) {
return params.payload;
}
@@ -734,6 +740,7 @@ export async function dispatchReplyFromConfig(
inboundAudio,
ttsAuto: sessionTtsAuto,
agentId: sessionAgentId,
accountId: replyRoute.accountId,
});
const normalizedPayload = await normalizeReplyMediaPayload(ttsPayload);
const result = await routeReplyToOriginating(normalizedPayload);
@@ -939,6 +946,8 @@ export async function dispatchReplyFromConfig(
cfg,
ttsAuto: sessionTtsAuto,
agentId: sessionAgentId,
channelId: deliveryChannel,
accountId: replyRoute.accountId,
})
? createTtsDirectiveTextStreamCleaner()
: undefined;
@@ -1010,6 +1019,7 @@ export async function dispatchReplyFromConfig(
inboundAudio,
ttsAuto: sessionTtsAuto,
agentId: sessionAgentId,
accountId: replyRoute.accountId,
});
const normalizedPayload = await normalizeReplyMediaPayload(ttsPayload);
const deliveryPayload = resolveToolDeliveryPayload(normalizedPayload);
@@ -1128,6 +1138,7 @@ export async function dispatchReplyFromConfig(
inboundAudio,
ttsAuto: sessionTtsAuto,
agentId: sessionAgentId,
accountId: replyRoute.accountId,
});
const normalizedPayload = await normalizeReplyMediaPayload(ttsPayload);
if (shouldRouteToOriginating) {
@@ -1198,7 +1209,11 @@ export async function dispatchReplyFromConfig(
routedFinalCount += finalReply.routedFinalCount;
}
const ttsMode = resolveConfiguredTtsMode(cfg, sessionAgentId);
const ttsMode = resolveConfiguredTtsMode(cfg, {
agentId: sessionAgentId,
channelId: deliveryChannel,
accountId: replyRoute.accountId,
});
// Generate TTS-only reply after block streaming completes (when there's no final reply).
// This handles the case where block streaming succeeds and drops final payloads,
// but we still want TTS audio to be generated from the accumulated block content.
@@ -1217,6 +1232,7 @@ export async function dispatchReplyFromConfig(
inboundAudio,
ttsAuto: sessionTtsAuto,
agentId: sessionAgentId,
accountId: replyRoute.accountId,
});
// Only send if TTS was actually applied (mediaUrl exists)
if (ttsSyntheticReply.mediaUrl) {

View File

@@ -7,7 +7,7 @@ import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
} from "../shared/string-coerce.js";
import { getChannelPlugin, getLoadedChannelPlugin, normalizeChannelId } from "./plugins/index.js";
import { getLoadedChannelPlugin, normalizeChannelId } from "./plugins/index.js";
import { parseExplicitTargetForChannel } from "./plugins/target-parsing.js";
import {
resolveBundledChannelThreadBindingDefaultPlacement,
@@ -233,11 +233,7 @@ export function resolveChannelDefaultBindingPlacement(
}
const pluginPlacement =
resolveRuntimeChannelPlugin(channel)?.conversationBindings?.defaultTopLevelPlacement;
return (
pluginPlacement ??
resolveBundledChannelThreadBindingDefaultPlacement(channel) ??
getChannelPlugin(channel)?.conversationBindings?.defaultTopLevelPlacement
);
return pluginPlacement ?? resolveBundledChannelThreadBindingDefaultPlacement(channel);
}
export function resolveCommandConversationResolution(
@@ -405,23 +401,6 @@ export function resolveInboundConversationResolution(
return artifactResolution;
}
const bundledPlugin = getChannelPlugin(channel);
const bundledConversation =
bundledPlugin !== plugin
? bundledPlugin?.messaging?.resolveInboundConversation?.(resolverParams)
: undefined;
const bundledResolution = normalizeResolutionTarget({
channel,
accountId,
conversation: bundledConversation,
source: "inbound-bundled-plugin",
threadId,
plugin: bundledPlugin ?? plugin,
});
if (bundledResolution || bundledConversation === null) {
return bundledResolution;
}
const parentConversationId =
resolveChannelTargetId({
channel,

View File

@@ -15,6 +15,7 @@ import type { loadOpenClawPlugins as loadOpenClawPluginsType } from "../../plugi
import type { PluginManifestRecord } from "../../plugins/manifest-registry.js";
import { loadPluginManifestRegistryForPluginRegistry } from "../../plugins/plugin-registry.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
import { normalizeOptionalString } from "../../shared/string-coerce.js";
import { sanitizeForLog } from "../../terminal/ansi.js";
import { getBundledChannelSetupPlugin } from "./bundled.js";
import { listChannelPlugins } from "./registry.js";
@@ -72,6 +73,10 @@ type ReadOnlyChannelPluginResolution = {
missingConfiguredChannelIds: string[];
};
type ManifestChannelConfigRecord = NonNullable<PluginManifestRecord["channelConfigs"]>[string];
type ChannelCommandDefaults = Pick<
NonNullable<ChannelPlugin["commands"]>,
"nativeCommandsAutoEnabled" | "nativeSkillsAutoEnabled"
>;
function addChannelPlugins(
byId: Map<string, ChannelPlugin>,
@@ -125,6 +130,26 @@ function normalizeManifestText(value: string | undefined, fallback: string): str
return sanitizeForLog(value?.trim() || fallback).trim();
}
function normalizeChannelCommandDefaults(
value: ChannelCommandDefaults | undefined,
): ChannelCommandDefaults | undefined {
if (!value) {
return undefined;
}
const nativeCommandsAutoEnabled =
typeof value.nativeCommandsAutoEnabled === "boolean"
? value.nativeCommandsAutoEnabled
: undefined;
const nativeSkillsAutoEnabled =
typeof value.nativeSkillsAutoEnabled === "boolean" ? value.nativeSkillsAutoEnabled : undefined;
return nativeCommandsAutoEnabled !== undefined || nativeSkillsAutoEnabled !== undefined
? {
...(nativeCommandsAutoEnabled !== undefined ? { nativeCommandsAutoEnabled } : {}),
...(nativeSkillsAutoEnabled !== undefined ? { nativeSkillsAutoEnabled } : {}),
}
: undefined;
}
function rebindChannelConfig(
cfg: OpenClawConfig,
sourceChannelId: string,
@@ -258,6 +283,9 @@ function buildManifestChannelPlugin(params: {
channelConfig?.description ?? catalogMeta?.blurb,
params.record.description || "",
);
const commands = normalizeChannelCommandDefaults(
channelConfig?.commands ?? catalogMeta?.commands,
);
return {
id: params.channelId,
meta: {
@@ -273,6 +301,7 @@ function buildManifestChannelPlugin(params: {
: {}),
},
capabilities: { chatTypes: ["direct"] },
...(commands ? { commands } : {}),
...(channelConfig
? {
configSchema: {
@@ -318,6 +347,47 @@ function canUseManifestChannelPlugin(record: PluginManifestRecord, channelId: st
return record.channelCatalogMeta?.id === channelId;
}
export function resolveReadOnlyChannelCommandDefaults(
channelId: string,
options: {
env?: NodeJS.ProcessEnv;
stateDir?: string;
workspaceDir?: string;
} = {},
): ChannelCommandDefaults | undefined {
const normalizedChannelId = normalizeOptionalString(channelId) ?? "";
if (!normalizedChannelId || !isSafeManifestChannelId(normalizedChannelId)) {
return undefined;
}
const registry = loadPluginManifestRegistryForPluginRegistry({
stateDir: options.stateDir,
workspaceDir: options.workspaceDir,
env: options.env ?? process.env,
includeDisabled: true,
});
for (const record of registry.plugins) {
if (!record.channels.includes(normalizedChannelId)) {
continue;
}
const channelConfigValue = record.channelConfigs
? readOwnRecordValue(record.channelConfigs as Record<string, unknown>, normalizedChannelId)
: undefined;
const channelConfig =
channelConfigValue &&
typeof channelConfigValue === "object" &&
!Array.isArray(channelConfigValue)
? (channelConfigValue as ManifestChannelConfigRecord)
: undefined;
const commands = normalizeChannelCommandDefaults(
channelConfig?.commands ?? record.channelCatalogMeta?.commands,
);
if (commands) {
return commands;
}
}
return undefined;
}
function rebindChannelPluginConfig(
config: ChannelPlugin["config"],
sourceChannelId: string,

View File

@@ -5,7 +5,8 @@ import {
resolveThreadBindingLifecycle as resolveSharedThreadBindingLifecycle,
type ThreadBindingLifecycleRecord,
} from "../shared/thread-binding-lifecycle.js";
import { getChannelPlugin } from "./plugins/index.js";
import { getLoadedChannelPlugin } from "./plugins/index.js";
import { resolveBundledChannelThreadBindingDefaultPlacement } from "./plugins/thread-binding-api.js";
export {
resolveThreadBindingLifecycle,
@@ -64,7 +65,11 @@ function resolveDefaultTopLevelPlacement(channel: string): "current" | "child" {
if (!normalized) {
return "current";
}
return getChannelPlugin(normalized)?.conversationBindings?.defaultTopLevelPlacement ?? "current";
return (
getLoadedChannelPlugin(normalized)?.conversationBindings?.defaultTopLevelPlacement ??
resolveBundledChannelThreadBindingDefaultPlacement(normalized) ??
"current"
);
}
function normalizeBoolean(value: unknown): boolean | undefined {

View File

@@ -69,36 +69,44 @@ exit 0
}
function expectWindowsRestartWaitOrdering(content: string, port = 18789) {
const endCommand = 'schtasks /End /TN "';
const pollAttemptsInit = "set /a attempts=0";
const pollLabel = ":wait_for_port_release";
const pollAttemptIncrement = "set /a attempts+=1";
const pollNetstatCheck = `netstat -ano | findstr /R /C:":${port} .*LISTENING" >nul`;
const forceKillLabel = ":force_kill_listener";
const forceKillCommand = "taskkill /F /PID %%P >>";
const portReleasedLabel = ":port_released";
const runCommand = 'schtasks /Run /TN "';
const endIndex = content.indexOf(endCommand);
const attemptsInitIndex = content.indexOf(pollAttemptsInit, endIndex);
const pollLabelIndex = content.indexOf(pollLabel, attemptsInitIndex);
const pollAttemptIncrementIndex = content.indexOf(pollAttemptIncrement, pollLabelIndex);
const pollNetstatCheckIndex = content.indexOf(pollNetstatCheck, pollAttemptIncrementIndex);
const forceKillLabelIndex = content.indexOf(forceKillLabel, pollNetstatCheckIndex);
const forceKillCommandIndex = content.indexOf(forceKillCommand, forceKillLabelIndex);
const portReleasedLabelIndex = content.indexOf(portReleasedLabel, forceKillCommandIndex);
const runIndex = content.indexOf(runCommand, portReleasedLabelIndex);
const stateCheck = "$taskState = Get-OpenClawScheduledTaskState -TaskName $taskName";
const runningGuard = 'if ($taskState -eq "Running")';
const endCommand =
'Invoke-OpenClawSchtasksWithTimeout -Arguments @("/End", "/TN", $taskName) -TimeoutSeconds 10';
const skipEndLog = "openclaw restart skipped schtasks end";
const pollLoop = "for ($attempt = 1; $attempt -le 10; $attempt++)";
const pollCall = `Get-OpenClawListenerPids -Port $port`;
const forceKillBranch = "if ($attempt -eq 10)";
const forceKillCommand = "Stop-Process -Id $listenerPid -Force";
const runCommand =
'Invoke-OpenClawSchtasksWithTimeout -Arguments @("/Run", "/TN", $taskName) -TimeoutSeconds 30';
const portAssignment = `$port = ${port}`;
const stateCheckIndex = content.indexOf(stateCheck);
const runningGuardIndex = content.indexOf(runningGuard, stateCheckIndex);
const endIndex = content.indexOf(endCommand, runningGuardIndex);
const skipEndLogIndex = content.indexOf(skipEndLog, endIndex);
const portAssignmentIndex = content.indexOf(portAssignment);
const pollLoopIndex = content.indexOf(pollLoop, skipEndLogIndex);
const pollCallIndex = content.indexOf(pollCall, pollLoopIndex);
const forceKillBranchIndex = content.indexOf(forceKillBranch, pollCallIndex);
const forceKillCommandIndex = content.indexOf(forceKillCommand, forceKillBranchIndex);
const runIndex = content.indexOf(runCommand, forceKillCommandIndex);
expect(endIndex).toBeGreaterThanOrEqual(0);
expect(attemptsInitIndex).toBeGreaterThan(endIndex);
expect(pollLabelIndex).toBeGreaterThan(attemptsInitIndex);
expect(pollAttemptIncrementIndex).toBeGreaterThan(pollLabelIndex);
expect(pollNetstatCheckIndex).toBeGreaterThan(pollAttemptIncrementIndex);
expect(forceKillLabelIndex).toBeGreaterThan(pollNetstatCheckIndex);
expect(forceKillCommandIndex).toBeGreaterThan(forceKillLabelIndex);
expect(portReleasedLabelIndex).toBeGreaterThan(forceKillCommandIndex);
expect(runIndex).toBeGreaterThan(portReleasedLabelIndex);
expect(stateCheckIndex).toBeGreaterThanOrEqual(0);
expect(runningGuardIndex).toBeGreaterThan(stateCheckIndex);
expect(endIndex).toBeGreaterThan(runningGuardIndex);
expect(skipEndLogIndex).toBeGreaterThan(endIndex);
expect(portAssignmentIndex).toBeGreaterThanOrEqual(0);
expect(pollLoopIndex).toBeGreaterThan(skipEndLogIndex);
expect(pollCallIndex).toBeGreaterThan(pollLoopIndex);
expect(forceKillBranchIndex).toBeGreaterThan(pollCallIndex);
expect(forceKillCommandIndex).toBeGreaterThan(forceKillBranchIndex);
expect(runIndex).toBeGreaterThan(forceKillCommandIndex);
expect(content).not.toContain("timeout /t 3 /nobreak >nul");
expect(content).not.toContain("findstr");
expect(content).not.toContain("netstat -ano |");
expect(content).not.toContain("schtasks /End /TN");
}
beforeEach(() => {
@@ -296,21 +304,25 @@ exit 0
await cleanupScript(scriptPath);
});
it("creates a schtasks restart script on Windows", async () => {
it("creates a guarded schtasks restart script on Windows", async () => {
Object.defineProperty(process, "platform", { value: "win32" });
const { scriptPath, content } = await prepareAndReadScript({
OPENCLAW_PROFILE: "default",
});
expect(scriptPath.endsWith(".bat")).toBe(true);
expect(scriptPath.endsWith(".cmd")).toBe(true);
expect(content).toContain("@echo off");
expect(content).toContain("powershell -NoProfile -ExecutionPolicy Bypass -Command");
expect(content).not.toContain("-File");
expect(content).toContain('$ErrorActionPreference = "Continue"');
expect(content).toContain("gateway-restart.log");
expect(content).toContain("openclaw restart attempt source=update target=OpenClaw Gateway");
expect(content).toContain('schtasks /End /TN "OpenClaw Gateway"');
expect(content).toContain('schtasks /Run /TN "OpenClaw Gateway" >>');
expect(content).toContain("$taskName = 'OpenClaw Gateway'");
expect(content).toContain("function Invoke-OpenClawSchtasksWithTimeout");
expect(content).toContain("function Get-OpenClawScheduledTaskState");
expect(content).toContain("Get-ScheduledTask -TaskName $TaskName");
expect(content).toContain("openclaw restart skipped schtasks end");
expectWindowsRestartWaitOrdering(content);
// Batch self-cleanup
expect(content).toContain('del "%~f0"');
expect(content).toContain('del "%~f0" >nul 2>&1');
await cleanupScript(scriptPath);
});
@@ -321,8 +333,11 @@ exit 0
OPENCLAW_PROFILE: "default",
OPENCLAW_WINDOWS_TASK_NAME: "OpenClaw Gateway (custom)",
});
expect(content).toContain('schtasks /End /TN "OpenClaw Gateway (custom)"');
expect(content).toContain('schtasks /Run /TN "OpenClaw Gateway (custom)"');
expect(content).toContain("$taskName = 'OpenClaw Gateway (custom)'");
expect(content).toContain("Get-OpenClawScheduledTaskState -TaskName $taskName");
expect(content).toContain(
'Invoke-OpenClawSchtasksWithTimeout -Arguments @("/End", "/TN", $taskName) -TimeoutSeconds 10',
);
expectWindowsRestartWaitOrdering(content);
await cleanupScript(scriptPath);
});
@@ -337,10 +352,10 @@ exit 0
},
customPort,
);
expect(content).toContain(`netstat -ano | findstr /R /C:":${customPort} .*LISTENING" >nul`);
expect(content).toContain(
`for /f "tokens=5" %%P in ('netstat -ano ^| findstr /R /C:":${customPort} .*LISTENING"') do (`,
);
expect(content).toContain(`$port = ${customPort}`);
expect(content).toContain("Get-NetTCPConnection -LocalPort $Port -State Listen");
expect(content).toContain("& netstat.exe -ano -p tcp");
expect(content).not.toContain("findstr");
expectWindowsRestartWaitOrdering(content, customPort);
await cleanupScript(scriptPath);
});
@@ -371,7 +386,7 @@ exit 0
const { scriptPath, content } = await prepareAndReadScript({
OPENCLAW_PROFILE: "production",
});
expect(content).toContain('schtasks /End /TN "OpenClaw Gateway (production)"');
expect(content).toContain("$taskName = 'OpenClaw Gateway (production)'");
expectWindowsRestartWaitOrdering(content);
await cleanupScript(scriptPath);
});

View File

@@ -10,8 +10,8 @@ import {
resolveGatewayWindowsTaskName,
} from "../../daemon/constants.js";
import {
renderCmdRestartLogSetup,
renderPosixRestartLogSetup,
resolveGatewayRestartLogPath,
shellEscapeRestartLogValue,
} from "../../daemon/restart-logs.js";
import { normalizeOptionalString } from "../../shared/string-coerce.js";
@@ -25,12 +25,15 @@ function shellEscape(value: string): string {
return value.replace(/'/g, "'\\''");
}
/** Validates a string is safe for embedding in a batch (cmd.exe) script. */
function isBatchSafe(value: string): boolean {
// Reject characters that have special meaning in batch: & | < > ^ % " ` $
/** Validates a task name is safe for embedding in Windows restart scripts. */
function isWindowsTaskNameSafe(value: string): boolean {
return /^[A-Za-z0-9 _\-().]+$/.test(value);
}
function powerShellSingleQuote(value: string): string {
return `'${value.replace(/'/g, "''")}'`;
}
function resolveSystemdUnit(env: NodeJS.ProcessEnv): string {
const override = normalizeOptionalString(env.OPENCLAW_SYSTEMD_UNIT);
if (override) {
@@ -138,45 +141,189 @@ exit "$status"
`;
} else if (platform === "win32") {
const taskName = resolveWindowsTaskName(env);
if (!isBatchSafe(taskName)) {
if (!isWindowsTaskNameSafe(taskName)) {
return null;
}
const port =
Number.isFinite(gatewayPort) && gatewayPort > 0 ? gatewayPort : DEFAULT_GATEWAY_PORT;
const restartLog = renderCmdRestartLogSetup({ ...process.env, ...env });
filename = `openclaw-restart-${timestamp}.bat`;
const restartLogPath = resolveGatewayRestartLogPath({ ...process.env, ...env });
const quotedLogPath = powerShellSingleQuote(restartLogPath);
const quotedTaskName = powerShellSingleQuote(taskName);
filename = `openclaw-restart-${timestamp}.cmd`;
scriptContent = `@echo off
REM Standalone restart script survives parent process termination.
REM Wait briefly to ensure file locks are released after update.
timeout /t 2 /nobreak >nul
${restartLog.lines.join("\r\n")}
>> ${restartLog.quotedLogPath} 2>&1 echo [%DATE% %TIME%] openclaw restart attempt source=update target=${taskName}
schtasks /End /TN "${taskName}" >> ${restartLog.quotedLogPath} 2>&1
REM Poll for gateway port release before rerun; force-kill listener if stuck.
set /a attempts=0
:wait_for_port_release
set /a attempts+=1
netstat -ano | findstr /R /C:":${port} .*LISTENING" >nul
if errorlevel 1 goto port_released
if %attempts% GEQ 10 goto force_kill_listener
timeout /t 1 /nobreak >nul
goto wait_for_port_release
:force_kill_listener
for /f "tokens=5" %%P in ('netstat -ano ^| findstr /R /C:":${port} .*LISTENING"') do (
taskkill /F /PID %%P >> ${restartLog.quotedLogPath} 2>&1
goto port_released
)
:port_released
schtasks /Run /TN "${taskName}" >> ${restartLog.quotedLogPath} 2>&1
REM Standalone restart script - survives parent process termination.
REM Keep this as a cmd wrapper so Group Policy script execution policies
REM cannot block the update restart handoff before schtasks.exe runs.
setlocal
set "OPENCLAW_RESTART_SCRIPT=%~f0"
powershell -NoProfile -ExecutionPolicy Bypass -Command "$p=$env:OPENCLAW_RESTART_SCRIPT; $s=Get-Content -Raw -LiteralPath $p; $m='# POWERSHELL'; $i=$s.IndexOf($m); if ($i -lt 0) { exit 1 }; Invoke-Expression $s.Substring($i)"
set "status=%ERRORLEVEL%"
if not "%status%"=="0" (
>> ${restartLog.quotedLogPath} 2>&1 echo [%DATE% %TIME%] openclaw restart failed source=update status=%status%
) else (
>> ${restartLog.quotedLogPath} 2>&1 echo [%DATE% %TIME%] openclaw restart done source=update
)
REM Self-cleanup
del "%~f0"
del "%~f0" >nul 2>&1
exit /b %status%
# POWERSHELL
# Wait briefly to ensure file locks are released after update.
$ErrorActionPreference = "Continue"
Start-Sleep -Seconds 2
$logPath = ${quotedLogPath}
try {
$logDir = Split-Path -Parent $logPath
New-Item -ItemType Directory -Path $logDir -Force | Out-Null
Add-Content -LiteralPath $logPath -Value "[$(Get-Date -Format o)] openclaw restart log initialized"
} catch {
# Restart should still run if log setup is unavailable.
}
function Write-RestartLog {
param([string]$Message)
try {
Add-Content -LiteralPath $logPath -Value "[$(Get-Date -Format o)] $Message"
} catch {
}
}
function Join-OpenClawProcessArguments {
param([string[]]$Arguments)
($Arguments | ForEach-Object {
if ($_ -match "\\s") {
'"' + $_ + '"'
} else {
$_
}
}) -join " "
}
function Invoke-OpenClawSchtasksWithTimeout {
param(
[string[]]$Arguments,
[int]$TimeoutSeconds
)
$process = $null
try {
$startInfo = [System.Diagnostics.ProcessStartInfo]::new()
$startInfo.FileName = "schtasks.exe"
$startInfo.Arguments = Join-OpenClawProcessArguments -Arguments $Arguments
$startInfo.UseShellExecute = $false
$startInfo.RedirectStandardOutput = $true
$startInfo.RedirectStandardError = $true
$process = [System.Diagnostics.Process]::Start($startInfo)
if (-not $process.WaitForExit($TimeoutSeconds * 1000)) {
try {
$process.Kill()
} catch {
}
Write-RestartLog "openclaw restart schtasks timeout source=update args=$($Arguments -join ' ')"
return 124
}
$stdout = $process.StandardOutput.ReadToEnd()
$stderr = $process.StandardError.ReadToEnd()
if ($stdout) {
Write-RestartLog $stdout.Trim()
}
if ($stderr) {
Write-RestartLog $stderr.Trim()
}
return $process.ExitCode
} catch {
Write-RestartLog "openclaw restart schtasks failed source=update args=$($Arguments -join ' ') error=$($_.Exception.Message)"
return 1
}
}
function Get-OpenClawScheduledTaskState {
param([string]$TaskName)
try {
$task = Get-ScheduledTask -TaskName $TaskName -ErrorAction Stop
if ($task -and $task.State) {
return [string]$task.State
}
} catch {
}
try {
$queryOutput = & schtasks.exe /Query /TN $TaskName /FO LIST 2>$null
foreach ($line in $queryOutput) {
if ($line -match "^\\s*Status:\\s*(.+?)\\s*$") {
return $Matches[1]
}
}
} catch {
}
return "Unknown"
}
function Get-OpenClawListenerPids {
param([int]$Port)
$listenerPids = @()
try {
if (Get-Command Get-NetTCPConnection -ErrorAction SilentlyContinue) {
$listenerPids += Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue |
ForEach-Object { [int]$_.OwningProcess }
}
} catch {
}
if ($listenerPids.Count -eq 0) {
try {
$portPattern = [regex]::Escape(":$Port")
$linePattern = "^\\s*TCP\\s+\\S+$portPattern\\s+\\S+\\s+LISTENING\\s+(\\d+)\\s*$"
& netstat.exe -ano -p tcp 2>$null | ForEach-Object {
if ($_ -match $linePattern) {
$listenerPids += [int]$Matches[1]
}
}
} catch {
}
}
$listenerPids | Sort-Object -Unique
}
$taskName = ${quotedTaskName}
$port = ${port}
Write-RestartLog "openclaw restart attempt source=update target=$taskName"
$taskState = Get-OpenClawScheduledTaskState -TaskName $taskName
if ($taskState -eq "Running") {
$endStatus = Invoke-OpenClawSchtasksWithTimeout -Arguments @("/End", "/TN", $taskName) -TimeoutSeconds 10
if ($endStatus -ne 0) {
Write-RestartLog "openclaw restart schtasks end did not complete cleanly source=update status=$endStatus"
}
} else {
Write-RestartLog "openclaw restart skipped schtasks end source=update state=$taskState"
}
for ($attempt = 1; $attempt -le 10; $attempt++) {
$listeners = @(Get-OpenClawListenerPids -Port $port)
if ($listeners.Count -eq 0) {
break
}
if ($attempt -eq 10) {
foreach ($listenerPid in $listeners) {
try {
Stop-Process -Id $listenerPid -Force -ErrorAction Stop
Write-RestartLog "openclaw restart killed stale listener source=update pid=$listenerPid"
} catch {
Write-RestartLog "openclaw restart failed to kill stale listener source=update pid=$listenerPid error=$($_.Exception.Message)"
}
}
break
}
Start-Sleep -Seconds 1
}
$status = Invoke-OpenClawSchtasksWithTimeout -Arguments @("/Run", "/TN", $taskName) -TimeoutSeconds 30
if ($status -eq 0) {
Write-RestartLog "openclaw restart done source=update"
} else {
Write-RestartLog "openclaw restart failed source=update status=$status"
}
exit $status
`;
} else {
return null;

View File

@@ -11,6 +11,7 @@ import type { AgentSummary } from "./agents.config.js";
import { buildAgentSummaries } from "./agents.config.js";
import {
buildProviderStatusIndex,
buildProviderSummaryMetadataIndex,
listProvidersForAgent,
summarizeBindings,
} from "./agents.providers.js";
@@ -107,11 +108,12 @@ export async function agentsListCommand(
// catalog entry, this keeps `agents list --json` on the config-only path.
const includeProviderDetails = !opts.json || opts.bindings === true;
const providerStatus = includeProviderDetails ? await buildProviderStatusIndex(cfg) : null;
const providerMetadata = includeProviderDetails ? buildProviderSummaryMetadataIndex(cfg) : null;
for (const summary of summaries) {
const bindings = bindingMap.get(summary.id) ?? [];
if (includeProviderDetails && providerStatus) {
const routes = summarizeBindings(cfg, bindings);
if (includeProviderDetails && providerStatus && providerMetadata) {
const routes = summarizeBindings(cfg, bindings, providerMetadata);
if (routes.length > 0) {
summary.routes = routes;
} else if (summary.isDefault) {
@@ -123,6 +125,7 @@ export async function agentsListCommand(
cfg,
bindings,
providerStatus,
providerMetadata,
});
if (providerLines.length > 0) {
summary.providers = providerLines;

View File

@@ -1,6 +1,6 @@
import { isChannelVisibleInConfiguredLists } from "../channels/plugins/exposure.js";
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
import { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index.js";
import { normalizeChannelId } from "../channels/plugins/index.js";
import { listReadOnlyChannelPluginsForConfig } from "../channels/plugins/read-only.js";
import type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
import type { ChannelId } from "../channels/plugins/types.public.js";
@@ -19,10 +19,37 @@ type ProviderAccountStatus = {
visibleInConfiguredLists?: boolean;
};
export type ProviderSummaryMetadata = {
label: string;
defaultAccountId: string;
visibleInConfiguredLists: boolean;
};
function providerAccountKey(provider: ChannelId, accountId?: string) {
return `${provider}:${accountId ?? DEFAULT_ACCOUNT_ID}`;
}
export function buildProviderSummaryMetadataIndex(
cfg: OpenClawConfig,
): Map<ChannelId, ProviderSummaryMetadata> {
return new Map(
listReadOnlyChannelPluginsForConfig(cfg, {
includeSetupRuntimeFallback: false,
}).map((plugin) => [
plugin.id,
{
label: plugin.meta.label,
defaultAccountId: resolveChannelDefaultAccountId({
plugin,
cfg,
accountIds: plugin.config.listAccountIds(cfg),
}),
visibleInConfiguredLists: isChannelVisibleInConfiguredLists(plugin.meta),
},
]),
);
}
function isUnresolvedSecretRefResolutionError(error: unknown): boolean {
return (
error instanceof Error &&
@@ -37,8 +64,7 @@ function formatChannelAccountLabel(params: {
accountId: string;
name?: string;
}): string {
const label =
params.providerLabel ?? getChannelPlugin(params.provider)?.meta.label ?? params.provider;
const label = params.providerLabel ?? params.provider;
const account = params.name?.trim()
? `${params.accountId} (${params.name.trim()})`
: params.accountId;
@@ -134,31 +160,26 @@ export async function buildProviderStatusIndex(
return map;
}
function resolveDefaultAccountId(cfg: OpenClawConfig, provider: ChannelId): string {
const plugin = getChannelPlugin(provider);
if (!plugin) {
return DEFAULT_ACCOUNT_ID;
}
return resolveChannelDefaultAccountId({ plugin, cfg });
function resolveDefaultAccountId(
provider: ChannelId,
metadataByProvider: ReadonlyMap<ChannelId, ProviderSummaryMetadata>,
): string {
return metadataByProvider.get(provider)?.defaultAccountId ?? DEFAULT_ACCOUNT_ID;
}
function shouldShowProviderEntry(entry: ProviderAccountStatus, cfg: OpenClawConfig): boolean {
if (entry.visibleInConfiguredLists !== undefined) {
if (!entry.visibleInConfiguredLists) {
const providerConfig = (cfg as Record<string, unknown>)[entry.provider];
return Boolean(entry.configured) || Boolean(providerConfig);
}
return Boolean(entry.configured);
function shouldShowProviderEntry(params: {
entry: ProviderAccountStatus;
cfg: OpenClawConfig;
metadataByProvider: ReadonlyMap<ChannelId, ProviderSummaryMetadata>;
}): boolean {
const visibleInConfiguredLists =
params.entry.visibleInConfiguredLists ??
params.metadataByProvider.get(params.entry.provider)?.visibleInConfiguredLists;
if (visibleInConfiguredLists === false) {
const providerConfig = (params.cfg as Record<string, unknown>)[params.entry.provider];
return Boolean(params.entry.configured) || Boolean(providerConfig);
}
const plugin = getChannelPlugin(entry.provider);
if (!plugin) {
return Boolean(entry.configured);
}
if (!isChannelVisibleInConfiguredLists(plugin.meta)) {
const providerConfig = (cfg as Record<string, unknown>)[plugin.id];
return Boolean(entry.configured) || Boolean(providerConfig);
}
return Boolean(entry.configured);
return Boolean(params.entry.configured);
}
function formatProviderEntry(entry: ProviderAccountStatus): string {
@@ -171,7 +192,11 @@ function formatProviderEntry(entry: ProviderAccountStatus): string {
return `${label}: ${formatProviderState(entry)}`;
}
export function summarizeBindings(cfg: OpenClawConfig, bindings: AgentBinding[]): string[] {
export function summarizeBindings(
cfg: OpenClawConfig,
bindings: AgentBinding[],
metadataByProvider = buildProviderSummaryMetadataIndex(cfg),
): string[] {
if (bindings.length === 0) {
return [];
}
@@ -181,11 +206,13 @@ export function summarizeBindings(cfg: OpenClawConfig, bindings: AgentBinding[])
if (!channel) {
continue;
}
const accountId = binding.match.accountId ?? resolveDefaultAccountId(cfg, channel);
const accountId =
binding.match.accountId ?? resolveDefaultAccountId(channel, metadataByProvider);
const key = providerAccountKey(channel, accountId);
if (!seen.has(key)) {
const label = formatChannelAccountLabel({
provider: channel,
providerLabel: metadataByProvider.get(channel)?.label,
accountId,
});
seen.set(key, label);
@@ -199,9 +226,12 @@ export function listProvidersForAgent(params: {
cfg: OpenClawConfig;
bindings: AgentBinding[];
providerStatus: Map<string, ProviderAccountStatus>;
providerMetadata?: ReadonlyMap<ChannelId, ProviderSummaryMetadata>;
}): string[] {
const allProviderEntries = [...params.providerStatus.values()];
const providerLines: string[] = [];
const metadataByProvider =
params.providerMetadata ?? buildProviderSummaryMetadataIndex(params.cfg);
if (params.bindings.length > 0) {
const seen = new Set<string>();
for (const binding of params.bindings) {
@@ -209,7 +239,8 @@ export function listProvidersForAgent(params: {
if (!channel) {
continue;
}
const accountId = binding.match.accountId ?? resolveDefaultAccountId(params.cfg, channel);
const accountId =
binding.match.accountId ?? resolveDefaultAccountId(channel, metadataByProvider);
const key = providerAccountKey(channel, accountId);
if (seen.has(key)) {
continue;
@@ -220,7 +251,11 @@ export function listProvidersForAgent(params: {
providerLines.push(formatProviderEntry(status));
} else {
providerLines.push(
`${formatChannelAccountLabel({ provider: channel, accountId })}: unknown`,
`${formatChannelAccountLabel({
provider: channel,
providerLabel: metadataByProvider.get(channel)?.label,
accountId,
})}: unknown`,
);
}
}
@@ -229,7 +264,7 @@ export function listProvidersForAgent(params: {
if (params.summaryIsDefault) {
for (const entry of allProviderEntries) {
if (shouldShowProviderEntry(entry, params.cfg)) {
if (shouldShowProviderEntry({ entry, cfg: params.cfg, metadataByProvider })) {
providerLines.push(formatProviderEntry(entry));
}
}

View File

@@ -1,9 +1,9 @@
import { resolveChannelDefaultAccountId } from "../../channels/plugins/helpers.js";
import { listChannelPlugins } from "../../channels/plugins/index.js";
import {
createMessageActionDiscoveryContext,
resolveMessageActionDiscoveryForPlugin,
} from "../../channels/plugins/message-action-discovery.js";
import { listReadOnlyChannelPluginsForConfig } from "../../channels/plugins/read-only.js";
import type {
ChannelCapabilities,
ChannelCapabilitiesDiagnostics,
@@ -239,7 +239,9 @@ export async function channelsCapabilitiesCommand(
return;
}
const plugins = listChannelPlugins();
const plugins = listReadOnlyChannelPluginsForConfig(cfg, {
includeSetupRuntimeFallback: true,
});
const selected =
!rawChannel || rawChannel === "all"
? plugins

View File

@@ -1,9 +1,7 @@
import { resolveChannelDefaultAccountId } from "../../channels/plugins/helpers.js";
import {
getChannelPlugin,
listChannelPlugins,
normalizeChannelId,
} from "../../channels/plugins/index.js";
import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
import { listReadOnlyChannelPluginsForConfig } from "../../channels/plugins/read-only.js";
import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js";
import { commitConfigWithPendingPluginInstalls } from "../../cli/plugins-install-record-commit.js";
import { refreshPluginRegistryAfterConfigMutation } from "../../cli/plugins-registry-refresh.js";
import { replaceConfigFile, type OpenClawConfig } from "../../config/config.js";
@@ -24,8 +22,12 @@ export type ChannelsRemoveOptions = {
delete?: boolean;
};
function listAccountIds(cfg: OpenClawConfig, channel: ChatChannel): string[] {
const plugin = getChannelPlugin(channel);
function listAccountIds(
cfg: OpenClawConfig,
channel: ChatChannel,
plugin?: ChannelPlugin,
): string[] {
plugin ??= getChannelPlugin(channel);
if (!plugin) {
return [];
}
@@ -47,23 +49,29 @@ export async function channelsRemoveCommand(
const useWizard = shouldUseWizard(params);
const prompter = useWizard ? createClackPrompter() : null;
const rawChannel = normalizeOptionalString(opts.channel) ?? "";
let lookupChannel = rawChannel;
let channel: ChatChannel | null = normalizeChannelId(rawChannel);
let accountId = normalizeAccountId(opts.account);
const deleteConfig = Boolean(opts.delete);
if (useWizard && prompter) {
await prompter.intro("Remove channel account");
const readOnlyPlugins = listReadOnlyChannelPluginsForConfig(cfg, {
includeSetupRuntimeFallback: true,
});
const selectedChannel = await prompter.select({
message: "Channel",
options: listChannelPlugins().map((plugin) => ({
options: readOnlyPlugins.map((plugin) => ({
value: plugin.id,
label: plugin.meta.label,
})),
});
channel = selectedChannel;
lookupChannel = selectedChannel;
accountId = await (async () => {
const ids = listAccountIds(cfg, selectedChannel);
const readOnlyPlugin = readOnlyPlugins.find((plugin) => plugin.id === selectedChannel);
const ids = listAccountIds(cfg, selectedChannel, readOnlyPlugin);
const choice = await prompter.select({
message: "Account",
options: ids.map((id) => ({
@@ -102,8 +110,7 @@ export async function channelsRemoveCommand(
}
}
const shouldResolveInstallablePlugin =
!useWizard && rawChannel && (!channel || !getChannelPlugin(channel));
const shouldResolveInstallablePlugin = Boolean(lookupChannel || channel);
const resolvedPluginState = shouldResolveInstallablePlugin
? await (async () => {
const { resolveInstallableChannelPlugin } =
@@ -111,7 +118,7 @@ export async function channelsRemoveCommand(
return await resolveInstallableChannelPlugin({
cfg,
runtime,
rawChannel,
rawChannel: lookupChannel,
allowInstall: true,
});
})()

Some files were not shown because too many files have changed in this diff Show More