feat: wire multi-agent config and routing

Co-authored-by: Mark Pors <1078320+pors@users.noreply.github.com>
main
Peter Steinberger 2026-01-09 12:44:23 +00:00
parent 81beda0772
commit 7b81d97ec2
189 changed files with 4340 additions and 2903 deletions

View File

@ -15,6 +15,7 @@
- Deps: bump Pi to 0.40.0 and drop pi-ai patch (upstream 429 fix). (#543) — thanks @mcinteerj - Deps: bump Pi to 0.40.0 and drop pi-ai patch (upstream 429 fix). (#543) — thanks @mcinteerj
- Security: per-agent mention patterns and group elevated directives now require explicit mention to avoid cross-agent toggles. - Security: per-agent mention patterns and group elevated directives now require explicit mention to avoid cross-agent toggles.
- Config: support inline env vars in config (`env.*` / `env.vars`) and document env precedence. - Config: support inline env vars in config (`env.*` / `env.vars`) and document env precedence.
- Config: migrate routing/agent config into agents.list/agents.defaults and messages/tools/audio with default agent selection and per-agent identity config.
- Agent: enable adaptive context pruning by default for tool-result trimming. - Agent: enable adaptive context pruning by default for tool-result trimming.
- Doctor: check config/state permissions and offer to tighten them. — thanks @steipete - Doctor: check config/state permissions and offer to tighten them. — thanks @steipete
- Doctor/Daemon: audit supervisor configs, add --repair/--force flows, surface service config audits in daemon status, and document user vs system services. — thanks @steipete - Doctor/Daemon: audit supervisor configs, add --repair/--force flows, surface service config audits in daemon status, and document user vs system services. — thanks @steipete
@ -109,8 +110,8 @@
- New default: DM pairing (`dmPolicy="pairing"` / `discord.dm.policy="pairing"` / `slack.dm.policy="pairing"`). - New default: DM pairing (`dmPolicy="pairing"` / `discord.dm.policy="pairing"` / `slack.dm.policy="pairing"`).
- To keep old “open to everyone” behavior: set `dmPolicy="open"` and include `"*"` in the relevant `allowFrom` (Discord/Slack: `discord.dm.allowFrom` / `slack.dm.allowFrom`). - To keep old “open to everyone” behavior: set `dmPolicy="open"` and include `"*"` in the relevant `allowFrom` (Discord/Slack: `discord.dm.allowFrom` / `slack.dm.allowFrom`).
- Approve requests via `clawdbot pairing list --provider <provider>` + `clawdbot pairing approve --provider <provider> <code>`. - Approve requests via `clawdbot pairing list --provider <provider>` + `clawdbot pairing approve --provider <provider> <code>`.
- Sandbox: default `agent.sandbox.scope` to `"agent"` (one container/workspace per agent). Use `"session"` for per-session isolation; `"shared"` disables cross-session isolation. - Sandbox: default `agents.defaults.sandbox.scope` to `"agent"` (one container/workspace per agent). Use `"session"` for per-session isolation; `"shared"` disables cross-session isolation.
- Timestamps in agent envelopes are now UTC (compact `YYYY-MM-DDTHH:mmZ`); removed `messages.timestampPrefix`. Add `agent.userTimezone` to tell the model the users local time (system prompt only). - Timestamps in agent envelopes are now UTC (compact `YYYY-MM-DDTHH:mmZ`); removed `messages.timestampPrefix`. Add `agents.defaults.userTimezone` to tell the model the users local time (system prompt only).
- Model config schema changes (auth profiles + model lists); doctor auto-migrates and the gateway rewrites legacy configs on startup. - Model config schema changes (auth profiles + model lists); doctor auto-migrates and the gateway rewrites legacy configs on startup.
- Commands: gate all slash commands to authorized senders; add `/compact` to manually compact session context. - Commands: gate all slash commands to authorized senders; add `/compact` to manually compact session context.
- Groups: `whatsapp.groups`, `telegram.groups`, and `imessage.groups` now act as allowlists when set. Add `"*"` to keep allow-all behavior. - Groups: `whatsapp.groups`, `telegram.groups`, and `imessage.groups` now act as allowlists when set. Add `"*"` to keep allow-all behavior.
@ -136,7 +137,7 @@
## 2026.1.5 ## 2026.1.5
### Highlights ### Highlights
- Models: add image-specific model config (`agent.imageModel` + fallbacks) and scan support. - Models: add image-specific model config (`agents.defaults.imageModel` + fallbacks) and scan support.
- Agent tools: new `image` tool routed to the image model (when configured). - Agent tools: new `image` tool routed to the image model (when configured).
- Config: default model shorthands (`opus`, `sonnet`, `gpt`, `gpt-mini`, `gemini`, `gemini-flash`). - Config: default model shorthands (`opus`, `sonnet`, `gpt`, `gpt-mini`, `gemini`, `gemini-flash`).
- Docs: document built-in model shorthands + precedence (user config wins). - Docs: document built-in model shorthands + precedence (user config wins).
@ -161,7 +162,7 @@
- Agent tools: OpenAI-compatible tool JSON Schemas (fix `browser`, normalize union schemas). - Agent tools: OpenAI-compatible tool JSON Schemas (fix `browser`, normalize union schemas).
- Onboarding: when running from source, auto-build missing Control UI assets (`bun run ui:build`). - Onboarding: when running from source, auto-build missing Control UI assets (`bun run ui:build`).
- Discord/Slack: route reaction + system notifications to the correct session (no main-session bleed). - Discord/Slack: route reaction + system notifications to the correct session (no main-session bleed).
- Agent tools: honor `agent.tools` allow/deny policy even when sandbox is off. - Agent tools: honor `tools.allow` / `tools.deny` policy even when sandbox is off.
- Discord: avoid duplicate replies when OpenAI emits repeated `message_end` events. - Discord: avoid duplicate replies when OpenAI emits repeated `message_end` events.
- Commands: unify /status (inline) and command auth across providers; group bypass for authorized control commands; remove Discord /clawd slash handler. - Commands: unify /status (inline) and command auth across providers; group bypass for authorized control commands; remove Discord /clawd slash handler.
- CLI: run `clawdbot agent` via the Gateway by default; use `--local` to force embedded mode. - CLI: run `clawdbot agent` via the Gateway by default; use `--local` to force embedded mode.

View File

@ -284,7 +284,7 @@ Runbook: [iOS connect](https://docs.clawd.bot/ios).
## Agent workspace + skills ## Agent workspace + skills
- Workspace root: `~/clawd` (configurable via `agent.workspace`). - Workspace root: `~/clawd` (configurable via `agents.defaults.workspace`).
- Injected prompt files: `AGENTS.md`, `SOUL.md`, `TOOLS.md`. - Injected prompt files: `AGENTS.md`, `SOUL.md`, `TOOLS.md`.
- Skills: `~/clawd/skills/<skill>/SKILL.md`. - Skills: `~/clawd/skills/<skill>/SKILL.md`.
@ -305,7 +305,7 @@ Minimal `~/.clawdbot/clawdbot.json` (model + defaults):
## Security model (important) ## Security model (important)
- **Default:** tools run on the host for the **main** session, so the agent has full access when its just you. - **Default:** tools run on the host for the **main** session, so the agent has full access when its just you.
- **Group/channel safety:** set `agent.sandbox.mode: "non-main"` to run **nonmain sessions** (groups/channels) inside persession Docker sandboxes; bash then runs in Docker for those sessions. - **Group/channel safety:** set `agents.defaults.sandbox.mode: "non-main"` to run **nonmain sessions** (groups/channels) inside persession Docker sandboxes; bash then runs in Docker for those sessions.
- **Sandbox defaults:** allowlist `bash`, `process`, `read`, `write`, `edit`, `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`; denylist `browser`, `canvas`, `nodes`, `cron`, `discord`, `gateway`. - **Sandbox defaults:** allowlist `bash`, `process`, `read`, `write`, `edit`, `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`; denylist `browser`, `canvas`, `nodes`, `cron`, `discord`, `gateway`.
Details: [Security guide](https://docs.clawd.bot/security) · [Docker + sandboxing](https://docs.clawd.bot/docker) · [Sandbox config](https://docs.clawd.bot/configuration) Details: [Security guide](https://docs.clawd.bot/security) · [Docker + sandboxing](https://docs.clawd.bot/docker) · [Sandbox config](https://docs.clawd.bot/configuration)

View File

@ -81,22 +81,33 @@ enum ClawdbotConfigFile {
static func agentWorkspace() -> String? { static func agentWorkspace() -> String? {
let root = self.loadDict() let root = self.loadDict()
let agent = root["agent"] as? [String: Any] let agents = root["agents"] as? [String: Any]
return agent?["workspace"] as? String let defaults = agents?["defaults"] as? [String: Any]
return defaults?["workspace"] as? String
} }
static func setAgentWorkspace(_ workspace: String?) { static func setAgentWorkspace(_ workspace: String?) {
var root = self.loadDict() var root = self.loadDict()
var agent = root["agent"] as? [String: Any] ?? [:] var agents = root["agents"] as? [String: Any] ?? [:]
var defaults = agents["defaults"] as? [String: Any] ?? [:]
let trimmed = workspace?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let trimmed = workspace?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if trimmed.isEmpty { if trimmed.isEmpty {
agent.removeValue(forKey: "workspace") defaults.removeValue(forKey: "workspace")
} else { } else {
agent["workspace"] = trimmed defaults["workspace"] = trimmed
}
if defaults.isEmpty {
agents.removeValue(forKey: "defaults")
} else {
agents["defaults"] = defaults
}
if agents.isEmpty {
root.removeValue(forKey: "agents")
} else {
root["agents"] = agents
} }
root["agent"] = agent
self.saveDict(root) self.saveDict(root)
self.logger.debug("agent workspace updated set=\(!trimmed.isEmpty)") self.logger.debug("agents.defaults.workspace updated set=\(!trimmed.isEmpty)")
} }
static func gatewayPassword() -> String? { static func gatewayPassword() -> String? {

View File

@ -387,13 +387,20 @@ struct ConfigSettings: View {
private func loadConfig() async { private func loadConfig() async {
let parsed = await ConfigStore.load() let parsed = await ConfigStore.load()
let agent = parsed["agent"] as? [String: Any] let agents = parsed["agents"] as? [String: Any]
let heartbeatMinutes = agent?["heartbeatMinutes"] as? Int let defaults = agents?["defaults"] as? [String: Any]
let heartbeatBody = agent?["heartbeatBody"] as? String let heartbeat = defaults?["heartbeat"] as? [String: Any]
let heartbeatEvery = heartbeat?["every"] as? String
let heartbeatBody = heartbeat?["prompt"] as? String
let browser = parsed["browser"] as? [String: Any] let browser = parsed["browser"] as? [String: Any]
let talk = parsed["talk"] as? [String: Any] let talk = parsed["talk"] as? [String: Any]
let loadedModel = (agent?["model"] as? String) ?? "" let loadedModel: String = {
if let raw = defaults?["model"] as? String { return raw }
if let modelDict = defaults?["model"] as? [String: Any],
let primary = modelDict["primary"] as? String { return primary }
return ""
}()
if !loadedModel.isEmpty { if !loadedModel.isEmpty {
self.configModel = loadedModel self.configModel = loadedModel
self.customModel = loadedModel self.customModel = loadedModel
@ -402,7 +409,13 @@ struct ConfigSettings: View {
self.customModel = SessionLoader.fallbackModel self.customModel = SessionLoader.fallbackModel
} }
if let heartbeatMinutes { self.heartbeatMinutes = heartbeatMinutes } if let heartbeatEvery {
let digits = heartbeatEvery.trimmingCharacters(in: .whitespacesAndNewlines)
.prefix { $0.isNumber }
if let minutes = Int(digits) {
self.heartbeatMinutes = minutes
}
}
if let heartbeatBody, !heartbeatBody.isEmpty { self.heartbeatBody = heartbeatBody } if let heartbeatBody, !heartbeatBody.isEmpty { self.heartbeatBody = heartbeatBody }
if let browser { if let browser {
@ -480,25 +493,49 @@ struct ConfigSettings: View {
@MainActor @MainActor
private static func buildAndSaveConfig(_ draft: ConfigDraft) async -> String? { private static func buildAndSaveConfig(_ draft: ConfigDraft) async -> String? {
var root = await ConfigStore.load() var root = await ConfigStore.load()
var agent = root["agent"] as? [String: Any] ?? [:] var agents = root["agents"] as? [String: Any] ?? [:]
var defaults = agents["defaults"] as? [String: Any] ?? [:]
var browser = root["browser"] as? [String: Any] ?? [:] var browser = root["browser"] as? [String: Any] ?? [:]
var talk = root["talk"] as? [String: Any] ?? [:] var talk = root["talk"] as? [String: Any] ?? [:]
let chosenModel = (draft.configModel == "__custom__" ? draft.customModel : draft.configModel) let chosenModel = (draft.configModel == "__custom__" ? draft.customModel : draft.configModel)
.trimmingCharacters(in: .whitespacesAndNewlines) .trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedModel = chosenModel let trimmedModel = chosenModel
if !trimmedModel.isEmpty { agent["model"] = trimmedModel } if !trimmedModel.isEmpty {
var model = defaults["model"] as? [String: Any] ?? [:]
model["primary"] = trimmedModel
defaults["model"] = model
var models = defaults["models"] as? [String: Any] ?? [:]
if models[trimmedModel] == nil {
models[trimmedModel] = [:]
}
defaults["models"] = models
}
if let heartbeatMinutes = draft.heartbeatMinutes { if let heartbeatMinutes = draft.heartbeatMinutes {
agent["heartbeatMinutes"] = heartbeatMinutes var heartbeat = defaults["heartbeat"] as? [String: Any] ?? [:]
heartbeat["every"] = "\(heartbeatMinutes)m"
defaults["heartbeat"] = heartbeat
} }
let trimmedBody = draft.heartbeatBody.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedBody = draft.heartbeatBody.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedBody.isEmpty { if !trimmedBody.isEmpty {
agent["heartbeatBody"] = trimmedBody var heartbeat = defaults["heartbeat"] as? [String: Any] ?? [:]
heartbeat["prompt"] = trimmedBody
defaults["heartbeat"] = heartbeat
} }
root["agent"] = agent if defaults.isEmpty {
agents.removeValue(forKey: "defaults")
} else {
agents["defaults"] = defaults
}
if agents.isEmpty {
root.removeValue(forKey: "agents")
} else {
root["agents"] = agents
}
browser["enabled"] = draft.browserEnabled browser["enabled"] = draft.browserEnabled
let trimmedUrl = draft.browserControlUrl.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedUrl = draft.browserControlUrl.trimmingCharacters(in: .whitespacesAndNewlines)

View File

@ -607,7 +607,7 @@ extension OnboardingView {
let saved = await self.saveAgentWorkspace(AgentWorkspace.displayPath(for: url)) let saved = await self.saveAgentWorkspace(AgentWorkspace.displayPath(for: url))
if saved { if saved {
self.workspaceStatus = self.workspaceStatus =
"Saved to ~/.clawdbot/clawdbot.json (agent.workspace)" "Saved to ~/.clawdbot/clawdbot.json (agents.defaults.workspace)"
} }
} }
} }

View File

@ -69,8 +69,9 @@ extension OnboardingView {
private func loadAgentWorkspace() async -> String? { private func loadAgentWorkspace() async -> String? {
let root = await ConfigStore.load() let root = await ConfigStore.load()
let agent = root["agent"] as? [String: Any] let agents = root["agents"] as? [String: Any]
return agent?["workspace"] as? String let defaults = agents?["defaults"] as? [String: Any]
return defaults?["workspace"] as? String
} }
@discardableResult @discardableResult
@ -86,17 +87,23 @@ extension OnboardingView {
@MainActor @MainActor
private static func buildAndSaveWorkspace(_ workspace: String?) async -> (Bool, String?) { private static func buildAndSaveWorkspace(_ workspace: String?) async -> (Bool, String?) {
var root = await ConfigStore.load() var root = await ConfigStore.load()
var agent = root["agent"] as? [String: Any] ?? [:] var agents = root["agents"] as? [String: Any] ?? [:]
var defaults = agents["defaults"] as? [String: Any] ?? [:]
let trimmed = workspace?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let trimmed = workspace?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if trimmed.isEmpty { if trimmed.isEmpty {
agent.removeValue(forKey: "workspace") defaults.removeValue(forKey: "workspace")
} else { } else {
agent["workspace"] = trimmed defaults["workspace"] = trimmed
} }
if agent.isEmpty { if defaults.isEmpty {
root.removeValue(forKey: "agent") agents.removeValue(forKey: "defaults")
} else { } else {
root["agent"] = agent agents["defaults"] = defaults
}
if agents.isEmpty {
root.removeValue(forKey: "agents")
} else {
root["agents"] = agents
} }
do { do {
try await ConfigStore.save(root) try await ConfigStore.save(root)

View File

@ -63,7 +63,7 @@ If you want a fixed channel, set `provider` + `to`. Otherwise `provider: "last"`
uses the last delivery route (falls back to WhatsApp). uses the last delivery route (falls back to WhatsApp).
To force a cheaper model for Gmail runs, set `model` in the mapping To force a cheaper model for Gmail runs, set `model` in the mapping
(`provider/model` or alias). If you enforce `agent.models`, include it there. (`provider/model` or alias). If you enforce `agents.defaults.models`, include it there.
To customize payload handling further, add `hooks.mappings` or a JS/TS transform module To customize payload handling further, add `hooks.mappings` or a JS/TS transform module
under `hooks.transformsDir` (see [`docs/webhook.md`](https://docs.clawd.bot/automation/webhook)). under `hooks.transformsDir` (see [`docs/webhook.md`](https://docs.clawd.bot/automation/webhook)).

View File

@ -134,7 +134,7 @@ curl -X POST http://127.0.0.1:18789/hooks/agent \
-d '{"message":"Summarize inbox","name":"Email","model":"openai/gpt-5.2-mini"}' -d '{"message":"Summarize inbox","name":"Email","model":"openai/gpt-5.2-mini"}'
``` ```
If you enforce `agent.models`, make sure the override model is included there. If you enforce `agents.defaults.models`, make sure the override model is included there.
```bash ```bash
curl -X POST http://127.0.0.1:18789/hooks/gmail \ curl -X POST http://127.0.0.1:18789/hooks/gmail \

View File

@ -488,10 +488,10 @@ Options:
Always includes the auth overview and OAuth expiry status for profiles in the auth store. Always includes the auth overview and OAuth expiry status for profiles in the auth store.
### `models set <model>` ### `models set <model>`
Set `agent.model.primary`. Set `agents.defaults.model.primary`.
### `models set-image <model>` ### `models set-image <model>`
Set `agent.imageModel.primary`. Set `agents.defaults.imageModel.primary`.
### `models aliases list|add|remove` ### `models aliases list|add|remove`
Options: Options:

View File

@ -42,7 +42,7 @@ Short, exact flow of one agent run.
## Timeouts ## Timeouts
- `agent.wait` default: 30s (just the wait). `timeoutMs` param overrides. - `agent.wait` default: 30s (just the wait). `timeoutMs` param overrides.
- Agent runtime: `agent.timeoutSeconds` default 600s; enforced in `runEmbeddedPiAgent` abort timer. - Agent runtime: `agents.defaults.timeoutSeconds` default 600s; enforced in `runEmbeddedPiAgent` abort timer.
## Where things can end early ## Where things can end early
- Agent timeout (abort) - Agent timeout (abort)

View File

@ -15,7 +15,7 @@ sessions.
**Important:** the workspace is the **default cwd**, not a hard sandbox. Tools **Important:** the workspace is the **default cwd**, not a hard sandbox. Tools
resolve relative paths against the workspace, but absolute paths can still reach resolve relative paths against the workspace, but absolute paths can still reach
elsewhere on the host unless sandboxing is enabled. If you need isolation, use elsewhere on the host unless sandboxing is enabled. If you need isolation, use
[`agent.sandbox`](/gateway/sandboxing) (and/or peragent sandbox config). [`agents.defaults.sandbox`](/gateway/sandboxing) (and/or peragent sandbox config).
When sandboxing is enabled and `workspaceAccess` is not `"rw"`, tools operate When sandboxing is enabled and `workspaceAccess` is not `"rw"`, tools operate
inside a sandbox workspace under `~/.clawdbot/sandboxes`, not your host workspace. inside a sandbox workspace under `~/.clawdbot/sandboxes`, not your host workspace.
@ -53,7 +53,7 @@ only one workspace is active at a time.
**Recommendation:** keep a single active workspace. If you no longer use the **Recommendation:** keep a single active workspace. If you no longer use the
legacy folders, archive or move them to Trash (for example `trash ~/clawdis`). legacy folders, archive or move them to Trash (for example `trash ~/clawdis`).
If you intentionally keep multiple workspaces, make sure If you intentionally keep multiple workspaces, make sure
`agent.workspace` points to the active one. `agents.defaults.workspace` points to the active one.
`clawdbot doctor` warns when it detects legacy workspace directories. `clawdbot doctor` warns when it detects legacy workspace directories.
@ -207,7 +207,7 @@ Suggested `.gitignore` starter:
## Moving the workspace to a new machine ## Moving the workspace to a new machine
1. Clone the repo to the desired path (default `~/clawd`). 1. Clone the repo to the desired path (default `~/clawd`).
2. Set `agent.workspace` to that path in `~/.clawdbot/clawdbot.json`. 2. Set `agents.defaults.workspace` to that path in `~/.clawdbot/clawdbot.json`.
3. Run `clawdbot setup --workspace <path>` to seed any missing files. 3. Run `clawdbot setup --workspace <path>` to seed any missing files.
4. If you need sessions, copy `~/.clawdbot/agents/<agentId>/sessions/` from the 4. If you need sessions, copy `~/.clawdbot/agents/<agentId>/sessions/` from the
old machine separately. old machine separately.
@ -216,5 +216,5 @@ Suggested `.gitignore` starter:
- Multi-agent routing can use different workspaces per agent. See - Multi-agent routing can use different workspaces per agent. See
`docs/provider-routing.md` for routing configuration. `docs/provider-routing.md` for routing configuration.
- If `agent.sandbox` is enabled, non-main sessions can use per-session sandbox - If `agents.defaults.sandbox` is enabled, non-main sessions can use per-session sandbox
workspaces under `agent.sandbox.workspaceRoot`. workspaces under `agents.defaults.sandbox.workspaceRoot`.

View File

@ -9,19 +9,19 @@ CLAWDBOT runs a single embedded agent runtime derived from **p-mono**.
## Workspace (required) ## Workspace (required)
CLAWDBOT uses a single agent workspace directory (`agent.workspace`) as the agents **only** working directory (`cwd`) for tools and context. CLAWDBOT uses a single agent workspace directory (`agents.defaults.workspace`) as the agents **only** working directory (`cwd`) for tools and context.
Recommended: use `clawdbot setup` to create `~/.clawdbot/clawdbot.json` if missing and initialize the workspace files. Recommended: use `clawdbot setup` to create `~/.clawdbot/clawdbot.json` if missing and initialize the workspace files.
Full workspace layout + backup guide: [`docs/agent-workspace.md`](/concepts/agent-workspace) Full workspace layout + backup guide: [`docs/agent-workspace.md`](/concepts/agent-workspace)
If `agent.sandbox` is enabled, non-main sessions can override this with If `agents.defaults.sandbox` is enabled, non-main sessions can override this with
per-session workspaces under `agent.sandbox.workspaceRoot` (see per-session workspaces under `agents.defaults.sandbox.workspaceRoot` (see
[`docs/configuration.md`](/gateway/configuration)). [`docs/configuration.md`](/gateway/configuration)).
## Bootstrap files (injected) ## Bootstrap files (injected)
Inside `agent.workspace`, CLAWDBOT expects these user-editable files: Inside `agents.defaults.workspace`, CLAWDBOT expects these user-editable files:
- `AGENTS.md` — operating instructions + “memory” - `AGENTS.md` — operating instructions + “memory”
- `SOUL.md` — persona, boundaries, tone - `SOUL.md` — persona, boundaries, tone
- `TOOLS.md` — user-maintained tool notes (e.g. `imsg`, `sag`, conventions) - `TOOLS.md` — user-maintained tool notes (e.g. `imsg`, `sag`, conventions)
@ -84,9 +84,9 @@ current turn ends, then a new agent turn starts with the queued payloads. See
[`docs/queue.md`](/concepts/queue) for mode + debounce/cap behavior. [`docs/queue.md`](/concepts/queue) for mode + debounce/cap behavior.
Block streaming sends completed assistant blocks as soon as they finish; disable Block streaming sends completed assistant blocks as soon as they finish; disable
via `agent.blockStreamingDefault: "off"` if you only want the final response. via `agents.defaults.blockStreamingDefault: "off"` if you only want the final response.
Tune the boundary via `agent.blockStreamingBreak` (`text_end` vs `message_end`; defaults to text_end). Tune the boundary via `agents.defaults.blockStreamingBreak` (`text_end` vs `message_end`; defaults to text_end).
Control soft block chunking with `agent.blockStreamingChunk` (defaults to Control soft block chunking with `agents.defaults.blockStreamingChunk` (defaults to
8001200 chars; prefers paragraph breaks, then newlines; sentences last). 8001200 chars; prefers paragraph breaks, then newlines; sentences last).
Verbose tool summaries are emitted at tool start (no debounce); Control UI Verbose tool summaries are emitted at tool start (no debounce); Control UI
streams tool output via agent events when available. streams tool output via agent events when available.
@ -95,7 +95,7 @@ More details: [Streaming + chunking](/concepts/streaming).
## Configuration (minimal) ## Configuration (minimal)
At minimum, set: At minimum, set:
- `agent.workspace` - `agents.defaults.workspace`
- `whatsapp.allowFrom` (strongly recommended) - `whatsapp.allowFrom` (strongly recommended)
--- ---

View File

@ -7,7 +7,7 @@ read_when:
Goal: let Clawd sit in WhatsApp groups, wake up only when pinged, and keep that thread separate from the personal DM session. Goal: let Clawd sit in WhatsApp groups, wake up only when pinged, and keep that thread separate from the personal DM session.
Note: `routing.groupChat.mentionPatterns` is now used by Telegram/Discord/Slack/iMessage as well; this doc focuses on WhatsApp-specific behavior. For multi-agent setups, you can override per agent with `routing.agents.<agentId>.mentionPatterns`. Note: `agents.list[].groupChat.mentionPatterns` is now used by Telegram/Discord/Slack/iMessage as well; this doc focuses on WhatsApp-specific behavior. For multi-agent setups, set `agents.list[].groupChat.mentionPatterns` per agent (or use `messages.groupChat.mentionPatterns` as a global fallback).
## Whats implemented (2025-12-03) ## Whats implemented (2025-12-03)
- Activation modes: `mention` (default) or `always`. `mention` requires a ping (real WhatsApp @-mentions via `mentionedJids`, regex patterns, or the bots E.164 anywhere in the text). `always` wakes the agent on every message but it should reply only when it can add meaningful value; otherwise it returns the silent token `NO_REPLY`. Defaults can be set in config (`whatsapp.groups`) and overridden per group via `/activation`. When `whatsapp.groups` is set, it also acts as a group allowlist (include `"*"` to allow all). - Activation modes: `mention` (default) or `always`. `mention` requires a ping (real WhatsApp @-mentions via `mentionedJids`, regex patterns, or the bots E.164 anywhere in the text). `always` wakes the agent on every message but it should reply only when it can add meaningful value; otherwise it returns the silent token `NO_REPLY`. Defaults can be set in config (`whatsapp.groups`) and overridden per group via `/activation`. When `whatsapp.groups` is set, it also acts as a group allowlist (include `"*"` to allow all).
@ -28,16 +28,21 @@ Add a `groupChat` block to `~/.clawdbot/clawdbot.json` so display-name pings wor
"*": { "requireMention": true } "*": { "requireMention": true }
} }
}, },
"routing": { "agents": {
"groupChat": { "list": [
"historyLimit": 50, {
"mentionPatterns": [ "id": "main",
"@?clawd", "groupChat": {
"@?clawd\\s*uk", "historyLimit": 50,
"@?clawdbot", "mentionPatterns": [
"\\+?447700900123" "@?clawd",
] "@?clawd\\s*uk",
} "@?clawdbot",
"\\+?447700900123"
]
}
}
]
} }
} }
``` ```
@ -70,4 +75,4 @@ Only the owner number (from `whatsapp.allowFrom`, or the bots own E.164 when
- Heartbeats are intentionally skipped for groups to avoid noisy broadcasts. - Heartbeats are intentionally skipped for groups to avoid noisy broadcasts.
- Echo suppression uses the combined batch string; if you send identical text twice without mentions, only the first will get a response. - Echo suppression uses the combined batch string; if you send identical text twice without mentions, only the first will get a response.
- Session store entries will appear as `agent:<agentId>:whatsapp:group:<jid>` in the session store (`~/.clawdbot/agents/<agentId>/sessions/sessions.json` by default); a missing entry just means the group hasnt triggered a run yet. - Session store entries will appear as `agent:<agentId>:whatsapp:group:<jid>` in the session store (`~/.clawdbot/agents/<agentId>/sessions/sessions.json` by default); a missing entry just means the group hasnt triggered a run yet.
- Typing indicators in groups follow `agent.typingMode` (default: `message` when unmentioned). - Typing indicators in groups follow `agents.defaults.typingMode` (default: `message` when unmentioned).

View File

@ -88,11 +88,16 @@ Group messages require a mention unless overridden per group. Defaults live per
"123": { requireMention: false } "123": { requireMention: false }
} }
}, },
routing: { agents: {
groupChat: { list: [
mentionPatterns: ["@clawd", "clawdbot", "\\+15555550123"], {
historyLimit: 50 id: "main",
} groupChat: {
mentionPatterns: ["@clawd", "clawdbot", "\\+15555550123"],
historyLimit: 50
}
}
]
} }
} }
``` ```
@ -100,7 +105,7 @@ Group messages require a mention unless overridden per group. Defaults live per
Notes: Notes:
- `mentionPatterns` are case-insensitive regexes. - `mentionPatterns` are case-insensitive regexes.
- Surfaces that provide explicit mentions still pass; patterns are a fallback. - Surfaces that provide explicit mentions still pass; patterns are a fallback.
- Per-agent override: `routing.agents.<agentId>.mentionPatterns` (useful when multiple agents share a group). - Per-agent override: `agents.list[].groupChat.mentionPatterns` (useful when multiple agents share a group).
- Mention gating is only enforced when mention detection is possible (native mentions or `mentionPatterns` are configured). - Mention gating is only enforced when mention detection is possible (native mentions or `mentionPatterns` are configured).
- Discord defaults live in `discord.guilds."*"` (overridable per guild/channel). - Discord defaults live in `discord.guilds."*"` (overridable per guild/channel).

View File

@ -9,7 +9,7 @@ read_when:
Clawdbot handles failures in two stages: Clawdbot handles failures in two stages:
1) **Auth profile rotation** within the current provider. 1) **Auth profile rotation** within the current provider.
2) **Model fallback** to the next model in `agent.model.fallbacks`. 2) **Model fallback** to the next model in `agents.defaults.model.fallbacks`.
This doc explains the runtime rules and the data that backs them. This doc explains the runtime rules and the data that backs them.
@ -82,14 +82,14 @@ State is stored in `auth-profiles.json` under `usageStats`:
## Model fallback ## Model fallback
If all profiles for a provider fail, Clawdbot moves to the next model in If all profiles for a provider fail, Clawdbot moves to the next model in
`agent.model.fallbacks`. This applies to auth failures, rate limits, and `agents.defaults.model.fallbacks`. This applies to auth failures, rate limits, and
timeouts that exhausted profile rotation. timeouts that exhausted profile rotation.
## Related config ## Related config
See [`docs/configuration.md`](/gateway/configuration) for: See [`docs/configuration.md`](/gateway/configuration) for:
- `auth.profiles` / `auth.order` - `auth.profiles` / `auth.order`
- `agent.model.primary` / `agent.model.fallbacks` - `agents.defaults.model.primary` / `agents.defaults.model.fallbacks`
- `agent.imageModel` routing - `agents.defaults.imageModel` routing
See [`docs/models.md`](/concepts/models) for the broader model selection and fallback overview. See [`docs/models.md`](/concepts/models) for the broader model selection and fallback overview.

View File

@ -14,20 +14,20 @@ rotation, cooldowns, and how that interacts with fallbacks.
Clawdbot selects models in this order: Clawdbot selects models in this order:
1) **Primary** model (`agent.model.primary` or `agent.model`). 1) **Primary** model (`agents.defaults.model.primary` or `agents.defaults.model`).
2) **Fallbacks** in `agent.model.fallbacks` (in order). 2) **Fallbacks** in `agents.defaults.model.fallbacks` (in order).
3) **Provider auth failover** happens inside a provider before moving to the 3) **Provider auth failover** happens inside a provider before moving to the
next model. next model.
Related: Related:
- `agent.models` is the allowlist/catalog of models Clawdbot can use (plus aliases). - `agents.defaults.models` is the allowlist/catalog of models Clawdbot can use (plus aliases).
- `agent.imageModel` is used **only when** the primary model cant accept images. - `agents.defaults.imageModel` is used **only when** the primary model cant accept images.
## Config keys (overview) ## Config keys (overview)
- `agent.model.primary` and `agent.model.fallbacks` - `agents.defaults.model.primary` and `agents.defaults.model.fallbacks`
- `agent.imageModel.primary` and `agent.imageModel.fallbacks` - `agents.defaults.imageModel.primary` and `agents.defaults.imageModel.fallbacks`
- `agent.models` (allowlist + aliases + provider params) - `agents.defaults.models` (allowlist + aliases + provider params)
- `models.providers` (custom providers written into `models.json`) - `models.providers` (custom providers written into `models.json`)
Model refs are normalized to lowercase. Provider aliases like `z.ai/*` normalize Model refs are normalized to lowercase. Provider aliases like `z.ai/*` normalize
@ -35,7 +35,7 @@ to `zai/*`.
## “Model is not allowed” (and why replies stop) ## “Model is not allowed” (and why replies stop)
If `agent.models` is set, it becomes the **allowlist** for `/model` and for If `agents.defaults.models` is set, it becomes the **allowlist** for `/model` and for
session overrides. When a user selects a model that isnt in that allowlist, session overrides. When a user selects a model that isnt in that allowlist,
Clawdbot returns: Clawdbot returns:
@ -46,8 +46,8 @@ Model "provider/model" is not allowed. Use /model to list available models.
This happens **before** a normal reply is generated, so the message can feel This happens **before** a normal reply is generated, so the message can feel
like it “didnt respond.” The fix is to either: like it “didnt respond.” The fix is to either:
- Add the model to `agent.models`, or - Add the model to `agents.defaults.models`, or
- Clear the allowlist (remove `agent.models`), or - Clear the allowlist (remove `agents.defaults.models`), or
- Pick a model from `/model list`. - Pick a model from `/model list`.
Example allowlist config: Example allowlist config:
@ -123,8 +123,8 @@ Key flags:
- `--max-age-days <days>`: skip older models - `--max-age-days <days>`: skip older models
- `--provider <name>`: provider prefix filter - `--provider <name>`: provider prefix filter
- `--max-candidates <n>`: fallback list size - `--max-candidates <n>`: fallback list size
- `--set-default`: set `agent.model.primary` to the first selection - `--set-default`: set `agents.defaults.model.primary` to the first selection
- `--set-image`: set `agent.imageModel.primary` to the first image selection - `--set-image`: set `agents.defaults.imageModel.primary` to the first image selection
Probing requires an OpenRouter API key (from auth profiles or Probing requires an OpenRouter API key (from auth profiles or
`OPENROUTER_API_KEY`). Without a key, use `--no-probe` to list candidates only. `OPENROUTER_API_KEY`). Without a key, use `--no-probe` to list candidates only.

View File

@ -32,7 +32,7 @@ reach other host locations unless sandboxing is enabled. See
- Config: `~/.clawdbot/clawdbot.json` (or `CLAWDBOT_CONFIG_PATH`) - Config: `~/.clawdbot/clawdbot.json` (or `CLAWDBOT_CONFIG_PATH`)
- State dir: `~/.clawdbot` (or `CLAWDBOT_STATE_DIR`) - State dir: `~/.clawdbot` (or `CLAWDBOT_STATE_DIR`)
- Workspace: `~/clawd` (or `~/clawd-<agentId>`) - Workspace: `~/clawd` (or `~/clawd-<agentId>`)
- Agent dir: `~/.clawdbot/agents/<agentId>/agent` (or `routing.agents.<agentId>.agentDir`) - Agent dir: `~/.clawdbot/agents/<agentId>/agent` (or `agents.list[].agentDir`)
- Sessions: `~/.clawdbot/agents/<agentId>/sessions` - Sessions: `~/.clawdbot/agents/<agentId>/sessions`
### Single-agent mode (default) ### Single-agent mode (default)
@ -52,7 +52,7 @@ Use the agent wizard to add a new isolated agent:
clawdbot agents add work clawdbot agents add work
``` ```
Then add `routing.bindings` (or let the wizard do it) to route inbound messages. Then add `bindings` (or let the wizard do it) to route inbound messages.
Verify with: Verify with:
@ -79,7 +79,7 @@ Bindings are **deterministic** and **most-specific wins**:
3. `teamId` (Slack) 3. `teamId` (Slack)
4. `accountId` match for a provider 4. `accountId` match for a provider
5. provider-level match (`accountId: "*"`) 5. provider-level match (`accountId: "*"`)
6. fallback to `routing.defaultAgentId` (default: `main`) 6. fallback to default agent (`agents.list[].default`, else first list entry, default: `main`)
## Multiple accounts / phone numbers ## Multiple accounts / phone numbers
@ -100,39 +100,42 @@ multiple phone numbers without mixing sessions.
```js ```js
{ {
routing: { agents: {
defaultAgentId: "home", list: [
{
agents: { id: "home",
home: { default: true,
name: "Home", name: "Home",
workspace: "~/clawd-home", workspace: "~/clawd-home",
agentDir: "~/.clawdbot/agents/home/agent", agentDir: "~/.clawdbot/agents/home/agent",
}, },
work: { {
id: "work",
name: "Work", name: "Work",
workspace: "~/clawd-work", workspace: "~/clawd-work",
agentDir: "~/.clawdbot/agents/work/agent", agentDir: "~/.clawdbot/agents/work/agent",
}, },
},
// Deterministic routing: first match wins (most-specific first).
bindings: [
{ agentId: "home", match: { provider: "whatsapp", accountId: "personal" } },
{ agentId: "work", match: { provider: "whatsapp", accountId: "biz" } },
// Optional per-peer override (example: send a specific group to work agent).
{
agentId: "work",
match: {
provider: "whatsapp",
accountId: "personal",
peer: { kind: "group", id: "1203630...@g.us" },
},
},
], ],
},
// Off by default: agent-to-agent messaging must be explicitly enabled + allowlisted. // Deterministic routing: first match wins (most-specific first).
bindings: [
{ agentId: "home", match: { provider: "whatsapp", accountId: "personal" } },
{ agentId: "work", match: { provider: "whatsapp", accountId: "biz" } },
// Optional per-peer override (example: send a specific group to work agent).
{
agentId: "work",
match: {
provider: "whatsapp",
accountId: "personal",
peer: { kind: "group", id: "1203630...@g.us" },
},
},
],
// Off by default: agent-to-agent messaging must be explicitly enabled + allowlisted.
tools: {
agentToAgent: { agentToAgent: {
enabled: false, enabled: false,
allow: ["home", "work"], allow: ["home", "work"],
@ -160,16 +163,18 @@ Starting with v2026.1.6, each agent can have its own sandbox and tool restrictio
```js ```js
{ {
routing: { agents: {
agents: { list: [
personal: { {
id: "personal",
workspace: "~/clawd-personal", workspace: "~/clawd-personal",
sandbox: { sandbox: {
mode: "off", // No sandbox for personal agent mode: "off", // No sandbox for personal agent
}, },
// No tool restrictions - all tools available // No tool restrictions - all tools available
}, },
family: { {
id: "family",
workspace: "~/clawd-family", workspace: "~/clawd-family",
sandbox: { sandbox: {
mode: "all", // Always sandboxed mode: "all", // Always sandboxed
@ -184,7 +189,7 @@ Starting with v2026.1.6, each agent can have its own sandbox and tool restrictio
deny: ["bash", "write", "edit"], // Deny others deny: ["bash", "write", "edit"], // Deny others
}, },
}, },
}, ],
}, },
} }
``` ```
@ -194,8 +199,8 @@ Starting with v2026.1.6, each agent can have its own sandbox and tool restrictio
- **Resource control**: Sandbox specific agents while keeping others on host - **Resource control**: Sandbox specific agents while keeping others on host
- **Flexible policies**: Different permissions per agent - **Flexible policies**: Different permissions per agent
Note: `agent.elevated` is **global** and sender-based; it is not configurable per agent. Note: `tools.elevated` is **global** and sender-based; it is not configurable per agent.
If you need per-agent boundaries, use `routing.agents[id].tools` to deny `bash`. If you need per-agent boundaries, use `agents.list[].tools` to deny `bash`.
For group targeting, you can set `routing.agents[id].mentionPatterns` so @mentions map cleanly to the intended agent. For group targeting, use `agents.list[].groupChat.mentionPatterns` so @mentions map cleanly to the intended agent.
See [Multi-Agent Sandbox & Tools](/multi-agent-sandbox-tools) for detailed examples. See [Multi-Agent Sandbox & Tools](/multi-agent-sandbox-tools) for detailed examples.

View File

@ -42,35 +42,33 @@ Examples:
Routing picks **one agent** for each inbound message: Routing picks **one agent** for each inbound message:
1. **Exact peer match** (`routing.bindings` with `peer.kind` + `peer.id`). 1. **Exact peer match** (`bindings` with `peer.kind` + `peer.id`).
2. **Guild match** (Discord) via `guildId`. 2. **Guild match** (Discord) via `guildId`.
3. **Team match** (Slack) via `teamId`. 3. **Team match** (Slack) via `teamId`.
4. **Account match** (`accountId` on the provider). 4. **Account match** (`accountId` on the provider).
5. **Provider match** (any account on that provider). 5. **Provider match** (any account on that provider).
6. **Default agent** (`routing.defaultAgentId`, fallback to `main`). 6. **Default agent** (`agents.list[].default`, else first list entry, fallback to `main`).
The matched agent determines which workspace and session store are used. The matched agent determines which workspace and session store are used.
## Config overview ## Config overview
- `routing.defaultAgentId`: default agent when no binding matches. - `agents.list`: named agent definitions (workspace, model, etc.).
- `routing.agents`: named agent definitions (workspace, model, etc.). - `bindings`: map inbound providers/accounts/peers to agents.
- `routing.bindings`: map inbound providers/accounts/peers to agents.
Example: Example:
```json5 ```json5
{ {
routing: { agents: {
defaultAgentId: "main", list: [
agents: { { id: "support", name: "Support", workspace: "~/clawd-support" }
support: { name: "Support", workspace: "~/clawd-support" }
},
bindings: [
{ match: { provider: "slack", teamId: "T123" }, agentId: "support" },
{ match: { provider: "telegram", peer: { kind: "group", id: "-100123" } }, agentId: "support" }
] ]
} },
bindings: [
{ match: { provider: "slack", teamId: "T123" }, agentId: "support" },
{ match: { provider: "telegram", peer: { kind: "group", id: "-100123" } }, agentId: "support" }
]
} }
``` ```

View File

@ -14,7 +14,7 @@ We now serialize command-based auto-replies (WhatsApp Web listener) through a ti
## How it works ## How it works
- A lane-aware FIFO queue drains each lane synchronously. - A lane-aware FIFO queue drains each lane synchronously.
- `runEmbeddedPiAgent` enqueues by **session key** (lane `session:<key>`) to guarantee only one active run per session. - `runEmbeddedPiAgent` enqueues by **session key** (lane `session:<key>`) to guarantee only one active run per session.
- Each session run is then queued into a **global lane** (`main` by default) so overall parallelism is capped by `agent.maxConcurrent`. - Each session run is then queued into a **global lane** (`main` by default) so overall parallelism is capped by `agents.defaults.maxConcurrent`.
- When verbose logging is enabled, queued commands emit a short notice if they waited more than ~2s before starting. - When verbose logging is enabled, queued commands emit a short notice if they waited more than ~2s before starting.
- Typing indicators (`onReplyStart`) still fire immediately on enqueue so user experience is unchanged while we wait our turn. - Typing indicators (`onReplyStart`) still fire immediately on enqueue so user experience is unchanged while we wait our turn.
@ -30,16 +30,16 @@ Inbound messages can steer the current run, wait for a followup turn, or do both
Steer-backlog means you can get a followup response after the steered run, so Steer-backlog means you can get a followup response after the steered run, so
streaming surfaces can look like duplicates. Prefer `collect`/`steer` if you want streaming surfaces can look like duplicates. Prefer `collect`/`steer` if you want
one response per inbound message. one response per inbound message.
Send `/queue collect` as a standalone command (per-session) or set `routing.queue.byProvider.discord: "collect"`. Send `/queue collect` as a standalone command (per-session) or set `messages.queue.byProvider.discord: "collect"`.
Defaults (when unset in config): Defaults (when unset in config):
- All surfaces → `collect` - All surfaces → `collect`
Configure globally or per provider via `routing.queue`: Configure globally or per provider via `messages.queue`:
```json5 ```json5
{ {
routing: { messages: {
queue: { queue: {
mode: "collect", mode: "collect",
debounceMs: 1000, debounceMs: 1000,
@ -67,7 +67,7 @@ Defaults: `debounceMs: 1000`, `cap: 20`, `drop: summarize`.
## Scope and guarantees ## Scope and guarantees
- Applies only to config-driven command replies; plain text replies are unaffected. - Applies only to config-driven command replies; plain text replies are unaffected.
- Default lane (`main`) is process-wide for inbound + main heartbeats; set `agent.maxConcurrent` to allow multiple sessions in parallel. - Default lane (`main`) is process-wide for inbound + main heartbeats; set `agents.defaults.maxConcurrent` to allow multiple sessions in parallel.
- Additional lanes may exist (e.g. `cron`) so background jobs can run in parallel without blocking inbound replies. - Additional lanes may exist (e.g. `cron`) so background jobs can run in parallel without blocking inbound replies.
- Per-session lanes guarantee that only one agent run touches a given session at a time. - Per-session lanes guarantee that only one agent run touches a given session at a time.
- No external dependencies or background worker threads; pure TypeScript + promises. - No external dependencies or background worker threads; pure TypeScript + promises.

View File

@ -2,7 +2,7 @@
summary: "Session pruning: tool-result trimming to reduce context bloat" summary: "Session pruning: tool-result trimming to reduce context bloat"
read_when: read_when:
- You want to reduce LLM context growth from tool outputs - You want to reduce LLM context growth from tool outputs
- You are tuning agent.contextPruning - You are tuning agents.defaults.contextPruning
--- ---
# Session Pruning # Session Pruning
@ -23,7 +23,7 @@ Session pruning trims **old tool results** from the in-memory context right befo
Pruning uses an estimated context window (chars ≈ tokens × 4). The window size is resolved in this order: Pruning uses an estimated context window (chars ≈ tokens × 4). The window size is resolved in this order:
1) Model definition `contextWindow` (from the model registry). 1) Model definition `contextWindow` (from the model registry).
2) `models.providers.*.models[].contextWindow` override. 2) `models.providers.*.models[].contextWindow` override.
3) `agent.contextTokens`. 3) `agents.defaults.contextTokens`.
4) Default `200000` tokens. 4) Default `200000` tokens.
## Modes ## Modes

View File

@ -132,19 +132,19 @@ Parameters:
- `cleanup?` (`delete|keep`, default `keep`) - `cleanup?` (`delete|keep`, default `keep`)
Allowlist: Allowlist:
- `routing.agents.<agentId>.subagents.allowAgents`: list of agent ids allowed via `agentId` (`["*"]` to allow any). Default: only the requester agent. - `agents.list[].subagents.allowAgents`: list of agent ids allowed via `agentId` (`["*"]` to allow any). Default: only the requester agent.
Discovery: Discovery:
- Use `agents_list` to discover which agent ids are allowed for `sessions_spawn`. - Use `agents_list` to discover which agent ids are allowed for `sessions_spawn`.
Behavior: Behavior:
- Starts a new `agent:<agentId>:subagent:<uuid>` session with `deliver: false`. - Starts a new `agent:<agentId>:subagent:<uuid>` session with `deliver: false`.
- Sub-agents default to the full tool set **minus session tools** (configurable via `agent.subagents.tools`). - Sub-agents default to the full tool set **minus session tools** (configurable via `tools.subagents.tools`).
- Sub-agents are not allowed to call `sessions_spawn` (no sub-agent → sub-agent spawning). - Sub-agents are not allowed to call `sessions_spawn` (no sub-agent → sub-agent spawning).
- Always non-blocking: returns `{ status: "accepted", runId, childSessionKey }` immediately. - Always non-blocking: returns `{ status: "accepted", runId, childSessionKey }` immediately.
- After completion, Clawdbot runs a sub-agent **announce step** and posts the result to the requester chat provider. - After completion, Clawdbot runs a sub-agent **announce step** and posts the result to the requester chat provider.
- Reply exactly `ANNOUNCE_SKIP` during the announce step to stay silent. - Reply exactly `ANNOUNCE_SKIP` during the announce step to stay silent.
- Sub-agent sessions are auto-archived after `agent.subagents.archiveAfterMinutes` (default: 60). - Sub-agent sessions are auto-archived after `agents.defaults.subagents.archiveAfterMinutes` (default: 60).
- Announce replies include a stats line (runtime, tokens, sessionKey/sessionId, transcript path, and optional cost). - Announce replies include a stats line (runtime, tokens, sessionKey/sessionId, transcript path, and optional cost).
## Sandbox Session Visibility ## Sandbox Session Visibility
@ -155,10 +155,12 @@ Config:
```json5 ```json5
{ {
agent: { agents: {
sandbox: { defaults: {
// default: "spawned" sandbox: {
sessionToolsVisibility: "spawned" // or "all" // default: "spawned"
sessionToolsVisibility: "spawned" // or "all"
}
} }
} }
} }

View File

@ -32,9 +32,9 @@ Legend:
- `provider send`: actual outbound messages (block replies). - `provider send`: actual outbound messages (block replies).
**Controls:** **Controls:**
- `agent.blockStreamingDefault`: `"on"`/`"off"` (default on). - `agents.defaults.blockStreamingDefault`: `"on"`/`"off"` (default on).
- `agent.blockStreamingBreak`: `"text_end"` or `"message_end"`. - `agents.defaults.blockStreamingBreak`: `"text_end"` or `"message_end"`.
- `agent.blockStreamingChunk`: `{ minChars, maxChars, breakPreference? }`. - `agents.defaults.blockStreamingChunk`: `{ minChars, maxChars, breakPreference? }`.
- Provider hard cap: `*.textChunkLimit` (e.g., `whatsapp.textChunkLimit`). - Provider hard cap: `*.textChunkLimit` (e.g., `whatsapp.textChunkLimit`).
- Discord soft cap: `discord.maxLinesPerMessage` (default 17) splits tall replies to avoid UI clipping. - Discord soft cap: `discord.maxLinesPerMessage` (default 17) splits tall replies to avoid UI clipping.

View File

@ -17,7 +17,7 @@ The prompt is intentionally compact and uses fixed sections:
- **Tooling**: current tool list + short descriptions. - **Tooling**: current tool list + short descriptions.
- **Skills**: tells the model how to load skill instructions on demand. - **Skills**: tells the model how to load skill instructions on demand.
- **Clawdbot Self-Update**: how to run `config.apply` and `update.run`. - **Clawdbot Self-Update**: how to run `config.apply` and `update.run`.
- **Workspace**: working directory (`agent.workspace`). - **Workspace**: working directory (`agents.defaults.workspace`).
- **Workspace Files (injected)**: indicates bootstrap files are included below. - **Workspace Files (injected)**: indicates bootstrap files are included below.
- **Time**: UTC default + the users local time (already converted). - **Time**: UTC default + the users local time (already converted).
- **Reply Tags**: optional reply tag syntax for supported providers. - **Reply Tags**: optional reply tag syntax for supported providers.
@ -43,9 +43,9 @@ Large files are truncated with a marker. Missing files inject a short missing-fi
The Time line is compact and explicit: The Time line is compact and explicit:
- Assume timestamps are **UTC** unless stated. - Assume timestamps are **UTC** unless stated.
- The listed **user time** is already converted to `agent.userTimezone` (if set). - The listed **user time** is already converted to `agents.defaults.userTimezone` (if set).
Use `agent.userTimezone` in `~/.clawdbot/clawdbot.json` to change the user time zone. Use `agents.defaults.userTimezone` in `~/.clawdbot/clawdbot.json` to change the user time zone.
## Skills ## Skills

View File

@ -26,7 +26,7 @@ These are typically UTC ISO strings (Discord) or UTC epoch strings (Slack). We d
## User timezone for the system prompt ## User timezone for the system prompt
Set `agent.userTimezone` to tell the model the user's local time zone. If it is Set `agents.defaults.userTimezone` to tell the model the user's local time zone. If it is
unset, Clawdbot resolves the **host timezone at runtime** (no config write). unset, Clawdbot resolves the **host timezone at runtime** (no config write).
```json5 ```json5

View File

@ -6,18 +6,18 @@ read_when:
# Typing indicators # Typing indicators
Typing indicators are sent to the chat provider while a run is active. Use Typing indicators are sent to the chat provider while a run is active. Use
`agent.typingMode` to control **when** typing starts and `typingIntervalSeconds` `agents.defaults.typingMode` to control **when** typing starts and `typingIntervalSeconds`
to control **how often** it refreshes. to control **how often** it refreshes.
## Defaults ## Defaults
When `agent.typingMode` is **unset**, Clawdbot keeps the legacy behavior: When `agents.defaults.typingMode` is **unset**, Clawdbot keeps the legacy behavior:
- **Direct chats**: typing starts immediately once the model loop begins. - **Direct chats**: typing starts immediately once the model loop begins.
- **Group chats with a mention**: typing starts immediately. - **Group chats with a mention**: typing starts immediately.
- **Group chats without a mention**: typing starts only when message text begins streaming. - **Group chats without a mention**: typing starts only when message text begins streaming.
- **Heartbeat runs**: typing is disabled. - **Heartbeat runs**: typing is disabled.
## Modes ## Modes
Set `agent.typingMode` to one of: Set `agents.defaults.typingMode` to one of:
- `never` — no typing indicator, ever. - `never` — no typing indicator, ever.
- `instant` — start typing **as soon as the model loop begins**, even if the run - `instant` — start typing **as soon as the model loop begins**, even if the run
later returns only the silent reply token. later returns only the silent reply token.

View File

@ -8,7 +8,7 @@ read_when:
# Workspace Memory v2 (offline): research notes # Workspace Memory v2 (offline): research notes
Target: Clawd-style workspace (`agent.workspace`, default `~/clawd`) where “memory” is stored as one Markdown file per day (`memory/YYYY-MM-DD.md`) plus a small set of stable files (e.g. `memory.md`, `SOUL.md`). Target: Clawd-style workspace (`agents.defaults.workspace`, default `~/clawd`) where “memory” is stored as one Markdown file per day (`memory/YYYY-MM-DD.md`) plus a small set of stable files (e.g. `memory.md`, `SOUL.md`).
This doc proposes an **offline-first** memory architecture that keeps Markdown as the canonical, reviewable source of truth, but adds **structured recall** (search, entity summaries, confidence updates) via a derived index. This doc proposes an **offline-first** memory architecture that keeps Markdown as the canonical, reviewable source of truth, but adds **structured recall** (search, entity summaries, confidence updates) via a derived index.
@ -159,7 +159,7 @@ Recommendation: **deep integration in Clawdbot**, but keep a separable core libr
### Why integrate into Clawdbot? ### Why integrate into Clawdbot?
- Clawdbot already knows: - Clawdbot already knows:
- the workspace path (`agent.workspace`) - the workspace path (`agents.defaults.workspace`)
- the session model + heartbeats - the session model + heartbeats
- logging + troubleshooting patterns - logging + troubleshooting patterns
- You want the agent itself to call the tools: - You want the agent itself to call the tools:

View File

@ -32,9 +32,9 @@ Environment overrides:
- `PI_BASH_JOB_TTL_MS`: TTL for finished sessions (ms, bounded to 1m3h) - `PI_BASH_JOB_TTL_MS`: TTL for finished sessions (ms, bounded to 1m3h)
Config (preferred): Config (preferred):
- `agent.bash.backgroundMs` (default 10000) - `tools.bash.backgroundMs` (default 10000)
- `agent.bash.timeoutSec` (default 1800) - `tools.bash.timeoutSec` (default 1800)
- `agent.bash.cleanupMs` (default 1800000) - `tools.bash.cleanupMs` (default 1800000)
## process tool ## process tool

View File

@ -189,52 +189,71 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number.
}, },
// Agent runtime // Agent runtime
agent: { agents: {
workspace: "~/clawd", defaults: {
userTimezone: "America/Chicago", workspace: "~/clawd",
model: { userTimezone: "America/Chicago",
primary: "anthropic/claude-sonnet-4-5", model: {
fallbacks: ["anthropic/claude-opus-4-5", "openai/gpt-5.2"] primary: "anthropic/claude-sonnet-4-5",
}, fallbacks: ["anthropic/claude-opus-4-5", "openai/gpt-5.2"]
imageModel: { },
primary: "openrouter/anthropic/claude-sonnet-4-5" imageModel: {
}, primary: "openrouter/anthropic/claude-sonnet-4-5"
models: { },
"anthropic/claude-opus-4-5": { alias: "opus" }, models: {
"anthropic/claude-sonnet-4-5": { alias: "sonnet" }, "anthropic/claude-opus-4-5": { alias: "opus" },
"openai/gpt-5.2": { alias: "gpt" } "anthropic/claude-sonnet-4-5": { alias: "sonnet" },
}, "openai/gpt-5.2": { alias: "gpt" }
thinkingDefault: "low", },
verboseDefault: "off", thinkingDefault: "low",
elevatedDefault: "on", verboseDefault: "off",
blockStreamingDefault: "on", elevatedDefault: "on",
blockStreamingBreak: "text_end", blockStreamingDefault: "on",
blockStreamingChunk: { blockStreamingBreak: "text_end",
minChars: 800, blockStreamingChunk: {
maxChars: 1200, minChars: 800,
breakPreference: "paragraph" maxChars: 1200,
}, breakPreference: "paragraph"
timeoutSeconds: 600, },
mediaMaxMb: 5, timeoutSeconds: 600,
typingIntervalSeconds: 5, mediaMaxMb: 5,
maxConcurrent: 3, typingIntervalSeconds: 5,
tools: { maxConcurrent: 3,
allow: ["bash", "process", "read", "write", "edit"], heartbeat: {
deny: ["browser", "canvas"] every: "30m",
}, model: "anthropic/claude-sonnet-4-5",
target: "last",
to: "+15555550123",
prompt: "HEARTBEAT",
ackMaxChars: 30
},
sandbox: {
mode: "non-main",
perSession: true,
workspaceRoot: "~/.clawdbot/sandboxes",
docker: {
image: "clawdbot-sandbox:bookworm-slim",
workdir: "/workspace",
readOnlyRoot: true,
tmpfs: ["/tmp", "/var/tmp", "/run"],
network: "none",
user: "1000:1000"
},
browser: {
enabled: false
}
}
}
},
tools: {
allow: ["bash", "process", "read", "write", "edit"],
deny: ["browser", "canvas"],
bash: { bash: {
backgroundMs: 10000, backgroundMs: 10000,
timeoutSec: 1800, timeoutSec: 1800,
cleanupMs: 1800000 cleanupMs: 1800000
}, },
heartbeat: {
every: "30m",
model: "anthropic/claude-sonnet-4-5",
target: "last",
to: "+15555550123",
prompt: "HEARTBEAT",
ackMaxChars: 30
},
elevated: { elevated: {
enabled: true, enabled: true,
allowFrom: { allowFrom: {
@ -246,22 +265,6 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number.
imessage: ["user@example.com"], imessage: ["user@example.com"],
webchat: ["session:demo"] webchat: ["session:demo"]
} }
},
sandbox: {
mode: "non-main",
perSession: true,
workspaceRoot: "~/.clawdbot/sandboxes",
docker: {
image: "clawdbot-sandbox:bookworm-slim",
workdir: "/workspace",
readOnlyRoot: true,
tmpfs: ["/tmp", "/var/tmp", "/run"],
network: "none",
user: "1000:1000"
},
browser: {
enabled: false
}
} }
}, },

View File

@ -9,11 +9,11 @@ CLAWDBOT reads an optional **JSON5** config from `~/.clawdbot/clawdbot.json` (co
If the file is missing, CLAWDBOT uses safe-ish defaults (embedded Pi agent + per-sender sessions + workspace `~/clawd`). You usually only need a config to: If the file is missing, CLAWDBOT uses safe-ish defaults (embedded Pi agent + per-sender sessions + workspace `~/clawd`). You usually only need a config to:
- restrict who can trigger the bot (`whatsapp.allowFrom`, `telegram.allowFrom`, etc.) - restrict who can trigger the bot (`whatsapp.allowFrom`, `telegram.allowFrom`, etc.)
- control group allowlists + mention behavior (`whatsapp.groups`, `telegram.groups`, `discord.guilds`, `routing.groupChat`) - control group allowlists + mention behavior (`whatsapp.groups`, `telegram.groups`, `discord.guilds`, `agents.list[].groupChat`)
- customize message prefixes (`messages`) - customize message prefixes (`messages`)
- set the agent's workspace (`agent.workspace`) - set the agent's workspace (`agents.defaults.workspace` or `agents.list[].workspace`)
- tune the embedded agent (`agent`) and session behavior (`session`) - tune the embedded agent defaults (`agents.defaults`) and session behavior (`session`)
- set the agent's identity (`identity`) - set per-agent identity (`agents.list[].identity`)
> **New to configuration?** Check out the [Configuration Examples](/gateway/configuration-examples) guide for complete examples with detailed explanations! > **New to configuration?** Check out the [Configuration Examples](/gateway/configuration-examples) guide for complete examples with detailed explanations!
@ -39,7 +39,7 @@ Example (via `gateway call`):
```bash ```bash
clawdbot gateway call config.apply --params '{ clawdbot gateway call config.apply --params '{
"raw": "{\\n agent: { workspace: \\"~/clawd\\" }\\n}\\n", "raw": "{\\n agents: { defaults: { workspace: \\"~/clawd\\" } }\\n}\\n",
"sessionKey": "agent:main:whatsapp:dm:+15555550123", "sessionKey": "agent:main:whatsapp:dm:+15555550123",
"restartDelayMs": 1000 "restartDelayMs": 1000
}' }'
@ -49,7 +49,7 @@ clawdbot gateway call config.apply --params '{
```json5 ```json5
{ {
agent: { workspace: "~/clawd" }, agents: { defaults: { workspace: "~/clawd" } },
whatsapp: { allowFrom: ["+15555550123"] } whatsapp: { allowFrom: ["+15555550123"] }
} }
``` ```
@ -65,16 +65,19 @@ To prevent the bot from responding to WhatsApp @-mentions in groups (only respon
```json5 ```json5
{ {
agent: { workspace: "~/clawd" }, agents: {
defaults: { workspace: "~/clawd" },
list: [
{
id: "main",
groupChat: { mentionPatterns: ["@clawd", "reisponde"] }
}
]
},
whatsapp: { whatsapp: {
// Allowlist is DMs only; including your own number enables self-chat mode. // Allowlist is DMs only; including your own number enables self-chat mode.
allowFrom: ["+15555550123"], allowFrom: ["+15555550123"],
groups: { "*": { requireMention: true } } groups: { "*": { requireMention: true } }
},
routing: {
groupChat: {
mentionPatterns: ["@clawd", "reisponde"]
}
} }
} }
``` ```
@ -175,17 +178,21 @@ rotation order used for failover.
} }
``` ```
### `identity` ### `agents.list[].identity`
Optional agent identity used for defaults and UX. This is written by the macOS onboarding assistant. Optional per-agent identity used for defaults and UX. This is written by the macOS onboarding assistant.
If set, CLAWDBOT derives defaults (only when you havent set them explicitly): If set, CLAWDBOT derives defaults (only when you havent set them explicitly):
- `messages.ackReaction` from `identity.emoji` (falls back to 👀) - `messages.ackReaction` from the **active agent**s `identity.emoji` (falls back to 👀)
- `routing.groupChat.mentionPatterns` from `identity.name` (so “@Samantha” works in groups across Telegram/Slack/Discord/iMessage/WhatsApp) - `agents.list[].groupChat.mentionPatterns` from the agents `identity.name`/`identity.emoji` (so “@Samantha” works in groups across Telegram/Slack/Discord/iMessage/WhatsApp)
```json5 ```json5
{ {
identity: { name: "Samantha", theme: "helpful sloth", emoji: "🦥" } agents: {
list: [
{ id: "main", identity: { name: "Samantha", theme: "helpful sloth", emoji: "🦥" } }
]
}
} }
``` ```
@ -311,25 +318,26 @@ Notes:
- `default` is used when `accountId` is omitted (CLI + routing). - `default` is used when `accountId` is omitted (CLI + routing).
- Env tokens only apply to the **default** account. - Env tokens only apply to the **default** account.
- Base provider settings (group policy, mention gating, etc.) apply to all accounts unless overridden per account. - Base provider settings (group policy, mention gating, etc.) apply to all accounts unless overridden per account.
- Use `routing.bindings[].match.accountId` to route each account to a different agent. - Use `bindings[].match.accountId` to route each account to a different agents.defaults.
### `routing.groupChat` ### Group chat mention gating (`agents.list[].groupChat` + `messages.groupChat`)
Group messages default to **require mention** (either metadata mention or regex patterns). Applies to WhatsApp, Telegram, Discord, and iMessage group chats. Group messages default to **require mention** (either metadata mention or regex patterns). Applies to WhatsApp, Telegram, Discord, and iMessage group chats.
**Mention types:** **Mention types:**
- **Metadata mentions**: Native platform @-mentions (e.g., WhatsApp tap-to-mention). Ignored in WhatsApp self-chat mode (see `whatsapp.allowFrom`). - **Metadata mentions**: Native platform @-mentions (e.g., WhatsApp tap-to-mention). Ignored in WhatsApp self-chat mode (see `whatsapp.allowFrom`).
- **Text patterns**: Regex patterns defined in `mentionPatterns`. Always checked regardless of self-chat mode. - **Text patterns**: Regex patterns defined in `agents.list[].groupChat.mentionPatterns`. Always checked regardless of self-chat mode.
- Mention gating is enforced only when mention detection is possible (native mentions or at least one `mentionPattern`). - Mention gating is enforced only when mention detection is possible (native mentions or at least one `mentionPattern`).
- Per-agent override: `routing.agents.<agentId>.mentionPatterns` (useful when multiple agents share a group).
```json5 ```json5
{ {
routing: { messages: {
groupChat: { groupChat: { historyLimit: 50 }
mentionPatterns: ["@clawd", "clawdbot", "clawd"], },
historyLimit: 50 agents: {
} list: [
{ id: "main", groupChat: { mentionPatterns: ["@clawd", "clawdbot", "clawd"] } }
]
} }
} }
``` ```
@ -337,11 +345,11 @@ Group messages default to **require mention** (either metadata mention or regex
Per-agent override (takes precedence when set, even `[]`): Per-agent override (takes precedence when set, even `[]`):
```json5 ```json5
{ {
routing: { agents: {
agents: { list: [
work: { mentionPatterns: ["@workbot", "\\+15555550123"] }, { id: "work", groupChat: { mentionPatterns: ["@workbot", "\\+15555550123"] } },
personal: { mentionPatterns: ["@homebot", "\\+15555550999"] } { id: "personal", groupChat: { mentionPatterns: ["@homebot", "\\+15555550999"] } }
} ]
} }
} }
``` ```
@ -356,11 +364,16 @@ To respond **only** to specific text triggers (ignoring native @-mentions):
allowFrom: ["+15555550123"], allowFrom: ["+15555550123"],
groups: { "*": { requireMention: true } } groups: { "*": { requireMention: true } }
}, },
routing: { agents: {
groupChat: { list: [
// Only these text patterns will trigger responses {
mentionPatterns: ["reisponde", "@clawd"] id: "main",
} groupChat: {
// Only these text patterns will trigger responses
mentionPatterns: ["reisponde", "@clawd"]
}
}
]
} }
} }
``` ```
@ -410,17 +423,22 @@ Notes:
- Discord/Slack use channel allowlists (`discord.guilds.*.channels`, `slack.channels`). - Discord/Slack use channel allowlists (`discord.guilds.*.channels`, `slack.channels`).
- Group DMs (Discord/Slack) are still controlled by `dm.groupEnabled` + `dm.groupChannels`. - Group DMs (Discord/Slack) are still controlled by `dm.groupEnabled` + `dm.groupChannels`.
### Multi-agent routing (`routing.agents` + `routing.bindings`) ### Multi-agent routing (`agents.list` + `bindings`)
Run multiple isolated agents (separate workspace, `agentDir`, sessions) inside one Gateway. Inbound messages are routed to an agent via bindings. Run multiple isolated agents (separate workspace, `agentDir`, sessions) inside one Gateway.
Inbound messages are routed to an agent via bindings.
- `routing.defaultAgentId`: fallback when no binding matches (default: `main`). - `agents.list[]`: per-agent overrides.
- `routing.agents.<agentId>`: per-agent overrides. - `id`: stable agent id (required).
- `default`: optional; when multiple are set, the first wins and a warning is logged.
If none are set, the **first entry** in the list is the default agent.
- `name`: display name for the agent. - `name`: display name for the agent.
- `workspace`: default `~/clawd-<agentId>` (for `main`, falls back to legacy `agent.workspace`). - `workspace`: default `~/clawd-<agentId>` (for `main`, falls back to `agents.defaults.workspace`).
- `agentDir`: default `~/.clawdbot/agents/<agentId>/agent`. - `agentDir`: default `~/.clawdbot/agents/<agentId>/agent`.
- `model`: per-agent default model (provider/model), overrides `agent.model` for that agent. - `model`: per-agent default model (provider/model), overrides `agents.defaults.model` for that agent.
- `sandbox`: per-agent sandbox config (overrides `agent.sandbox`). - `identity`: per-agent name/theme/emoji (used for mention patterns + ack reactions).
- `groupChat`: per-agent mention-gating (`mentionPatterns`).
- `sandbox`: per-agent sandbox config (overrides `agents.defaults.sandbox`).
- `mode`: `"off"` | `"non-main"` | `"all"` - `mode`: `"off"` | `"non-main"` | `"all"`
- `workspaceAccess`: `"none"` | `"ro"` | `"rw"` - `workspaceAccess`: `"none"` | `"ro"` | `"rw"`
- `scope`: `"session"` | `"agent"` | `"shared"` - `scope`: `"session"` | `"agent"` | `"shared"`
@ -428,13 +446,13 @@ Run multiple isolated agents (separate workspace, `agentDir`, sessions) inside o
- `docker`: per-agent docker overrides (e.g. `image`, `network`, `env`, `setupCommand`, limits; ignored when `scope: "shared"`) - `docker`: per-agent docker overrides (e.g. `image`, `network`, `env`, `setupCommand`, limits; ignored when `scope: "shared"`)
- `browser`: per-agent sandboxed browser overrides (ignored when `scope: "shared"`) - `browser`: per-agent sandboxed browser overrides (ignored when `scope: "shared"`)
- `prune`: per-agent sandbox pruning overrides (ignored when `scope: "shared"`) - `prune`: per-agent sandbox pruning overrides (ignored when `scope: "shared"`)
- `tools`: per-agent sandbox tool policy (deny wins; overrides `agent.sandbox.tools`)
- `subagents`: per-agent sub-agent defaults. - `subagents`: per-agent sub-agent defaults.
- `allowAgents`: allowlist of agent ids for `sessions_spawn` from this agent (`["*"]` = allow any; default: only same agent) - `allowAgents`: allowlist of agent ids for `sessions_spawn` from this agent (`["*"]` = allow any; default: only same agent)
- `tools`: per-agent tool restrictions (overrides `agent.tools`; applied before sandbox tool policy). - `tools`: per-agent tool restrictions (applied before sandbox tool policy).
- `allow`: array of allowed tool names - `allow`: array of allowed tool names
- `deny`: array of denied tool names (deny wins) - `deny`: array of denied tool names (deny wins)
- `routing.bindings[]`: routes inbound messages to an `agentId`. - `agents.defaults`: shared agent defaults (model, workspace, sandbox, etc.).
- `bindings[]`: routes inbound messages to an `agentId`.
- `match.provider` (required) - `match.provider` (required)
- `match.accountId` (optional; `*` = any account; omitted = default account) - `match.accountId` (optional; `*` = any account; omitted = default account)
- `match.peer` (optional; `{ kind: dm|group|channel, id }`) - `match.peer` (optional; `{ kind: dm|group|channel, id }`)
@ -446,9 +464,9 @@ Deterministic match order:
3) `match.teamId` 3) `match.teamId`
4) `match.accountId` (exact, no peer/guild/team) 4) `match.accountId` (exact, no peer/guild/team)
5) `match.accountId: "*"` (provider-wide, no peer/guild/team) 5) `match.accountId: "*"` (provider-wide, no peer/guild/team)
6) `routing.defaultAgentId` 6) default agent (`agents.list[].default`, else first list entry, else `"main"`)
Within each match tier, the first matching entry in `routing.bindings` wins. Within each match tier, the first matching entry in `bindings` wins.
#### Per-agent access profiles (multi-agent) #### Per-agent access profiles (multi-agent)
@ -464,13 +482,14 @@ additional examples.
Full access (no sandbox): Full access (no sandbox):
```json5 ```json5
{ {
routing: { agents: {
agents: { list: [
personal: { {
id: "personal",
workspace: "~/clawd-personal", workspace: "~/clawd-personal",
sandbox: { mode: "off" } sandbox: { mode: "off" }
} }
} ]
} }
} }
``` ```
@ -478,9 +497,10 @@ Full access (no sandbox):
Read-only tools + read-only workspace: Read-only tools + read-only workspace:
```json5 ```json5
{ {
routing: { agents: {
agents: { list: [
family: { {
id: "family",
workspace: "~/clawd-family", workspace: "~/clawd-family",
sandbox: { sandbox: {
mode: "all", mode: "all",
@ -492,7 +512,7 @@ Read-only tools + read-only workspace:
deny: ["write", "edit", "bash", "process", "browser"] deny: ["write", "edit", "bash", "process", "browser"]
} }
} }
} ]
} }
} }
``` ```
@ -500,9 +520,10 @@ Read-only tools + read-only workspace:
No filesystem access (messaging/session tools enabled): No filesystem access (messaging/session tools enabled):
```json5 ```json5
{ {
routing: { agents: {
agents: { list: [
public: { {
id: "public",
workspace: "~/clawd-public", workspace: "~/clawd-public",
sandbox: { sandbox: {
mode: "all", mode: "all",
@ -514,7 +535,7 @@ No filesystem access (messaging/session tools enabled):
deny: ["read", "write", "edit", "bash", "process", "browser", "canvas", "nodes", "cron", "gateway", "image"] deny: ["read", "write", "edit", "bash", "process", "browser", "canvas", "nodes", "cron", "gateway", "image"]
} }
} }
} ]
} }
} }
``` ```
@ -523,17 +544,16 @@ Example: two WhatsApp accounts → two agents:
```json5 ```json5
{ {
routing: { agents: {
defaultAgentId: "home", list: [
agents: { { id: "home", default: true, workspace: "~/clawd-home" },
home: { workspace: "~/clawd-home" }, { id: "work", workspace: "~/clawd-work" }
work: { workspace: "~/clawd-work" }, ]
},
bindings: [
{ agentId: "home", match: { provider: "whatsapp", accountId: "personal" } },
{ agentId: "work", match: { provider: "whatsapp", accountId: "biz" } },
],
}, },
bindings: [
{ agentId: "home", match: { provider: "whatsapp", accountId: "personal" } },
{ agentId: "work", match: { provider: "whatsapp", accountId: "biz" } }
],
whatsapp: { whatsapp: {
accounts: { accounts: {
personal: {}, personal: {},
@ -543,13 +563,13 @@ Example: two WhatsApp accounts → two agents:
} }
``` ```
### `routing.agentToAgent` (optional) ### `tools.agentToAgent` (optional)
Agent-to-agent messaging is opt-in: Agent-to-agent messaging is opt-in:
```json5 ```json5
{ {
routing: { tools: {
agentToAgent: { agentToAgent: {
enabled: false, enabled: false,
allow: ["home", "work"] allow: ["home", "work"]
@ -558,13 +578,13 @@ Agent-to-agent messaging is opt-in:
} }
``` ```
### `routing.queue` ### `messages.queue`
Controls how inbound messages behave when an agent run is already active. Controls how inbound messages behave when an agent run is already active.
```json5 ```json5
{ {
routing: { messages: {
queue: { queue: {
mode: "collect", // steer | followup | collect | steer-backlog (steer+backlog ok) | interrupt (queue=steer legacy) mode: "collect", // steer | followup | collect | steer-backlog (steer+backlog ok) | interrupt (queue=steer legacy)
debounceMs: 1000, debounceMs: 1000,
@ -859,7 +879,7 @@ Example wrapper:
exec ssh -T mac-mini "imsg rpc" exec ssh -T mac-mini "imsg rpc"
``` ```
### `agent.workspace` ### `agents.defaults.workspace`
Sets the **single global workspace directory** used by the agent for file operations. Sets the **single global workspace directory** used by the agent for file operations.
@ -867,14 +887,14 @@ Default: `~/clawd`.
```json5 ```json5
{ {
agent: { workspace: "~/clawd" } agents: { defaults: { workspace: "~/clawd" } }
} }
``` ```
If `agent.sandbox` is enabled, non-main sessions can override this with their If `agents.defaults.sandbox` is enabled, non-main sessions can override this with their
own per-scope workspaces under `agent.sandbox.workspaceRoot`. own per-scope workspaces under `agents.defaults.sandbox.workspaceRoot`.
### `agent.skipBootstrap` ### `agents.defaults.skipBootstrap`
Disables automatic creation of the workspace bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, and `BOOTSTRAP.md`). Disables automatic creation of the workspace bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, and `BOOTSTRAP.md`).
@ -882,18 +902,18 @@ Use this for pre-seeded deployments where your workspace files come from a repo.
```json5 ```json5
{ {
agent: { skipBootstrap: true } agents: { defaults: { skipBootstrap: true } }
} }
``` ```
### `agent.userTimezone` ### `agents.defaults.userTimezone`
Sets the users timezone for **system prompt context** (not for timestamps in Sets the users timezone for **system prompt context** (not for timestamps in
message envelopes). If unset, Clawdbot uses the host timezone at runtime. message envelopes). If unset, Clawdbot uses the host timezone at runtime.
```json5 ```json5
{ {
agent: { userTimezone: "America/Chicago" } agents: { defaults: { userTimezone: "America/Chicago" } }
} }
``` ```
@ -917,7 +937,7 @@ streaming, final replies) across providers unless already present.
`ackReaction` sends a best-effort emoji reaction to acknowledge inbound messages `ackReaction` sends a best-effort emoji reaction to acknowledge inbound messages
on providers that support reactions (Slack/Discord/Telegram). Defaults to the on providers that support reactions (Slack/Discord/Telegram). Defaults to the
configured `identity.emoji` when set, otherwise `"👀"`. Set it to `""` to disable. active agents `identity.emoji` when set, otherwise `"👀"`. Set it to `""` to disable.
`ackReactionScope` controls when reactions fire: `ackReactionScope` controls when reactions fire:
- `group-mentions` (default): only when a group/room requires mentions **and** the bot was mentioned - `group-mentions` (default): only when a group/room requires mentions **and** the bot was mentioned
@ -947,22 +967,22 @@ Defaults for Talk mode (macOS/iOS/Android). Voice IDs fall back to `ELEVENLABS_V
} }
``` ```
### `agent` ### `agents.defaults`
Controls the embedded agent runtime (model/thinking/verbose/timeouts). Controls the embedded agent runtime (model/thinking/verbose/timeouts).
`agent.models` defines the configured model catalog (and acts as the allowlist for `/model`). `agents.defaults.models` defines the configured model catalog (and acts as the allowlist for `/model`).
`agent.model.primary` sets the default model; `agent.model.fallbacks` are global failovers. `agents.defaults.model.primary` sets the default model; `agents.defaults.model.fallbacks` are global failovers.
`agent.imageModel` is optional and is **only used if the primary model lacks image input**. `agents.defaults.imageModel` is optional and is **only used if the primary model lacks image input**.
Each `agent.models` entry can include: Each `agents.defaults.models` entry can include:
- `alias` (optional model shortcut, e.g. `/opus`). - `alias` (optional model shortcut, e.g. `/opus`).
- `params` (optional provider-specific API params passed through to the model request). - `params` (optional provider-specific API params passed through to the model request).
Z.AI GLM-4.x models automatically enable thinking mode unless you: Z.AI GLM-4.x models automatically enable thinking mode unless you:
- set `--thinking off`, or - set `--thinking off`, or
- define `agent.models["zai/<model>"].params.thinking` yourself. - define `agents.defaults.models["zai/<model>"].params.thinking` yourself.
Clawdbot also ships a few built-in alias shorthands. Defaults only apply when the model Clawdbot also ships a few built-in alias shorthands. Defaults only apply when the model
is already present in `agent.models`: is already present in `agents.defaults.models`:
- `opus` -> `anthropic/claude-opus-4-5` - `opus` -> `anthropic/claude-opus-4-5`
- `sonnet` -> `anthropic/claude-sonnet-4-5` - `sonnet` -> `anthropic/claude-sonnet-4-5`
@ -975,61 +995,63 @@ If you configure the same alias name (case-insensitive) yourself, your value win
```json5 ```json5
{ {
agent: { agents: {
models: { defaults: {
"anthropic/claude-opus-4-5": { alias: "Opus" }, models: {
"anthropic/claude-sonnet-4-1": { alias: "Sonnet" }, "anthropic/claude-opus-4-5": { alias: "Opus" },
"openrouter/deepseek/deepseek-r1:free": {}, "anthropic/claude-sonnet-4-1": { alias: "Sonnet" },
"zai/glm-4.7": { "openrouter/deepseek/deepseek-r1:free": {},
alias: "GLM", "zai/glm-4.7": {
params: { alias: "GLM",
thinking: { params: {
type: "enabled", thinking: {
clear_thinking: false type: "enabled",
clear_thinking: false
}
} }
} }
} },
}, model: {
model: { primary: "anthropic/claude-opus-4-5",
primary: "anthropic/claude-opus-4-5", fallbacks: [
fallbacks: [ "openrouter/deepseek/deepseek-r1:free",
"openrouter/deepseek/deepseek-r1:free", "openrouter/meta-llama/llama-3.3-70b-instruct:free"
"openrouter/meta-llama/llama-3.3-70b-instruct:free" ]
] },
}, imageModel: {
imageModel: { primary: "openrouter/qwen/qwen-2.5-vl-72b-instruct:free",
primary: "openrouter/qwen/qwen-2.5-vl-72b-instruct:free", fallbacks: [
fallbacks: [ "openrouter/google/gemini-2.0-flash-vision:free"
"openrouter/google/gemini-2.0-flash-vision:free" ]
] },
}, thinkingDefault: "low",
thinkingDefault: "low", verboseDefault: "off",
verboseDefault: "off", elevatedDefault: "on",
elevatedDefault: "on", timeoutSeconds: 600,
timeoutSeconds: 600, mediaMaxMb: 5,
mediaMaxMb: 5, heartbeat: {
heartbeat: { every: "30m",
every: "30m", target: "last"
target: "last" },
}, maxConcurrent: 3,
maxConcurrent: 3, subagents: {
subagents: { maxConcurrent: 1,
maxConcurrent: 1, archiveAfterMinutes: 60
archiveAfterMinutes: 60 },
}, bash: {
bash: { backgroundMs: 10000,
backgroundMs: 10000, timeoutSec: 1800,
timeoutSec: 1800, cleanupMs: 1800000
cleanupMs: 1800000 },
}, contextTokens: 200000
contextTokens: 200000 }
} }
} }
``` ```
#### `agent.contextPruning` (tool-result pruning) #### `agents.defaults.contextPruning` (tool-result pruning)
`agent.contextPruning` prunes **old tool results** from the in-memory context right before a request is sent to the LLM. `agents.defaults.contextPruning` prunes **old tool results** from the in-memory context right before a request is sent to the LLM.
It does **not** modify the session history on disk (`*.jsonl` remains complete). It does **not** modify the session history on disk (`*.jsonl` remains complete).
This is intended to reduce token usage for chatty agents that accumulate large tool outputs over time. This is intended to reduce token usage for chatty agents that accumulate large tool outputs over time.
@ -1061,22 +1083,14 @@ Notes / current limitations:
Default (adaptive): Default (adaptive):
```json5 ```json5
{ {
agent: { agents: { defaults: { contextPruning: { mode: "adaptive" } } }
contextPruning: {
mode: "adaptive"
}
}
} }
``` ```
To disable: To disable:
```json5 ```json5
{ {
agent: { agents: { defaults: { contextPruning: { mode: "off" } } }
contextPruning: {
mode: "off"
}
}
} }
``` ```
@ -1091,28 +1105,26 @@ Defaults (when `mode` is `"adaptive"` or `"aggressive"`):
Example (aggressive, minimal): Example (aggressive, minimal):
```json5 ```json5
{ {
agent: { agents: { defaults: { contextPruning: { mode: "aggressive" } } }
contextPruning: {
mode: "aggressive"
}
}
} }
``` ```
Example (adaptive tuned): Example (adaptive tuned):
```json5 ```json5
{ {
agent: { agents: {
contextPruning: { defaults: {
mode: "adaptive", contextPruning: {
keepLastAssistants: 3, mode: "adaptive",
softTrimRatio: 0.3, keepLastAssistants: 3,
hardClearRatio: 0.5, softTrimRatio: 0.3,
minPrunableToolChars: 50000, hardClearRatio: 0.5,
softTrim: { maxChars: 4000, headChars: 1500, tailChars: 1500 }, minPrunableToolChars: 50000,
hardClear: { enabled: true, placeholder: "[Old tool result content cleared]" }, softTrim: { maxChars: 4000, headChars: 1500, tailChars: 1500 },
// Optional: restrict pruning to specific tools (deny wins; supports "*" wildcards) hardClear: { enabled: true, placeholder: "[Old tool result content cleared]" },
tools: { deny: ["browser", "canvas"] }, // Optional: restrict pruning to specific tools (deny wins; supports "*" wildcards)
tools: { deny: ["browser", "canvas"] },
}
} }
} }
} }
@ -1121,36 +1133,34 @@ Example (adaptive tuned):
See [/concepts/session-pruning](/concepts/session-pruning) for behavior details. See [/concepts/session-pruning](/concepts/session-pruning) for behavior details.
Block streaming: Block streaming:
- `agent.blockStreamingDefault`: `"on"`/`"off"` (default on). - `agents.defaults.blockStreamingDefault`: `"on"`/`"off"` (default on).
- `agent.blockStreamingBreak`: `"text_end"` or `"message_end"` (default: text_end). - `agents.defaults.blockStreamingBreak`: `"text_end"` or `"message_end"` (default: text_end).
- `agent.blockStreamingChunk`: soft chunking for streamed blocks. Defaults to - `agents.defaults.blockStreamingChunk`: soft chunking for streamed blocks. Defaults to
8001200 chars, prefers paragraph breaks (`\n\n`), then newlines, then sentences. 8001200 chars, prefers paragraph breaks (`\n\n`), then newlines, then sentences.
Example: Example:
```json5 ```json5
{ {
agent: { agents: { defaults: { blockStreamingChunk: { minChars: 800, maxChars: 1200 } } }
blockStreamingChunk: { minChars: 800, maxChars: 1200 }
}
} }
``` ```
See [/concepts/streaming](/concepts/streaming) for behavior + chunking details. See [/concepts/streaming](/concepts/streaming) for behavior + chunking details.
Typing indicators: Typing indicators:
- `agent.typingMode`: `"never" | "instant" | "thinking" | "message"`. Defaults to - `agents.defaults.typingMode`: `"never" | "instant" | "thinking" | "message"`. Defaults to
`instant` for direct chats / mentions and `message` for unmentioned group chats. `instant` for direct chats / mentions and `message` for unmentioned group chats.
- `session.typingMode`: per-session override for the mode. - `session.typingMode`: per-session override for the mode.
- `agent.typingIntervalSeconds`: how often the typing signal is refreshed (default: 6s). - `agents.defaults.typingIntervalSeconds`: how often the typing signal is refreshed (default: 6s).
- `session.typingIntervalSeconds`: per-session override for the refresh interval. - `session.typingIntervalSeconds`: per-session override for the refresh interval.
See [/concepts/typing-indicators](/concepts/typing-indicators) for behavior details. See [/concepts/typing-indicators](/concepts/typing-indicators) for behavior details.
`agent.model.primary` should be set as `provider/model` (e.g. `anthropic/claude-opus-4-5`). `agents.defaults.model.primary` should be set as `provider/model` (e.g. `anthropic/claude-opus-4-5`).
Aliases come from `agent.models.*.alias` (e.g. `Opus`). Aliases come from `agents.defaults.models.*.alias` (e.g. `Opus`).
If you omit the provider, CLAWDBOT currently assumes `anthropic` as a temporary If you omit the provider, CLAWDBOT currently assumes `anthropic` as a temporary
deprecation fallback. deprecation fallback.
Z.AI models are available as `zai/<model>` (e.g. `zai/glm-4.7`) and require Z.AI models are available as `zai/<model>` (e.g. `zai/glm-4.7`) and require
`ZAI_API_KEY` (or legacy `Z_AI_API_KEY`) in the environment. `ZAI_API_KEY` (or legacy `Z_AI_API_KEY`) in the environment.
`agent.heartbeat` configures periodic heartbeat runs: `agents.defaults.heartbeat` configures periodic heartbeat runs:
- `every`: duration string (`ms`, `s`, `m`, `h`); default unit minutes. Default: - `every`: duration string (`ms`, `s`, `m`, `h`); default unit minutes. Default:
`30m`. Set `0m` to disable. `30m`. Set `0m` to disable.
- `model`: optional override model for heartbeat runs (`provider/model`). - `model`: optional override model for heartbeat runs (`provider/model`).
@ -1162,31 +1172,27 @@ Z.AI models are available as `zai/<model>` (e.g. `zai/glm-4.7`) and require
Heartbeats run full agent turns. Shorter intervals burn more tokens; be mindful Heartbeats run full agent turns. Shorter intervals burn more tokens; be mindful
of `every`, keep `HEARTBEAT.md` tiny, and/or choose a cheaper `model`. of `every`, keep `HEARTBEAT.md` tiny, and/or choose a cheaper `model`.
`agent.bash` configures background bash defaults: `tools.bash` configures background bash defaults:
- `backgroundMs`: time before auto-background (ms, default 10000) - `backgroundMs`: time before auto-background (ms, default 10000)
- `timeoutSec`: auto-kill after this runtime (seconds, default 1800) - `timeoutSec`: auto-kill after this runtime (seconds, default 1800)
- `cleanupMs`: how long to keep finished sessions in memory (ms, default 1800000) - `cleanupMs`: how long to keep finished sessions in memory (ms, default 1800000)
`agent.subagents` configures sub-agent defaults: `agents.defaults.subagents` configures sub-agent defaults:
- `maxConcurrent`: max concurrent sub-agent runs (default 1) - `maxConcurrent`: max concurrent sub-agent runs (default 1)
- `archiveAfterMinutes`: auto-archive sub-agent sessions after N minutes (default 60; set `0` to disable) - `archiveAfterMinutes`: auto-archive sub-agent sessions after N minutes (default 60; set `0` to disable)
- `tools.allow` / `tools.deny`: per-subagent tool allow/deny policy (deny wins) - Per-subagent tool policy: `tools.subagents.tools.allow` / `tools.subagents.tools.deny` (deny wins)
`agent.tools` configures a global tool allow/deny policy (deny wins). `tools.allow` / `tools.deny` configure a global tool allow/deny policy (deny wins).
This is applied even when the Docker sandbox is **off**. This is applied even when the Docker sandbox is **off**.
Example (disable browser/canvas everywhere): Example (disable browser/canvas everywhere):
```json5 ```json5
{ {
agent: { tools: { deny: ["browser", "canvas"] }
tools: {
deny: ["browser", "canvas"]
}
}
} }
``` ```
`agent.elevated` controls elevated (host) bash access: `tools.elevated` controls elevated (host) bash access:
- `enabled`: allow elevated mode (default true) - `enabled`: allow elevated mode (default true)
- `allowFrom`: per-provider allowlists (empty = disabled) - `allowFrom`: per-provider allowlists (empty = disabled)
- `whatsapp`: E.164 numbers - `whatsapp`: E.164 numbers
@ -1199,7 +1205,7 @@ Example (disable browser/canvas everywhere):
Example: Example:
```json5 ```json5
{ {
agent: { tools: {
elevated: { elevated: {
enabled: true, enabled: true,
allowFrom: { allowFrom: {
@ -1212,16 +1218,16 @@ Example:
``` ```
Notes: Notes:
- `agent.elevated` is **global** (not per-agent). Availability is based on sender allowlists. - `tools.elevated` is **global** (not per-agent). Availability is based on sender allowlists.
- `/elevated on|off` stores state per session key; inline directives apply to a single message. - `/elevated on|off` stores state per session key; inline directives apply to a single message.
- Elevated `bash` runs on the host and bypasses sandboxing. - Elevated `bash` runs on the host and bypasses sandboxing.
- Tool policy still applies; if `bash` is denied, elevated cannot be used. - Tool policy still applies; if `bash` is denied, elevated cannot be used.
`agent.maxConcurrent` sets the maximum number of embedded agent runs that can `agents.defaults.maxConcurrent` sets the maximum number of embedded agent runs that can
execute in parallel across sessions. Each session is still serialized (one run execute in parallel across sessions. Each session is still serialized (one run
per session key at a time). Default: 1. per session key at a time). Default: 1.
### `agent.sandbox` ### `agents.defaults.sandbox`
Optional **Docker sandboxing** for the embedded agent. Intended for non-main Optional **Docker sandboxing** for the embedded agent. Intended for non-main
sessions so they cannot access your host system. sessions so they cannot access your host system.
@ -1236,7 +1242,8 @@ Defaults (if enabled):
- `"ro"`: keep the sandbox workspace at `/workspace`, and mount the agent workspace read-only at `/agent` (disables `write`/`edit`) - `"ro"`: keep the sandbox workspace at `/workspace`, and mount the agent workspace read-only at `/agent` (disables `write`/`edit`)
- `"rw"`: mount the agent workspace read/write at `/workspace` - `"rw"`: mount the agent workspace read/write at `/workspace`
- auto-prune: idle > 24h OR age > 7d - auto-prune: idle > 24h OR age > 7d
- tools: allow only `bash`, `process`, `read`, `write`, `edit`, `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn` (deny wins) - tool policy: allow only `bash`, `process`, `read`, `write`, `edit`, `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn` (deny wins)
- configure via `tools.sandbox.tools`, override per-agent via `agents.list[].tools.sandbox.tools`
- optional sandboxed browser (Chromium + CDP, noVNC observer) - optional sandboxed browser (Chromium + CDP, noVNC observer)
- hardening knobs: `network`, `user`, `pidsLimit`, `memory`, `cpus`, `ulimits`, `seccompProfile`, `apparmorProfile` - hardening knobs: `network`, `user`, `pidsLimit`, `memory`, `cpus`, `ulimits`, `seccompProfile`, `apparmorProfile`
@ -1248,54 +1255,60 @@ Legacy: `perSession` is still supported (`true` → `scope: "session"`,
```json5 ```json5
{ {
agent: { agents: {
sandbox: { defaults: {
mode: "non-main", // off | non-main | all sandbox: {
scope: "agent", // session | agent | shared (agent is default) mode: "non-main", // off | non-main | all
workspaceAccess: "none", // none | ro | rw scope: "agent", // session | agent | shared (agent is default)
workspaceRoot: "~/.clawdbot/sandboxes", workspaceAccess: "none", // none | ro | rw
docker: { workspaceRoot: "~/.clawdbot/sandboxes",
image: "clawdbot-sandbox:bookworm-slim", docker: {
containerPrefix: "clawdbot-sbx-", image: "clawdbot-sandbox:bookworm-slim",
workdir: "/workspace", containerPrefix: "clawdbot-sbx-",
readOnlyRoot: true, workdir: "/workspace",
tmpfs: ["/tmp", "/var/tmp", "/run"], readOnlyRoot: true,
network: "none", tmpfs: ["/tmp", "/var/tmp", "/run"],
user: "1000:1000", network: "none",
capDrop: ["ALL"], user: "1000:1000",
env: { LANG: "C.UTF-8" }, capDrop: ["ALL"],
setupCommand: "apt-get update && apt-get install -y git curl jq", env: { LANG: "C.UTF-8" },
// Per-agent override (multi-agent): routing.agents.<agentId>.sandbox.docker.* setupCommand: "apt-get update && apt-get install -y git curl jq",
pidsLimit: 256, // Per-agent override (multi-agent): agents.list[].sandbox.docker.*
memory: "1g", pidsLimit: 256,
memorySwap: "2g", memory: "1g",
cpus: 1, memorySwap: "2g",
ulimits: { cpus: 1,
nofile: { soft: 1024, hard: 2048 }, ulimits: {
nproc: 256 nofile: { soft: 1024, hard: 2048 },
nproc: 256
},
seccompProfile: "/path/to/seccomp.json",
apparmorProfile: "clawdbot-sandbox",
dns: ["1.1.1.1", "8.8.8.8"],
extraHosts: ["internal.service:10.0.0.5"]
}, },
seccompProfile: "/path/to/seccomp.json", browser: {
apparmorProfile: "clawdbot-sandbox", enabled: false,
dns: ["1.1.1.1", "8.8.8.8"], image: "clawdbot-sandbox-browser:bookworm-slim",
extraHosts: ["internal.service:10.0.0.5"] containerPrefix: "clawdbot-sbx-browser-",
}, cdpPort: 9222,
browser: { vncPort: 5900,
enabled: false, noVncPort: 6080,
image: "clawdbot-sandbox-browser:bookworm-slim", headless: false,
containerPrefix: "clawdbot-sbx-browser-", enableNoVnc: true
cdpPort: 9222, },
vncPort: 5900, prune: {
noVncPort: 6080, idleHours: 24, // 0 disables idle pruning
headless: false, maxAgeDays: 7 // 0 disables max-age pruning
enableNoVnc: true }
}, }
}
},
tools: {
sandbox: {
tools: { tools: {
allow: ["bash", "process", "read", "write", "edit", "sessions_list", "sessions_history", "sessions_send", "sessions_spawn"], allow: ["bash", "process", "read", "write", "edit", "sessions_list", "sessions_history", "sessions_send", "sessions_spawn"],
deny: ["browser", "canvas", "nodes", "cron", "discord", "gateway"] deny: ["browser", "canvas", "nodes", "cron", "discord", "gateway"]
},
prune: {
idleHours: 24, // 0 disables idle pruning
maxAgeDays: 7 // 0 disables max-age pruning
} }
} }
} }
@ -1307,7 +1320,7 @@ Build the default sandbox image once with:
scripts/sandbox-setup.sh scripts/sandbox-setup.sh
``` ```
Note: sandbox containers default to `network: "none"`; set `agent.sandbox.docker.network` Note: sandbox containers default to `network: "none"`; set `agents.defaults.sandbox.docker.network`
to `"bridge"` (or your custom network) if the agent needs outbound access. to `"bridge"` (or your custom network) if the agent needs outbound access.
Note: inbound attachments are staged into the active workspace at `media/inbound/*`. With `workspaceAccess: "rw"`, that means files are written into the agent workspace. Note: inbound attachments are staged into the active workspace at `media/inbound/*`. With `workspaceAccess: "rw"`, that means files are written into the agent workspace.
@ -1317,7 +1330,7 @@ Build the optional browser image with:
scripts/sandbox-browser-setup.sh scripts/sandbox-browser-setup.sh
``` ```
When `agent.sandbox.browser.enabled=true`, the browser tool uses a sandboxed When `agents.defaults.sandbox.browser.enabled=true`, the browser tool uses a sandboxed
Chromium instance (CDP). If noVNC is enabled (default when headless=false), Chromium instance (CDP). If noVNC is enabled (default when headless=false),
the noVNC URL is injected into the system prompt so the agent can reference it. the noVNC URL is injected into the system prompt so the agent can reference it.
This does not require `browser.enabled` in the main config; the sandbox control This does not require `browser.enabled` in the main config; the sandbox control
@ -1335,14 +1348,16 @@ When `models.providers` is present, Clawdbot writes/merges a `models.json` into
- default behavior: **merge** (keeps existing providers, overrides on name) - default behavior: **merge** (keeps existing providers, overrides on name)
- set `models.mode: "replace"` to overwrite the file contents - set `models.mode: "replace"` to overwrite the file contents
Select the model via `agent.model.primary` (provider/model). Select the model via `agents.defaults.model.primary` (provider/model).
```json5 ```json5
{ {
agent: { agents: {
model: { primary: "custom-proxy/llama-3.1-8b" }, defaults: {
models: { model: { primary: "custom-proxy/llama-3.1-8b" },
"custom-proxy/llama-3.1-8b": {} models: {
"custom-proxy/llama-3.1-8b": {}
}
} }
}, },
models: { models: {
@ -1376,9 +1391,11 @@ in your environment and reference the model by provider/model.
```json5 ```json5
{ {
agent: { agents: {
model: "zai/glm-4.7", defaults: {
allowedModels: ["zai/glm-4.7"] model: { primary: "zai/glm-4.7" },
models: { "zai/glm-4.7": {} }
}
} }
} }
``` ```
@ -1401,11 +1418,13 @@ via **LM Studio** using the **Responses API**.
```json5 ```json5
{ {
agent: { agents: {
model: { primary: "lmstudio/minimax-m2.1-gs32" }, defaults: {
models: { model: { primary: "lmstudio/minimax-m2.1-gs32" },
"anthropic/claude-opus-4-5": { alias: "Opus" }, models: {
"lmstudio/minimax-m2.1-gs32": { alias: "Minimax" } "anthropic/claude-opus-4-5": { alias: "Opus" },
"lmstudio/minimax-m2.1-gs32": { alias: "Minimax" }
}
} }
}, },
models: { models: {
@ -1475,7 +1494,7 @@ Controls session scoping, idle expiry, reset triggers, and where the session sto
Fields: Fields:
- `mainKey`: direct-chat bucket key (default: `"main"`). Useful when you want to “rename” the primary DM thread without changing `agentId`. - `mainKey`: direct-chat bucket key (default: `"main"`). Useful when you want to “rename” the primary DM thread without changing `agentId`.
- Sandbox note: `agent.sandbox.mode: "non-main"` uses this key to detect the main session. Any session key that does not match `mainKey` (groups/channels) is sandboxed. - Sandbox note: `agents.defaults.sandbox.mode: "non-main"` uses this key to detect the main session. Any session key that does not match `mainKey` (groups/channels) is sandboxed.
- `agentToAgent.maxPingPongTurns`: max reply-back turns between requester/target (05, default 5). - `agentToAgent.maxPingPongTurns`: max reply-back turns between requester/target (05, default 5).
- `sendPolicy.default`: `allow` or `deny` fallback when no rule matches. - `sendPolicy.default`: `allow` or `deny` fallback when no rule matches.
- `sendPolicy.rules[]`: match by `provider`, `chatType` (`direct|group|room`), or `keyPrefix` (e.g. `cron:`). First deny wins; otherwise allow. - `sendPolicy.rules[]`: match by `provider`, `chatType` (`direct|group|room`), or `keyPrefix` (e.g. `cron:`). First deny wins; otherwise allow.
@ -1684,7 +1703,7 @@ Hot-applied (no full gateway restart):
- `hooks` (webhook auth/path/mappings) + `hooks.gmail` (Gmail watcher restarted) - `hooks` (webhook auth/path/mappings) + `hooks.gmail` (Gmail watcher restarted)
- `browser` (browser control server restart) - `browser` (browser control server restart)
- `cron` (cron service restart + concurrency update) - `cron` (cron service restart + concurrency update)
- `agent.heartbeat` (heartbeat runner restart) - `agents.defaults.heartbeat` (heartbeat runner restart)
- `web` (WhatsApp web provider restart) - `web` (WhatsApp web provider restart)
- `telegram`, `discord`, `signal`, `imessage` (provider restarts) - `telegram`, `discord`, `signal`, `imessage` (provider restarts)
- `agent`, `models`, `routing`, `messages`, `session`, `whatsapp`, `logging`, `skills`, `ui`, `talk`, `identity`, `wizard` (dynamic reads) - `agent`, `models`, `routing`, `messages`, `session`, `whatsapp`, `logging`, `skills`, `ui`, `talk`, `identity`, `wizard` (dynamic reads)
@ -1701,7 +1720,7 @@ Requires full Gateway restart:
To run multiple gateways on one host, isolate per-instance state + config and use unique ports: To run multiple gateways on one host, isolate per-instance state + config and use unique ports:
- `CLAWDBOT_CONFIG_PATH` (per-instance config) - `CLAWDBOT_CONFIG_PATH` (per-instance config)
- `CLAWDBOT_STATE_DIR` (sessions/creds) - `CLAWDBOT_STATE_DIR` (sessions/creds)
- `agent.workspace` (memories) - `agents.defaults.workspace` (memories)
- `gateway.port` (unique per instance) - `gateway.port` (unique per instance)
Convenience flags (CLI): Convenience flags (CLI):
@ -1771,7 +1790,7 @@ Mapping notes:
- `transform` can point to a JS/TS module that returns a hook action. - `transform` can point to a JS/TS module that returns a hook action.
- `deliver: true` sends the final reply to a provider; `provider` defaults to `last` (falls back to WhatsApp). - `deliver: true` sends the final reply to a provider; `provider` defaults to `last` (falls back to WhatsApp).
- If there is no prior delivery route, set `provider` + `to` explicitly (required for Telegram/Discord/Slack/Signal/iMessage). - If there is no prior delivery route, set `provider` + `to` explicitly (required for Telegram/Discord/Slack/Signal/iMessage).
- `model` overrides the LLM for this hook run (`provider/model` or alias; must be allowed if `agent.models` is set). - `model` overrides the LLM for this hook run (`provider/model` or alias; must be allowed if `agents.defaults.models` is set).
Gmail helper config (used by `clawdbot hooks gmail setup` / `run`): Gmail helper config (used by `clawdbot hooks gmail setup` / `run`):
@ -1886,7 +1905,7 @@ clawdbot dns setup --apply
## Template variables ## Template variables
Template placeholders are expanded in `routing.transcribeAudio.command` (and any future templated command fields). Template placeholders are expanded in `audio.transcription.command` (and any future templated command fields).
| Variable | Description | | Variable | Description |
|----------|-------------| |----------|-------------|

View File

@ -94,8 +94,18 @@ legacy config format, so stale configs are repaired without manual intervention.
Current migrations: Current migrations:
- `routing.allowFrom``whatsapp.allowFrom` - `routing.allowFrom``whatsapp.allowFrom`
- `routing.groupChat.requireMention``whatsapp/telegram/imessage.groups."*".requireMention`
- `routing.groupChat.historyLimit``messages.groupChat.historyLimit`
- `routing.groupChat.mentionPatterns``messages.groupChat.mentionPatterns`
- `routing.queue``messages.queue`
- `routing.bindings` → top-level `bindings`
- `routing.agents`/`routing.defaultAgentId` → `agents.list` + `agents.list[].default`
- `routing.agentToAgent``tools.agentToAgent`
- `routing.transcribeAudio``audio.transcription`
- `identity``agents.list[].identity`
- `agent.*``agents.defaults` + `tools.*` (tools/elevated/bash/sandbox/subagents)
- `agent.model`/`allowedModels`/`modelAliases`/`modelFallbacks`/`imageModelFallbacks` - `agent.model`/`allowedModels`/`modelAliases`/`modelFallbacks`/`imageModelFallbacks`
`agent.models` + `agent.model.primary/fallbacks` + `agent.imageModel.primary/fallbacks` `agents.defaults.models` + `agents.defaults.model.primary/fallbacks` + `agents.defaults.imageModel.primary/fallbacks`
### 3) Legacy state migrations (disk layout) ### 3) Legacy state migrations (disk layout)
Doctor can migrate older on-disk layouts into the current structure: Doctor can migrate older on-disk layouts into the current structure:

View File

@ -22,7 +22,7 @@ Short guide to verify the WhatsApp Web / Baileys stack without guessing.
## When something fails ## When something fails
- `logged out` or status 409515 → relink with `clawdbot providers logout` then `clawdbot providers login`. - `logged out` or status 409515 → relink with `clawdbot providers logout` then `clawdbot providers login`.
- Gateway unreachable → start it: `clawdbot gateway --port 18789` (use `--force` if the port is busy). - Gateway unreachable → start it: `clawdbot gateway --port 18789` (use `--force` if the port is busy).
- No inbound messages → confirm linked phone is online and the sender is allowed (`whatsapp.allowFrom`); for group chats, ensure allowlist + mention rules match (`whatsapp.groups`, `routing.groupChat.mentionPatterns`). - No inbound messages → confirm linked phone is online and the sender is allowed (`whatsapp.allowFrom`); for group chats, ensure allowlist + mention rules match (`whatsapp.groups`, `agents.list[].groupChat.mentionPatterns`).
## Dedicated "health" command ## Dedicated "health" command
`clawdbot health --json` asks the running Gateway for its health snapshot (no direct Baileys socket from the CLI). It reports linked creds, auth age, Baileys connect result/status code, session-store summary, and a probe duration. It exits non-zero if the Gateway is unreachable or the probe fails/timeouts. Use `--timeout <ms>` to override the 10s default. `clawdbot health --json` asks the running Gateway for its health snapshot (no direct Baileys socket from the CLI). It reports linked creds, auth age, Baileys connect result/status code, session-store summary, and a probe duration. It exits non-zero if the Gateway is unreachable or the probe fails/timeouts. Use `--timeout <ms>` to override the 10s default.

View File

@ -10,8 +10,8 @@ surface anything that needs attention without spamming you.
## Defaults ## Defaults
- Interval: `30m` (set `agent.heartbeat.every`; use `0m` to disable). - Interval: `30m` (set `agents.defaults.heartbeat.every`; use `0m` to disable).
- Prompt body (configurable via `agent.heartbeat.prompt`): - Prompt body (configurable via `agents.defaults.heartbeat.prompt`):
`Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.` `Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`
- The heartbeat prompt is sent **verbatim** as the user message. The system - The heartbeat prompt is sent **verbatim** as the user message. The system
prompt includes a “Heartbeat” section and the run is flagged internally. prompt includes a “Heartbeat” section and the run is flagged internally.
@ -33,14 +33,16 @@ and logged; a message that is only `HEARTBEAT_OK` is dropped.
```json5 ```json5
{ {
agent: { agents: {
heartbeat: { defaults: {
every: "30m", // default: 30m (0m disables) heartbeat: {
model: "anthropic/claude-opus-4-5", every: "30m", // default: 30m (0m disables)
target: "last", // last | whatsapp | telegram | discord | slack | signal | imessage | none model: "anthropic/claude-opus-4-5",
to: "+15551234567", // optional provider-specific override target: "last", // last | whatsapp | telegram | discord | slack | signal | imessage | none
prompt: "Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.", to: "+15551234567", // optional provider-specific override
ackMaxChars: 30 // max chars allowed after HEARTBEAT_OK prompt: "Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.",
ackMaxChars: 30 // max chars allowed after HEARTBEAT_OK
}
} }
} }
} }

View File

@ -68,7 +68,7 @@ Defaults (can be overridden via env/flags/config):
- `bridge.port=19002` (derived: `gateway.port+1`) - `bridge.port=19002` (derived: `gateway.port+1`)
- `browser.controlUrl=http://127.0.0.1:19003` (derived: `gateway.port+2`) - `browser.controlUrl=http://127.0.0.1:19003` (derived: `gateway.port+2`)
- `canvasHost.port=19005` (derived: `gateway.port+4`) - `canvasHost.port=19005` (derived: `gateway.port+4`)
- `agent.workspace` default becomes `~/clawd-dev` when you run `setup`/`onboard` under `--dev`. - `agents.defaults.workspace` default becomes `~/clawd-dev` when you run `setup`/`onboard` under `--dev`.
Derived ports (rules of thumb): Derived ports (rules of thumb):
- Base port = `gateway.port` (or `CLAWDBOT_GATEWAY_PORT` / `--port`) - Base port = `gateway.port` (or `CLAWDBOT_GATEWAY_PORT` / `--port`)
@ -81,7 +81,7 @@ Checklist per instance:
- unique `gateway.port` - unique `gateway.port`
- unique `CLAWDBOT_CONFIG_PATH` - unique `CLAWDBOT_CONFIG_PATH`
- unique `CLAWDBOT_STATE_DIR` - unique `CLAWDBOT_STATE_DIR`
- unique `agent.workspace` - unique `agents.defaults.workspace`
- separate WhatsApp numbers (if using WA) - separate WhatsApp numbers (if using WA)
Example: Example:

View File

@ -1,15 +1,15 @@
--- ---
summary: "How Clawdbot sandboxing works: modes, scopes, workspace access, and images" summary: "How Clawdbot sandboxing works: modes, scopes, workspace access, and images"
title: Sandboxing title: Sandboxing
read_when: "You want a dedicated explanation of sandboxing or need to tune agent.sandbox." read_when: "You want a dedicated explanation of sandboxing or need to tune agents.defaults.sandbox."
status: active status: active
--- ---
# Sandboxing # Sandboxing
Clawdbot can run **tools inside Docker containers** to reduce blast radius. Clawdbot can run **tools inside Docker containers** to reduce blast radius.
This is **optional** and controlled by configuration (`agent.sandbox` or This is **optional** and controlled by configuration (`agents.defaults.sandbox` or
`routing.agents[id].sandbox`). If sandboxing is off, tools run on the host. `agents.list[].sandbox`). If sandboxing is off, tools run on the host.
The Gateway stays on the host; tool execution runs in an isolated sandbox The Gateway stays on the host; tool execution runs in an isolated sandbox
when enabled. when enabled.
@ -18,16 +18,16 @@ and process access when the model does something dumb.
## What gets sandboxed ## What gets sandboxed
- Tool execution (`bash`, `read`, `write`, `edit`, `process`, etc.). - Tool execution (`bash`, `read`, `write`, `edit`, `process`, etc.).
- Optional sandboxed browser (`agent.sandbox.browser`). - Optional sandboxed browser (`agents.defaults.sandbox.browser`).
Not sandboxed: Not sandboxed:
- The Gateway process itself. - The Gateway process itself.
- Any tool explicitly allowed to run on the host (e.g. `agent.elevated`). - Any tool explicitly allowed to run on the host (e.g. `tools.elevated`).
- **Elevated bash runs on the host and bypasses sandboxing.** - **Elevated bash runs on the host and bypasses sandboxing.**
- If sandboxing is off, `agent.elevated` does not change execution (already on host). See [Elevated Mode](/tools/elevated). - If sandboxing is off, `tools.elevated` does not change execution (already on host). See [Elevated Mode](/tools/elevated).
## Modes ## Modes
`agent.sandbox.mode` controls **when** sandboxing is used: `agents.defaults.sandbox.mode` controls **when** sandboxing is used:
- `"off"`: no sandboxing. - `"off"`: no sandboxing.
- `"non-main"`: sandbox only **non-main** sessions (default if you want normal chats on host). - `"non-main"`: sandbox only **non-main** sessions (default if you want normal chats on host).
- `"all"`: every session runs in a sandbox. - `"all"`: every session runs in a sandbox.
@ -35,13 +35,13 @@ Note: `"non-main"` is based on `session.mainKey` (default `"main"`), not agent i
Group/channel sessions use their own keys, so they count as non-main and will be sandboxed. Group/channel sessions use their own keys, so they count as non-main and will be sandboxed.
## Scope ## Scope
`agent.sandbox.scope` controls **how many containers** are created: `agents.defaults.sandbox.scope` controls **how many containers** are created:
- `"session"` (default): one container per session. - `"session"` (default): one container per session.
- `"agent"`: one container per agent. - `"agent"`: one container per agent.
- `"shared"`: one container shared by all sandboxed sessions. - `"shared"`: one container shared by all sandboxed sessions.
## Workspace access ## Workspace access
`agent.sandbox.workspaceAccess` controls **what the sandbox can see**: `agents.defaults.sandbox.workspaceAccess` controls **what the sandbox can see**:
- `"none"` (default): tools see a sandbox workspace under `~/.clawdbot/sandboxes`. - `"none"` (default): tools see a sandbox workspace under `~/.clawdbot/sandboxes`.
- `"ro"`: mounts the agent workspace read-only at `/agent` (disables `write`/`edit`). - `"ro"`: mounts the agent workspace read-only at `/agent` (disables `write`/`edit`).
- `"rw"`: mounts the agent workspace read/write at `/workspace`. - `"rw"`: mounts the agent workspace read/write at `/workspace`.
@ -66,7 +66,7 @@ scripts/sandbox-browser-setup.sh
``` ```
By default, sandbox containers run with **no network**. By default, sandbox containers run with **no network**.
Override with `agent.sandbox.docker.network`. Override with `agents.defaults.sandbox.docker.network`.
Docker installs and the containerized gateway live here: Docker installs and the containerized gateway live here:
[Docker](/install/docker) [Docker](/install/docker)
@ -75,28 +75,30 @@ Docker installs and the containerized gateway live here:
Tool allow/deny policies still apply before sandbox rules. If a tool is denied Tool allow/deny policies still apply before sandbox rules. If a tool is denied
globally or per-agent, sandboxing doesnt bring it back. globally or per-agent, sandboxing doesnt bring it back.
`agent.elevated` is an explicit escape hatch that runs `bash` on the host. `tools.elevated` is an explicit escape hatch that runs `bash` on the host.
Keep it locked down. Keep it locked down.
## Multi-agent overrides ## Multi-agent overrides
Each agent can override sandbox + tools: Each agent can override sandbox + tools:
`routing.agents[id].sandbox` and `routing.agents[id].tools`. `agents.list[].sandbox` and `agents.list[].tools` (plus `agents.list[].tools.sandbox.tools` for sandbox tool policy).
See [Multi-Agent Sandbox & Tools](/multi-agent-sandbox-tools) for precedence. See [Multi-Agent Sandbox & Tools](/multi-agent-sandbox-tools) for precedence.
## Minimal enable example ## Minimal enable example
```json5 ```json5
{ {
agent: { agents: {
sandbox: { defaults: {
mode: "non-main", sandbox: {
scope: "session", mode: "non-main",
workspaceAccess: "none" scope: "session",
workspaceAccess: "none"
}
} }
} }
} }
``` ```
## Related docs ## Related docs
- [Sandbox Configuration](/gateway/configuration#agent-sandbox) - [Sandbox Configuration](/gateway/configuration#agentsdefaults-sandbox)
- [Multi-Agent Sandbox & Tools](/multi-agent-sandbox-tools) - [Multi-Agent Sandbox & Tools](/multi-agent-sandbox-tools)
- [Security](/gateway/security) - [Security](/gateway/security)

View File

@ -127,10 +127,13 @@ Keep config + state private on the gateway host:
"*": { "requireMention": true } "*": { "requireMention": true }
} }
}, },
"routing": { "agents": {
"groupChat": { "list": [
"mentionPatterns": ["@clawd", "@mybot"] {
} "id": "main",
"groupChat": { "mentionPatterns": ["@clawd", "@mybot"] }
}
]
} }
} }
``` ```
@ -146,7 +149,7 @@ Consider running your AI on a separate phone number from your personal one:
### 4. Read-Only Mode (Today, via sandbox + tools) ### 4. Read-Only Mode (Today, via sandbox + tools)
You can already build a read-only profile by combining: You can already build a read-only profile by combining:
- `sandbox.workspaceAccess: "ro"` (or `"none"` for no workspace access) - `agents.defaults.sandbox.workspaceAccess: "ro"` (or `"none"` for no workspace access)
- tool allow/deny lists that block `write`, `edit`, `bash`, `process`, etc. - tool allow/deny lists that block `write`, `edit`, `bash`, `process`, etc.
We may add a single `readOnlyMode` flag later to simplify this configuration. We may add a single `readOnlyMode` flag later to simplify this configuration.
@ -158,18 +161,18 @@ Dedicated doc: [Sandboxing](/gateway/sandboxing)
Two complementary approaches: Two complementary approaches:
- **Run the full Gateway in Docker** (container boundary): [Docker](/install/docker) - **Run the full Gateway in Docker** (container boundary): [Docker](/install/docker)
- **Tool sandbox** (`agent.sandbox`, host gateway + Docker-isolated tools): [Sandboxing](/gateway/sandboxing) - **Tool sandbox** (`agents.defaults.sandbox`, host gateway + Docker-isolated tools): [Sandboxing](/gateway/sandboxing)
Note: to prevent cross-agent access, keep `sandbox.scope` at `"agent"` (default) Note: to prevent cross-agent access, keep `agents.defaults.sandbox.scope` at `"agent"` (default)
or `"session"` for stricter per-session isolation. `scope: "shared"` uses a or `"session"` for stricter per-session isolation. `scope: "shared"` uses a
single container/workspace. single container/workspace.
Also consider agent workspace access inside the sandbox: Also consider agent workspace access inside the sandbox:
- `agent.sandbox.workspaceAccess: "none"` (default) keeps the agent workspace off-limits; tools run against a sandbox workspace under `~/.clawdbot/sandboxes` - `agents.defaults.sandbox.workspaceAccess: "none"` (default) keeps the agent workspace off-limits; tools run against a sandbox workspace under `~/.clawdbot/sandboxes`
- `workspaceAccess: "ro"` mounts the agent workspace read-only at `/agent` (disables `write`/`edit`) - `agents.defaults.sandbox.workspaceAccess: "ro"` mounts the agent workspace read-only at `/agent` (disables `write`/`edit`)
- `workspaceAccess: "rw"` mounts the agent workspace read/write at `/workspace` - `agents.defaults.sandbox.workspaceAccess: "rw"` mounts the agent workspace read/write at `/workspace`
Important: `agent.elevated` is a **global**, sender-based escape hatch that runs bash on the host. Keep `agent.elevated.allowFrom` tight and dont enable it for strangers. See [Elevated Mode](/tools/elevated). Important: `tools.elevated` is a **global**, sender-based escape hatch that runs bash on the host. Keep `tools.elevated.allowFrom` tight and dont enable it for strangers. See [Elevated Mode](/tools/elevated).
## Per-agent access profiles (multi-agent) ## Per-agent access profiles (multi-agent)
@ -187,13 +190,14 @@ Common use cases:
```json5 ```json5
{ {
routing: { agents: {
agents: { list: [
personal: { {
id: "personal",
workspace: "~/clawd-personal", workspace: "~/clawd-personal",
sandbox: { mode: "off" } sandbox: { mode: "off" }
} }
} ]
} }
} }
``` ```
@ -202,9 +206,10 @@ Common use cases:
```json5 ```json5
{ {
routing: { agents: {
agents: { list: [
family: { {
id: "family",
workspace: "~/clawd-family", workspace: "~/clawd-family",
sandbox: { sandbox: {
mode: "all", mode: "all",
@ -216,7 +221,7 @@ Common use cases:
deny: ["write", "edit", "bash", "process", "browser"] deny: ["write", "edit", "bash", "process", "browser"]
} }
} }
} ]
} }
} }
``` ```
@ -225,9 +230,10 @@ Common use cases:
```json5 ```json5
{ {
routing: { agents: {
agents: { list: [
public: { {
id: "public",
workspace: "~/clawd-public", workspace: "~/clawd-public",
sandbox: { sandbox: {
mode: "all", mode: "all",
@ -239,7 +245,7 @@ Common use cases:
deny: ["read", "write", "edit", "bash", "process", "browser", "canvas", "nodes", "cron", "gateway", "image"] deny: ["read", "write", "edit", "bash", "process", "browser", "canvas", "nodes", "cron", "gateway", "image"]
} }
} }
} ]
} }
} }
``` ```

View File

@ -127,12 +127,12 @@ or state drift because only one workspace is active.
Symptoms: `pwd` or file tools show `~/.clawdbot/sandboxes/...` even though you Symptoms: `pwd` or file tools show `~/.clawdbot/sandboxes/...` even though you
expected the host workspace. expected the host workspace.
**Why:** `agent.sandbox.mode: "non-main"` keys off `session.mainKey` (default `"main"`). **Why:** `agents.defaults.sandbox.mode: "non-main"` keys off `session.mainKey` (default `"main"`).
Group/channel sessions use their own keys, so they are treated as non-main and Group/channel sessions use their own keys, so they are treated as non-main and
get sandbox workspaces. get sandbox workspaces.
**Fix options:** **Fix options:**
- If you want host workspaces for an agent: set `routing.agents.<id>.sandbox.mode: "off"`. - If you want host workspaces for an agent: set `agents.list[].sandbox.mode: "off"`.
- If you want host workspace access inside sandbox: set `workspaceAccess: "rw"` for that agent. - If you want host workspace access inside sandbox: set `workspaceAccess: "rw"` for that agent.
### "Agent was aborted" ### "Agent was aborted"
@ -157,8 +157,8 @@ Look for `AllowFrom: ...` in the output.
**Check 2:** For group chats, is mention required? **Check 2:** For group chats, is mention required?
```bash ```bash
# The message must match mentionPatterns or explicit mentions; defaults live in provider groups/guilds. # The message must match mentionPatterns or explicit mentions; defaults live in provider groups/guilds.
# Multi-agent: `routing.agents.<agentId>.mentionPatterns` overrides global patterns. # Multi-agent: `agents.list[].groupChat.mentionPatterns` overrides global patterns.
grep -n "routing\\|groupChat\\|mentionPatterns\\|whatsapp\\.groups\\|telegram\\.groups\\|imessage\\.groups\\|discord\\.guilds" \ grep -n "agents\\|groupChat\\|mentionPatterns\\|whatsapp\\.groups\\|telegram\\.groups\\|imessage\\.groups\\|discord\\.guilds" \
"${CLAWDBOT_CONFIG_PATH:-$HOME/.clawdbot/clawdbot.json}" "${CLAWDBOT_CONFIG_PATH:-$HOME/.clawdbot/clawdbot.json}"
``` ```

View File

@ -109,12 +109,12 @@ Deep dive: [Sandboxing](/gateway/sandboxing)
### What it does ### What it does
When `agent.sandbox` is enabled, **non-main sessions** run tools inside a Docker When `agents.defaults.sandbox` is enabled, **non-main sessions** run tools inside a Docker
container. The gateway stays on your host, but the tool execution is isolated: container. The gateway stays on your host, but the tool execution is isolated:
- scope: `"agent"` by default (one container + workspace per agent) - scope: `"agent"` by default (one container + workspace per agent)
- scope: `"session"` for per-session isolation - scope: `"session"` for per-session isolation
- per-scope workspace folder mounted at `/workspace` - per-scope workspace folder mounted at `/workspace`
- optional agent workspace access (`agent.sandbox.workspaceAccess`) - optional agent workspace access (`agents.defaults.sandbox.workspaceAccess`)
- allow/deny tool policy (deny wins) - allow/deny tool policy (deny wins)
- inbound media is copied into the active sandbox workspace (`media/inbound/*`) so tools can read it (with `workspaceAccess: "rw"`, this lands in the agent workspace) - inbound media is copied into the active sandbox workspace (`media/inbound/*`) so tools can read it (with `workspaceAccess: "rw"`, this lands in the agent workspace)
@ -124,7 +124,7 @@ one container and one workspace.
### Per-agent sandbox profiles (multi-agent) ### Per-agent sandbox profiles (multi-agent)
If you use multi-agent routing, each agent can override sandbox + tool settings: If you use multi-agent routing, each agent can override sandbox + tool settings:
`routing.agents[id].sandbox` and `routing.agents[id].tools`. This lets you run `agents.list[].sandbox` and `agents.list[].tools` (plus `agents.list[].tools.sandbox.tools`). This lets you run
mixed access levels in one gateway: mixed access levels in one gateway:
- Full access (personal agent) - Full access (personal agent)
- Read-only tools + read-only workspace (family/work agent) - Read-only tools + read-only workspace (family/work agent)
@ -149,54 +149,60 @@ precedence, and troubleshooting.
```json5 ```json5
{ {
agent: { agents: {
sandbox: { defaults: {
mode: "non-main", // off | non-main | all sandbox: {
scope: "agent", // session | agent | shared (agent is default) mode: "non-main", // off | non-main | all
workspaceAccess: "none", // none | ro | rw scope: "agent", // session | agent | shared (agent is default)
workspaceRoot: "~/.clawdbot/sandboxes", workspaceAccess: "none", // none | ro | rw
docker: { workspaceRoot: "~/.clawdbot/sandboxes",
image: "clawdbot-sandbox:bookworm-slim", docker: {
workdir: "/workspace", image: "clawdbot-sandbox:bookworm-slim",
readOnlyRoot: true, workdir: "/workspace",
tmpfs: ["/tmp", "/var/tmp", "/run"], readOnlyRoot: true,
network: "none", tmpfs: ["/tmp", "/var/tmp", "/run"],
user: "1000:1000", network: "none",
capDrop: ["ALL"], user: "1000:1000",
env: { LANG: "C.UTF-8" }, capDrop: ["ALL"],
setupCommand: "apt-get update && apt-get install -y git curl jq", env: { LANG: "C.UTF-8" },
pidsLimit: 256, setupCommand: "apt-get update && apt-get install -y git curl jq",
memory: "1g", pidsLimit: 256,
memorySwap: "2g", memory: "1g",
cpus: 1, memorySwap: "2g",
ulimits: { cpus: 1,
nofile: { soft: 1024, hard: 2048 }, ulimits: {
nproc: 256 nofile: { soft: 1024, hard: 2048 },
nproc: 256
},
seccompProfile: "/path/to/seccomp.json",
apparmorProfile: "clawdbot-sandbox",
dns: ["1.1.1.1", "8.8.8.8"],
extraHosts: ["internal.service:10.0.0.5"]
}, },
seccompProfile: "/path/to/seccomp.json", prune: {
apparmorProfile: "clawdbot-sandbox", idleHours: 24, // 0 disables idle pruning
dns: ["1.1.1.1", "8.8.8.8"], maxAgeDays: 7 // 0 disables max-age pruning
extraHosts: ["internal.service:10.0.0.5"] }
}, }
}
},
tools: {
sandbox: {
tools: { tools: {
allow: ["bash", "process", "read", "write", "edit", "sessions_list", "sessions_history", "sessions_send", "sessions_spawn"], allow: ["bash", "process", "read", "write", "edit", "sessions_list", "sessions_history", "sessions_send", "sessions_spawn"],
deny: ["browser", "canvas", "nodes", "cron", "discord", "gateway"] deny: ["browser", "canvas", "nodes", "cron", "discord", "gateway"]
},
prune: {
idleHours: 24, // 0 disables idle pruning
maxAgeDays: 7 // 0 disables max-age pruning
} }
} }
} }
} }
``` ```
Hardening knobs live under `agent.sandbox.docker`: Hardening knobs live under `agents.defaults.sandbox.docker`:
`network`, `user`, `pidsLimit`, `memory`, `memorySwap`, `cpus`, `ulimits`, `network`, `user`, `pidsLimit`, `memory`, `memorySwap`, `cpus`, `ulimits`,
`seccompProfile`, `apparmorProfile`, `dns`, `extraHosts`. `seccompProfile`, `apparmorProfile`, `dns`, `extraHosts`.
Multi-agent: override `agent.sandbox.{docker,browser,prune}.*` per agent via `routing.agents.<agentId>.sandbox.{docker,browser,prune}.*` Multi-agent: override `agents.defaults.sandbox.{docker,browser,prune}.*` per agent via `agents.list[].sandbox.{docker,browser,prune}.*`
(ignored when `agent.sandbox.scope` / `routing.agents.<agentId>.sandbox.scope` is `"shared"`). (ignored when `agents.defaults.sandbox.scope` / `agents.list[].sandbox.scope` is `"shared"`).
### Build the default sandbox image ### Build the default sandbox image
@ -217,7 +223,7 @@ This builds `clawdbot-sandbox-common:bookworm-slim`. To use it:
```json5 ```json5
{ {
agent: { sandbox: { docker: { image: "clawdbot-sandbox-common:bookworm-slim" } } } agents: { defaults: { sandbox: { docker: { image: "clawdbot-sandbox-common:bookworm-slim" } } } }
} }
``` ```
@ -235,16 +241,18 @@ an optional noVNC observer (headful via Xvfb).
Notes: Notes:
- Headful (Xvfb) reduces bot blocking vs headless. - Headful (Xvfb) reduces bot blocking vs headless.
- Headless can still be used by setting `agent.sandbox.browser.headless=true`. - Headless can still be used by setting `agents.defaults.sandbox.browser.headless=true`.
- No full desktop environment (GNOME) is needed; Xvfb provides the display. - No full desktop environment (GNOME) is needed; Xvfb provides the display.
Use config: Use config:
```json5 ```json5
{ {
agent: { agents: {
sandbox: { defaults: {
browser: { enabled: true } sandbox: {
browser: { enabled: true }
}
} }
} }
} }
@ -254,8 +262,10 @@ Custom browser image:
```json5 ```json5
{ {
agent: { agents: {
sandbox: { browser: { image: "my-clawdbot-browser" } } defaults: {
sandbox: { browser: { image: "my-clawdbot-browser" } }
}
} }
} }
``` ```
@ -266,7 +276,7 @@ When enabled, the agent receives:
Remember: if you use an allowlist for tools, add `browser` (and remove it from Remember: if you use an allowlist for tools, add `browser` (and remove it from
deny) or the tool remains blocked. deny) or the tool remains blocked.
Prune rules (`agent.sandbox.prune`) apply to browser containers too. Prune rules (`agents.defaults.sandbox.prune`) apply to browser containers too.
### Custom sandbox image ### Custom sandbox image
@ -278,8 +288,10 @@ docker build -t my-clawdbot-sbx -f Dockerfile.sandbox .
```json5 ```json5
{ {
agent: { agents: {
sandbox: { docker: { image: "my-clawdbot-sbx" } } defaults: {
sandbox: { docker: { image: "my-clawdbot-sbx" } }
}
} }
} }
``` ```
@ -310,7 +322,7 @@ Example:
## Troubleshooting ## Troubleshooting
- Image missing: build with [`scripts/sandbox-setup.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/sandbox-setup.sh) or set `agent.sandbox.docker.image`. - Image missing: build with [`scripts/sandbox-setup.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/sandbox-setup.sh) or set `agents.defaults.sandbox.docker.image`.
- Container not running: it will auto-create per session on demand. - Container not running: it will auto-create per session on demand.
- Permission errors in sandbox: set `docker.user` to a UID:GID that matches your - Permission errors in sandbox: set `docker.user` to a UID:GID that matches your
mounted workspace ownership (or chown the workspace folder). mounted workspace ownership (or chown the workspace folder).

View File

@ -10,8 +10,8 @@ status: active
## Overview ## Overview
Each agent in a multi-agent setup can now have its own: Each agent in a multi-agent setup can now have its own:
- **Sandbox configuration** (`mode`, `scope`, `workspaceRoot`, `workspaceAccess`, `tools`) - **Sandbox configuration** (`agents.list[].sandbox` overrides `agents.defaults.sandbox`)
- **Tool restrictions** (`allow`, `deny`) - **Tool restrictions** (`tools.allow` / `tools.deny`, plus `agents.list[].tools`)
This allows you to run multiple agents with different security profiles: This allows you to run multiple agents with different security profiles:
- Personal assistant with full access - Personal assistant with full access
@ -28,18 +28,17 @@ For how sandboxing behaves at runtime, see [Sandboxing](/gateway/sandboxing).
```json ```json
{ {
"routing": { "agents": {
"defaultAgentId": "main", "list": [
"agents": { {
"main": { "id": "main",
"default": true,
"name": "Personal Assistant", "name": "Personal Assistant",
"workspace": "~/clawd", "workspace": "~/clawd",
"sandbox": { "sandbox": { "mode": "off" }
"mode": "off"
}
// No tool restrictions - all tools available
}, },
"family": { {
"id": "family",
"name": "Family Bot", "name": "Family Bot",
"workspace": "~/clawd-family", "workspace": "~/clawd-family",
"sandbox": { "sandbox": {
@ -51,21 +50,21 @@ For how sandboxing behaves at runtime, see [Sandboxing](/gateway/sandboxing).
"deny": ["bash", "write", "edit", "process", "browser"] "deny": ["bash", "write", "edit", "process", "browser"]
} }
} }
}, ]
"bindings": [ },
{ "bindings": [
"agentId": "family", {
"match": { "agentId": "family",
"provider": "whatsapp", "match": {
"accountId": "*", "provider": "whatsapp",
"peer": { "accountId": "*",
"kind": "group", "peer": {
"id": "120363424282127706@g.us" "kind": "group",
} "id": "120363424282127706@g.us"
} }
} }
] }
} ]
} }
``` ```
@ -79,13 +78,15 @@ For how sandboxing behaves at runtime, see [Sandboxing](/gateway/sandboxing).
```json ```json
{ {
"routing": { "agents": {
"agents": { "list": [
"personal": { {
"id": "personal",
"workspace": "~/clawd-personal", "workspace": "~/clawd-personal",
"sandbox": { "mode": "off" } "sandbox": { "mode": "off" }
}, },
"work": { {
"id": "work",
"workspace": "~/clawd-work", "workspace": "~/clawd-work",
"sandbox": { "sandbox": {
"mode": "all", "mode": "all",
@ -97,7 +98,7 @@ For how sandboxing behaves at runtime, see [Sandboxing](/gateway/sandboxing).
"deny": ["browser", "gateway", "discord"] "deny": ["browser", "gateway", "discord"]
} }
} }
} ]
} }
} }
``` ```
@ -108,21 +109,23 @@ For how sandboxing behaves at runtime, see [Sandboxing](/gateway/sandboxing).
```json ```json
{ {
"agent": { "agents": {
"sandbox": { "defaults": {
"mode": "non-main", // Global default "sandbox": {
"scope": "session" "mode": "non-main", // Global default
} "scope": "session"
}, }
"routing": { },
"agents": { "list": [
"main": { {
"id": "main",
"workspace": "~/clawd", "workspace": "~/clawd",
"sandbox": { "sandbox": {
"mode": "off" // Override: main never sandboxed "mode": "off" // Override: main never sandboxed
} }
}, },
"public": { {
"id": "public",
"workspace": "~/clawd-public", "workspace": "~/clawd-public",
"sandbox": { "sandbox": {
"mode": "all", // Override: public always sandboxed "mode": "all", // Override: public always sandboxed
@ -133,7 +136,7 @@ For how sandboxing behaves at runtime, see [Sandboxing](/gateway/sandboxing).
"deny": ["bash", "write", "edit"] "deny": ["bash", "write", "edit"]
} }
} }
} ]
} }
} }
``` ```
@ -142,40 +145,40 @@ For how sandboxing behaves at runtime, see [Sandboxing](/gateway/sandboxing).
## Configuration Precedence ## Configuration Precedence
When both global (`agent.*`) and agent-specific (`routing.agents[id].*`) configs exist: When both global (`agents.defaults.*`) and agent-specific (`agents.list[].*`) configs exist:
### Sandbox Config ### Sandbox Config
Agent-specific settings override global: Agent-specific settings override global:
``` ```
routing.agents[id].sandbox.mode > agent.sandbox.mode agents.list[].sandbox.mode > agents.defaults.sandbox.mode
routing.agents[id].sandbox.scope > agent.sandbox.scope agents.list[].sandbox.scope > agents.defaults.sandbox.scope
routing.agents[id].sandbox.workspaceRoot > agent.sandbox.workspaceRoot agents.list[].sandbox.workspaceRoot > agents.defaults.sandbox.workspaceRoot
routing.agents[id].sandbox.workspaceAccess > agent.sandbox.workspaceAccess agents.list[].sandbox.workspaceAccess > agents.defaults.sandbox.workspaceAccess
routing.agents[id].sandbox.docker.* > agent.sandbox.docker.* agents.list[].sandbox.docker.* > agents.defaults.sandbox.docker.*
routing.agents[id].sandbox.browser.* > agent.sandbox.browser.* agents.list[].sandbox.browser.* > agents.defaults.sandbox.browser.*
routing.agents[id].sandbox.prune.* > agent.sandbox.prune.* agents.list[].sandbox.prune.* > agents.defaults.sandbox.prune.*
``` ```
**Notes:** **Notes:**
- `routing.agents[id].sandbox.{docker,browser,prune}.*` overrides `agent.sandbox.{docker,browser,prune}.*` for that agent (ignored when sandbox scope resolves to `"shared"`). - `agents.list[].sandbox.{docker,browser,prune}.*` overrides `agents.defaults.sandbox.{docker,browser,prune}.*` for that agent (ignored when sandbox scope resolves to `"shared"`).
### Tool Restrictions ### Tool Restrictions
The filtering order is: The filtering order is:
1. **Global tool policy** (`agent.tools`) 1. **Global tool policy** (`tools.allow` / `tools.deny`)
2. **Agent-specific tool policy** (`routing.agents[id].tools`) 2. **Agent-specific tool policy** (`agents.list[].tools`)
3. **Sandbox tool policy** (`agent.sandbox.tools` or `routing.agents[id].sandbox.tools`) 3. **Sandbox tool policy** (`tools.sandbox.tools` or `agents.list[].tools.sandbox.tools`)
4. **Subagent tool policy** (if applicable) 4. **Subagent tool policy** (`tools.subagents.tools`, if applicable)
Each level can further restrict tools, but cannot grant back denied tools from earlier levels. Each level can further restrict tools, but cannot grant back denied tools from earlier levels.
If `routing.agents[id].sandbox.tools` is set, it replaces `agent.sandbox.tools` for that agent. If `agents.list[].tools.sandbox.tools` is set, it replaces `tools.sandbox.tools` for that agent.
### Elevated Mode (global) ### Elevated Mode (global)
`agent.elevated` is **global** and **sender-based** (per-provider allowlist). It is **not** configurable per agent. `tools.elevated` is **global** and **sender-based** (per-provider allowlist). It is **not** configurable per agent.
Mitigation patterns: Mitigation patterns:
- Deny `bash` for untrusted agents (`routing.agents[id].tools.deny: ["bash"]`) - Deny `bash` for untrusted agents (`agents.list[].tools.deny: ["bash"]`)
- Avoid allowlisting senders that route to restricted agents - Avoid allowlisting senders that route to restricted agents
- Disable elevated globally (`agent.elevated.enabled: false`) if you only want sandboxed execution - Disable elevated globally (`tools.elevated.enabled: false`) if you only want sandboxed execution
--- ---
@ -184,10 +187,16 @@ Mitigation patterns:
**Before (single agent):** **Before (single agent):**
```json ```json
{ {
"agent": { "agents": {
"workspace": "~/clawd", "defaults": {
"workspace": "~/clawd",
"sandbox": {
"mode": "non-main"
}
}
},
"tools": {
"sandbox": { "sandbox": {
"mode": "non-main",
"tools": { "tools": {
"allow": ["read", "write", "bash"], "allow": ["read", "write", "bash"],
"deny": [] "deny": []
@ -200,21 +209,20 @@ Mitigation patterns:
**After (multi-agent with different profiles):** **After (multi-agent with different profiles):**
```json ```json
{ {
"routing": { "agents": {
"defaultAgentId": "main", "list": [
"agents": { {
"main": { "id": "main",
"default": true,
"workspace": "~/clawd", "workspace": "~/clawd",
"sandbox": { "sandbox": { "mode": "off" }
"mode": "off"
}
} }
} ]
} }
} }
``` ```
The global `agent.workspace` and `agent.sandbox` are still supported for backward compatibility, but we recommend using `routing.agents` for clarity in multi-agent setups. Legacy `agent.*` configs are migrated by `clawdbot doctor`; prefer `agents.defaults` + `agents.list` going forward.
--- ---
@ -254,10 +262,10 @@ The global `agent.workspace` and `agent.sandbox` are still supported for backwar
## Common Pitfall: "non-main" ## Common Pitfall: "non-main"
`sandbox.mode: "non-main"` is based on `session.mainKey` (default `"main"`), `agents.defaults.sandbox.mode: "non-main"` is based on `session.mainKey` (default `"main"`),
not the agent id. Group/channel sessions always get their own keys, so they not the agent id. Group/channel sessions always get their own keys, so they
are treated as non-main and will be sandboxed. If you want an agent to never are treated as non-main and will be sandboxed. If you want an agent to never
sandbox, set `routing.agents.<id>.sandbox.mode: "off"`. sandbox, set `agents.list[].sandbox.mode: "off"`.
--- ---
@ -289,8 +297,8 @@ After configuring multi-agent sandbox and tools:
## Troubleshooting ## Troubleshooting
### Agent not sandboxed despite `mode: "all"` ### Agent not sandboxed despite `mode: "all"`
- Check if there's a global `agent.sandbox.mode` that overrides it - Check if there's a global `agents.defaults.sandbox.mode` that overrides it
- Agent-specific config takes precedence, so set `routing.agents[id].sandbox.mode: "all"` - Agent-specific config takes precedence, so set `agents.list[].sandbox.mode: "all"`
### Tools still available despite deny list ### Tools still available despite deny list
- Check tool filtering order: global → agent → sandbox → subagent - Check tool filtering order: global → agent → sandbox → subagent
@ -306,5 +314,5 @@ After configuring multi-agent sandbox and tools:
## See Also ## See Also
- [Multi-Agent Routing](/concepts/multi-agent) - [Multi-Agent Routing](/concepts/multi-agent)
- [Sandbox Configuration](/gateway/configuration#agent-sandbox) - [Sandbox Configuration](/gateway/configuration#agentsdefaults-sandbox)
- [Session Management](/concepts/session) - [Session Management](/concepts/session)

View File

@ -6,7 +6,7 @@ read_when:
# Audio / Voice Notes — 2025-12-05 # Audio / Voice Notes — 2025-12-05
## What works ## What works
- **Optional transcription**: If `routing.transcribeAudio.command` is set in `~/.clawdbot/clawdbot.json`, CLAWDBOT will: - **Optional transcription**: If `audio.transcription.command` is set in `~/.clawdbot/clawdbot.json`, CLAWDBOT will:
1) Download inbound audio to a temp path when WhatsApp only provides a URL. 1) Download inbound audio to a temp path when WhatsApp only provides a URL.
2) Run the configured CLI (templated with `{{MediaPath}}`), expecting transcript on stdout. 2) Run the configured CLI (templated with `{{MediaPath}}`), expecting transcript on stdout.
3) Replace `Body` with the transcript, set `{{Transcript}}`, and prepend the original media path plus a `Transcript:` section in the command prompt so models see both. 3) Replace `Body` with the transcript, set `{{Transcript}}`, and prepend the original media path plus a `Transcript:` section in the command prompt so models see both.
@ -17,8 +17,8 @@ read_when:
Requires `OPENAI_API_KEY` in env and `openai` CLI installed: Requires `OPENAI_API_KEY` in env and `openai` CLI installed:
```json5 ```json5
{ {
routing: { audio: {
transcribeAudio: { transcription: {
command: [ command: [
"openai", "openai",
"api", "api",

View File

@ -20,7 +20,7 @@ CLAWDBOT is now **web-only** (Baileys). This document captures the current media
## Web Provider Behavior ## Web Provider Behavior
- Input: local file path **or** HTTP(S) URL. - Input: local file path **or** HTTP(S) URL.
- Flow: load into a Buffer, detect media kind, and build the correct payload: - Flow: load into a Buffer, detect media kind, and build the correct payload:
- **Images:** resize & recompress to JPEG (max side 2048px) targeting `agent.mediaMaxMb` (default 5MB), capped at 6MB. - **Images:** resize & recompress to JPEG (max side 2048px) targeting `agents.defaults.mediaMaxMb` (default 5MB), capped at 6MB.
- **Audio/Voice/Video:** pass-through up to 16MB; audio is sent as a voice note (`ptt: true`). - **Audio/Voice/Video:** pass-through up to 16MB; audio is sent as a voice note (`ptt: true`).
- **Documents:** anything else, up to 100MB, with filename preserved when available. - **Documents:** anything else, up to 100MB, with filename preserved when available.
- WhatsApp GIF-style playback: send an MP4 with `gifPlayback: true` (CLI: `--gif-playback`) so mobile clients loop inline. - WhatsApp GIF-style playback: send an MP4 with `gifPlayback: true` (CLI: `--gif-playback`) so mobile clients loop inline.

View File

@ -136,8 +136,8 @@ Example “single server, only allow me, only allow #help”:
Notes: Notes:
- `requireMention: true` means the bot only replies when mentioned (recommended for shared channels). - `requireMention: true` means the bot only replies when mentioned (recommended for shared channels).
- `routing.groupChat.mentionPatterns` also count as mentions for guild messages. - `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`) also count as mentions for guild messages.
- Multi-agent override: `routing.agents.<agentId>.mentionPatterns` takes precedence. - Multi-agent override: set per-agent patterns on `agents.list[].groupChat.mentionPatterns`.
- If `channels` is present, any channel not listed is denied by default. - If `channels` is present, any channel not listed is denied by default.
### 6) Verify it works ### 6) Verify it works

View File

@ -66,8 +66,8 @@ DMs:
Groups: Groups:
- `imessage.groupPolicy = open | allowlist | disabled`. - `imessage.groupPolicy = open | allowlist | disabled`.
- `imessage.groupAllowFrom` controls who can trigger in groups when `allowlist` is set. - `imessage.groupAllowFrom` controls who can trigger in groups when `allowlist` is set.
- Mention gating uses `routing.groupChat.mentionPatterns` (iMessage has no native mention metadata). - Mention gating uses `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`) because iMessage has no native mention metadata.
- Multi-agent override: `routing.agents.<agentId>.mentionPatterns` takes precedence. - Multi-agent override: set per-agent patterns on `agents.list[].groupChat.mentionPatterns`.
## How it works (behavior) ## How it works (behavior)
- `imsg` streams message events; the gateway normalizes them into the shared provider envelope. - `imsg` streams message events; the gateway normalizes them into the shared provider envelope.
@ -112,5 +112,5 @@ Provider options:
- `imessage.textChunkLimit`: outbound chunk size (chars). - `imessage.textChunkLimit`: outbound chunk size (chars).
Related global options: Related global options:
- `routing.groupChat.mentionPatterns`. - `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`).
- `messages.responsePrefix`. - `messages.responsePrefix`.

View File

@ -92,6 +92,6 @@ Provider options:
- `signal.mediaMaxMb`: inbound/outbound media cap (MB). - `signal.mediaMaxMb`: inbound/outbound media cap (MB).
Related global options: Related global options:
- `routing.groupChat.mentionPatterns` (Signal does not support native mentions). - `agents.list[].groupChat.mentionPatterns` (Signal does not support native mentions).
- Multi-agent override: `routing.agents.<agentId>.mentionPatterns` takes precedence. - `messages.groupChat.mentionPatterns` (global fallback).
- `messages.responsePrefix`. - `messages.responsePrefix`.

View File

@ -248,8 +248,8 @@ Slack tool actions can be gated with `slack.actions.*`:
| emojiList | enabled | Custom emoji list | | emojiList | enabled | Custom emoji list |
## Notes ## Notes
- Mention gating is controlled via `slack.channels` (set `requireMention` to `true`); `routing.groupChat.mentionPatterns` also count as mentions. - Mention gating is controlled via `slack.channels` (set `requireMention` to `true`); `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`) also count as mentions.
- Multi-agent override: `routing.agents.<agentId>.mentionPatterns` takes precedence. - Multi-agent override: set per-agent patterns on `agents.list[].groupChat.mentionPatterns`.
- Reaction notifications follow `slack.reactionNotifications` (use `reactionAllowlist` with mode `allowlist`). - Reaction notifications follow `slack.reactionNotifications` (use `reactionAllowlist` with mode `allowlist`).
- Bot-authored messages are ignored by default; enable via `slack.allowBots` or `slack.channels.<id>.allowBots`. - Bot-authored messages are ignored by default; enable via `slack.allowBots` or `slack.channels.<id>.allowBots`.
- For the Slack tool, reaction removal semantics are in [/tools/reactions](/tools/reactions). - For the Slack tool, reaction removal semantics are in [/tools/reactions](/tools/reactions).

View File

@ -64,10 +64,10 @@ group messages, so use admin if you need full visibility.
## How it works (behavior) ## How it works (behavior)
- Inbound messages are normalized into the shared provider envelope with reply context and media placeholders. - Inbound messages are normalized into the shared provider envelope with reply context and media placeholders.
- Group replies require a mention by default (native @mention or `routing.groupChat.mentionPatterns`). - Group replies require a mention by default (native @mention or `agents.list[].groupChat.mentionPatterns` / `messages.groupChat.mentionPatterns`).
- Multi-agent override: `routing.agents.<agentId>.mentionPatterns` takes precedence. - Multi-agent override: set per-agent patterns on `agents.list[].groupChat.mentionPatterns`.
- Replies always route back to the same Telegram chat. - Replies always route back to the same Telegram chat.
- Long-polling uses grammY runner with per-chat sequencing; overall concurrency is capped by `agent.maxConcurrent`. - Long-polling uses grammY runner with per-chat sequencing; overall concurrency is capped by `agents.defaults.maxConcurrent`.
## Formatting (Telegram HTML) ## Formatting (Telegram HTML)
- Outbound Telegram text uses `parse_mode: "HTML"` (Telegrams supported tag subset). - Outbound Telegram text uses `parse_mode: "HTML"` (Telegrams supported tag subset).
@ -81,7 +81,7 @@ group messages, so use admin if you need full visibility.
## Group activation modes ## Group activation modes
By default, the bot only responds to mentions in groups (`@botname` or patterns in `routing.groupChat.mentionPatterns`). To change this behavior: By default, the bot only responds to mentions in groups (`@botname` or patterns in `agents.list[].groupChat.mentionPatterns`). To change this behavior:
### Via config (recommended) ### Via config (recommended)
@ -280,7 +280,7 @@ Provider options:
- `telegram.actions.sendMessage`: gate Telegram tool message sends. - `telegram.actions.sendMessage`: gate Telegram tool message sends.
Related global options: Related global options:
- `routing.groupChat.mentionPatterns` (mention gating patterns). - `agents.list[].groupChat.mentionPatterns` (mention gating patterns).
- `routing.agents.<agentId>.mentionPatterns` overrides for multi-agent setups. - `messages.groupChat.mentionPatterns` (global fallback).
- `commands.native`, `commands.text`, `commands.useAccessGroups` (command behavior). - `commands.native`, `commands.text`, `commands.useAccessGroups` (command behavior).
- `messages.responsePrefix`, `messages.ackReaction`, `messages.ackReactionScope`. - `messages.responsePrefix`, `messages.ackReaction`, `messages.ackReactionScope`.

View File

@ -148,7 +148,7 @@ Behavior:
## Limits ## Limits
- Outbound text is chunked to `whatsapp.textChunkLimit` (default 4000). - Outbound text is chunked to `whatsapp.textChunkLimit` (default 4000).
- Media items are capped by `agent.mediaMaxMb` (default 5 MB). - Media items are capped by `agents.defaults.mediaMaxMb` (default 5 MB).
## Outbound send (text + media) ## Outbound send (text + media)
- Uses active web listener; error if gateway not running. - Uses active web listener; error if gateway not running.
@ -164,13 +164,13 @@ Behavior:
## Media limits + optimization ## Media limits + optimization
- Default cap: 5 MB (per media item). - Default cap: 5 MB (per media item).
- Override: `agent.mediaMaxMb`. - Override: `agents.defaults.mediaMaxMb`.
- Images are auto-optimized to JPEG under cap (resize + quality sweep). - Images are auto-optimized to JPEG under cap (resize + quality sweep).
- Oversize media => error; media reply falls back to text warning. - Oversize media => error; media reply falls back to text warning.
## Heartbeats ## Heartbeats
- **Gateway heartbeat** logs connection health (`web.heartbeatSeconds`, default 60s). - **Gateway heartbeat** logs connection health (`web.heartbeatSeconds`, default 60s).
- **Agent heartbeat** is global (`agent.heartbeat.*`) and runs in the main session. - **Agent heartbeat** is global (`agents.defaults.heartbeat.*`) and runs in the main session.
- Uses the configured heartbeat prompt (default: `Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`) + `HEARTBEAT_OK` skip behavior. - Uses the configured heartbeat prompt (default: `Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`) + `HEARTBEAT_OK` skip behavior.
- Delivery defaults to the last used provider (or configured target). - Delivery defaults to the last used provider (or configured target).
@ -189,16 +189,15 @@ Behavior:
- `whatsapp.groupPolicy` (group policy). - `whatsapp.groupPolicy` (group policy).
- `whatsapp.groups` (group allowlist + mention gating defaults; use `"*"` to allow all) - `whatsapp.groups` (group allowlist + mention gating defaults; use `"*"` to allow all)
- `whatsapp.actions.reactions` (gate WhatsApp tool reactions). - `whatsapp.actions.reactions` (gate WhatsApp tool reactions).
- `routing.groupChat.mentionPatterns` - `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`)
- Multi-agent override: `routing.agents.<agentId>.mentionPatterns` takes precedence. - `messages.groupChat.historyLimit`
- `routing.groupChat.historyLimit`
- `messages.messagePrefix` (inbound prefix) - `messages.messagePrefix` (inbound prefix)
- `messages.responsePrefix` (outbound prefix) - `messages.responsePrefix` (outbound prefix)
- `agent.mediaMaxMb` - `agents.defaults.mediaMaxMb`
- `agent.heartbeat.every` - `agents.defaults.heartbeat.every`
- `agent.heartbeat.model` (optional override) - `agents.defaults.heartbeat.model` (optional override)
- `agent.heartbeat.target` - `agents.defaults.heartbeat.target`
- `agent.heartbeat.to` - `agents.defaults.heartbeat.to`
- `session.*` (scope, idle, store, mainKey) - `session.*` (scope, idle, store, mainKey)
- `web.enabled` (disable provider startup when false) - `web.enabled` (disable provider startup when false)
- `web.heartbeatSeconds` - `web.heartbeatSeconds`

View File

@ -8,7 +8,7 @@ read_when:
## First run (recommended) ## First run (recommended)
Clawdbot uses a dedicated workspace directory for the agent. Default: `~/clawd` (configurable via `agent.workspace`). Clawdbot uses a dedicated workspace directory for the agent. Default: `~/clawd` (configurable via `agents.defaults.workspace`).
1) Create the workspace (if it doesnt already exist): 1) Create the workspace (if it doesnt already exist):
@ -30,13 +30,11 @@ cp docs/reference/templates/TOOLS.md ~/clawd/TOOLS.md
cp docs/reference/AGENTS.default.md ~/clawd/AGENTS.md cp docs/reference/AGENTS.default.md ~/clawd/AGENTS.md
``` ```
4) Optional: choose a different workspace by setting `agent.workspace` (supports `~`): 4) Optional: choose a different workspace by setting `agents.defaults.workspace` (supports `~`):
```json5 ```json5
{ {
agent: { agents: { defaults: { workspace: "~/clawd" } }
workspace: "~/clawd"
}
} }
``` ```

View File

@ -18,7 +18,7 @@ Youre putting an agent in a position to:
Start conservative: Start conservative:
- Always set `whatsapp.allowFrom` (never run open-to-the-world on your personal Mac). - Always set `whatsapp.allowFrom` (never run open-to-the-world on your personal Mac).
- Use a dedicated WhatsApp number for the assistant. - Use a dedicated WhatsApp number for the assistant.
- Heartbeats now default to every 30 minutes. Disable until you trust the setup by setting `agent.heartbeat.every: "0m"`. - Heartbeats now default to every 30 minutes. Disable until you trust the setup by setting `agents.defaults.heartbeat.every: "0m"`.
## Prerequisites ## Prerequisites
@ -103,7 +103,7 @@ clawdbot setup
Full workspace layout + backup guide: [`docs/agent-workspace.md`](/concepts/agent-workspace) Full workspace layout + backup guide: [`docs/agent-workspace.md`](/concepts/agent-workspace)
Optional: choose a different workspace with `agent.workspace` (supports `~`). Optional: choose a different workspace with `agents.defaults.workspace` (supports `~`).
```json5 ```json5
{ {
@ -173,9 +173,9 @@ Example:
By default, CLAWDBOT runs a heartbeat every 30 minutes with the prompt: By default, CLAWDBOT runs a heartbeat every 30 minutes with the prompt:
`Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.` `Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`
Set `agent.heartbeat.every: "0m"` to disable. Set `agents.defaults.heartbeat.every: "0m"` to disable.
- If the agent replies with `HEARTBEAT_OK` (optionally with short padding; see `agent.heartbeat.ackMaxChars`), CLAWDBOT suppresses outbound delivery for that heartbeat. - If the agent replies with `HEARTBEAT_OK` (optionally with short padding; see `agents.defaults.heartbeat.ackMaxChars`), CLAWDBOT suppresses outbound delivery for that heartbeat.
- Heartbeats run full agent turns — shorter intervals burn more tokens. - Heartbeats run full agent turns — shorter intervals burn more tokens.
```json5 ```json5

View File

@ -115,14 +115,14 @@ Everything lives under `$CLAWDBOT_STATE_DIR` (default: `~/.clawdbot`):
Legacy singleagent path: `~/.clawdbot/agent/*` (migrated by `clawdbot doctor`). Legacy singleagent path: `~/.clawdbot/agent/*` (migrated by `clawdbot doctor`).
Your **workspace** (AGENTS.md, memory files, skills, etc.) is separate and configured via `agent.workspace` (default: `~/clawd`). Your **workspace** (AGENTS.md, memory files, skills, etc.) is separate and configured via `agents.defaults.workspace` (default: `~/clawd`).
### Can agents work outside the workspace? ### Can agents work outside the workspace?
Yes. The workspace is the **default cwd** and memory anchor, not a hard sandbox. Yes. The workspace is the **default cwd** and memory anchor, not a hard sandbox.
Relative paths resolve inside the workspace, but absolute paths can access other Relative paths resolve inside the workspace, but absolute paths can access other
host locations unless sandboxing is enabled. If you need isolation, use host locations unless sandboxing is enabled. If you need isolation, use
[`agent.sandbox`](/gateway/sandboxing) or peragent sandbox settings. If you [`agents.defaults.sandbox`](/gateway/sandboxing) or peragent sandbox settings. If you
want a repo to be the default working directory, point that agents want a repo to be the default working directory, point that agents
`workspace` to the repo root. The Clawdbot repo is just source code; keep the `workspace` to the repo root. The Clawdbot repo is just source code; keep the
workspace separate unless you intentionally want the agent to work inside it. workspace separate unless you intentionally want the agent to work inside it.
@ -259,7 +259,7 @@ Direct chats collapse to the main session by default. Groups/channels have their
Clawdbots default model is whatever you set as: Clawdbots default model is whatever you set as:
``` ```
agent.model.primary agents.defaults.model.primary
``` ```
Models are referenced as `provider/model` (example: `anthropic/claude-opus-4-5`). If you omit the provider, Clawdbot currently assumes `anthropic` as a temporary deprecation fallback — but you should still **explicitly** set `provider/model`. Models are referenced as `provider/model` (example: `anthropic/claude-opus-4-5`). If you omit the provider, Clawdbot currently assumes `anthropic` as a temporary deprecation fallback — but you should still **explicitly** set `provider/model`.
@ -282,7 +282,7 @@ You can list available models with `/model`, `/model list`, or `/model status`.
### Why do I see “Model … is not allowed” and then no reply? ### Why do I see “Model … is not allowed” and then no reply?
If `agent.models` is set, it becomes the **allowlist** for `/model` and any If `agents.defaults.models` is set, it becomes the **allowlist** for `/model` and any
session overrides. Choosing a model that isnt in that list returns: session overrides. Choosing a model that isnt in that list returns:
``` ```
@ -290,11 +290,11 @@ Model "provider/model" is not allowed. Use /model to list available models.
``` ```
That error is returned **instead of** a normal reply. Fix: add the model to That error is returned **instead of** a normal reply. Fix: add the model to
`agent.models`, remove the allowlist, or pick a model from `/model list`. `agents.defaults.models`, remove the allowlist, or pick a model from `/model list`.
### Are opus / sonnet / gpt builtin shortcuts? ### Are opus / sonnet / gpt builtin shortcuts?
Yes. Clawdbot ships a few default shorthands (only applied when the model exists in `agent.models`): Yes. Clawdbot ships a few default shorthands (only applied when the model exists in `agents.defaults.models`):
- `opus``anthropic/claude-opus-4-5` - `opus``anthropic/claude-opus-4-5`
- `sonnet``anthropic/claude-sonnet-4-5` - `sonnet``anthropic/claude-sonnet-4-5`
@ -307,7 +307,7 @@ If you set your own alias with the same name, your value wins.
### How do I define/override model shortcuts (aliases)? ### How do I define/override model shortcuts (aliases)?
Aliases come from `agent.models.<modelId>.alias`. Example: Aliases come from `agents.defaults.models.<modelId>.alias`. Example:
```json5 ```json5
{ {
@ -359,7 +359,7 @@ If you reference a provider/model but the required provider key is missing, you
Failover happens in two stages: Failover happens in two stages:
1) **Auth profile rotation** within the same provider. 1) **Auth profile rotation** within the same provider.
2) **Model fallback** to the next model in `agent.model.fallbacks`. 2) **Model fallback** to the next model in `agents.defaults.model.fallbacks`.
Cooldowns apply to failing profiles (exponential backoff), so Clawdbot can keep responding even when a provider is ratelimited or temporarily failing. Cooldowns apply to failing profiles (exponential backoff), so Clawdbot can keep responding even when a provider is ratelimited or temporarily failing.
@ -387,7 +387,7 @@ It means the system attempted to use the auth profile ID `anthropic:default`, bu
If your model config includes Google Gemini as a fallback (or you switched to a Gemini shorthand), Clawdbot will try it during model fallback. If you havent configured Google credentials, youll see `No API key found for provider "google"`. If your model config includes Google Gemini as a fallback (or you switched to a Gemini shorthand), Clawdbot will try it during model fallback. If you havent configured Google credentials, youll see `No API key found for provider "google"`.
Fix: either provide Google auth, or remove/avoid Google models in `agent.model.fallbacks` / aliases so fallback doesnt route there. Fix: either provide Google auth, or remove/avoid Google models in `agents.defaults.model.fallbacks` / aliases so fallback doesnt route there.
## Auth profiles: what they are and how to manage them ## Auth profiles: what they are and how to manage them
@ -506,7 +506,7 @@ Yes, but you must isolate:
- `CLAWDBOT_CONFIG_PATH` (perinstance config) - `CLAWDBOT_CONFIG_PATH` (perinstance config)
- `CLAWDBOT_STATE_DIR` (perinstance state) - `CLAWDBOT_STATE_DIR` (perinstance state)
- `agent.workspace` (workspace isolation) - `agents.defaults.workspace` (workspace isolation)
- `gateway.port` (unique ports) - `gateway.port` (unique ports)
There are convenience CLI flags like `--dev` and `--profile <name>` that shift state dirs and ports. There are convenience CLI flags like `--dev` and `--profile <name>` that shift state dirs and ports.
@ -619,7 +619,7 @@ You can add options like `debounce:2s cap:25 drop:summarize` for followup modes.
### “All models failed” — what should I check first? ### “All models failed” — what should I check first?
- **Credentials** present for the provider(s) being tried (auth profiles + env vars). - **Credentials** present for the provider(s) being tried (auth profiles + env vars).
- **Model routing**: confirm `agent.model.primary` and fallbacks are models you can access. - **Model routing**: confirm `agents.defaults.model.primary` and fallbacks are models you can access.
- **Gateway logs** in `/tmp/clawdbot/…` for the exact provider error. - **Gateway logs** in `/tmp/clawdbot/…` for the exact provider error.
- **`/model status`** to see current configured models + shorthands. - **`/model status`** to see current configured models + shorthands.
@ -658,7 +658,7 @@ clawdbot providers login
**Q: “Whats the default model for Anthropic with an API key?”** **Q: “Whats the default model for Anthropic with an API key?”**
**A:** In Clawdbot, credentials and model selection are separate. Setting `ANTHROPIC_API_KEY` (or storing an Anthropic API key in auth profiles) enables authentication, but the actual default model is whatever you configure in `agent.model.primary` (for example, `anthropic/claude-sonnet-4-5` or `anthropic/claude-opus-4-5`). If you see `No credentials found for profile "anthropic:default"`, it means the Gateway couldnt find Anthropic credentials in the expected `auth-profiles.json` for the agent thats running. **A:** In Clawdbot, credentials and model selection are separate. Setting `ANTHROPIC_API_KEY` (or storing an Anthropic API key in auth profiles) enables authentication, but the actual default model is whatever you configure in `agents.defaults.model.primary` (for example, `anthropic/claude-sonnet-4-5` or `anthropic/claude-opus-4-5`). If you see `No credentials found for profile "anthropic:default"`, it means the Gateway couldnt find Anthropic credentials in the expected `auth-profiles.json` for the agent thats running.
--- ---

View File

@ -19,7 +19,7 @@ Recommended path: use the **CLI onboarding wizard** (`clawdbot onboard`). It set
If you want the deeper reference pages, jump to: [Wizard](/start/wizard), [Setup](/start/setup), [Pairing](/start/pairing), [Security](/gateway/security). If you want the deeper reference pages, jump to: [Wizard](/start/wizard), [Setup](/start/setup), [Pairing](/start/pairing), [Security](/gateway/security).
Sandboxing note: `agent.sandbox.mode: "non-main"` uses `session.mainKey` (default `"main"`), Sandboxing note: `agents.defaults.sandbox.mode: "non-main"` uses `session.mainKey` (default `"main"`),
so group/channel sessions are sandboxed. If you want the main agent to always so group/channel sessions are sandboxed. If you want the main agent to always
run on host, set an explicit per-agent override: run on host, set an explicit per-agent override:

View File

@ -71,12 +71,12 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` (
2) **Model/Auth** 2) **Model/Auth**
- **Anthropic OAuth (Claude CLI)**: on macOS the wizard checks Keychain item "Claude Code-credentials" (choose "Always Allow" so launchd starts don't block); on Linux/Windows it reuses `~/.claude/.credentials.json` if present. - **Anthropic OAuth (Claude CLI)**: on macOS the wizard checks Keychain item "Claude Code-credentials" (choose "Always Allow" so launchd starts don't block); on Linux/Windows it reuses `~/.claude/.credentials.json` if present.
- **Anthropic token (paste setup-token)**: run `claude setup-token` in your terminal, then paste the token (you can name it; blank = default). - **Anthropic token (paste setup-token)**: run `claude setup-token` in your terminal, then paste the token (you can name it; blank = default).
- **OpenAI Codex OAuth (Codex CLI)**: if `~/.codex/auth.json` exists, the wizard can reuse it. - **OpenAI Codex OAuth (Codex CLI)**: if `~/.codex/auth.json` exists, the wizard can reuse it.
- **OpenAI Codex OAuth**: browser flow; paste the `code#state`. - **OpenAI Codex OAuth**: browser flow; paste the `code#state`.
- Sets `agent.model` to `openai-codex/gpt-5.2` when model is unset or `openai/*`. - Sets `agents.defaults.model` to `openai-codex/gpt-5.2` when model is unset or `openai/*`.
- **OpenAI API key**: uses `OPENAI_API_KEY` if present or prompts for a key, then saves it to `~/.clawdbot/.env` so launchd can read it. - **OpenAI API key**: uses `OPENAI_API_KEY` if present or prompts for a key, then saves it to `~/.clawdbot/.env` so launchd can read it.
- **API key**: stores the key for you. - **API key**: stores the key for you.
- **Minimax M2.1 (LM Studio)**: config is autowritten for the LM Studio endpoint. - **Minimax M2.1 (LM Studio)**: config is autowritten for the LM Studio endpoint.
- **Skip**: no auth configured yet. - **Skip**: no auth configured yet.
- Wizard runs a model check and warns if the configured model is unknown or missing auth. - Wizard runs a model check and warns if the configured model is unknown or missing auth.
@ -144,14 +144,14 @@ Use `clawdbot agents add <name>` to create a separate agent with its own workspa
sessions, and auth profiles. Running without `--workspace` launches the wizard. sessions, and auth profiles. Running without `--workspace` launches the wizard.
What it sets: What it sets:
- `routing.agents.<agentId>.name` - `agents.list[].name`
- `routing.agents.<agentId>.workspace` - `agents.list[].workspace`
- `routing.agents.<agentId>.agentDir` - `agents.list[].agentDir`
Notes: Notes:
- Default workspaces follow `~/clawd-<agentId>`. - Default workspaces follow `~/clawd-<agentId>`.
- Add `routing.bindings` to route inbound messages (the wizard can do this). - Add `bindings` to route inbound messages (the wizard can do this).
- Non-interactive flags: `--model`, `--agent-dir`, `--bind`, `--non-interactive`. - Non-interactive flags: `--model`, `--agent-dir`, `--bind`, `--non-interactive`.
## Noninteractive mode ## Noninteractive mode
@ -213,8 +213,8 @@ Notes:
## What the wizard writes ## What the wizard writes
Typical fields in `~/.clawdbot/clawdbot.json`: Typical fields in `~/.clawdbot/clawdbot.json`:
- `agent.workspace` - `agents.defaults.workspace`
- `agent.model` / `models.providers` (if Minimax chosen) - `agents.defaults.model` / `models.providers` (if Minimax chosen)
- `gateway.*` (mode, bind, auth, tailscale) - `gateway.*` (mode, bind, auth, tailscale)
- `telegram.botToken`, `discord.token`, `signal.*`, `imessage.*` - `telegram.botToken`, `discord.token`, `signal.*`, `imessage.*`
- `skills.install.nodeManager` - `skills.install.nodeManager`
@ -224,7 +224,7 @@ Typical fields in `~/.clawdbot/clawdbot.json`:
- `wizard.lastRunCommand` - `wizard.lastRunCommand`
- `wizard.lastRunMode` - `wizard.lastRunMode`
`clawdbot agents add` writes `routing.agents.<agentId>` and optional `routing.bindings`. `clawdbot agents add` writes `agents.list[]` and optional `bindings`.
WhatsApp credentials go under `~/.clawdbot/credentials/whatsapp/<accountId>/`. WhatsApp credentials go under `~/.clawdbot/credentials/whatsapp/<accountId>/`.
Sessions are stored under `~/.clawdbot/agents/<agentId>/sessions/`. Sessions are stored under `~/.clawdbot/agents/<agentId>/sessions/`.

View File

@ -12,7 +12,7 @@ read_when:
- Only `on|off` are accepted; anything else returns a hint and does not change state. - Only `on|off` are accepted; anything else returns a hint and does not change state.
## What it controls (and what it doesnt) ## What it controls (and what it doesnt)
- **Global availability gate**: `agent.elevated` is global (not per-agent). If disabled or sender not allowlisted, elevated is unavailable everywhere. - **Global availability gate**: `tools.elevated` is global (not per-agent). If disabled or sender not allowlisted, elevated is unavailable everywhere.
- **Per-session state**: `/elevated on|off` sets the elevated level for the current session key. - **Per-session state**: `/elevated on|off` sets the elevated level for the current session key.
- **Inline directive**: `/elevated on` inside a message applies to that message only. - **Inline directive**: `/elevated on` inside a message applies to that message only.
- **Groups**: In group chats, elevated directives are only honored when the agent is mentioned. Command-only messages that bypass mention requirements are treated as mentioned. - **Groups**: In group chats, elevated directives are only honored when the agent is mentioned. Command-only messages that bypass mention requirements are treated as mentioned.
@ -31,7 +31,7 @@ Note:
## Resolution order ## Resolution order
1. Inline directive on the message (applies only to that message). 1. Inline directive on the message (applies only to that message).
2. Session override (set by sending a directive-only message). 2. Session override (set by sending a directive-only message).
3. Global default (`agent.elevatedDefault` in config). 3. Global default (`agents.defaults.elevatedDefault` in config).
## Setting a session default ## Setting a session default
- Send a message that is **only** the directive (whitespace allowed), e.g. `/elevated on`. - Send a message that is **only** the directive (whitespace allowed), e.g. `/elevated on`.
@ -40,10 +40,10 @@ Note:
- Send `/elevated` (or `/elevated:`) with no argument to see the current elevated level. - Send `/elevated` (or `/elevated:`) with no argument to see the current elevated level.
## Availability + allowlists ## Availability + allowlists
- Feature gate: `agent.elevated.enabled` (default can be off via config even if the code supports it). - Feature gate: `tools.elevated.enabled` (default can be off via config even if the code supports it).
- Sender allowlist: `agent.elevated.allowFrom` with per-provider allowlists (e.g. `discord`, `whatsapp`). - Sender allowlist: `tools.elevated.allowFrom` with per-provider allowlists (e.g. `discord`, `whatsapp`).
- Both must pass; otherwise elevated is treated as unavailable. - Both must pass; otherwise elevated is treated as unavailable.
- Discord fallback: if `agent.elevated.allowFrom.discord` is omitted, the `discord.dm.allowFrom` list is used as a fallback. Set `agent.elevated.allowFrom.discord` (even `[]`) to override. - Discord fallback: if `tools.elevated.allowFrom.discord` is omitted, the `discord.dm.allowFrom` list is used as a fallback. Set `tools.elevated.allowFrom.discord` (even `[]`) to override.
## Logging + status ## Logging + status
- Elevated bash calls are logged at info level. - Elevated bash calls are logged at info level.

View File

@ -13,16 +13,12 @@ and the agent should rely on them directly.
## Disabling tools ## Disabling tools
You can globally allow/deny tools via `agent.tools` in `clawdbot.json` You can globally allow/deny tools via `tools.allow` / `tools.deny` in `clawdbot.json`
(deny wins). This prevents disallowed tools from being sent to providers. (deny wins). This prevents disallowed tools from being sent to providers.
```json5 ```json5
{ {
agent: { tools: { deny: ["browser"] }
tools: {
deny: ["browser"]
}
}
} }
``` ```
@ -43,7 +39,7 @@ Notes:
- Returns `status: "running"` with a `sessionId` when backgrounded. - Returns `status: "running"` with a `sessionId` when backgrounded.
- Use `process` to poll/log/write/kill/clear background sessions. - Use `process` to poll/log/write/kill/clear background sessions.
- If `process` is disallowed, `bash` runs synchronously and ignores `yieldMs`/`background`. - If `process` is disallowed, `bash` runs synchronously and ignores `yieldMs`/`background`.
- `elevated` is gated by `agent.elevated` (global sender allowlist) and runs on the host. - `elevated` is gated by `tools.elevated` (global sender allowlist) and runs on the host.
- `elevated` only changes behavior when the agent is sandboxed (otherwise its a no-op). - `elevated` only changes behavior when the agent is sandboxed (otherwise its a no-op).
### `process` ### `process`
@ -145,7 +141,7 @@ Core parameters:
- `maxBytesMb` (optional size cap) - `maxBytesMb` (optional size cap)
Notes: Notes:
- Only available when `agent.imageModel` is configured (primary or fallbacks). - Only available when `agents.defaults.imageModel` is configured (primary or fallbacks).
- Uses the image model directly (independent of the main chat model). - Uses the image model directly (independent of the main chat model).
### `message` ### `message`
@ -219,7 +215,7 @@ Notes:
List agent ids that the current session may target with `sessions_spawn`. List agent ids that the current session may target with `sessions_spawn`.
Notes: Notes:
- Result is restricted to per-agent allowlists (`routing.agents.<agentId>.subagents.allowAgents`). - Result is restricted to per-agent allowlists (`agents.list[].subagents.allowAgents`).
- When `["*"]` is configured, the tool includes all configured agents and marks `allowAny: true`. - When `["*"]` is configured, the tool includes all configured agents and marks `allowAny: true`.
## Parameters (common) ## Parameters (common)

View File

@ -46,7 +46,7 @@ Text + native (when enabled):
- `/verbose on|off` (alias: `/v`) - `/verbose on|off` (alias: `/v`)
- `/reasoning on|off|stream` (alias: `/reason`; `stream` = Telegram draft only) - `/reasoning on|off|stream` (alias: `/reason`; `stream` = Telegram draft only)
- `/elevated on|off` (alias: `/elev`) - `/elevated on|off` (alias: `/elev`)
- `/model <name>` (or `/<alias>` from `agent.models.*.alias`) - `/model <name>` (or `/<alias>` from `agents.defaults.models.*.alias`)
- `/queue <mode>` (plus options like `debounce:2s cap:25 drop:summarize`; send `/queue` to see current settings) - `/queue <mode>` (plus options like `debounce:2s cap:25 drop:summarize`; send `/queue` to see current settings)
Text-only: Text-only:

View File

@ -30,13 +30,13 @@ Tool params:
- `cleanup?` (`delete|keep`, default `keep`) - `cleanup?` (`delete|keep`, default `keep`)
Allowlist: Allowlist:
- `routing.agents.<agentId>.subagents.allowAgents`: list of agent ids that can be targeted via `agentId` (`["*"]` to allow any). Default: only the requester agent. - `agents.list[].subagents.allowAgents`: list of agent ids that can be targeted via `agentId` (`["*"]` to allow any). Default: only the requester agent.
Discovery: Discovery:
- Use `agents_list` to see which agent ids are currently allowed for `sessions_spawn`. - Use `agents_list` to see which agent ids are currently allowed for `sessions_spawn`.
Auto-archive: Auto-archive:
- Sub-agent sessions are automatically archived after `agent.subagents.archiveAfterMinutes` (default: 60). - Sub-agent sessions are automatically archived after `agents.defaults.subagents.archiveAfterMinutes` (default: 60).
- Archive uses `sessions.delete` and renames the transcript to `*.deleted.<timestamp>` (same folder). - Archive uses `sessions.delete` and renames the transcript to `*.deleted.<timestamp>` (same folder).
- `cleanup: "delete"` archives immediately after announce (still keeps the transcript via rename). - `cleanup: "delete"` archives immediately after announce (still keeps the transcript via rename).
- Auto-archive is best-effort; pending timers are lost if the gateway restarts. - Auto-archive is best-effort; pending timers are lost if the gateway restarts.
@ -67,9 +67,15 @@ Override via config:
```json5 ```json5
{ {
agent: { agents: {
defaults: {
subagents: {
maxConcurrent: 1
}
}
},
tools: {
subagents: { subagents: {
maxConcurrent: 1,
tools: { tools: {
// deny wins // deny wins
deny: ["gateway", "cron"], deny: ["gateway", "cron"],
@ -85,7 +91,7 @@ Override via config:
Sub-agents use a dedicated in-process queue lane: Sub-agents use a dedicated in-process queue lane:
- Lane name: `subagent` - Lane name: `subagent`
- Concurrency: `agent.subagents.maxConcurrent` (default `1`) - Concurrency: `agents.defaults.subagents.maxConcurrent` (default `1`)
## Limitations ## Limitations

View File

@ -17,7 +17,7 @@ read_when:
## Resolution order ## Resolution order
1. Inline directive on the message (applies only to that message). 1. Inline directive on the message (applies only to that message).
2. Session override (set by sending a directive-only message). 2. Session override (set by sending a directive-only message).
3. Global default (`agent.thinkingDefault` in config). 3. Global default (`agents.defaults.thinkingDefault` in config).
4. Fallback: low for reasoning-capable models; off otherwise. 4. Fallback: low for reasoning-capable models; off otherwise.
## Setting a session default ## Setting a session default

View File

@ -231,8 +231,10 @@ const cfg = JSON5.parse(fs.readFileSync(process.env.CONFIG_PATH, "utf-8"));
const expectedWorkspace = process.env.WORKSPACE_DIR; const expectedWorkspace = process.env.WORKSPACE_DIR;
const errors = []; const errors = [];
if (cfg?.agent?.workspace !== expectedWorkspace) { if (cfg?.agents?.defaults?.workspace !== expectedWorkspace) {
errors.push(`agent.workspace mismatch (got ${cfg?.agent?.workspace ?? "unset"})`); errors.push(
`agents.defaults.workspace mismatch (got ${cfg?.agents?.defaults?.workspace ?? "unset"})`,
);
} }
if (cfg?.gateway?.mode !== "local") { if (cfg?.gateway?.mode !== "local") {
errors.push(`gateway.mode mismatch (got ${cfg?.gateway?.mode ?? "unset"})`); errors.push(`gateway.mode mismatch (got ${cfg?.gateway?.mode ?? "unset"})`);

View File

@ -59,7 +59,7 @@ EOF
cat <<NOTE cat <<NOTE
Built ${TARGET_IMAGE}. Built ${TARGET_IMAGE}.
To use it, set agent.sandbox.docker.image to "${TARGET_IMAGE}" and restart. To use it, set agents.defaults.sandbox.docker.image to "${TARGET_IMAGE}" and restart.
If you want a clean re-create, remove old sandbox containers: If you want a clean re-create, remove old sandbox containers:
docker rm -f \$(docker ps -aq --filter label=clawdbot.sandbox=1) docker rm -f \$(docker ps -aq --filter label=clawdbot.sandbox=1)
NOTE NOTE

View File

@ -11,10 +11,8 @@ describe("resolveAgentConfig", () => {
it("should return undefined when agent id does not exist", () => { it("should return undefined when agent id does not exist", () => {
const cfg: ClawdbotConfig = { const cfg: ClawdbotConfig = {
routing: { agents: {
agents: { list: [{ id: "main", workspace: "~/clawd" }],
main: { workspace: "~/clawd" },
},
}, },
}; };
const result = resolveAgentConfig(cfg, "nonexistent"); const result = resolveAgentConfig(cfg, "nonexistent");
@ -23,15 +21,16 @@ describe("resolveAgentConfig", () => {
it("should return basic agent config", () => { it("should return basic agent config", () => {
const cfg: ClawdbotConfig = { const cfg: ClawdbotConfig = {
routing: { agents: {
agents: { list: [
main: { {
id: "main",
name: "Main Agent", name: "Main Agent",
workspace: "~/clawd", workspace: "~/clawd",
agentDir: "~/.clawdbot/agents/main", agentDir: "~/.clawdbot/agents/main",
model: "anthropic/claude-opus-4", model: "anthropic/claude-opus-4",
}, },
}, ],
}, },
}; };
const result = resolveAgentConfig(cfg, "main"); const result = resolveAgentConfig(cfg, "main");
@ -40,6 +39,9 @@ describe("resolveAgentConfig", () => {
workspace: "~/clawd", workspace: "~/clawd",
agentDir: "~/.clawdbot/agents/main", agentDir: "~/.clawdbot/agents/main",
model: "anthropic/claude-opus-4", model: "anthropic/claude-opus-4",
identity: undefined,
groupChat: undefined,
subagents: undefined,
sandbox: undefined, sandbox: undefined,
tools: undefined, tools: undefined,
}); });
@ -47,9 +49,10 @@ describe("resolveAgentConfig", () => {
it("should return agent-specific sandbox config", () => { it("should return agent-specific sandbox config", () => {
const cfg: ClawdbotConfig = { const cfg: ClawdbotConfig = {
routing: { agents: {
agents: { list: [
work: { {
id: "work",
workspace: "~/clawd-work", workspace: "~/clawd-work",
sandbox: { sandbox: {
mode: "all", mode: "all",
@ -57,13 +60,9 @@ describe("resolveAgentConfig", () => {
perSession: false, perSession: false,
workspaceAccess: "ro", workspaceAccess: "ro",
workspaceRoot: "~/sandboxes", workspaceRoot: "~/sandboxes",
tools: {
allow: ["read"],
deny: ["bash"],
},
}, },
}, },
}, ],
}, },
}; };
const result = resolveAgentConfig(cfg, "work"); const result = resolveAgentConfig(cfg, "work");
@ -73,25 +72,22 @@ describe("resolveAgentConfig", () => {
perSession: false, perSession: false,
workspaceAccess: "ro", workspaceAccess: "ro",
workspaceRoot: "~/sandboxes", workspaceRoot: "~/sandboxes",
tools: {
allow: ["read"],
deny: ["bash"],
},
}); });
}); });
it("should return agent-specific tools config", () => { it("should return agent-specific tools config", () => {
const cfg: ClawdbotConfig = { const cfg: ClawdbotConfig = {
routing: { agents: {
agents: { list: [
restricted: { {
id: "restricted",
workspace: "~/clawd-restricted", workspace: "~/clawd-restricted",
tools: { tools: {
allow: ["read"], allow: ["read"],
deny: ["bash", "write", "edit"], deny: ["bash", "write", "edit"],
}, },
}, },
}, ],
}, },
}; };
const result = resolveAgentConfig(cfg, "restricted"); const result = resolveAgentConfig(cfg, "restricted");
@ -103,9 +99,10 @@ describe("resolveAgentConfig", () => {
it("should return both sandbox and tools config", () => { it("should return both sandbox and tools config", () => {
const cfg: ClawdbotConfig = { const cfg: ClawdbotConfig = {
routing: { agents: {
agents: { list: [
family: { {
id: "family",
workspace: "~/clawd-family", workspace: "~/clawd-family",
sandbox: { sandbox: {
mode: "all", mode: "all",
@ -116,7 +113,7 @@ describe("resolveAgentConfig", () => {
deny: ["bash"], deny: ["bash"],
}, },
}, },
}, ],
}, },
}; };
const result = resolveAgentConfig(cfg, "family"); const result = resolveAgentConfig(cfg, "family");
@ -126,10 +123,8 @@ describe("resolveAgentConfig", () => {
it("should normalize agent id", () => { it("should normalize agent id", () => {
const cfg: ClawdbotConfig = { const cfg: ClawdbotConfig = {
routing: { agents: {
agents: { list: [{ id: "main", workspace: "~/clawd" }],
main: { workspace: "~/clawd" },
},
}, },
}; };
// Should normalize to "main" (default) // Should normalize to "main" (default)

View File

@ -11,6 +11,24 @@ import {
import { resolveUserPath } from "../utils.js"; import { resolveUserPath } from "../utils.js";
import { DEFAULT_AGENT_WORKSPACE_DIR } from "./workspace.js"; import { DEFAULT_AGENT_WORKSPACE_DIR } from "./workspace.js";
type AgentEntry = NonNullable<
NonNullable<ClawdbotConfig["agents"]>["list"]
>[number];
type ResolvedAgentConfig = {
name?: string;
workspace?: string;
agentDir?: string;
model?: string;
identity?: AgentEntry["identity"];
groupChat?: AgentEntry["groupChat"];
subagents?: AgentEntry["subagents"];
sandbox?: AgentEntry["sandbox"];
tools?: AgentEntry["tools"];
};
let defaultAgentWarned = false;
export function resolveAgentIdFromSessionKey( export function resolveAgentIdFromSessionKey(
sessionKey?: string | null, sessionKey?: string | null,
): string { ): string {
@ -18,46 +36,51 @@ export function resolveAgentIdFromSessionKey(
return normalizeAgentId(parsed?.agentId ?? DEFAULT_AGENT_ID); return normalizeAgentId(parsed?.agentId ?? DEFAULT_AGENT_ID);
} }
function listAgents(cfg: ClawdbotConfig): AgentEntry[] {
const list = cfg.agents?.list;
if (!Array.isArray(list)) return [];
return list.filter((entry): entry is AgentEntry =>
Boolean(entry && typeof entry === "object"),
);
}
export function resolveDefaultAgentId(cfg: ClawdbotConfig): string {
const agents = listAgents(cfg);
if (agents.length === 0) return DEFAULT_AGENT_ID;
const defaults = agents.filter((agent) => agent?.default);
if (defaults.length > 1 && !defaultAgentWarned) {
defaultAgentWarned = true;
console.warn(
"Multiple agents marked default=true; using the first entry as default.",
);
}
const chosen = (defaults[0] ?? agents[0])?.id?.trim();
return normalizeAgentId(chosen || DEFAULT_AGENT_ID);
}
function resolveAgentEntry(
cfg: ClawdbotConfig,
agentId: string,
): AgentEntry | undefined {
const id = normalizeAgentId(agentId);
return listAgents(cfg).find((entry) => normalizeAgentId(entry.id) === id);
}
export function resolveAgentConfig( export function resolveAgentConfig(
cfg: ClawdbotConfig, cfg: ClawdbotConfig,
agentId: string, agentId: string,
): ): ResolvedAgentConfig | undefined {
| {
name?: string;
workspace?: string;
agentDir?: string;
model?: string;
subagents?: {
allowAgents?: string[];
};
sandbox?: {
mode?: "off" | "non-main" | "all";
workspaceAccess?: "none" | "ro" | "rw";
scope?: "session" | "agent" | "shared";
perSession?: boolean;
workspaceRoot?: string;
tools?: {
allow?: string[];
deny?: string[];
};
};
tools?: {
allow?: string[];
deny?: string[];
};
}
| undefined {
const id = normalizeAgentId(agentId); const id = normalizeAgentId(agentId);
const agents = cfg.routing?.agents; const entry = resolveAgentEntry(cfg, id);
if (!agents || typeof agents !== "object") return undefined; if (!entry) return undefined;
const entry = agents[id];
if (!entry || typeof entry !== "object") return undefined;
return { return {
name: typeof entry.name === "string" ? entry.name : undefined, name: typeof entry.name === "string" ? entry.name : undefined,
workspace: workspace:
typeof entry.workspace === "string" ? entry.workspace : undefined, typeof entry.workspace === "string" ? entry.workspace : undefined,
agentDir: typeof entry.agentDir === "string" ? entry.agentDir : undefined, agentDir: typeof entry.agentDir === "string" ? entry.agentDir : undefined,
model: typeof entry.model === "string" ? entry.model : undefined, model: typeof entry.model === "string" ? entry.model : undefined,
identity: entry.identity,
groupChat: entry.groupChat,
subagents: subagents:
typeof entry.subagents === "object" && entry.subagents typeof entry.subagents === "object" && entry.subagents
? entry.subagents ? entry.subagents
@ -71,9 +94,10 @@ export function resolveAgentWorkspaceDir(cfg: ClawdbotConfig, agentId: string) {
const id = normalizeAgentId(agentId); const id = normalizeAgentId(agentId);
const configured = resolveAgentConfig(cfg, id)?.workspace?.trim(); const configured = resolveAgentConfig(cfg, id)?.workspace?.trim();
if (configured) return resolveUserPath(configured); if (configured) return resolveUserPath(configured);
if (id === DEFAULT_AGENT_ID) { const defaultAgentId = resolveDefaultAgentId(cfg);
const legacy = cfg.agent?.workspace?.trim(); if (id === defaultAgentId) {
if (legacy) return resolveUserPath(legacy); const fallback = cfg.agents?.defaults?.workspace?.trim();
if (fallback) return resolveUserPath(fallback);
return DEFAULT_AGENT_WORKSPACE_DIR; return DEFAULT_AGENT_WORKSPACE_DIR;
} }
return path.join(os.homedir(), `clawd-${id}`); return path.join(os.homedir(), `clawd-${id}`);

View File

@ -925,7 +925,10 @@ export function resolveAuthProfileOrder(params: {
// Still put preferredProfile first if specified // Still put preferredProfile first if specified
if (preferredProfile && ordered.includes(preferredProfile)) { if (preferredProfile && ordered.includes(preferredProfile)) {
return [preferredProfile, ...ordered.filter((e) => e !== preferredProfile)]; return [
preferredProfile,
...ordered.filter((e) => e !== preferredProfile),
];
} }
return ordered; return ordered;
} }

View File

@ -108,7 +108,7 @@ function formatUserTime(date: Date, timeZone: string): string | undefined {
} }
function buildModelAliasLines(cfg?: ClawdbotConfig) { function buildModelAliasLines(cfg?: ClawdbotConfig) {
const models = cfg?.agent?.models ?? {}; const models = cfg?.agents?.defaults?.models ?? {};
const entries: Array<{ alias: string; model: string }> = []; const entries: Array<{ alias: string; model: string }> = [];
for (const [keyRaw, entryRaw] of Object.entries(models)) { for (const [keyRaw, entryRaw] of Object.entries(models)) {
const model = String(keyRaw ?? "").trim(); const model = String(keyRaw ?? "").trim();
@ -134,7 +134,9 @@ function buildSystemPrompt(params: {
contextFiles?: EmbeddedContextFile[]; contextFiles?: EmbeddedContextFile[];
modelDisplay: string; modelDisplay: string;
}) { }) {
const userTimezone = resolveUserTimezone(params.config?.agent?.userTimezone); const userTimezone = resolveUserTimezone(
params.config?.agents?.defaults?.userTimezone,
);
const userTime = formatUserTime(new Date(), userTimezone); const userTime = formatUserTime(new Date(), userTimezone);
return buildAgentSystemPrompt({ return buildAgentSystemPrompt({
workspaceDir: params.workspaceDir, workspaceDir: params.workspaceDir,
@ -143,7 +145,7 @@ function buildSystemPrompt(params: {
ownerNumbers: params.ownerNumbers, ownerNumbers: params.ownerNumbers,
reasoningTagHint: false, reasoningTagHint: false,
heartbeatPrompt: resolveHeartbeatPrompt( heartbeatPrompt: resolveHeartbeatPrompt(
params.config?.agent?.heartbeat?.prompt, params.config?.agents?.defaults?.heartbeat?.prompt,
), ),
runtimeInfo: { runtimeInfo: {
host: "clawdbot", host: "clawdbot",

View File

@ -46,7 +46,7 @@ describe("gateway tool", () => {
expect(tool).toBeDefined(); expect(tool).toBeDefined();
if (!tool) throw new Error("missing gateway tool"); if (!tool) throw new Error("missing gateway tool");
const raw = '{\n agent: { workspace: "~/clawd" }\n}\n'; const raw = '{\n agents: { defaults: { workspace: "~/clawd" } }\n}\n';
await tool.execute("call2", { await tool.execute("call2", {
action: "config.apply", action: "config.apply",
raw, raw,

View File

@ -52,18 +52,20 @@ describe("agents_list", () => {
mainKey: "main", mainKey: "main",
scope: "per-sender", scope: "per-sender",
}, },
routing: { agents: {
agents: { list: [
main: { {
id: "main",
name: "Main", name: "Main",
subagents: { subagents: {
allowAgents: ["research"], allowAgents: ["research"],
}, },
}, },
research: { {
id: "research",
name: "Research", name: "Research",
}, },
}, ],
}, },
}; };
@ -87,20 +89,23 @@ describe("agents_list", () => {
mainKey: "main", mainKey: "main",
scope: "per-sender", scope: "per-sender",
}, },
routing: { agents: {
agents: { list: [
main: { {
id: "main",
subagents: { subagents: {
allowAgents: ["*"], allowAgents: ["*"],
}, },
}, },
research: { {
id: "research",
name: "Research", name: "Research",
}, },
coder: { {
id: "coder",
name: "Coder", name: "Coder",
}, },
}, ],
}, },
}; };
@ -131,14 +136,15 @@ describe("agents_list", () => {
mainKey: "main", mainKey: "main",
scope: "per-sender", scope: "per-sender",
}, },
routing: { agents: {
agents: { list: [
main: { {
id: "main",
subagents: { subagents: {
allowAgents: ["research"], allowAgents: ["research"],
}, },
}, },
}, ],
}, },
}; };

View File

@ -314,14 +314,15 @@ describe("subagents", () => {
mainKey: "main", mainKey: "main",
scope: "per-sender", scope: "per-sender",
}, },
routing: { agents: {
agents: { list: [
main: { {
id: "main",
subagents: { subagents: {
allowAgents: ["beta"], allowAgents: ["beta"],
}, },
}, },
}, ],
}, },
}; };
@ -365,14 +366,15 @@ describe("subagents", () => {
mainKey: "main", mainKey: "main",
scope: "per-sender", scope: "per-sender",
}, },
routing: { agents: {
agents: { list: [
main: { {
id: "main",
subagents: { subagents: {
allowAgents: ["*"], allowAgents: ["*"],
}, },
}, },
}, ],
}, },
}; };
@ -416,14 +418,15 @@ describe("subagents", () => {
mainKey: "main", mainKey: "main",
scope: "per-sender", scope: "per-sender",
}, },
routing: { agents: {
agents: { list: [
main: { {
id: "main",
subagents: { subagents: {
allowAgents: ["Research"], allowAgents: ["Research"],
}, },
}, },
}, ],
}, },
}; };
@ -467,14 +470,15 @@ describe("subagents", () => {
mainKey: "main", mainKey: "main",
scope: "per-sender", scope: "per-sender",
}, },
routing: { agents: {
agents: { list: [
main: { {
id: "main",
subagents: { subagents: {
allowAgents: ["alpha"], allowAgents: ["alpha"],
}, },
}, },
}, ],
}, },
}; };

View File

@ -34,7 +34,7 @@ function buildAllowedModelKeys(
defaultProvider: string, defaultProvider: string,
): Set<string> | null { ): Set<string> | null {
const rawAllowlist = (() => { const rawAllowlist = (() => {
const modelMap = cfg?.agent?.models ?? {}; const modelMap = cfg?.agents?.defaults?.models ?? {};
return Object.keys(modelMap); return Object.keys(modelMap);
})(); })();
if (rawAllowlist.length === 0) return null; if (rawAllowlist.length === 0) return null;
@ -85,7 +85,7 @@ function resolveImageFallbackCandidates(params: {
if (params.modelOverride?.trim()) { if (params.modelOverride?.trim()) {
addRaw(params.modelOverride, false); addRaw(params.modelOverride, false);
} else { } else {
const imageModel = params.cfg?.agent?.imageModel as const imageModel = params.cfg?.agents?.defaults?.imageModel as
| { primary?: string } | { primary?: string }
| string | string
| undefined; | undefined;
@ -95,7 +95,7 @@ function resolveImageFallbackCandidates(params: {
} }
const imageFallbacks = (() => { const imageFallbacks = (() => {
const imageModel = params.cfg?.agent?.imageModel as const imageModel = params.cfg?.agents?.defaults?.imageModel as
| { fallbacks?: string[] } | { fallbacks?: string[] }
| string | string
| undefined; | undefined;
@ -142,7 +142,7 @@ function resolveFallbackCandidates(params: {
addCandidate({ provider, model }, false); addCandidate({ provider, model }, false);
const modelFallbacks = (() => { const modelFallbacks = (() => {
const model = params.cfg?.agent?.model as const model = params.cfg?.agents?.defaults?.model as
| { fallbacks?: string[] } | { fallbacks?: string[] }
| string | string
| undefined; | undefined;
@ -253,7 +253,7 @@ export async function runWithImageModelFallback<T>(params: {
}); });
if (candidates.length === 0) { if (candidates.length === 0) {
throw new Error( throw new Error(
"No image model configured. Set agent.imageModel.primary or agent.imageModel.fallbacks.", "No image model configured. Set agents.defaults.imageModel.primary or agents.defaults.imageModel.fallbacks.",
); );
} }

View File

@ -18,9 +18,11 @@ const catalog = [
describe("buildAllowedModelSet", () => { describe("buildAllowedModelSet", () => {
it("always allows the configured default model", () => { it("always allows the configured default model", () => {
const cfg = { const cfg = {
agent: { agents: {
models: { defaults: {
"openai/gpt-4": { alias: "gpt4" }, models: {
"openai/gpt-4": { alias: "gpt4" },
},
}, },
}, },
} as ClawdbotConfig; } as ClawdbotConfig;
@ -41,7 +43,7 @@ describe("buildAllowedModelSet", () => {
it("includes the default model when no allowlist is set", () => { it("includes the default model when no allowlist is set", () => {
const cfg = { const cfg = {
agent: {}, agents: { defaults: {} },
} as ClawdbotConfig; } as ClawdbotConfig;
const allowed = buildAllowedModelSet({ const allowed = buildAllowedModelSet({

View File

@ -65,7 +65,7 @@ export function buildModelAliasIndex(params: {
const byAlias = new Map<string, { alias: string; ref: ModelRef }>(); const byAlias = new Map<string, { alias: string; ref: ModelRef }>();
const byKey = new Map<string, string[]>(); const byKey = new Map<string, string[]>();
const rawModels = params.cfg.agent?.models ?? {}; const rawModels = params.cfg.agents?.defaults?.models ?? {};
for (const [keyRaw, entryRaw] of Object.entries(rawModels)) { for (const [keyRaw, entryRaw] of Object.entries(rawModels)) {
const parsed = parseModelRef(String(keyRaw ?? ""), params.defaultProvider); const parsed = parseModelRef(String(keyRaw ?? ""), params.defaultProvider);
if (!parsed) continue; if (!parsed) continue;
@ -109,7 +109,7 @@ export function resolveConfiguredModelRef(params: {
defaultModel: string; defaultModel: string;
}): ModelRef { }): ModelRef {
const rawModel = (() => { const rawModel = (() => {
const raw = params.cfg.agent?.model as const raw = params.cfg.agents?.defaults?.model as
| { primary?: string } | { primary?: string }
| string | string
| undefined; | undefined;
@ -128,7 +128,7 @@ export function resolveConfiguredModelRef(params: {
aliasIndex, aliasIndex,
}); });
if (resolved) return resolved.ref; if (resolved) return resolved.ref;
// TODO(steipete): drop this fallback once provider-less agent.model is fully deprecated. // TODO(steipete): drop this fallback once provider-less agents.defaults.model is fully deprecated.
return { provider: "anthropic", model: trimmed }; return { provider: "anthropic", model: trimmed };
} }
return { provider: params.defaultProvider, model: params.defaultModel }; return { provider: params.defaultProvider, model: params.defaultModel };
@ -145,7 +145,7 @@ export function buildAllowedModelSet(params: {
allowedKeys: Set<string>; allowedKeys: Set<string>;
} { } {
const rawAllowlist = (() => { const rawAllowlist = (() => {
const modelMap = params.cfg.agent?.models ?? {}; const modelMap = params.cfg.agents?.defaults?.models ?? {};
return Object.keys(modelMap); return Object.keys(modelMap);
})(); })();
const allowAny = rawAllowlist.length === 0; const allowAny = rawAllowlist.length === 0;
@ -203,7 +203,7 @@ export function resolveThinkingDefault(params: {
model: string; model: string;
catalog?: ModelCatalogEntry[]; catalog?: ModelCatalogEntry[];
}): ThinkLevel { }): ThinkLevel {
const configured = params.cfg.agent?.thinkingDefault; const configured = params.cfg.agents?.defaults?.thinkingDefault;
if (configured) return configured; if (configured) return configured;
const candidate = params.catalog?.find( const candidate = params.catalog?.find(
(entry) => entry.provider === params.provider && entry.id === params.model, (entry) => entry.provider === params.provider && entry.id === params.model,

View File

@ -110,12 +110,14 @@ describe("resolveExtraParams", () => {
it("respects explicit thinking config from user (disable thinking)", () => { it("respects explicit thinking config from user (disable thinking)", () => {
const result = resolveExtraParams({ const result = resolveExtraParams({
cfg: { cfg: {
agent: { agents: {
models: { defaults: {
"zai/glm-4.7": { models: {
params: { "zai/glm-4.7": {
thinking: { params: {
type: "disabled", thinking: {
type: "disabled",
},
}, },
}, },
}, },
@ -136,12 +138,14 @@ describe("resolveExtraParams", () => {
it("preserves other params while adding thinking config", () => { it("preserves other params while adding thinking config", () => {
const result = resolveExtraParams({ const result = resolveExtraParams({
cfg: { cfg: {
agent: { agents: {
models: { defaults: {
"zai/glm-4.7": { models: {
params: { "zai/glm-4.7": {
temperature: 0.7, params: {
max_tokens: 4096, temperature: 0.7,
max_tokens: 4096,
},
}, },
}, },
}, },
@ -164,13 +168,15 @@ describe("resolveExtraParams", () => {
it("does not override explicit thinking config even if partial", () => { it("does not override explicit thinking config even if partial", () => {
const result = resolveExtraParams({ const result = resolveExtraParams({
cfg: { cfg: {
agent: { agents: {
models: { defaults: {
"zai/glm-4.7": { models: {
params: { "zai/glm-4.7": {
thinking: { params: {
type: "enabled", thinking: {
// User explicitly omitted clear_thinking type: "enabled",
// User explicitly omitted clear_thinking
},
}, },
}, },
}, },
@ -214,12 +220,14 @@ describe("resolveExtraParams", () => {
it("passes through params for non-GLM models without modification", () => { it("passes through params for non-GLM models without modification", () => {
const result = resolveExtraParams({ const result = resolveExtraParams({
cfg: { cfg: {
agent: { agents: {
models: { defaults: {
"openai/gpt-4": { models: {
params: { "openai/gpt-4": {
logprobs: true, params: {
top_logprobs: 5, logprobs: true,
top_logprobs: 5,
},
}, },
}, },
}, },
@ -264,7 +272,7 @@ describe("resolveExtraParams", () => {
it("handles config with empty models gracefully", () => { it("handles config with empty models gracefully", () => {
const result = resolveExtraParams({ const result = resolveExtraParams({
cfg: { agent: { models: {} } }, cfg: { agents: { defaults: { models: {} } } },
provider: "zai", provider: "zai",
modelId: "glm-4.7", modelId: "glm-4.7",
}); });
@ -280,12 +288,14 @@ describe("resolveExtraParams", () => {
it("model alias lookup uses exact provider/model key", () => { it("model alias lookup uses exact provider/model key", () => {
const result = resolveExtraParams({ const result = resolveExtraParams({
cfg: { cfg: {
agent: { agents: {
models: { defaults: {
"zai/glm-4.7": { models: {
alias: "smart", "zai/glm-4.7": {
params: { alias: "smart",
custom_param: "value", params: {
custom_param: "value",
},
}, },
}, },
}, },
@ -307,11 +317,13 @@ describe("resolveExtraParams", () => {
it("treats thinking: null as explicit config (no auto-enable)", () => { it("treats thinking: null as explicit config (no auto-enable)", () => {
const result = resolveExtraParams({ const result = resolveExtraParams({
cfg: { cfg: {
agent: { agents: {
models: { defaults: {
"zai/glm-4.7": { models: {
params: { "zai/glm-4.7": {
thinking: null, params: {
thinking: null,
},
}, },
}, },
}, },
@ -374,11 +386,13 @@ describe("resolveExtraParams", () => {
it("thinkLevel: 'off' still passes through explicit config", () => { it("thinkLevel: 'off' still passes through explicit config", () => {
const result = resolveExtraParams({ const result = resolveExtraParams({
cfg: { cfg: {
agent: { agents: {
models: { defaults: {
"zai/glm-4.7": { models: {
params: { "zai/glm-4.7": {
custom_param: "value", params: {
custom_param: "value",
},
}, },
}, },
}, },

View File

@ -105,7 +105,7 @@ import { loadWorkspaceBootstrapFiles } from "./workspace.js";
* - GLM-4.5/4.6: Interleaved thinking (clear_thinking: true) - reasoning cleared each turn * - GLM-4.5/4.6: Interleaved thinking (clear_thinking: true) - reasoning cleared each turn
* *
* Users can override via config: * Users can override via config:
* agent.models["zai/glm-4.7"].params.thinking = { type: "disabled" } * agents.defaults.models["zai/glm-4.7"].params.thinking = { type: "disabled" }
* *
* Or disable via runtime flag: --thinking off * Or disable via runtime flag: --thinking off
* *
@ -119,7 +119,7 @@ export function resolveExtraParams(params: {
thinkLevel?: string; thinkLevel?: string;
}): Record<string, unknown> | undefined { }): Record<string, unknown> | undefined {
const modelKey = `${params.provider}/${params.modelId}`; const modelKey = `${params.provider}/${params.modelId}`;
const modelConfig = params.cfg?.agent?.models?.[modelKey]; const modelConfig = params.cfg?.agents?.defaults?.models?.[modelKey];
let extraParams = modelConfig?.params ? { ...modelConfig.params } : undefined; let extraParams = modelConfig?.params ? { ...modelConfig.params } : undefined;
// Auto-enable thinking for ZAI GLM-4.x models when not explicitly configured // Auto-enable thinking for ZAI GLM-4.x models when not explicitly configured
@ -200,10 +200,10 @@ function resolveContextWindowTokens(params: {
if (fromModelsConfig) return fromModelsConfig; if (fromModelsConfig) return fromModelsConfig;
const fromAgentConfig = const fromAgentConfig =
typeof params.cfg?.agent?.contextTokens === "number" && typeof params.cfg?.agents?.defaults?.contextTokens === "number" &&
Number.isFinite(params.cfg.agent.contextTokens) && Number.isFinite(params.cfg.agents.defaults.contextTokens) &&
params.cfg.agent.contextTokens > 0 params.cfg.agents.defaults.contextTokens > 0
? Math.floor(params.cfg.agent.contextTokens) ? Math.floor(params.cfg.agents.defaults.contextTokens)
: undefined; : undefined;
if (fromAgentConfig) return fromAgentConfig; if (fromAgentConfig) return fromAgentConfig;
@ -217,7 +217,7 @@ function buildContextPruningExtension(params: {
modelId: string; modelId: string;
model: Model<Api> | undefined; model: Model<Api> | undefined;
}): { additionalExtensionPaths?: string[] } { }): { additionalExtensionPaths?: string[] } {
const raw = params.cfg?.agent?.contextPruning; const raw = params.cfg?.agents?.defaults?.contextPruning;
if (raw?.mode !== "adaptive" && raw?.mode !== "aggressive") return {}; if (raw?.mode !== "adaptive" && raw?.mode !== "aggressive") return {};
const settings = computeEffectiveSettings(raw); const settings = computeEffectiveSettings(raw);
@ -254,7 +254,7 @@ export type EmbeddedPiRunMeta = {
}; };
function buildModelAliasLines(cfg?: ClawdbotConfig) { function buildModelAliasLines(cfg?: ClawdbotConfig) {
const models = cfg?.agent?.models ?? {}; const models = cfg?.agents?.defaults?.models ?? {};
const entries: Array<{ alias: string; model: string }> = []; const entries: Array<{ alias: string; model: string }> = [];
for (const [keyRaw, entryRaw] of Object.entries(models)) { for (const [keyRaw, entryRaw] of Object.entries(models)) {
const model = String(keyRaw ?? "").trim(); const model = String(keyRaw ?? "").trim();
@ -844,7 +844,7 @@ export async function compactEmbeddedPiSession(params: {
const contextFiles = buildBootstrapContextFiles(bootstrapFiles); const contextFiles = buildBootstrapContextFiles(bootstrapFiles);
const tools = createClawdbotCodingTools({ const tools = createClawdbotCodingTools({
bash: { bash: {
...params.config?.agent?.bash, ...params.config?.tools?.bash,
elevated: params.bashElevated, elevated: params.bashElevated,
}, },
sandbox, sandbox,
@ -865,7 +865,7 @@ export async function compactEmbeddedPiSession(params: {
const sandboxInfo = buildEmbeddedSandboxInfo(sandbox); const sandboxInfo = buildEmbeddedSandboxInfo(sandbox);
const reasoningTagHint = provider === "ollama"; const reasoningTagHint = provider === "ollama";
const userTimezone = resolveUserTimezone( const userTimezone = resolveUserTimezone(
params.config?.agent?.userTimezone, params.config?.agents?.defaults?.userTimezone,
); );
const userTime = formatUserTime(new Date(), userTimezone); const userTime = formatUserTime(new Date(), userTimezone);
const appendPrompt = buildEmbeddedSystemPrompt({ const appendPrompt = buildEmbeddedSystemPrompt({
@ -875,7 +875,7 @@ export async function compactEmbeddedPiSession(params: {
ownerNumbers: params.ownerNumbers, ownerNumbers: params.ownerNumbers,
reasoningTagHint, reasoningTagHint,
heartbeatPrompt: resolveHeartbeatPrompt( heartbeatPrompt: resolveHeartbeatPrompt(
params.config?.agent?.heartbeat?.prompt, params.config?.agents?.defaults?.heartbeat?.prompt,
), ),
runtimeInfo, runtimeInfo,
sandboxInfo, sandboxInfo,
@ -1157,7 +1157,7 @@ export async function runEmbeddedPiAgent(params: {
// `createClawdbotCodingTools()` normalizes schemas so the session can pass them through unchanged. // `createClawdbotCodingTools()` normalizes schemas so the session can pass them through unchanged.
const tools = createClawdbotCodingTools({ const tools = createClawdbotCodingTools({
bash: { bash: {
...params.config?.agent?.bash, ...params.config?.tools?.bash,
elevated: params.bashElevated, elevated: params.bashElevated,
}, },
sandbox, sandbox,
@ -1178,7 +1178,7 @@ export async function runEmbeddedPiAgent(params: {
const sandboxInfo = buildEmbeddedSandboxInfo(sandbox); const sandboxInfo = buildEmbeddedSandboxInfo(sandbox);
const reasoningTagHint = provider === "ollama"; const reasoningTagHint = provider === "ollama";
const userTimezone = resolveUserTimezone( const userTimezone = resolveUserTimezone(
params.config?.agent?.userTimezone, params.config?.agents?.defaults?.userTimezone,
); );
const userTime = formatUserTime(new Date(), userTimezone); const userTime = formatUserTime(new Date(), userTimezone);
const appendPrompt = buildEmbeddedSystemPrompt({ const appendPrompt = buildEmbeddedSystemPrompt({
@ -1188,7 +1188,7 @@ export async function runEmbeddedPiAgent(params: {
ownerNumbers: params.ownerNumbers, ownerNumbers: params.ownerNumbers,
reasoningTagHint, reasoningTagHint,
heartbeatPrompt: resolveHeartbeatPrompt( heartbeatPrompt: resolveHeartbeatPrompt(
params.config?.agent?.heartbeat?.prompt, params.config?.agents?.defaults?.heartbeat?.prompt,
), ),
runtimeInfo, runtimeInfo,
sandboxInfo, sandboxInfo,
@ -1444,7 +1444,8 @@ export async function runEmbeddedPiAgent(params: {
} }
const fallbackConfigured = const fallbackConfigured =
(params.config?.agent?.model?.fallbacks?.length ?? 0) > 0; (params.config?.agents?.defaults?.model?.fallbacks?.length ?? 0) >
0;
const authFailure = isAuthAssistantError(lastAssistant); const authFailure = isAuthAssistantError(lastAssistant);
const rateLimitFailure = isRateLimitAssistantError(lastAssistant); const rateLimitFailure = isRateLimitAssistantError(lastAssistant);

View File

@ -6,18 +6,17 @@ import type { SandboxDockerConfig } from "./sandbox.js";
describe("Agent-specific tool filtering", () => { describe("Agent-specific tool filtering", () => {
it("should apply global tool policy when no agent-specific policy exists", () => { it("should apply global tool policy when no agent-specific policy exists", () => {
const cfg: ClawdbotConfig = { const cfg: ClawdbotConfig = {
agent: { tools: {
tools: { allow: ["read", "write"],
allow: ["read", "write"], deny: ["bash"],
deny: ["bash"],
},
}, },
routing: { agents: {
agents: { list: [
main: { {
id: "main",
workspace: "~/clawd", workspace: "~/clawd",
}, },
}, ],
}, },
}; };
@ -36,22 +35,21 @@ describe("Agent-specific tool filtering", () => {
it("should apply agent-specific tool policy", () => { it("should apply agent-specific tool policy", () => {
const cfg: ClawdbotConfig = { const cfg: ClawdbotConfig = {
agent: { tools: {
tools: { allow: ["read", "write", "bash"],
allow: ["read", "write", "bash"], deny: [],
deny: [],
},
}, },
routing: { agents: {
agents: { list: [
restricted: { {
id: "restricted",
workspace: "~/clawd-restricted", workspace: "~/clawd-restricted",
tools: { tools: {
allow: ["read"], // Agent override: only read allow: ["read"], // Agent override: only read
deny: ["bash", "write", "edit"], deny: ["bash", "write", "edit"],
}, },
}, },
}, ],
}, },
}; };
@ -71,20 +69,22 @@ describe("Agent-specific tool filtering", () => {
it("should allow different tool policies for different agents", () => { it("should allow different tool policies for different agents", () => {
const cfg: ClawdbotConfig = { const cfg: ClawdbotConfig = {
routing: { agents: {
agents: { list: [
main: { {
id: "main",
workspace: "~/clawd", workspace: "~/clawd",
// No tools restriction - all tools available // No tools restriction - all tools available
}, },
family: { {
id: "family",
workspace: "~/clawd-family", workspace: "~/clawd-family",
tools: { tools: {
allow: ["read"], allow: ["read"],
deny: ["bash", "write", "edit", "process"], deny: ["bash", "write", "edit", "process"],
}, },
}, },
}, ],
}, },
}; };
@ -116,20 +116,19 @@ describe("Agent-specific tool filtering", () => {
it("should prefer agent-specific tool policy over global", () => { it("should prefer agent-specific tool policy over global", () => {
const cfg: ClawdbotConfig = { const cfg: ClawdbotConfig = {
agent: { tools: {
tools: { deny: ["browser"], // Global deny
deny: ["browser"], // Global deny
},
}, },
routing: { agents: {
agents: { list: [
work: { {
id: "work",
workspace: "~/clawd-work", workspace: "~/clawd-work",
tools: { tools: {
deny: ["bash", "process"], // Agent deny (override) deny: ["bash", "process"], // Agent deny (override)
}, },
}, },
}, ],
}, },
}; };
@ -149,19 +148,16 @@ describe("Agent-specific tool filtering", () => {
it("should work with sandbox tools filtering", () => { it("should work with sandbox tools filtering", () => {
const cfg: ClawdbotConfig = { const cfg: ClawdbotConfig = {
agent: { agents: {
sandbox: { defaults: {
mode: "all", sandbox: {
scope: "agent", mode: "all",
tools: { scope: "agent",
allow: ["read", "write", "bash"], // Sandbox allows these
deny: [],
}, },
}, },
}, list: [
routing: { {
agents: { id: "restricted",
restricted: {
workspace: "~/clawd-restricted", workspace: "~/clawd-restricted",
sandbox: { sandbox: {
mode: "all", mode: "all",
@ -172,6 +168,14 @@ describe("Agent-specific tool filtering", () => {
deny: ["bash", "write"], deny: ["bash", "write"],
}, },
}, },
],
},
tools: {
sandbox: {
tools: {
allow: ["read", "write", "bash"], // Sandbox allows these
deny: [],
},
}, },
}, },
}; };
@ -216,10 +220,8 @@ describe("Agent-specific tool filtering", () => {
it("should run bash synchronously when process is denied", async () => { it("should run bash synchronously when process is denied", async () => {
const cfg: ClawdbotConfig = { const cfg: ClawdbotConfig = {
agent: { tools: {
tools: { deny: ["process"],
deny: ["process"],
},
}, },
}; };

View File

@ -171,7 +171,7 @@ describe("createClawdbotCodingTools", () => {
sessionKey: "agent:main:subagent:test", sessionKey: "agent:main:subagent:test",
// Intentionally partial config; only fields used by pi-tools are provided. // Intentionally partial config; only fields used by pi-tools are provided.
config: { config: {
agent: { tools: {
subagents: { subagents: {
tools: { tools: {
// Policy matching is case-insensitive // Policy matching is case-insensitive
@ -325,7 +325,7 @@ describe("createClawdbotCodingTools", () => {
it("filters tools by agent tool policy even without sandbox", () => { it("filters tools by agent tool policy even without sandbox", () => {
const tools = createClawdbotCodingTools({ const tools = createClawdbotCodingTools({
config: { agent: { tools: { deny: ["browser"] } } }, config: { tools: { deny: ["browser"] } },
}); });
// NOTE: bash is capitalized to bypass Anthropic OAuth blocking // NOTE: bash is capitalized to bypass Anthropic OAuth blocking
expect(tools.some((tool) => tool.name === "Bash")).toBe(true); expect(tools.some((tool) => tool.name === "Bash")).toBe(true);

View File

@ -429,7 +429,7 @@ const DEFAULT_SUBAGENT_TOOL_DENY = [
]; ];
function resolveSubagentToolPolicy(cfg?: ClawdbotConfig): SandboxToolPolicy { function resolveSubagentToolPolicy(cfg?: ClawdbotConfig): SandboxToolPolicy {
const configured = cfg?.agent?.subagents?.tools; const configured = cfg?.tools?.subagents?.tools;
const deny = [ const deny = [
...DEFAULT_SUBAGENT_TOOL_DENY, ...DEFAULT_SUBAGENT_TOOL_DENY,
...(Array.isArray(configured?.deny) ? configured.deny : []), ...(Array.isArray(configured?.deny) ? configured.deny : []),
@ -466,7 +466,7 @@ function resolveEffectiveToolPolicy(params: {
? resolveAgentConfig(params.config, agentId) ? resolveAgentConfig(params.config, agentId)
: undefined; : undefined;
const hasAgentTools = agentConfig?.tools !== undefined; const hasAgentTools = agentConfig?.tools !== undefined;
const globalTools = params.config?.agent?.tools; const globalTools = params.config?.tools;
return { return {
agentId, agentId,
policy: hasAgentTools ? agentConfig?.tools : globalTools, policy: hasAgentTools ? agentConfig?.tools : globalTools,

View File

@ -56,18 +56,19 @@ describe("Agent-specific sandbox config", () => {
const { resolveSandboxContext } = await import("./sandbox.js"); const { resolveSandboxContext } = await import("./sandbox.js");
const cfg: ClawdbotConfig = { const cfg: ClawdbotConfig = {
agent: { agents: {
sandbox: { defaults: {
mode: "all", sandbox: {
scope: "agent", mode: "all",
}, scope: "agent",
},
routing: {
agents: {
main: {
workspace: "~/clawd",
}, },
}, },
list: [
{
id: "main",
workspace: "~/clawd",
},
],
}, },
}; };
@ -85,18 +86,19 @@ describe("Agent-specific sandbox config", () => {
const { resolveSandboxContext } = await import("./sandbox.js"); const { resolveSandboxContext } = await import("./sandbox.js");
const cfg: ClawdbotConfig = { const cfg: ClawdbotConfig = {
agent: { agents: {
sandbox: { defaults: {
mode: "all", sandbox: {
scope: "agent", mode: "all",
docker: { scope: "agent",
setupCommand: "echo global", docker: {
setupCommand: "echo global",
},
}, },
}, },
}, list: [
routing: { {
agents: { id: "work",
work: {
workspace: "~/clawd-work", workspace: "~/clawd-work",
sandbox: { sandbox: {
mode: "all", mode: "all",
@ -106,7 +108,7 @@ describe("Agent-specific sandbox config", () => {
}, },
}, },
}, },
}, ],
}, },
}; };
@ -133,18 +135,19 @@ describe("Agent-specific sandbox config", () => {
const { resolveSandboxContext } = await import("./sandbox.js"); const { resolveSandboxContext } = await import("./sandbox.js");
const cfg: ClawdbotConfig = { const cfg: ClawdbotConfig = {
agent: { agents: {
sandbox: { defaults: {
mode: "all", sandbox: {
scope: "shared", mode: "all",
docker: { scope: "shared",
setupCommand: "echo global", docker: {
setupCommand: "echo global",
},
}, },
}, },
}, list: [
routing: { {
agents: { id: "work",
work: {
workspace: "~/clawd-work", workspace: "~/clawd-work",
sandbox: { sandbox: {
mode: "all", mode: "all",
@ -154,7 +157,7 @@ describe("Agent-specific sandbox config", () => {
}, },
}, },
}, },
}, ],
}, },
}; };
@ -182,19 +185,20 @@ describe("Agent-specific sandbox config", () => {
const { resolveSandboxContext } = await import("./sandbox.js"); const { resolveSandboxContext } = await import("./sandbox.js");
const cfg: ClawdbotConfig = { const cfg: ClawdbotConfig = {
agent: { agents: {
sandbox: { defaults: {
mode: "all", sandbox: {
scope: "agent", mode: "all",
docker: { scope: "agent",
image: "global-image", docker: {
network: "none", image: "global-image",
network: "none",
},
}, },
}, },
}, list: [
routing: { {
agents: { id: "work",
work: {
workspace: "~/clawd-work", workspace: "~/clawd-work",
sandbox: { sandbox: {
mode: "all", mode: "all",
@ -205,7 +209,7 @@ describe("Agent-specific sandbox config", () => {
}, },
}, },
}, },
}, ],
}, },
}; };
@ -224,21 +228,22 @@ describe("Agent-specific sandbox config", () => {
const { resolveSandboxContext } = await import("./sandbox.js"); const { resolveSandboxContext } = await import("./sandbox.js");
const cfg: ClawdbotConfig = { const cfg: ClawdbotConfig = {
agent: { agents: {
sandbox: { defaults: {
mode: "all", // Global default sandbox: {
scope: "agent", mode: "all", // Global default
scope: "agent",
},
}, },
}, list: [
routing: { {
agents: { id: "main",
main: {
workspace: "~/clawd", workspace: "~/clawd",
sandbox: { sandbox: {
mode: "off", // Agent override mode: "off", // Agent override
}, },
}, },
}, ],
}, },
}; };
@ -256,21 +261,22 @@ describe("Agent-specific sandbox config", () => {
const { resolveSandboxContext } = await import("./sandbox.js"); const { resolveSandboxContext } = await import("./sandbox.js");
const cfg: ClawdbotConfig = { const cfg: ClawdbotConfig = {
agent: { agents: {
sandbox: { defaults: {
mode: "off", // Global default sandbox: {
mode: "off", // Global default
},
}, },
}, list: [
routing: { {
agents: { id: "family",
family: {
workspace: "~/clawd-family", workspace: "~/clawd-family",
sandbox: { sandbox: {
mode: "all", // Agent override mode: "all", // Agent override
scope: "agent", scope: "agent",
}, },
}, },
}, ],
}, },
}; };
@ -288,22 +294,23 @@ describe("Agent-specific sandbox config", () => {
const { resolveSandboxContext } = await import("./sandbox.js"); const { resolveSandboxContext } = await import("./sandbox.js");
const cfg: ClawdbotConfig = { const cfg: ClawdbotConfig = {
agent: { agents: {
sandbox: { defaults: {
mode: "all", sandbox: {
scope: "session", // Global default mode: "all",
scope: "session", // Global default
},
}, },
}, list: [
routing: { {
agents: { id: "work",
work: {
workspace: "~/clawd-work", workspace: "~/clawd-work",
sandbox: { sandbox: {
mode: "all", mode: "all",
scope: "agent", // Agent override scope: "agent", // Agent override
}, },
}, },
}, ],
}, },
}; };
@ -322,16 +329,17 @@ describe("Agent-specific sandbox config", () => {
const { resolveSandboxContext } = await import("./sandbox.js"); const { resolveSandboxContext } = await import("./sandbox.js");
const cfg: ClawdbotConfig = { const cfg: ClawdbotConfig = {
agent: { agents: {
sandbox: { defaults: {
mode: "all", sandbox: {
scope: "agent", mode: "all",
workspaceRoot: "~/.clawdbot/sandboxes", // Global default scope: "agent",
workspaceRoot: "~/.clawdbot/sandboxes", // Global default
},
}, },
}, list: [
routing: { {
agents: { id: "isolated",
isolated: {
workspace: "~/clawd-isolated", workspace: "~/clawd-isolated",
sandbox: { sandbox: {
mode: "all", mode: "all",
@ -339,7 +347,7 @@ describe("Agent-specific sandbox config", () => {
workspaceRoot: "/tmp/isolated-sandboxes", // Agent override workspaceRoot: "/tmp/isolated-sandboxes", // Agent override
}, },
}, },
}, ],
}, },
}; };
@ -359,28 +367,30 @@ describe("Agent-specific sandbox config", () => {
const { resolveSandboxContext } = await import("./sandbox.js"); const { resolveSandboxContext } = await import("./sandbox.js");
const cfg: ClawdbotConfig = { const cfg: ClawdbotConfig = {
agent: { agents: {
sandbox: { defaults: {
mode: "non-main", sandbox: {
scope: "session", mode: "non-main",
scope: "session",
},
}, },
}, list: [
routing: { {
agents: { id: "main",
main: {
workspace: "~/clawd", workspace: "~/clawd",
sandbox: { sandbox: {
mode: "off", // main: no sandbox mode: "off", // main: no sandbox
}, },
}, },
family: { {
id: "family",
workspace: "~/clawd-family", workspace: "~/clawd-family",
sandbox: { sandbox: {
mode: "all", // family: always sandbox mode: "all", // family: always sandbox
scope: "agent", scope: "agent",
}, },
}, },
}, ],
}, },
}; };
@ -406,29 +416,38 @@ describe("Agent-specific sandbox config", () => {
const { resolveSandboxContext } = await import("./sandbox.js"); const { resolveSandboxContext } = await import("./sandbox.js");
const cfg: ClawdbotConfig = { const cfg: ClawdbotConfig = {
agent: { agents: {
sandbox: { defaults: {
mode: "all", sandbox: {
scope: "agent", mode: "all",
tools: { scope: "agent",
allow: ["read"],
deny: ["bash"],
}, },
}, },
}, list: [
routing: { {
agents: { id: "restricted",
restricted: {
workspace: "~/clawd-restricted", workspace: "~/clawd-restricted",
sandbox: { sandbox: {
mode: "all", mode: "all",
scope: "agent", scope: "agent",
tools: { },
allow: ["read", "write"], tools: {
deny: ["edit"], sandbox: {
tools: {
allow: ["read", "write"],
deny: ["edit"],
},
}, },
}, },
}, },
],
},
tools: {
sandbox: {
tools: {
allow: ["read"],
deny: ["bash"],
},
}, },
}, },
}; };

View File

@ -22,7 +22,10 @@ import {
import { normalizeAgentId } from "../routing/session-key.js"; import { normalizeAgentId } from "../routing/session-key.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import { resolveUserPath } from "../utils.js"; import { resolveUserPath } from "../utils.js";
import { resolveAgentIdFromSessionKey } from "./agent-scope.js"; import {
resolveAgentConfig,
resolveAgentIdFromSessionKey,
} from "./agent-scope.js";
import { syncSkillsToWorkspace } from "./skills.js"; import { syncSkillsToWorkspace } from "./skills.js";
import { import {
DEFAULT_AGENT_WORKSPACE_DIR, DEFAULT_AGENT_WORKSPACE_DIR,
@ -345,15 +348,14 @@ export function resolveSandboxConfigForAgent(
cfg?: ClawdbotConfig, cfg?: ClawdbotConfig,
agentId?: string, agentId?: string,
): SandboxConfig { ): SandboxConfig {
const agent = cfg?.agent?.sandbox; const agent = cfg?.agents?.defaults?.sandbox;
// Agent-specific sandbox config overrides global // Agent-specific sandbox config overrides global
let agentSandbox: typeof agent | undefined; let agentSandbox: typeof agent | undefined;
if (agentId && cfg?.routing?.agents) { const agentConfig =
const agentConfig = cfg.routing.agents[agentId]; cfg && agentId ? resolveAgentConfig(cfg, agentId) : undefined;
if (agentConfig && typeof agentConfig === "object") { if (agentConfig?.sandbox) {
agentSandbox = agentConfig.sandbox; agentSandbox = agentConfig.sandbox;
}
} }
const scope = resolveSandboxScope({ const scope = resolveSandboxScope({
@ -382,9 +384,13 @@ export function resolveSandboxConfigForAgent(
}), }),
tools: { tools: {
allow: allow:
agentSandbox?.tools?.allow ?? agent?.tools?.allow ?? DEFAULT_TOOL_ALLOW, agentConfig?.tools?.sandbox?.tools?.allow ??
cfg?.tools?.sandbox?.tools?.allow ??
DEFAULT_TOOL_ALLOW,
deny: deny:
agentSandbox?.tools?.deny ?? agent?.tools?.deny ?? DEFAULT_TOOL_DENY, agentConfig?.tools?.sandbox?.tools?.deny ??
cfg?.tools?.sandbox?.tools?.deny ??
DEFAULT_TOOL_DENY,
}, },
prune: resolveSandboxPruneConfig({ prune: resolveSandboxPruneConfig({
scope, scope,
@ -1059,7 +1065,7 @@ export async function resolveSandboxContext(params: {
await ensureSandboxWorkspace( await ensureSandboxWorkspace(
sandboxWorkspaceDir, sandboxWorkspaceDir,
agentWorkspaceDir, agentWorkspaceDir,
params.config?.agent?.skipBootstrap, params.config?.agents?.defaults?.skipBootstrap,
); );
if (cfg.workspaceAccess === "none") { if (cfg.workspaceAccess === "none") {
try { try {
@ -1133,7 +1139,7 @@ export async function ensureSandboxWorkspaceForSession(params: {
await ensureSandboxWorkspace( await ensureSandboxWorkspace(
sandboxWorkspaceDir, sandboxWorkspaceDir,
agentWorkspaceDir, agentWorkspaceDir,
params.config?.agent?.skipBootstrap, params.config?.agents?.defaults?.skipBootstrap,
); );
if (cfg.workspaceAccess === "none") { if (cfg.workspaceAccess === "none") {
try { try {

View File

@ -24,7 +24,7 @@ let listenerStarted = false;
function resolveArchiveAfterMs() { function resolveArchiveAfterMs() {
const cfg = loadConfig(); const cfg = loadConfig();
const minutes = cfg.agent?.subagents?.archiveAfterMinutes ?? 60; const minutes = cfg.agents?.defaults?.subagents?.archiveAfterMinutes ?? 60;
if (!Number.isFinite(minutes) || minutes <= 0) return undefined; if (!Number.isFinite(minutes) || minutes <= 0) return undefined;
return Math.max(1, Math.floor(minutes)) * 60_000; return Math.max(1, Math.floor(minutes)) * 60_000;
} }

View File

@ -8,7 +8,7 @@ const normalizeNumber = (value: unknown): number | undefined =>
: undefined; : undefined;
export function resolveAgentTimeoutSeconds(cfg?: ClawdbotConfig): number { export function resolveAgentTimeoutSeconds(cfg?: ClawdbotConfig): number {
const raw = normalizeNumber(cfg?.agent?.timeoutSeconds); const raw = normalizeNumber(cfg?.agents?.defaults?.timeoutSeconds);
const seconds = raw ?? DEFAULT_AGENT_TIMEOUT_SECONDS; const seconds = raw ?? DEFAULT_AGENT_TIMEOUT_SECONDS;
return Math.max(seconds, 1); return Math.max(seconds, 1);
} }

View File

@ -55,19 +55,17 @@ export function createAgentsListTool(opts?: {
.map((value) => normalizeAgentId(value)), .map((value) => normalizeAgentId(value)),
); );
const configuredAgents = cfg.routing?.agents ?? {}; const configuredAgents = Array.isArray(cfg.agents?.list)
const configuredIds = Object.keys(configuredAgents).map((key) => ? cfg.agents?.list
normalizeAgentId(key), : [];
const configuredIds = configuredAgents.map((entry) =>
normalizeAgentId(entry.id),
); );
const configuredNameMap = new Map<string, string>(); const configuredNameMap = new Map<string, string>();
for (const [key, value] of Object.entries(configuredAgents)) { for (const entry of configuredAgents) {
if (!value || typeof value !== "object") continue; const name = entry?.name?.trim() ?? "";
const name =
typeof (value as { name?: unknown }).name === "string"
? ((value as { name?: string }).name?.trim() ?? "")
: "";
if (!name) continue; if (!name) continue;
configuredNameMap.set(normalizeAgentId(key), name); configuredNameMap.set(normalizeAgentId(entry.id), name);
} }
const allowed = new Set<string>(); const allowed = new Set<string>();

View File

@ -23,7 +23,7 @@ import type { AnyAgentTool } from "./common.js";
const DEFAULT_PROMPT = "Describe the image."; const DEFAULT_PROMPT = "Describe the image.";
function ensureImageToolConfigured(cfg?: ClawdbotConfig): boolean { function ensureImageToolConfigured(cfg?: ClawdbotConfig): boolean {
const imageModel = cfg?.agent?.imageModel as const imageModel = cfg?.agents?.defaults?.imageModel as
| { primary?: string; fallbacks?: string[] } | { primary?: string; fallbacks?: string[] }
| string | string
| undefined; | undefined;
@ -45,7 +45,7 @@ function pickMaxBytes(
) { ) {
return Math.floor(maxBytesMb * 1024 * 1024); return Math.floor(maxBytesMb * 1024 * 1024);
} }
const configured = cfg?.agent?.mediaMaxMb; const configured = cfg?.agents?.defaults?.mediaMaxMb;
if ( if (
typeof configured === "number" && typeof configured === "number" &&
Number.isFinite(configured) && Number.isFinite(configured) &&
@ -141,7 +141,7 @@ export function createImageTool(options?: {
label: "Image", label: "Image",
name: "image", name: "image",
description: description:
"Analyze an image with the configured image model (agent.imageModel). Provide a prompt and image path or URL.", "Analyze an image with the configured image model (agents.defaults.imageModel). Provide a prompt and image path or URL.",
parameters: Type.Object({ parameters: Type.Object({
prompt: Type.Optional(Type.String()), prompt: Type.Optional(Type.String()),
image: Type.String(), image: Type.String(),

View File

@ -25,7 +25,7 @@ const SessionsHistoryToolSchema = Type.Object({
function resolveSandboxSessionToolsVisibility( function resolveSandboxSessionToolsVisibility(
cfg: ReturnType<typeof loadConfig>, cfg: ReturnType<typeof loadConfig>,
) { ) {
return cfg.agent?.sandbox?.sessionToolsVisibility ?? "spawned"; return cfg.agents?.defaults?.sandbox?.sessionToolsVisibility ?? "spawned";
} }
async function isSpawnedSessionAllowed(params: { async function isSpawnedSessionAllowed(params: {
@ -97,7 +97,7 @@ export function createSessionsHistoryTool(opts?: {
} }
} }
const routingA2A = cfg.routing?.agentToAgent; const routingA2A = cfg.tools?.agentToAgent;
const a2aEnabled = routingA2A?.enabled === true; const a2aEnabled = routingA2A?.enabled === true;
const allowPatterns = Array.isArray(routingA2A?.allow) const allowPatterns = Array.isArray(routingA2A?.allow)
? routingA2A.allow ? routingA2A.allow
@ -126,14 +126,13 @@ export function createSessionsHistoryTool(opts?: {
return jsonResult({ return jsonResult({
status: "forbidden", status: "forbidden",
error: error:
"Agent-to-agent history is disabled. Set routing.agentToAgent.enabled=true to allow cross-agent access.", "Agent-to-agent history is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent access.",
}); });
} }
if (!matchesAllow(requesterAgentId) || !matchesAllow(targetAgentId)) { if (!matchesAllow(requesterAgentId) || !matchesAllow(targetAgentId)) {
return jsonResult({ return jsonResult({
status: "forbidden", status: "forbidden",
error: error: "Agent-to-agent history denied by tools.agentToAgent.allow.",
"Agent-to-agent history denied by routing.agentToAgent.allow.",
}); });
} }
} }

View File

@ -13,7 +13,7 @@ vi.mock("../../config/config.js", async (importOriginal) => {
loadConfig: () => loadConfig: () =>
({ ({
session: { scope: "per-sender", mainKey: "main" }, session: { scope: "per-sender", mainKey: "main" },
routing: { agentToAgent: { enabled: false } }, tools: { agentToAgent: { enabled: false } },
}) as never, }) as never,
}; };
}); });
@ -32,7 +32,7 @@ describe("sessions_list gating", () => {
}); });
}); });
it("filters out other agents when routing.agentToAgent.enabled is false", async () => { it("filters out other agents when tools.agentToAgent.enabled is false", async () => {
const tool = createSessionsListTool({ agentSessionKey: "agent:main:main" }); const tool = createSessionsListTool({ agentSessionKey: "agent:main:main" });
const result = await tool.execute("call1", {}); const result = await tool.execute("call1", {});
expect(result.details).toMatchObject({ expect(result.details).toMatchObject({

View File

@ -53,7 +53,7 @@ const SessionsListToolSchema = Type.Object({
function resolveSandboxSessionToolsVisibility( function resolveSandboxSessionToolsVisibility(
cfg: ReturnType<typeof loadConfig>, cfg: ReturnType<typeof loadConfig>,
) { ) {
return cfg.agent?.sandbox?.sessionToolsVisibility ?? "spawned"; return cfg.agents?.defaults?.sandbox?.sessionToolsVisibility ?? "spawned";
} }
export function createSessionsListTool(opts?: { export function createSessionsListTool(opts?: {
@ -126,7 +126,7 @@ export function createSessionsListTool(opts?: {
const sessions = Array.isArray(list?.sessions) ? list.sessions : []; const sessions = Array.isArray(list?.sessions) ? list.sessions : [];
const storePath = typeof list?.path === "string" ? list.path : undefined; const storePath = typeof list?.path === "string" ? list.path : undefined;
const routingA2A = cfg.routing?.agentToAgent; const routingA2A = cfg.tools?.agentToAgent;
const a2aEnabled = routingA2A?.enabled === true; const a2aEnabled = routingA2A?.enabled === true;
const allowPatterns = Array.isArray(routingA2A?.allow) const allowPatterns = Array.isArray(routingA2A?.allow)
? routingA2A.allow ? routingA2A.allow

View File

@ -13,7 +13,7 @@ vi.mock("../../config/config.js", async (importOriginal) => {
loadConfig: () => loadConfig: () =>
({ ({
session: { scope: "per-sender", mainKey: "main" }, session: { scope: "per-sender", mainKey: "main" },
routing: { agentToAgent: { enabled: false } }, tools: { agentToAgent: { enabled: false } },
}) as never, }) as never,
}; };
}); });
@ -25,7 +25,7 @@ describe("sessions_send gating", () => {
callGatewayMock.mockReset(); callGatewayMock.mockReset();
}); });
it("blocks cross-agent sends when routing.agentToAgent.enabled is false", async () => { it("blocks cross-agent sends when tools.agentToAgent.enabled is false", async () => {
const tool = createSessionsSendTool({ const tool = createSessionsSendTool({
agentSessionKey: "agent:main:main", agentSessionKey: "agent:main:main",
agentProvider: "whatsapp", agentProvider: "whatsapp",

View File

@ -54,7 +54,7 @@ export function createSessionsSendTool(opts?: {
const cfg = loadConfig(); const cfg = loadConfig();
const { mainKey, alias } = resolveMainSessionAlias(cfg); const { mainKey, alias } = resolveMainSessionAlias(cfg);
const visibility = const visibility =
cfg.agent?.sandbox?.sessionToolsVisibility ?? "spawned"; cfg.agents?.defaults?.sandbox?.sessionToolsVisibility ?? "spawned";
const requesterInternalKey = const requesterInternalKey =
typeof opts?.agentSessionKey === "string" && opts.agentSessionKey.trim() typeof opts?.agentSessionKey === "string" && opts.agentSessionKey.trim()
? resolveInternalSessionKey({ ? resolveInternalSessionKey({
@ -126,7 +126,7 @@ export function createSessionsSendTool(opts?: {
mainKey, mainKey,
}); });
const routingA2A = cfg.routing?.agentToAgent; const routingA2A = cfg.tools?.agentToAgent;
const a2aEnabled = routingA2A?.enabled === true; const a2aEnabled = routingA2A?.enabled === true;
const allowPatterns = Array.isArray(routingA2A?.allow) const allowPatterns = Array.isArray(routingA2A?.allow)
? routingA2A.allow ? routingA2A.allow
@ -156,7 +156,7 @@ export function createSessionsSendTool(opts?: {
runId: crypto.randomUUID(), runId: crypto.randomUUID(),
status: "forbidden", status: "forbidden",
error: error:
"Agent-to-agent messaging is disabled. Set routing.agentToAgent.enabled=true to allow cross-agent sends.", "Agent-to-agent messaging is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent sends.",
sessionKey: displayKey, sessionKey: displayKey,
}); });
} }
@ -165,7 +165,7 @@ export function createSessionsSendTool(opts?: {
runId: crypto.randomUUID(), runId: crypto.randomUUID(),
status: "forbidden", status: "forbidden",
error: error:
"Agent-to-agent messaging denied by routing.agentToAgent.allow.", "Agent-to-agent messaging denied by tools.agentToAgent.allow.",
sessionKey: displayKey, sessionKey: displayKey,
}); });
} }

View File

@ -85,9 +85,11 @@ describe("block streaming", () => {
onBlockReply, onBlockReply,
}, },
{ {
agent: { agents: {
model: "anthropic/claude-opus-4-5", defaults: {
workspace: path.join(home, "clawd"), model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
}, },
whatsapp: { allowFrom: ["*"] }, whatsapp: { allowFrom: ["*"] },
session: { store: path.join(home, "sessions.json") }, session: { store: path.join(home, "sessions.json") },
@ -140,9 +142,11 @@ describe("block streaming", () => {
onBlockReply, onBlockReply,
}, },
{ {
agent: { agents: {
model: "anthropic/claude-opus-4-5", defaults: {
workspace: path.join(home, "clawd"), model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
}, },
telegram: { allowFrom: ["*"] }, telegram: { allowFrom: ["*"] },
session: { store: path.join(home, "sessions.json") }, session: { store: path.join(home, "sessions.json") },
@ -185,9 +189,11 @@ describe("block streaming", () => {
onBlockReply, onBlockReply,
}, },
{ {
agent: { agents: {
model: "anthropic/claude-opus-4-5", defaults: {
workspace: path.join(home, "clawd"), model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
}, },
whatsapp: { allowFrom: ["*"] }, whatsapp: { allowFrom: ["*"] },
session: { store: path.join(home, "sessions.json") }, session: { store: path.join(home, "sessions.json") },
@ -239,9 +245,11 @@ describe("block streaming", () => {
blockReplyTimeoutMs: 10, blockReplyTimeoutMs: 10,
}, },
{ {
agent: { agents: {
model: "anthropic/claude-opus-4-5", defaults: {
workspace: path.join(home, "clawd"), model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
}, },
telegram: { allowFrom: ["*"] }, telegram: { allowFrom: ["*"] },
session: { store: path.join(home, "sessions.json") }, session: { store: path.join(home, "sessions.json") },

View File

@ -78,11 +78,13 @@ describe("directive behavior", () => {
}, },
{}, {},
{ {
agent: { agents: {
model: "anthropic/claude-opus-4-5", defaults: {
workspace: path.join(home, "clawd"), model: "anthropic/claude-opus-4-5",
models: { workspace: path.join(home, "clawd"),
"anthropic/claude-opus-4-5": { alias: " help " }, models: {
"anthropic/claude-opus-4-5": { alias: " help " },
},
}, },
}, },
whatsapp: { allowFrom: ["*"] }, whatsapp: { allowFrom: ["*"] },
@ -108,9 +110,11 @@ describe("directive behavior", () => {
}, },
{}, {},
{ {
agent: { agents: {
model: "anthropic/claude-opus-4-5", defaults: {
workspace: path.join(home, "clawd"), model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
}, },
whatsapp: { allowFrom: ["*"] }, whatsapp: { allowFrom: ["*"] },
session: { store: path.join(home, "sessions.json") }, session: { store: path.join(home, "sessions.json") },
@ -138,11 +142,13 @@ describe("directive behavior", () => {
}, },
{}, {},
{ {
agent: { agents: {
model: "anthropic/claude-opus-4-5", defaults: {
workspace: path.join(home, "clawd"), model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
}, },
routing: { messages: {
queue: { queue: {
mode: "collect", mode: "collect",
debounceMs: 1500, debounceMs: 1500,
@ -174,10 +180,12 @@ describe("directive behavior", () => {
{ Body: "/think", From: "+1222", To: "+1222" }, { Body: "/think", From: "+1222", To: "+1222" },
{}, {},
{ {
agent: { agents: {
model: "anthropic/claude-opus-4-5", defaults: {
workspace: path.join(home, "clawd"), model: "anthropic/claude-opus-4-5",
thinkingDefault: "high", workspace: path.join(home, "clawd"),
thinkingDefault: "high",
},
}, },
session: { store: path.join(home, "sessions.json") }, session: { store: path.join(home, "sessions.json") },
}, },
@ -198,9 +206,11 @@ describe("directive behavior", () => {
{ Body: "/think", From: "+1222", To: "+1222" }, { Body: "/think", From: "+1222", To: "+1222" },
{}, {},
{ {
agent: { agents: {
model: "anthropic/claude-opus-4-5", defaults: {
workspace: path.join(home, "clawd"), model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
}, },
session: { store: path.join(home, "sessions.json") }, session: { store: path.join(home, "sessions.json") },
}, },
@ -232,9 +242,11 @@ describe("directive behavior", () => {
}, },
{}, {},
{ {
agent: { agents: {
model: "anthropic/claude-opus-4-5", defaults: {
workspace: path.join(home, "clawd"), model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
}, },
whatsapp: { allowFrom: ["*"] }, whatsapp: { allowFrom: ["*"] },
session: { store: path.join(home, "sessions.json") }, session: { store: path.join(home, "sessions.json") },
@ -270,9 +282,11 @@ describe("directive behavior", () => {
}, },
{}, {},
{ {
agent: { agents: {
model: "anthropic/claude-opus-4-5", defaults: {
workspace: path.join(home, "clawd"), model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
}, },
whatsapp: { allowFrom: ["*"] }, whatsapp: { allowFrom: ["*"] },
session: { store: path.join(home, "sessions.json") }, session: { store: path.join(home, "sessions.json") },
@ -303,9 +317,11 @@ describe("directive behavior", () => {
}, },
{}, {},
{ {
agent: { agents: {
model: "anthropic/claude-opus-4-5", defaults: {
workspace: path.join(home, "clawd"), model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
}, },
whatsapp: { whatsapp: {
allowFrom: ["*"], allowFrom: ["*"],
@ -330,9 +346,11 @@ describe("directive behavior", () => {
{ Body: "/verbose on", From: "+1222", To: "+1222" }, { Body: "/verbose on", From: "+1222", To: "+1222" },
{}, {},
{ {
agent: { agents: {
model: "anthropic/claude-opus-4-5", defaults: {
workspace: path.join(home, "clawd"), model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
}, },
session: { store: path.join(home, "sessions.json") }, session: { store: path.join(home, "sessions.json") },
}, },
@ -352,10 +370,12 @@ describe("directive behavior", () => {
{ Body: "/think", From: "+1222", To: "+1222" }, { Body: "/think", From: "+1222", To: "+1222" },
{}, {},
{ {
agent: { agents: {
model: "anthropic/claude-opus-4-5", defaults: {
workspace: path.join(home, "clawd"), model: "anthropic/claude-opus-4-5",
thinkingDefault: "high", workspace: path.join(home, "clawd"),
thinkingDefault: "high",
},
}, },
session: { store: path.join(home, "sessions.json") }, session: { store: path.join(home, "sessions.json") },
}, },
@ -376,9 +396,11 @@ describe("directive behavior", () => {
{ Body: "/think", From: "+1222", To: "+1222" }, { Body: "/think", From: "+1222", To: "+1222" },
{}, {},
{ {
agent: { agents: {
model: "anthropic/claude-opus-4-5", defaults: {
workspace: path.join(home, "clawd"), model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
}, },
session: { store: path.join(home, "sessions.json") }, session: { store: path.join(home, "sessions.json") },
}, },
@ -399,10 +421,12 @@ describe("directive behavior", () => {
{ Body: "/verbose", From: "+1222", To: "+1222" }, { Body: "/verbose", From: "+1222", To: "+1222" },
{}, {},
{ {
agent: { agents: {
model: "anthropic/claude-opus-4-5", defaults: {
workspace: path.join(home, "clawd"), model: "anthropic/claude-opus-4-5",
verboseDefault: "on", workspace: path.join(home, "clawd"),
verboseDefault: "on",
},
}, },
session: { store: path.join(home, "sessions.json") }, session: { store: path.join(home, "sessions.json") },
}, },
@ -423,9 +447,11 @@ describe("directive behavior", () => {
{ Body: "/reasoning", From: "+1222", To: "+1222" }, { Body: "/reasoning", From: "+1222", To: "+1222" },
{}, {},
{ {
agent: { agents: {
model: "anthropic/claude-opus-4-5", defaults: {
workspace: path.join(home, "clawd"), model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
}, },
session: { store: path.join(home, "sessions.json") }, session: { store: path.join(home, "sessions.json") },
}, },
@ -452,10 +478,14 @@ describe("directive behavior", () => {
}, },
{}, {},
{ {
agent: { agents: {
model: "anthropic/claude-opus-4-5", defaults: {
workspace: path.join(home, "clawd"), model: "anthropic/claude-opus-4-5",
elevatedDefault: "on", workspace: path.join(home, "clawd"),
elevatedDefault: "on",
},
},
tools: {
elevated: { elevated: {
allowFrom: { whatsapp: ["+1222"] }, allowFrom: { whatsapp: ["+1222"] },
}, },
@ -486,13 +516,17 @@ describe("directive behavior", () => {
}, },
{}, {},
{ {
agent: { agents: {
model: "anthropic/claude-opus-4-5", defaults: {
workspace: path.join(home, "clawd"), model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
sandbox: { mode: "off" },
},
},
tools: {
elevated: { elevated: {
allowFrom: { whatsapp: ["+1222"] }, allowFrom: { whatsapp: ["+1222"] },
}, },
sandbox: { mode: "off" },
}, },
whatsapp: { allowFrom: ["+1222"] }, whatsapp: { allowFrom: ["+1222"] },
session: { store: path.join(home, "sessions.json") }, session: { store: path.join(home, "sessions.json") },
@ -520,9 +554,13 @@ describe("directive behavior", () => {
}, },
{}, {},
{ {
agent: { agents: {
model: "anthropic/claude-opus-4-5", defaults: {
workspace: path.join(home, "clawd"), model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
tools: {
elevated: { elevated: {
allowFrom: { whatsapp: ["+1222"] }, allowFrom: { whatsapp: ["+1222"] },
}, },
@ -552,9 +590,13 @@ describe("directive behavior", () => {
}, },
{}, {},
{ {
agent: { agents: {
model: "anthropic/claude-opus-4-5", defaults: {
workspace: path.join(home, "clawd"), model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
tools: {
elevated: { elevated: {
allowFrom: { whatsapp: ["+1222"] }, allowFrom: { whatsapp: ["+1222"] },
}, },
@ -585,9 +627,13 @@ describe("directive behavior", () => {
}, },
{}, {},
{ {
agent: { agents: {
model: "anthropic/claude-opus-4-5", defaults: {
workspace: path.join(home, "clawd"), model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
tools: {
elevated: { elevated: {
allowFrom: { whatsapp: ["+1222"] }, allowFrom: { whatsapp: ["+1222"] },
}, },
@ -613,9 +659,11 @@ describe("directive behavior", () => {
{ Body: "/queue interrupt", From: "+1222", To: "+1222" }, { Body: "/queue interrupt", From: "+1222", To: "+1222" },
{}, {},
{ {
agent: { agents: {
model: "anthropic/claude-opus-4-5", defaults: {
workspace: path.join(home, "clawd"), model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
}, },
whatsapp: { allowFrom: ["*"] }, whatsapp: { allowFrom: ["*"] },
session: { store: storePath }, session: { store: storePath },
@ -644,9 +692,11 @@ describe("directive behavior", () => {
}, },
{}, {},
{ {
agent: { agents: {
model: "anthropic/claude-opus-4-5", defaults: {
workspace: path.join(home, "clawd"), model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
}, },
whatsapp: { allowFrom: ["*"] }, whatsapp: { allowFrom: ["*"] },
session: { store: storePath }, session: { store: storePath },
@ -677,9 +727,11 @@ describe("directive behavior", () => {
{ Body: "/queue interrupt", From: "+1222", To: "+1222" }, { Body: "/queue interrupt", From: "+1222", To: "+1222" },
{}, {},
{ {
agent: { agents: {
model: "anthropic/claude-opus-4-5", defaults: {
workspace: path.join(home, "clawd"), model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
}, },
whatsapp: { allowFrom: ["*"] }, whatsapp: { allowFrom: ["*"] },
session: { store: storePath }, session: { store: storePath },
@ -690,9 +742,11 @@ describe("directive behavior", () => {
{ Body: "/queue reset", From: "+1222", To: "+1222" }, { Body: "/queue reset", From: "+1222", To: "+1222" },
{}, {},
{ {
agent: { agents: {
model: "anthropic/claude-opus-4-5", defaults: {
workspace: path.join(home, "clawd"), model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
}, },
whatsapp: { allowFrom: ["*"] }, whatsapp: { allowFrom: ["*"] },
session: { store: storePath }, session: { store: storePath },
@ -749,9 +803,11 @@ describe("directive behavior", () => {
ctx, ctx,
{}, {},
{ {
agent: { agents: {
model: "anthropic/claude-opus-4-5", defaults: {
workspace: path.join(home, "clawd"), model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
}, },
whatsapp: { whatsapp: {
allowFrom: ["*"], allowFrom: ["*"],
@ -810,9 +866,11 @@ describe("directive behavior", () => {
{ Body: "/verbose on", From: ctx.From, To: ctx.To }, { Body: "/verbose on", From: ctx.From, To: ctx.To },
{}, {},
{ {
agent: { agents: {
model: "anthropic/claude-opus-4-5", defaults: {
workspace: path.join(home, "clawd"), model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
}, },
whatsapp: { whatsapp: {
allowFrom: ["*"], allowFrom: ["*"],
@ -825,9 +883,11 @@ describe("directive behavior", () => {
ctx, ctx,
{}, {},
{ {
agent: { agents: {
model: "anthropic/claude-opus-4-5", defaults: {
workspace: path.join(home, "clawd"), model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
}, },
whatsapp: { whatsapp: {
allowFrom: ["*"], allowFrom: ["*"],
@ -853,12 +913,14 @@ describe("directive behavior", () => {
{ Body: "/model", From: "+1222", To: "+1222" }, { Body: "/model", From: "+1222", To: "+1222" },
{}, {},
{ {
agent: { agents: {
model: { primary: "anthropic/claude-opus-4-5" }, defaults: {
workspace: path.join(home, "clawd"), model: { primary: "anthropic/claude-opus-4-5" },
models: { workspace: path.join(home, "clawd"),
"anthropic/claude-opus-4-5": {}, models: {
"openai/gpt-4.1-mini": {}, "anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
},
}, },
}, },
session: { store: storePath }, session: { store: storePath },
@ -883,12 +945,14 @@ describe("directive behavior", () => {
{ Body: "/model status", From: "+1222", To: "+1222" }, { Body: "/model status", From: "+1222", To: "+1222" },
{}, {},
{ {
agent: { agents: {
model: { primary: "anthropic/claude-opus-4-5" }, defaults: {
workspace: path.join(home, "clawd"), model: { primary: "anthropic/claude-opus-4-5" },
models: { workspace: path.join(home, "clawd"),
"anthropic/claude-opus-4-5": {}, models: {
"openai/gpt-4.1-mini": {}, "anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
},
}, },
}, },
session: { store: storePath }, session: { store: storePath },
@ -913,12 +977,14 @@ describe("directive behavior", () => {
{ Body: "/model list", From: "+1222", To: "+1222" }, { Body: "/model list", From: "+1222", To: "+1222" },
{}, {},
{ {
agent: { agents: {
model: { primary: "anthropic/claude-opus-4-5" }, defaults: {
workspace: path.join(home, "clawd"), model: { primary: "anthropic/claude-opus-4-5" },
models: { workspace: path.join(home, "clawd"),
"anthropic/claude-opus-4-5": {}, models: {
"openai/gpt-4.1-mini": {}, "anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
},
}, },
}, },
session: { store: storePath }, session: { store: storePath },
@ -943,12 +1009,14 @@ describe("directive behavior", () => {
{ Body: "/model", From: "+1222", To: "+1222" }, { Body: "/model", From: "+1222", To: "+1222" },
{}, {},
{ {
agent: { agents: {
model: { primary: "anthropic/claude-opus-4-5" }, defaults: {
workspace: path.join(home, "clawd"), model: { primary: "anthropic/claude-opus-4-5" },
models: { workspace: path.join(home, "clawd"),
"anthropic/claude-opus-4-5": {}, models: {
"openai/gpt-4.1-mini": {}, "anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
},
}, },
}, },
session: { store: storePath }, session: { store: storePath },
@ -972,11 +1040,13 @@ describe("directive behavior", () => {
{ Body: "/model list", From: "+1222", To: "+1222" }, { Body: "/model list", From: "+1222", To: "+1222" },
{}, {},
{ {
agent: { agents: {
model: { primary: "anthropic/claude-opus-4-5" }, defaults: {
workspace: path.join(home, "clawd"), model: { primary: "anthropic/claude-opus-4-5" },
models: { workspace: path.join(home, "clawd"),
"anthropic/claude-opus-4-5": {}, models: {
"anthropic/claude-opus-4-5": {},
},
}, },
}, },
session: { store: storePath }, session: { store: storePath },
@ -999,12 +1069,14 @@ describe("directive behavior", () => {
{ Body: "/model openai/gpt-4.1-mini", From: "+1222", To: "+1222" }, { Body: "/model openai/gpt-4.1-mini", From: "+1222", To: "+1222" },
{}, {},
{ {
agent: { agents: {
model: { primary: "anthropic/claude-opus-4-5" }, defaults: {
workspace: path.join(home, "clawd"), model: { primary: "anthropic/claude-opus-4-5" },
models: { workspace: path.join(home, "clawd"),
"anthropic/claude-opus-4-5": {}, models: {
"openai/gpt-4.1-mini": {}, "anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
},
}, },
}, },
session: { store: storePath }, session: { store: storePath },
@ -1030,12 +1102,14 @@ describe("directive behavior", () => {
{ Body: "/model Opus", From: "+1222", To: "+1222" }, { Body: "/model Opus", From: "+1222", To: "+1222" },
{}, {},
{ {
agent: { agents: {
model: { primary: "openai/gpt-4.1-mini" }, defaults: {
workspace: path.join(home, "clawd"), model: { primary: "openai/gpt-4.1-mini" },
models: { workspace: path.join(home, "clawd"),
"openai/gpt-4.1-mini": {}, models: {
"anthropic/claude-opus-4-5": { alias: "Opus" }, "openai/gpt-4.1-mini": {},
"anthropic/claude-opus-4-5": { alias: "Opus" },
},
}, },
}, },
session: { store: storePath }, session: { store: storePath },
@ -1081,12 +1155,14 @@ describe("directive behavior", () => {
{ Body: "/model Opus@anthropic:work", From: "+1222", To: "+1222" }, { Body: "/model Opus@anthropic:work", From: "+1222", To: "+1222" },
{}, {},
{ {
agent: { agents: {
model: { primary: "openai/gpt-4.1-mini" }, defaults: {
workspace: path.join(home, "clawd"), model: { primary: "openai/gpt-4.1-mini" },
models: { workspace: path.join(home, "clawd"),
"openai/gpt-4.1-mini": {}, models: {
"anthropic/claude-opus-4-5": { alias: "Opus" }, "openai/gpt-4.1-mini": {},
"anthropic/claude-opus-4-5": { alias: "Opus" },
},
}, },
}, },
session: { store: storePath }, session: { store: storePath },
@ -1112,12 +1188,14 @@ describe("directive behavior", () => {
{ Body: "/model Opus", From: "+1222", To: "+1222" }, { Body: "/model Opus", From: "+1222", To: "+1222" },
{}, {},
{ {
agent: { agents: {
model: { primary: "openai/gpt-4.1-mini" }, defaults: {
workspace: path.join(home, "clawd"), model: { primary: "openai/gpt-4.1-mini" },
models: { workspace: path.join(home, "clawd"),
"openai/gpt-4.1-mini": {}, models: {
"anthropic/claude-opus-4-5": { alias: "Opus" }, "openai/gpt-4.1-mini": {},
"anthropic/claude-opus-4-5": { alias: "Opus" },
},
}, },
}, },
session: { store: storePath }, session: { store: storePath },
@ -1151,12 +1229,14 @@ describe("directive behavior", () => {
}, },
{}, {},
{ {
agent: { agents: {
model: { primary: "anthropic/claude-opus-4-5" }, defaults: {
workspace: path.join(home, "clawd"), model: { primary: "anthropic/claude-opus-4-5" },
models: { workspace: path.join(home, "clawd"),
"anthropic/claude-opus-4-5": {}, models: {
"openai/gpt-4.1-mini": {}, "anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
},
}, },
}, },
whatsapp: { whatsapp: {
@ -1204,9 +1284,11 @@ describe("directive behavior", () => {
}, },
{}, {},
{ {
agent: { agents: {
model: "anthropic/claude-opus-4-5", defaults: {
workspace: path.join(home, "clawd"), model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
}, },
whatsapp: { whatsapp: {
allowFrom: ["*"], allowFrom: ["*"],
@ -1242,9 +1324,13 @@ describe("directive behavior", () => {
}, },
{}, {},
{ {
agent: { agents: {
model: "anthropic/claude-opus-4-5", defaults: {
workspace: path.join(home, "clawd"), model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
tools: {
elevated: { elevated: {
allowFrom: { whatsapp: ["+1004"] }, allowFrom: { whatsapp: ["+1004"] },
}, },

View File

@ -57,9 +57,11 @@ async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
function makeCfg(home: string) { function makeCfg(home: string) {
return { return {
agent: { agents: {
model: "anthropic/claude-opus-4-5", defaults: {
workspace: join(home, "clawd"), model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
}, },
whatsapp: { whatsapp: {
allowFrom: ["*"], allowFrom: ["*"],

View File

@ -53,9 +53,11 @@ async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
function makeCfg(home: string) { function makeCfg(home: string) {
return { return {
agent: { agents: {
model: "anthropic/claude-opus-4-5", defaults: {
workspace: path.join(home, "clawd"), model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
}, },
whatsapp: { allowFrom: ["*"] }, whatsapp: { allowFrom: ["*"] },
session: { store: path.join(home, "sessions.json") }, session: { store: path.join(home, "sessions.json") },

View File

@ -50,13 +50,15 @@ async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
function makeCfg(home: string, queue?: Record<string, unknown>) { function makeCfg(home: string, queue?: Record<string, unknown>) {
return { return {
agent: { agents: {
model: "anthropic/claude-opus-4-5", defaults: {
workspace: path.join(home, "clawd"), model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
}, },
whatsapp: { allowFrom: ["*"] }, whatsapp: { allowFrom: ["*"] },
session: { store: path.join(home, "sessions.json") }, session: { store: path.join(home, "sessions.json") },
routing: queue ? { queue } : undefined, messages: queue ? { queue } : undefined,
}; };
} }

View File

@ -25,13 +25,18 @@ const usageMocks = vi.hoisted(() => ({
vi.mock("../infra/provider-usage.js", () => usageMocks); vi.mock("../infra/provider-usage.js", () => usageMocks);
import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
import { import {
abortEmbeddedPiRun, abortEmbeddedPiRun,
compactEmbeddedPiSession, compactEmbeddedPiSession,
runEmbeddedPiAgent, runEmbeddedPiAgent,
} from "../agents/pi-embedded.js"; } from "../agents/pi-embedded.js";
import { ensureSandboxWorkspaceForSession } from "../agents/sandbox.js"; import { ensureSandboxWorkspaceForSession } from "../agents/sandbox.js";
import { loadSessionStore, resolveSessionKey } from "../config/sessions.js"; import {
loadSessionStore,
resolveAgentIdFromSessionKey,
resolveSessionKey,
} from "../config/sessions.js";
import { getReplyFromConfig } from "./reply.js"; import { getReplyFromConfig } from "./reply.js";
import { HEARTBEAT_TOKEN } from "./tokens.js"; import { HEARTBEAT_TOKEN } from "./tokens.js";
@ -61,9 +66,11 @@ async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
function makeCfg(home: string) { function makeCfg(home: string) {
return { return {
agent: { agents: {
model: "anthropic/claude-opus-4-5", defaults: {
workspace: join(home, "clawd"), model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
}, },
whatsapp: { whatsapp: {
allowFrom: ["*"], allowFrom: ["*"],
@ -345,9 +352,11 @@ describe("trigger handling", () => {
it("allows owner to set send policy", async () => { it("allows owner to set send policy", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const cfg = { const cfg = {
agent: { agents: {
model: "anthropic/claude-opus-4-5", defaults: {
workspace: join(home, "clawd"), model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
}, },
whatsapp: { whatsapp: {
allowFrom: ["+1000"], allowFrom: ["+1000"],
@ -381,9 +390,13 @@ describe("trigger handling", () => {
it("allows approved sender to toggle elevated mode", async () => { it("allows approved sender to toggle elevated mode", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const cfg = { const cfg = {
agent: { agents: {
model: "anthropic/claude-opus-4-5", defaults: {
workspace: join(home, "clawd"), model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
tools: {
elevated: { elevated: {
allowFrom: { whatsapp: ["+1000"] }, allowFrom: { whatsapp: ["+1000"] },
}, },
@ -420,9 +433,13 @@ describe("trigger handling", () => {
it("rejects elevated toggles when disabled", async () => { it("rejects elevated toggles when disabled", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const cfg = { const cfg = {
agent: { agents: {
model: "anthropic/claude-opus-4-5", defaults: {
workspace: join(home, "clawd"), model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
tools: {
elevated: { elevated: {
enabled: false, enabled: false,
allowFrom: { whatsapp: ["+1000"] }, allowFrom: { whatsapp: ["+1000"] },
@ -467,9 +484,13 @@ describe("trigger handling", () => {
}, },
}); });
const cfg = { const cfg = {
agent: { agents: {
model: "anthropic/claude-opus-4-5", defaults: {
workspace: join(home, "clawd"), model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
tools: {
elevated: { elevated: {
allowFrom: { whatsapp: ["+1000"] }, allowFrom: { whatsapp: ["+1000"] },
}, },
@ -510,9 +531,13 @@ describe("trigger handling", () => {
}, },
}); });
const cfg = { const cfg = {
agent: { agents: {
model: "anthropic/claude-opus-4-5", defaults: {
workspace: join(home, "clawd"), model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
tools: {
elevated: { elevated: {
allowFrom: { whatsapp: ["+1000"] }, allowFrom: { whatsapp: ["+1000"] },
}, },
@ -545,9 +570,13 @@ describe("trigger handling", () => {
it("allows elevated directive in groups when mentioned", async () => { it("allows elevated directive in groups when mentioned", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const cfg = { const cfg = {
agent: { agents: {
model: "anthropic/claude-opus-4-5", defaults: {
workspace: join(home, "clawd"), model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
tools: {
elevated: { elevated: {
allowFrom: { whatsapp: ["+1000"] }, allowFrom: { whatsapp: ["+1000"] },
}, },
@ -589,9 +618,13 @@ describe("trigger handling", () => {
it("allows elevated directive in direct chats without mentions", async () => { it("allows elevated directive in direct chats without mentions", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const cfg = { const cfg = {
agent: { agents: {
model: "anthropic/claude-opus-4-5", defaults: {
workspace: join(home, "clawd"), model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
tools: {
elevated: { elevated: {
allowFrom: { whatsapp: ["+1000"] }, allowFrom: { whatsapp: ["+1000"] },
}, },
@ -635,9 +668,13 @@ describe("trigger handling", () => {
}, },
}); });
const cfg = { const cfg = {
agent: { agents: {
model: "anthropic/claude-opus-4-5", defaults: {
workspace: join(home, "clawd"), model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
tools: {
elevated: { elevated: {
allowFrom: { whatsapp: ["+1000"] }, allowFrom: { whatsapp: ["+1000"] },
}, },
@ -668,9 +705,11 @@ describe("trigger handling", () => {
it("falls back to discord dm allowFrom for elevated approval", async () => { it("falls back to discord dm allowFrom for elevated approval", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const cfg = { const cfg = {
agent: { agents: {
model: "anthropic/claude-opus-4-5", defaults: {
workspace: join(home, "clawd"), model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
}, },
discord: { discord: {
dm: { dm: {
@ -708,9 +747,13 @@ describe("trigger handling", () => {
it("treats explicit discord elevated allowlist as override", async () => { it("treats explicit discord elevated allowlist as override", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const cfg = { const cfg = {
agent: { agents: {
model: "anthropic/claude-opus-4-5", defaults: {
workspace: join(home, "clawd"), model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
tools: {
elevated: { elevated: {
allowFrom: { discord: [] }, allowFrom: { discord: [] },
}, },
@ -799,9 +842,12 @@ describe("trigger handling", () => {
}); });
const cfg = makeCfg(home); const cfg = makeCfg(home);
cfg.agent = { cfg.agents = {
...cfg.agent, ...cfg.agents,
heartbeat: { model: "anthropic/claude-haiku-4-5-20251001" }, defaults: {
...cfg.agents?.defaults,
heartbeat: { model: "anthropic/claude-haiku-4-5-20251001" },
},
}; };
await getReplyFromConfig( await getReplyFromConfig(
@ -941,15 +987,17 @@ describe("trigger handling", () => {
}, },
{}, {},
{ {
agent: { agents: {
model: "anthropic/claude-opus-4-5", defaults: {
workspace: join(home, "clawd"), model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
}, },
whatsapp: { whatsapp: {
allowFrom: ["*"], allowFrom: ["*"],
groups: { "*": { requireMention: false } }, groups: { "*": { requireMention: false } },
}, },
routing: { messages: {
groupChat: {}, groupChat: {},
}, },
session: { store: join(home, "sessions.json") }, session: { store: join(home, "sessions.json") },
@ -985,9 +1033,11 @@ describe("trigger handling", () => {
}, },
{}, {},
{ {
agent: { agents: {
model: "anthropic/claude-opus-4-5", defaults: {
workspace: join(home, "clawd"), model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
}, },
whatsapp: { whatsapp: {
allowFrom: ["*"], allowFrom: ["*"],
@ -1024,9 +1074,11 @@ describe("trigger handling", () => {
}, },
{}, {},
{ {
agent: { agents: {
model: "anthropic/claude-opus-4-5", defaults: {
workspace: join(home, "clawd"), model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
}, },
whatsapp: { whatsapp: {
allowFrom: ["*"], allowFrom: ["*"],
@ -1056,9 +1108,11 @@ describe("trigger handling", () => {
}, },
{}, {},
{ {
agent: { agents: {
model: "anthropic/claude-opus-4-5", defaults: {
workspace: join(home, "clawd"), model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
}, },
whatsapp: { whatsapp: {
allowFrom: ["+1999"], allowFrom: ["+1999"],
@ -1083,9 +1137,11 @@ describe("trigger handling", () => {
}, },
{}, {},
{ {
agent: { agents: {
model: "anthropic/claude-opus-4-5", defaults: {
workspace: join(home, "clawd"), model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
}, },
whatsapp: { whatsapp: {
allowFrom: ["+1999"], allowFrom: ["+1999"],
@ -1124,9 +1180,11 @@ describe("trigger handling", () => {
}, },
{}, {},
{ {
agent: { agents: {
model: "anthropic/claude-opus-4-5", defaults: {
workspace: join(home, "clawd"), model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
}, },
whatsapp: { whatsapp: {
allowFrom: ["*"], allowFrom: ["*"],
@ -1229,12 +1287,14 @@ describe("trigger handling", () => {
}); });
const cfg = { const cfg = {
agent: { agents: {
model: "anthropic/claude-opus-4-5", defaults: {
workspace: join(home, "clawd"), model: "anthropic/claude-opus-4-5",
sandbox: { workspace: join(home, "clawd"),
mode: "non-main" as const, sandbox: {
workspaceRoot: join(home, "sandboxes"), mode: "non-main" as const,
workspaceRoot: join(home, "sandboxes"),
},
}, },
}, },
whatsapp: { whatsapp: {
@ -1272,10 +1332,11 @@ describe("trigger handling", () => {
ctx, ctx,
cfg.session?.mainKey, cfg.session?.mainKey,
); );
const agentId = resolveAgentIdFromSessionKey(sessionKey);
const sandbox = await ensureSandboxWorkspaceForSession({ const sandbox = await ensureSandboxWorkspaceForSession({
config: cfg, config: cfg,
sessionKey, sessionKey,
workspaceDir: cfg.agent.workspace, workspaceDir: resolveAgentWorkspaceDir(cfg, agentId),
}); });
expect(sandbox).not.toBeNull(); expect(sandbox).not.toBeNull();
if (!sandbox) { if (!sandbox) {

View File

@ -212,7 +212,7 @@ export async function getReplyFromConfig(
): Promise<ReplyPayload | ReplyPayload[] | undefined> { ): Promise<ReplyPayload | ReplyPayload[] | undefined> {
const cfg = configOverride ?? loadConfig(); const cfg = configOverride ?? loadConfig();
const agentId = resolveAgentIdFromSessionKey(ctx.SessionKey); const agentId = resolveAgentIdFromSessionKey(ctx.SessionKey);
const agentCfg = cfg.agent; const agentCfg = cfg.agents?.defaults;
const sessionCfg = cfg.session; const sessionCfg = cfg.session;
const { defaultProvider, defaultModel, aliasIndex } = resolveDefaultModel({ const { defaultProvider, defaultModel, aliasIndex } = resolveDefaultModel({
cfg, cfg,
@ -239,7 +239,7 @@ export async function getReplyFromConfig(
resolveAgentWorkspaceDir(cfg, agentId) ?? DEFAULT_AGENT_WORKSPACE_DIR; resolveAgentWorkspaceDir(cfg, agentId) ?? DEFAULT_AGENT_WORKSPACE_DIR;
const workspace = await ensureAgentWorkspace({ const workspace = await ensureAgentWorkspace({
dir: workspaceDirRaw, dir: workspaceDirRaw,
ensureBootstrapFiles: !cfg.agent?.skipBootstrap, ensureBootstrapFiles: !agentCfg?.skipBootstrap,
}); });
const workspaceDir = workspace.dir; const workspaceDir = workspace.dir;
const agentDir = resolveAgentDir(cfg, agentId); const agentDir = resolveAgentDir(cfg, agentId);
@ -257,7 +257,7 @@ export async function getReplyFromConfig(
opts?.onTypingController?.(typing); opts?.onTypingController?.(typing);
let transcribedText: string | undefined; let transcribedText: string | undefined;
if (cfg.routing?.transcribeAudio && isAudio(ctx.MediaType)) { if (cfg.audio?.transcription && isAudio(ctx.MediaType)) {
const transcribed = await transcribeInboundAudio(cfg, ctx, defaultRuntime); const transcribed = await transcribeInboundAudio(cfg, ctx, defaultRuntime);
if (transcribed?.text) { if (transcribed?.text) {
transcribedText = transcribed.text; transcribedText = transcribed.text;
@ -329,7 +329,7 @@ export async function getReplyFromConfig(
cmd.textAliases.map((a) => a.replace(/^\//, "").toLowerCase()), cmd.textAliases.map((a) => a.replace(/^\//, "").toLowerCase()),
), ),
); );
const configuredAliases = Object.values(cfg.agent?.models ?? {}) const configuredAliases = Object.values(cfg.agents?.defaults?.models ?? {})
.map((entry) => entry.alias?.trim()) .map((entry) => entry.alias?.trim())
.filter((alias): alias is string => Boolean(alias)) .filter((alias): alias is string => Boolean(alias))
.filter((alias) => !reservedCommands.has(alias.toLowerCase())); .filter((alias) => !reservedCommands.has(alias.toLowerCase()));
@ -391,7 +391,7 @@ export async function getReplyFromConfig(
sessionCtx.Provider?.trim().toLowerCase() ?? sessionCtx.Provider?.trim().toLowerCase() ??
ctx.Provider?.trim().toLowerCase() ?? ctx.Provider?.trim().toLowerCase() ??
""; "";
const elevatedConfig = agentCfg?.elevated; const elevatedConfig = cfg.tools?.elevated;
const discordElevatedFallback = const discordElevatedFallback =
messageProviderKey === "discord" ? cfg.discord?.dm?.allowFrom : undefined; messageProviderKey === "discord" ? cfg.discord?.dm?.allowFrom : undefined;
const elevatedEnabled = elevatedConfig?.enabled !== false; const elevatedEnabled = elevatedConfig?.enabled !== false;

View File

@ -34,7 +34,7 @@ export function resolveBlockStreamingChunking(
} { } {
const providerKey = normalizeChunkProvider(provider); const providerKey = normalizeChunkProvider(provider);
const textLimit = resolveTextChunkLimit(cfg, providerKey); const textLimit = resolveTextChunkLimit(cfg, providerKey);
const chunkCfg = cfg?.agent?.blockStreamingChunk; const chunkCfg = cfg?.agents?.defaults?.blockStreamingChunk;
const maxRequested = Math.max( const maxRequested = Math.max(
1, 1,
Math.floor(chunkCfg?.maxChars ?? DEFAULT_BLOCK_STREAM_MAX), Math.floor(chunkCfg?.maxChars ?? DEFAULT_BLOCK_STREAM_MAX),

View File

@ -163,18 +163,19 @@ export async function buildStatusReply(params: {
? (normalizeGroupActivation(sessionEntry?.groupActivation) ?? ? (normalizeGroupActivation(sessionEntry?.groupActivation) ??
defaultGroupActivation()) defaultGroupActivation())
: undefined; : undefined;
const agentDefaults = cfg.agents?.defaults ?? {};
const statusText = buildStatusMessage({ const statusText = buildStatusMessage({
config: cfg, config: cfg,
agent: { agent: {
...cfg.agent, ...agentDefaults,
model: { model: {
...cfg.agent?.model, ...agentDefaults.model,
primary: `${provider}/${model}`, primary: `${provider}/${model}`,
}, },
contextTokens, contextTokens,
thinkingDefault: cfg.agent?.thinkingDefault, thinkingDefault: agentDefaults.thinkingDefault,
verboseDefault: cfg.agent?.verboseDefault, verboseDefault: agentDefaults.verboseDefault,
elevatedDefault: cfg.agent?.elevatedDefault, elevatedDefault: agentDefaults.elevatedDefault,
}, },
sessionEntry, sessionEntry,
sessionKey, sessionKey,

View File

@ -23,6 +23,7 @@ import {
resolveConfiguredModelRef, resolveConfiguredModelRef,
resolveModelRefFromString, resolveModelRefFromString,
} from "../../agents/model-selection.js"; } from "../../agents/model-selection.js";
import { resolveSandboxConfigForAgent } from "../../agents/sandbox.js";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import { import {
resolveAgentIdFromSessionKey, resolveAgentIdFromSessionKey,
@ -363,16 +364,16 @@ export async function handleDirectiveOnly(params: {
currentElevatedLevel, currentElevatedLevel,
} = params; } = params;
const runtimeIsSandboxed = (() => { const runtimeIsSandboxed = (() => {
const sandboxMode = params.cfg.agent?.sandbox?.mode ?? "off";
if (sandboxMode === "off") return false;
const sessionKey = params.sessionKey?.trim(); const sessionKey = params.sessionKey?.trim();
if (!sessionKey) return false; if (!sessionKey) return false;
const agentId = resolveAgentIdFromSessionKey(sessionKey); const agentId = resolveAgentIdFromSessionKey(sessionKey);
const sandboxCfg = resolveSandboxConfigForAgent(params.cfg, agentId);
if (sandboxCfg.mode === "off") return false;
const mainKey = resolveAgentMainSessionKey({ const mainKey = resolveAgentMainSessionKey({
cfg: params.cfg, cfg: params.cfg,
agentId, agentId,
}); });
if (sandboxMode === "all") return true; if (sandboxCfg.mode === "all") return true;
return sessionKey !== mainKey; return sessionKey !== mainKey;
})(); })();
const shouldHintDirectRuntime = const shouldHintDirectRuntime =
@ -394,7 +395,9 @@ export async function handleDirectiveOnly(params: {
provider: string; provider: string;
id: string; id: string;
}> = []; }> = [];
for (const raw of Object.keys(params.cfg.agent?.models ?? {})) { for (const raw of Object.keys(
params.cfg.agents?.defaults?.models ?? {},
)) {
const resolved = resolveModelRefFromString({ const resolved = resolveModelRefFromString({
raw: String(raw), raw: String(raw),
defaultProvider, defaultProvider,
@ -851,7 +854,7 @@ export async function persistInlineDirectives(params: {
model: string; model: string;
initialModelLabel: string; initialModelLabel: string;
formatModelSwitchEvent: (label: string, alias?: string) => string; formatModelSwitchEvent: (label: string, alias?: string) => string;
agentCfg: ClawdbotConfig["agent"] | undefined; agentCfg: NonNullable<ClawdbotConfig["agents"]>["defaults"] | undefined;
}): Promise<{ provider: string; model: string; contextTokens: number }> { }): Promise<{ provider: string; model: string; contextTokens: number }> {
const { const {
directives, directives,
@ -1007,13 +1010,16 @@ export function resolveDefaultModel(params: {
agentModelOverride && agentModelOverride.length > 0 agentModelOverride && agentModelOverride.length > 0
? { ? {
...params.cfg, ...params.cfg,
agent: { agents: {
...params.cfg.agent, ...params.cfg.agents,
model: { defaults: {
...(typeof params.cfg.agent?.model === "object" ...params.cfg.agents?.defaults,
? params.cfg.agent.model model: {
: undefined), ...(typeof params.cfg.agents?.defaults?.model === "object"
primary: agentModelOverride, ? params.cfg.agents.defaults.model
: undefined),
primary: agentModelOverride,
},
}, },
}, },
} }

View File

@ -9,7 +9,7 @@ import {
describe("mention helpers", () => { describe("mention helpers", () => {
it("builds regexes and skips invalid patterns", () => { it("builds regexes and skips invalid patterns", () => {
const regexes = buildMentionRegexes({ const regexes = buildMentionRegexes({
routing: { messages: {
groupChat: { mentionPatterns: ["\\bclawd\\b", "(invalid"] }, groupChat: { mentionPatterns: ["\\bclawd\\b", "(invalid"] },
}, },
}); });
@ -23,7 +23,7 @@ describe("mention helpers", () => {
it("matches patterns case-insensitively", () => { it("matches patterns case-insensitively", () => {
const regexes = buildMentionRegexes({ const regexes = buildMentionRegexes({
routing: { groupChat: { mentionPatterns: ["\\bclawd\\b"] } }, messages: { groupChat: { mentionPatterns: ["\\bclawd\\b"] } },
}); });
expect(matchesMentionPatterns("CLAWD: hi", regexes)).toBe(true); expect(matchesMentionPatterns("CLAWD: hi", regexes)).toBe(true);
}); });
@ -31,11 +31,16 @@ describe("mention helpers", () => {
it("uses per-agent mention patterns when configured", () => { it("uses per-agent mention patterns when configured", () => {
const regexes = buildMentionRegexes( const regexes = buildMentionRegexes(
{ {
routing: { messages: {
groupChat: { mentionPatterns: ["\\bglobal\\b"] }, groupChat: { mentionPatterns: ["\\bglobal\\b"] },
agents: { },
work: { mentionPatterns: ["\\bworkbot\\b"] }, agents: {
}, list: [
{
id: "work",
groupChat: { mentionPatterns: ["\\bworkbot\\b"] },
},
],
}, },
}, },
"work", "work",

View File

@ -1,23 +1,62 @@
import { resolveAgentConfig } from "../../agents/agent-scope.js";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import type { MsgContext } from "../templating.js"; import type { MsgContext } from "../templating.js";
function escapeRegExp(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function deriveMentionPatterns(identity?: { name?: string; emoji?: string }) {
const patterns: string[] = [];
const name = identity?.name?.trim();
if (name) {
const parts = name.split(/\s+/).filter(Boolean).map(escapeRegExp);
const re = parts.length ? parts.join(String.raw`\s+`) : escapeRegExp(name);
patterns.push(String.raw`\b@?${re}\b`);
}
const emoji = identity?.emoji?.trim();
if (emoji) {
patterns.push(escapeRegExp(emoji));
}
return patterns;
}
const BACKSPACE_CHAR = "\u0008";
function normalizeMentionPattern(pattern: string): string {
if (!pattern.includes(BACKSPACE_CHAR)) return pattern;
return pattern.split(BACKSPACE_CHAR).join("\\b");
}
function normalizeMentionPatterns(patterns: string[]): string[] {
return patterns.map(normalizeMentionPattern);
}
function resolveMentionPatterns( function resolveMentionPatterns(
cfg: ClawdbotConfig | undefined, cfg: ClawdbotConfig | undefined,
agentId?: string, agentId?: string,
): string[] { ): string[] {
if (!cfg) return []; if (!cfg) return [];
const agentConfig = agentId ? cfg.routing?.agents?.[agentId] : undefined; const agentConfig = agentId ? resolveAgentConfig(cfg, agentId) : undefined;
if (agentConfig && Object.hasOwn(agentConfig, "mentionPatterns")) { const agentGroupChat = agentConfig?.groupChat;
return agentConfig.mentionPatterns ?? []; if (agentGroupChat && Object.hasOwn(agentGroupChat, "mentionPatterns")) {
return agentGroupChat.mentionPatterns ?? [];
} }
return cfg.routing?.groupChat?.mentionPatterns ?? []; const globalGroupChat = cfg.messages?.groupChat;
if (globalGroupChat && Object.hasOwn(globalGroupChat, "mentionPatterns")) {
return globalGroupChat.mentionPatterns ?? [];
}
const derived = deriveMentionPatterns(agentConfig?.identity);
return derived.length > 0 ? derived : [];
} }
export function buildMentionRegexes( export function buildMentionRegexes(
cfg: ClawdbotConfig | undefined, cfg: ClawdbotConfig | undefined,
agentId?: string, agentId?: string,
): RegExp[] { ): RegExp[] {
const patterns = resolveMentionPatterns(cfg, agentId); const patterns = normalizeMentionPatterns(
resolveMentionPatterns(cfg, agentId),
);
return patterns return patterns
.map((pattern) => { .map((pattern) => {
try { try {
@ -66,7 +105,9 @@ export function stripMentions(
agentId?: string, agentId?: string,
): string { ): string {
let result = text; let result = text;
const patterns = resolveMentionPatterns(cfg, agentId); const patterns = normalizeMentionPatterns(
resolveMentionPatterns(cfg, agentId),
);
for (const p of patterns) { for (const p of patterns) {
try { try {
const re = new RegExp(p, "gi"); const re = new RegExp(p, "gi");

View File

@ -33,7 +33,9 @@ type ModelSelectionState = {
export async function createModelSelectionState(params: { export async function createModelSelectionState(params: {
cfg: ClawdbotConfig; cfg: ClawdbotConfig;
agentCfg: ClawdbotConfig["agent"] | undefined; agentCfg:
| NonNullable<NonNullable<ClawdbotConfig["agents"]>["defaults"]>
| undefined;
sessionEntry?: SessionEntry; sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>; sessionStore?: Record<string, SessionEntry>;
sessionKey?: string; sessionKey?: string;
@ -201,7 +203,9 @@ export function resolveModelDirectiveSelection(params: {
} }
export function resolveContextTokens(params: { export function resolveContextTokens(params: {
agentCfg: ClawdbotConfig["agent"] | undefined; agentCfg:
| NonNullable<NonNullable<ClawdbotConfig["agents"]>["defaults"]>
| undefined;
model: string; model: string;
}): number { }): number {
return ( return (

View File

@ -553,7 +553,7 @@ export function resolveQueueSettings(params: {
inlineOptions?: Partial<QueueSettings>; inlineOptions?: Partial<QueueSettings>;
}): QueueSettings { }): QueueSettings {
const providerKey = params.provider?.trim().toLowerCase(); const providerKey = params.provider?.trim().toLowerCase();
const queueCfg = params.cfg.routing?.queue; const queueCfg = params.cfg.messages?.queue;
const providerModeRaw = const providerModeRaw =
providerKey && queueCfg?.byProvider providerKey && queueCfg?.byProvider
? (queueCfg.byProvider as Record<string, string | undefined>)[providerKey] ? (queueCfg.byProvider as Record<string, string | undefined>)[providerKey]

View File

@ -35,7 +35,9 @@ import type {
VerboseLevel, VerboseLevel,
} from "./thinking.js"; } from "./thinking.js";
type AgentConfig = NonNullable<ClawdbotConfig["agent"]>; type AgentConfig = Partial<
NonNullable<NonNullable<ClawdbotConfig["agents"]>["defaults"]>
>;
export const formatTokenCount = formatTokenCountShared; export const formatTokenCount = formatTokenCountShared;
@ -188,7 +190,11 @@ export function buildStatusMessage(args: StatusArgs): string {
const now = args.now ?? Date.now(); const now = args.now ?? Date.now();
const entry = args.sessionEntry; const entry = args.sessionEntry;
const resolved = resolveConfiguredModelRef({ const resolved = resolveConfiguredModelRef({
cfg: { agent: args.agent ?? {} }, cfg: {
agents: {
defaults: args.agent ?? {},
},
} as ClawdbotConfig,
defaultProvider: DEFAULT_PROVIDER, defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL, defaultModel: DEFAULT_MODEL,
}); });

View File

@ -37,8 +37,8 @@ describe("transcribeInboundAudio", () => {
vi.stubGlobal("fetch", fetchMock); vi.stubGlobal("fetch", fetchMock);
const cfg = { const cfg = {
routing: { audio: {
transcribeAudio: { transcription: {
command: ["echo", "{{MediaPath}}"], command: ["echo", "{{MediaPath}}"],
timeoutSeconds: 5, timeoutSeconds: 5,
}, },
@ -64,7 +64,7 @@ describe("transcribeInboundAudio", () => {
it("returns undefined when no transcription command", async () => { it("returns undefined when no transcription command", async () => {
const { transcribeInboundAudio } = await import("./transcription.js"); const { transcribeInboundAudio } = await import("./transcription.js");
const res = await transcribeInboundAudio( const res = await transcribeInboundAudio(
{ routing: {} } as never, { audio: {} } as never,
{} as never, {} as never,
runtime as never, runtime as never,
); );

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