diff --git a/README.md b/README.md
index 355a20572..e82f546b4 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@
**Clawdbot** is a *personal AI assistant* you run on your own devices.
-It answers you on the surfaces you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, WebChat), can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
+It answers you on the providers you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, WebChat), can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
@@ -104,7 +104,8 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
## Highlights
- **[Local-first Gateway](https://docs.clawd.bot/gateway)** — single control plane for sessions, providers, tools, and events.
-- **[Multi-surface inbox](https://docs.clawd.bot/surface)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, WebChat, macOS, iOS/Android.
+- **[Multi-provider inbox](https://docs.clawd.bot/surface)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, WebChat, macOS, iOS/Android.
+- **[Multi-agent routing](docs/configuration.md)** — route inbound providers/accounts/peers to isolated agents (workspaces + per-agent sessions).
- **[Voice Wake](https://docs.clawd.bot/voicewake) + [Talk Mode](https://docs.clawd.bot/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs.
- **[Live Canvas](https://docs.clawd.bot/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.clawd.bot/mac/canvas#canvas-a2ui).
- **[First-class tools](https://docs.clawd.bot/tools)** — browser, canvas, nodes, cron, sessions, and Discord/Slack actions.
@@ -120,9 +121,9 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
- [Session model](https://docs.clawd.bot/session): `main` for direct chats, group isolation, activation modes, queue modes, reply-back. Group rules: [Groups](https://docs.clawd.bot/groups).
- [Media pipeline](https://docs.clawd.bot/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.clawd.bot/audio).
-### Surfaces + providers
+### Providers
- [Providers](https://docs.clawd.bot/surface): [WhatsApp](https://docs.clawd.bot/whatsapp) (Baileys), [Telegram](https://docs.clawd.bot/telegram) (grammY), [Slack](https://docs.clawd.bot/slack) (Bolt), [Discord](https://docs.clawd.bot/discord) (discord.js), [Signal](https://docs.clawd.bot/signal) (signal-cli), [iMessage](https://docs.clawd.bot/imessage) (imsg), [WebChat](https://docs.clawd.bot/webchat).
-- [Group routing](https://docs.clawd.bot/group-messages): mention gating, reply tags, per-surface chunking and routing. Surface rules: [Surface routing](https://docs.clawd.bot/surface).
+- [Group routing](https://docs.clawd.bot/group-messages): mention gating, reply tags, per-provider chunking and routing. Provider rules: [Providers](https://docs.clawd.bot/surface).
### Apps + nodes
- [macOS app](https://docs.clawd.bot/macos): menu bar control plane, [Voice Wake](https://docs.clawd.bot/voicewake)/PTT, [Talk Mode](https://docs.clawd.bot/talk) overlay, [WebChat](https://docs.clawd.bot/webchat), debug tools, [remote gateway](https://docs.clawd.bot/remote) control.
diff --git a/docs/AGENTS.default.md b/docs/AGENTS.default.md
index cdbb83258..e60a3a7b0 100644
--- a/docs/AGENTS.default.md
+++ b/docs/AGENTS.default.md
@@ -83,7 +83,7 @@ git commit -m "Add Clawd workspace"
## What Clawdbot Does
- Runs WhatsApp gateway + Pi coding agent so the assistant can read/write chats, fetch context, and run skills via the host Mac.
- macOS app manages permissions (screen recording, notifications, microphone) and exposes the `clawdbot` CLI via its bundled binary.
-- Direct chats collapse into the shared `main` session by default; groups stay isolated as `surface:group:` (rooms: `surface:channel:`); heartbeats keep background tasks alive.
+- Direct chats collapse into the agent's `main` session by default; groups stay isolated as `agent:::group:` (rooms/channels: `agent:::channel:`); heartbeats keep background tasks alive.
## Core Skills (enable in Settings → Skills)
- **mcporter** — Tool server runtime/CLI for managing external skill backends.
diff --git a/docs/agent-loop.md b/docs/agent-loop.md
index 69bfe2a24..a352f7112 100644
--- a/docs/agent-loop.md
+++ b/docs/agent-loop.md
@@ -36,7 +36,7 @@ Short, exact flow of one agent run. Source of truth: current code in `src/`.
- `assistant`: streamed deltas from pi-agent-core
- `tool`: streamed tool events from pi-agent-core
-## Chat surface handling
+## Chat provider handling
- `createAgentEventHandler` in `src/gateway/server-chat.ts`:
- buffers assistant deltas
- emits chat `delta` messages
diff --git a/docs/clawd.md b/docs/clawd.md
index b78cb8bb6..5e9518754 100644
--- a/docs/clawd.md
+++ b/docs/clawd.md
@@ -144,8 +144,8 @@ Example:
## Sessions and memory
-- Session files: `~/.clawdbot/sessions/{{SessionId}}.jsonl`
-- Session metadata (token usage, last route, etc): `~/.clawdbot/sessions/sessions.json` (legacy: `~/.clawdbot/sessions.json`)
+- Session files: `~/.clawdbot/agents//sessions/{{SessionId}}.jsonl`
+- Session metadata (token usage, last route, etc): `~/.clawdbot/agents//sessions/sessions.json` (legacy: `~/.clawdbot/sessions/sessions.json`)
- `/new` or `/reset` starts a fresh session for that chat (configurable via `resetTriggers`). If sent alone, the agent replies with a short hello to confirm the reset.
- `/compact [instructions]` compacts the session context and reports the remaining context budget.
diff --git a/docs/configuration.md b/docs/configuration.md
index 5b5297ea6..aabf72baf 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -91,18 +91,21 @@ Env var equivalent:
### Auth storage (OAuth + API keys)
-Clawdbot stores **auth profiles** (OAuth + API keys) in:
-- `~/.clawdbot/agent/auth-profiles.json`
+Clawdbot stores **per-agent** auth profiles (OAuth + API keys) in:
+- `/auth-profiles.json` (default: `~/.clawdbot/agents//agent/auth-profiles.json`)
Legacy OAuth imports:
- `~/.clawdbot/credentials/oauth.json` (or `$CLAWDBOT_STATE_DIR/credentials/oauth.json`)
The embedded Pi agent maintains a runtime cache at:
-- `~/.clawdbot/agent/auth.json` (managed automatically; don’t edit manually)
+- `/auth.json` (managed automatically; don’t edit manually)
+
+Legacy agent dir (pre multi-agent):
+- `~/.clawdbot/agent/*` (migrated by `clawdbot doctor` into `~/.clawdbot/agents//agent/*`)
Overrides:
- OAuth dir (legacy import only): `CLAWDBOT_OAUTH_DIR`
-- Agent dir: `CLAWDBOT_AGENT_DIR` (preferred), `PI_CODING_AGENT_DIR` (legacy)
+- Agent dir (legacy/default agent only): `CLAWDBOT_AGENT_DIR` (preferred), `PI_CODING_AGENT_DIR` (legacy)
On first use, Clawdbot imports `oauth.json` entries into `auth-profiles.json`.
@@ -212,6 +215,29 @@ For groups, use `whatsapp.groupPolicy` + `whatsapp.groupAllowFrom`.
}
```
+### `whatsapp.accounts` (multi-account)
+
+Run multiple WhatsApp accounts in one gateway:
+
+```json5
+{
+ whatsapp: {
+ accounts: {
+ default: {}, // optional; keeps the default id stable
+ personal: {},
+ biz: {
+ // Optional override. Default: ~/.clawdbot/credentials/whatsapp/biz
+ // authDir: "~/.clawdbot/credentials/whatsapp/biz",
+ }
+ }
+ }
+}
+```
+
+Notes:
+- Outbound commands default to account `default` if present; otherwise the first configured account id (sorted).
+- The legacy single-account Baileys auth dir is migrated by `clawdbot doctor` into `whatsapp/default`.
+
### `routing.groupChat`
Group messages default to **require mention** (either metadata mention or regex patterns). Applies to WhatsApp, Telegram, Discord, and iMessage group chats.
@@ -296,6 +322,69 @@ Notes:
- Discord/Slack use channel allowlists (`discord.guilds.*.channels`, `slack.channels`).
- Group DMs (Discord/Slack) are still controlled by `dm.groupEnabled` + `dm.groupChannels`.
+### Multi-agent routing (`routing.agents` + `routing.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`).
+- `routing.agents.`: per-agent overrides.
+ - `workspace`: default `~/clawd-` (for `main`, falls back to legacy `agent.workspace`).
+ - `agentDir`: default `~/.clawdbot/agents//agent`.
+- `routing.bindings[]`: routes inbound messages to an `agentId`.
+ - `match.provider` (required)
+ - `match.accountId` (optional; `*` = any account; omitted = default account)
+ - `match.peer` (optional; `{ kind: dm|group|channel, id }`)
+ - `match.guildId` / `match.teamId` (optional; provider-specific)
+
+Deterministic match order:
+1) `match.peer`
+2) `match.guildId`
+3) `match.teamId`
+4) `match.accountId` (exact, no peer/guild/team)
+5) `match.accountId: "*"` (provider-wide, no peer/guild/team)
+6) `routing.defaultAgentId`
+
+Within each match tier, the first matching entry in `routing.bindings` wins.
+
+Example: two WhatsApp accounts → two agents:
+
+```json5
+{
+ routing: {
+ defaultAgentId: "home",
+ agents: {
+ home: { workspace: "~/clawd-home" },
+ work: { workspace: "~/clawd-work" },
+ },
+ bindings: [
+ { agentId: "home", match: { provider: "whatsapp", accountId: "personal" } },
+ { agentId: "work", match: { provider: "whatsapp", accountId: "biz" } },
+ ],
+ },
+ whatsapp: {
+ accounts: {
+ personal: {},
+ biz: {},
+ }
+ }
+}
+```
+
+### `routing.agentToAgent` (optional)
+
+Agent-to-agent messaging is opt-in:
+
+```json5
+{
+ routing: {
+ agentToAgent: {
+ enabled: false,
+ allow: ["home", "work"]
+ }
+ }
+}
+```
+
### `routing.queue`
Controls how inbound messages behave when an agent run is already active.
@@ -308,7 +397,7 @@ Controls how inbound messages behave when an agent run is already active.
debounceMs: 1000,
cap: 20,
drop: "summarize", // old | new | summarize
- bySurface: {
+ byProvider: {
whatsapp: "collect",
telegram: "collect",
discord: "collect",
@@ -673,7 +762,7 @@ Z.AI models are available as `zai/` (e.g. `zai/glm-4.7`) and require
- `every`: duration string (`ms`, `s`, `m`, `h`); default unit minutes. Omit or set
`0m` to disable.
- `model`: optional override model for heartbeat runs (`provider/model`).
-- `target`: optional delivery channel (`last`, `whatsapp`, `telegram`, `discord`, `imessage`, `none`). Default: `last`.
+- `target`: optional delivery provider (`last`, `whatsapp`, `telegram`, `discord`, `imessage`, `none`). Default: `last`.
- `to`: optional recipient override (E.164 for WhatsApp, chat id for Telegram).
- `prompt`: optional override for the heartbeat body (default: `HEARTBEAT`).
- `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery (default: 30).
@@ -699,7 +788,7 @@ Example (disable browser/canvas everywhere):
`agent.elevated` controls elevated (host) bash access:
- `enabled`: allow elevated mode (default true)
-- `allowFrom`: per-surface allowlists (empty = disabled)
+- `allowFrom`: per-provider allowlists (empty = disabled)
- `whatsapp`: E.164 numbers
- `telegram`: chat ids or usernames
- `discord`: user ids or usernames (falls back to `discord.dm.allowFrom` if omitted)
@@ -919,15 +1008,18 @@ Controls session scoping, idle expiry, reset triggers, and where the session sto
scope: "per-sender",
idleMinutes: 60,
resetTriggers: ["/new", "/reset"],
- store: "~/.clawdbot/sessions/sessions.json",
- // mainKey is ignored; primary key is fixed to "main"
+ // Default is already per-agent under ~/.clawdbot/agents//sessions/sessions.json
+ // You can override with {agentId} templating:
+ store: "~/.clawdbot/agents/{agentId}/sessions/sessions.json",
+ // Direct chats collapse to agent:: (default: "main").
+ mainKey: "main",
agentToAgent: {
// Max ping-pong reply turns between requester/target (0–5).
maxPingPongTurns: 5
},
sendPolicy: {
rules: [
- { action: "deny", match: { surface: "discord", chatType: "group" } }
+ { action: "deny", match: { provider: "discord", chatType: "group" } }
],
default: "allow"
}
@@ -936,9 +1028,10 @@ Controls session scoping, idle expiry, reset triggers, and where the session sto
```
Fields:
+- `mainKey`: direct-chat bucket key (default: `"main"`). Useful when you want to “rename” the primary DM thread without changing `agentId`.
- `agentToAgent.maxPingPongTurns`: max reply-back turns between requester/target (0–5, default 5).
- `sendPolicy.default`: `allow` or `deny` fallback when no rule matches.
-- `sendPolicy.rules[]`: match by `surface` (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.
### `skills` (skills config)
@@ -1171,7 +1264,7 @@ clawdbot gateway --port 19001
### `hooks` (Gateway webhooks)
-Enable a simple HTTP webhook surface on the Gateway HTTP server.
+Enable a simple HTTP webhook endpoint on the Gateway HTTP server.
Defaults:
- enabled: `false`
@@ -1208,7 +1301,7 @@ Requests must include the hook token:
Endpoints:
- `POST /hooks/wake` → `{ text, mode?: "now"|"next-heartbeat" }`
-- `POST /hooks/agent` → `{ message, name?, sessionKey?, wakeMode?, deliver?, channel?, to?, thinking?, timeoutSeconds? }`
+- `POST /hooks/agent` → `{ message, name?, sessionKey?, wakeMode?, deliver?, provider?, to?, thinking?, timeoutSeconds? }`
- `POST /hooks/` → resolved via `hooks.mappings`
`/hooks/agent` always posts a summary into the main session (and can optionally trigger an immediate heartbeat via `wakeMode: "now"`).
@@ -1338,7 +1431,7 @@ Template placeholders are expanded in `routing.transcribeAudio.command` (and any
|----------|-------------|
| `{{Body}}` | Full inbound message body |
| `{{BodyStripped}}` | Body with group mentions stripped (best default for agents) |
-| `{{From}}` | Sender identifier (E.164 for WhatsApp; may differ per surface) |
+| `{{From}}` | Sender identifier (E.164 for WhatsApp; may differ per provider) |
| `{{To}}` | Destination identifier |
| `{{MessageSid}}` | Provider message id (when available) |
| `{{SessionId}}` | Current session UUID |
@@ -1352,7 +1445,7 @@ Template placeholders are expanded in `routing.transcribeAudio.command` (and any
| `{{GroupMembers}}` | Group members preview (best effort) |
| `{{SenderName}}` | Sender display name (best effort) |
| `{{SenderE164}}` | Sender phone number (best effort) |
-| `{{Surface}}` | Surface hint (whatsapp|telegram|discord|imessage|webchat|…) |
+| `{{Provider}}` | Provider hint (whatsapp|telegram|discord|imessage|webchat|…) |
## Cron (Gateway scheduler)
diff --git a/docs/cron.md b/docs/cron.md
index a981880d8..8e5030879 100644
--- a/docs/cron.md
+++ b/docs/cron.md
@@ -75,7 +75,7 @@ Each job is a JSON object with stable keys (unknown keys ignored for forward com
- For `sessionTarget:"main"`, `wakeMode` controls whether we trigger the heartbeat immediately or just enqueue and wait.
- `payload` (one of)
- `{"kind":"systemEvent","text":string}` (enqueue as `System:`)
- - `{"kind":"agentTurn","message":string,"deliver"?:boolean,"channel"?: "last"|"whatsapp"|"telegram"|"discord"|"signal"|"imessage","to"?:string,"timeoutSeconds"?:number}`
+ - `{"kind":"agentTurn","message":string,"deliver"?:boolean,"provider"?: "last"|"whatsapp"|"telegram"|"discord"|"signal"|"imessage","to"?:string,"timeoutSeconds"?:number}`
- `isolation` (optional; only meaningful for isolated jobs)
- `{"postToMainPrefix"?: string}`
- `runtime` (optional)
@@ -173,7 +173,7 @@ When due:
- Execute via the same agent runner path as other command-mode runs, but pinned to:
- `sessionKey = cron:`
- `sessionId = store[sessionKey].sessionId` (create if missing)
-- Optionally deliver output (`payload.deliver === true`) to the configured channel/to.
+- Optionally deliver output (`payload.deliver === true`) to the configured provider/to.
- Isolated jobs always enqueue a summary system event to the main session when they finish (derived from the last agent text output).
- Prefix defaults to `Cron`, and can be customized via `isolation.postToMainPrefix`.
- If `deliver` is omitted/false, nothing is sent to external providers; you still get the main-session summary and can inspect the full isolated transcript in `cron:`.
@@ -275,7 +275,7 @@ Add a `cron` command group (all commands should also support `--json` where sens
- `--wake now|next-heartbeat`
- payload flags (choose one):
- `--system-event ""`
- - `--message "" [--deliver] [--channel last|whatsapp|telegram|discord|slack|signal|imessage] [--to ]`
+ - `--message "" [--deliver] [--provider last|whatsapp|telegram|discord|slack|signal|imessage] [--to ]`
- `clawdbot cron edit ...` (patch-by-flags, non-interactive)
- `clawdbot cron rm `
@@ -313,7 +313,7 @@ clawdbot cron add \
--wake now \
--message "Daily check: scan calendar + inbox; deliver only if urgent." \
--deliver \
- --channel last
+ --provider last
```
### Run weekly (every Wednesday)
@@ -328,7 +328,7 @@ clawdbot cron add \
--wake now \
--message "Weekly: summarize status and remind me of goals." \
--deliver \
- --channel last
+ --provider last
```
### “Next heartbeat”
diff --git a/docs/discord.md b/docs/discord.md
index 11c581b4f..9c7b81de9 100644
--- a/docs/discord.md
+++ b/docs/discord.md
@@ -1,7 +1,7 @@
---
summary: "Discord bot support status, capabilities, and configuration"
read_when:
- - Working on Discord surface features
+ - Working on Discord provider features
---
# Discord (Bot API)
@@ -11,9 +11,9 @@ Status: ready for DM and guild text channels via the official Discord bot gatewa
## Goals
- Talk to Clawdbot via Discord DMs or guild channels.
-- Share the same `main` session used by WhatsApp/Telegram/WebChat; guild channels stay isolated as `discord:group:` (display names use `discord:#`).
+- Direct chats collapse into the agent's main session (default `agent:main:main`); guild channels stay isolated as `agent::discord:channel:` (display names use `discord:#`).
- Group DMs are ignored by default; enable via `discord.dm.groupEnabled` and optionally restrict by `discord.dm.groupChannels`.
-- Keep routing deterministic: replies always go back to the surface they arrived on.
+- Keep routing deterministic: replies always go back to the provider they arrived on.
## How it works
1. Create a Discord application → Bot, enable the intents you need (DMs + guild messages + message content), and grab the bot token.
@@ -32,7 +32,7 @@ Status: ready for DM and guild text channels via the official Discord bot gatewa
10. Optional slash commands: enable `discord.slashCommand` to accept user-installed app commands (ephemeral replies). Slash invocations respect the same DM/guild allowlists.
11. Optional guild context history: set `discord.historyLimit` (default 20) to include the last N guild messages as context when replying to a mention. Set `0` to disable.
12. Reactions: the agent can trigger reactions via the `discord` tool (gated by `discord.actions.*`).
- - The `discord` tool is only exposed when the current surface is Discord.
+ - The `discord` tool is only exposed when the current provider is Discord.
12. Slash commands use isolated session keys (`${sessionPrefix}:${userId}`) rather than the shared `main` session.
Note: Discord does not provide a simple username → id lookup without extra guild context, so prefer ids or `<@id>` mentions for DM delivery targets.
diff --git a/docs/doctor.md b/docs/doctor.md
index 51292f5fa..a68c36be0 100644
--- a/docs/doctor.md
+++ b/docs/doctor.md
@@ -16,6 +16,7 @@ read_when:
- Checks sandbox Docker images when sandboxing is enabled (offers to build or switch to legacy names).
- Detects legacy Clawdis services (launchd/systemd/schtasks) and offers to migrate them.
- On Linux, checks if systemd user lingering is enabled and can enable it (required to keep the Gateway alive after logout).
+- Migrates legacy on-disk state layouts (sessions, agentDir, provider auth dirs) into the current per-agent/per-account structure.
## Legacy config file migration
If `~/.clawdis/clawdis.json` exists and `~/.clawdbot/clawdbot.json` does not, doctor will migrate the file and normalize old paths/image names.
@@ -35,6 +36,19 @@ Current migrations:
- `agent.model`/`allowedModels`/`modelAliases`/`modelFallbacks`/`imageModelFallbacks`
→ `agent.models` + `agent.model.primary/fallbacks` + `agent.imageModel.primary/fallbacks`
+## Legacy state migrations (disk layout)
+
+Doctor can migrate older on-disk layouts into the current structure:
+- Sessions store + transcripts:
+ - from `~/.clawdbot/sessions/` to `~/.clawdbot/agents//sessions/`
+- Agent dir:
+ - from `~/.clawdbot/agent/` to `~/.clawdbot/agents//agent/`
+- WhatsApp auth state (Baileys):
+ - from legacy `~/.clawdbot/credentials/*.json` (except `oauth.json`)
+ - to `~/.clawdbot/credentials/whatsapp//...` (default account id: `default`)
+
+These migrations are best-effort and idempotent; doctor will emit warnings when it leaves any legacy folders behind as backups.
+
## Usage
```bash
diff --git a/docs/elevated.md b/docs/elevated.md
index fcffe2de6..b95a9eb78 100644
--- a/docs/elevated.md
+++ b/docs/elevated.md
@@ -22,7 +22,7 @@ read_when:
## Availability + allowlists
- Feature gate: `agent.elevated.enabled` (default can be off via config even if the code supports it).
-- Sender allowlist: `agent.elevated.allowFrom` with per-surface allowlists (e.g. `discord`, `whatsapp`).
+- Sender allowlist: `agent.elevated.allowFrom` with per-provider allowlists (e.g. `discord`, `whatsapp`).
- 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.
diff --git a/docs/faq.md b/docs/faq.md
index 493057c68..f12502c0d 100644
--- a/docs/faq.md
+++ b/docs/faq.md
@@ -18,8 +18,9 @@ Everything lives under `~/.clawdbot/`:
| `~/.clawdbot/agent/auth-profiles.json` | Auth profiles (OAuth + API keys) |
| `~/.clawdbot/agent/auth.json` | Runtime API key cache (managed automatically) |
| `~/.clawdbot/credentials/` | WhatsApp/Telegram auth tokens |
-| `~/.clawdbot/sessions/` | Conversation history & state |
-| `~/.clawdbot/sessions/sessions.json` | Session metadata |
+| `~/.clawdbot/agents/` | Per-agent state (agentDir + sessions) |
+| `~/.clawdbot/agents//sessions/` | Conversation history & state (per agent) |
+| `~/.clawdbot/agents//sessions/sessions.json` | Session metadata (per agent) |
Your **workspace** (AGENTS.md, memory files, skills) is separate — configured via `agent.workspace` in your config (default: `~/clawd`).
diff --git a/docs/grammy.md b/docs/grammy.md
index 215085258..e5fc77b48 100644
--- a/docs/grammy.md
+++ b/docs/grammy.md
@@ -17,7 +17,7 @@ Updated: 2025-12-07
- **Gateway:** `monitorTelegramProvider` builds a grammY `Bot`, wires mention/allowlist gating, media download via `getFile`/`download`, and delivers replies with `sendMessage/sendPhoto/sendVideo/sendAudio/sendDocument`. Supports long-poll or webhook via `webhookCallback`.
- **Proxy:** optional `telegram.proxy` uses `undici.ProxyAgent` through grammY’s `client.baseFetch`.
- **Webhook support:** `webhook-set.ts` wraps `setWebhook/deleteWebhook`; `webhook.ts` hosts the callback with health + graceful shutdown. Gateway enables webhook mode when `telegram.webhookUrl` is set (otherwise it long-polls).
-- **Sessions:** direct chats map to `main`; groups map to `telegram:group:`; replies route back to the same surface.
+- **Sessions:** direct chats collapse into the agent main session (`agent::`); groups use `agent::telegram:group:`; replies route back to the same provider.
- **Config knobs:** `telegram.botToken`, `telegram.dmPolicy`, `telegram.groups` (allowlist + mention defaults), `telegram.allowFrom`, `telegram.groupAllowFrom`, `telegram.groupPolicy`, `telegram.mediaMaxMb`, `telegram.proxy`, `telegram.webhookSecret`, `telegram.webhookUrl`.
- **Tests:** grammy mocks cover DM + group mention gating and outbound send; more media/webhook fixtures still welcome.
diff --git a/docs/group-messages.md b/docs/group-messages.md
index 5975b5a71..723c97ec1 100644
--- a/docs/group-messages.md
+++ b/docs/group-messages.md
@@ -70,4 +70,4 @@ Only the owner number (from `whatsapp.allowFrom`, or the bot’s own E.164 when
## Known considerations
- 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.
-- Session store entries will appear as `whatsapp:group:` in the session store (`~/.clawdbot/sessions/sessions.json` by default); a missing entry just means the group hasn’t triggered a run yet.
+- Session store entries will appear as `agent::whatsapp:group:` in the session store (`~/.clawdbot/agents//sessions/sessions.json` by default); a missing entry just means the group hasn’t triggered a run yet.
diff --git a/docs/groups.md b/docs/groups.md
index 05527f9d0..3446936e5 100644
--- a/docs/groups.md
+++ b/docs/groups.md
@@ -8,12 +8,12 @@ read_when:
Clawdbot treats group chats consistently across surfaces: WhatsApp, Telegram, Discord, Slack, Signal, iMessage.
## Session keys
-- Group sessions use `surface:group:` session keys (rooms/channels use `surface:channel:`).
+- Group sessions use `agent:::group:` session keys (rooms/channels use `agent:::channel:`).
- Direct chats use the main session (or per-sender if configured).
- Heartbeats are skipped for group sessions.
## Display labels
-- UI labels use `displayName` when available, formatted as `surface:`.
+- UI labels use `displayName` when available, formatted as `:`.
- `#room` is reserved for rooms/channels; group chats use `g-` (lowercase, spaces -> `-`, keep `#@+._-`).
## Group policy
diff --git a/docs/health.md b/docs/health.md
index 761dcd3aa..06193277a 100644
--- a/docs/health.md
+++ b/docs/health.md
@@ -15,8 +15,8 @@ Short guide to verify the WhatsApp Web / Baileys stack without guessing.
- Logs: tail `/tmp/clawdbot/clawdbot-*.log` and filter for `web-heartbeat`, `web-reconnect`, `web-auto-reply`, `web-inbound`.
## Deep diagnostics
-- Creds on disk: `ls -l ~/.clawdbot/credentials/creds.json` (mtime should be recent).
-- Session store: `ls -l ~/.clawdbot/sessions/sessions.json` (legacy: `~/.clawdbot/sessions.json`; path can be overridden in config). Count and recent recipients are surfaced via `status`.
+- Creds on disk: `ls -l ~/.clawdbot/credentials/whatsapp//creds.json` (mtime should be recent).
+- Session store: `ls -l ~/.clawdbot/agents//sessions/sessions.json` (path can be overridden in config). Count and recent recipients are surfaced via `status`.
- Relink flow: `clawdbot logout && clawdbot login --verbose` when status codes 409–515 or `loggedOut` appear in logs. (Note: the QR login flow auto-restarts once for status 515 after pairing.)
## When something fails
diff --git a/docs/heartbeat.md b/docs/heartbeat.md
index fee828592..0f57d13f4 100644
--- a/docs/heartbeat.md
+++ b/docs/heartbeat.md
@@ -51,8 +51,8 @@ and final replies:
to `0m` to disable.
- `model`: optional model override for heartbeat runs (`provider/model`).
- `target`: where heartbeat output is delivered.
- - `last` (default): send to the last used external channel.
- - `whatsapp` / `telegram`: force the channel (optionally set `to`).
+ - `last` (default): send to the last used external provider.
+ - `whatsapp` / `telegram`: force the provider (optionally set `to`).
- `none`: do not deliver externally; output stays in the session (WebChat-visible).
- `to`: optional recipient override (E.164 for WhatsApp, chat id for Telegram).
- `prompt`: optional override for the heartbeat body (default: `HEARTBEAT`).
diff --git a/docs/hubs.md b/docs/hubs.md
index f53c85a5f..6b3d99907 100644
--- a/docs/hubs.md
+++ b/docs/hubs.md
@@ -28,6 +28,7 @@ Use these hubs to discover every page, including deep dives and reference docs t
- [Architecture](https://docs.clawd.bot/architecture)
- [Agent runtime](https://docs.clawd.bot/agent)
- [Agent loop](https://docs.clawd.bot/agent-loop)
+- [Multi-agent routing](https://docs.clawd.bot/multi-agent)
- [Sessions](https://docs.clawd.bot/session)
- [Sessions (alias)](https://docs.clawd.bot/sessions)
- [Session tools](https://docs.clawd.bot/session-tool)
@@ -37,7 +38,7 @@ Use these hubs to discover every page, including deep dives and reference docs t
- [Presence](https://docs.clawd.bot/presence)
- [Discovery + transports](https://docs.clawd.bot/discovery)
- [Bonjour](https://docs.clawd.bot/bonjour)
-- [Surface routing](https://docs.clawd.bot/surface)
+- [Provider routing](https://docs.clawd.bot/provider-routing)
- [Groups](https://docs.clawd.bot/groups)
- [Group messages](https://docs.clawd.bot/group-messages)
diff --git a/docs/imessage.md b/docs/imessage.md
index aa45aafd6..17ddbc21b 100644
--- a/docs/imessage.md
+++ b/docs/imessage.md
@@ -13,6 +13,31 @@ Status: external CLI integration. No daemon.
- JSON-RPC runs over stdin/stdout (one JSON object per line).
- Gateway owns the process; no TCP port needed.
+## Multi-account (Apple IDs)
+
+iMessage “multi-account” in one Gateway process is not currently supported in a meaningful way:
+- Messages accounts are owned by the signed-in macOS user session.
+- `imsg` reads the local Messages DB and sends via that user’s configured services.
+- There isn’t a robust “pick AppleID X as the sender” switch we can depend on.
+
+### Practical approach: multiple gateways on multiple Macs/users
+
+If you need two iMessage identities:
+- Run one Gateway on each macOS user/machine that’s signed into the desired Apple ID.
+- Connect to the desired Gateway remotely (Tailscale preferred; SSH tunnel is the universal fallback).
+
+See:
+- `docs/remote.md` (SSH tunnel to `127.0.0.1:18789`)
+- `docs/discovery.md` (bridge vs SSH transport model)
+
+### Could we do “iMessage over SSH” from a single Gateway?
+
+Maybe, but it’s a new design:
+- Outbound could theoretically pipe `imsg rpc` over SSH (stdio bridge).
+- Inbound still needs a remote watcher (DB polling / event stream) and a transport back to the main Gateway.
+
+That’s closer to “remote provider instances” (or “multi-gateway aggregation”) than a small config tweak.
+
## Requirements
- macOS with Messages signed in.
- Full Disk Access for Clawdbot + the `imsg` binary (Messages DB access).
diff --git a/docs/index.md b/docs/index.md
index 35db37c07..6b7f82470 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -64,6 +64,7 @@ Most operations flow through the **Gateway** (`clawdbot gateway`), a single long
- 🎮 **Discord Bot** — DMs + guild channels via discord.js
- 💬 **iMessage** — Local imsg CLI integration (macOS)
- 🤖 **Agent bridge** — Pi (RPC mode) with tool streaming
+- 🧠 **Multi-agent routing** — Route provider accounts/peers to isolated agents (workspace + per-agent sessions)
- 🔐 **Subscription auth** — Anthropic (Claude Pro/Max) + OpenAI (ChatGPT/Codex) via OAuth
- 💬 **Sessions** — Direct chats collapse into shared `main` (default); groups are isolated
- 👥 **Group Chat Support** — Mention-based by default; owner can toggle `/activation always|mention`
@@ -131,6 +132,7 @@ Example:
- [Docs hubs (all pages linked)](https://docs.clawd.bot/hubs)
- [FAQ](https://docs.clawd.bot/faq) ← *common questions answered*
- [Configuration](https://docs.clawd.bot/configuration)
+ - [Multi-agent routing](https://docs.clawd.bot/multi-agent)
- [Updating / rollback](https://docs.clawd.bot/updating)
- [Pairing (DM + nodes)](https://docs.clawd.bot/pairing)
- [Nix mode](https://docs.clawd.bot/nix)
diff --git a/docs/mac/voicewake.md b/docs/mac/voicewake.md
index 36afae944..898903928 100644
--- a/docs/mac/voicewake.md
+++ b/docs/mac/voicewake.md
@@ -46,7 +46,7 @@ Hardening:
## Forwarding behavior
- When Voice Wake is enabled, transcripts are forwarded to the active gateway/agent (the same local vs remote mode used by the rest of the mac app).
-- Replies are delivered to the **last-used main surface** (WhatsApp/Telegram/Discord/WebChat). If delivery fails, the error is logged and the run is still visible via WebChat/session logs.
+- Replies are delivered to the **last-used main provider** (WhatsApp/Telegram/Discord/WebChat). If delivery fails, the error is logged and the run is still visible via WebChat/session logs.
## Forwarding payload
- `VoiceWakeForwarder.prefixedTranscript(_:)` prepends the machine hint before sending. Shared between wake-word and push-to-talk paths.
diff --git a/docs/macos.md b/docs/macos.md
index 61ae0767c..e7f1eef9f 100644
--- a/docs/macos.md
+++ b/docs/macos.md
@@ -79,7 +79,7 @@ Query parameters:
- `sessionKey` (optional): explicit session key to use.
- `thinking` (optional): thinking hint (e.g. `low`; omit for default).
- `deliver` (optional): `true|false` (default: false).
-- `to` / `channel` (optional): forwarded to the Gateway `agent` method (only meaningful with `deliver=true`).
+- `to` / `provider` (optional): forwarded to the Gateway `agent` method (only meaningful with `deliver=true`).
- `timeoutSeconds` (optional): timeout hint forwarded to the Gateway.
- `key` (optional): unattended mode key (see below).
diff --git a/docs/multi-agent.md b/docs/multi-agent.md
new file mode 100644
index 000000000..e00c688c0
--- /dev/null
+++ b/docs/multi-agent.md
@@ -0,0 +1,74 @@
+---
+title: Multi-Agent Routing
+read_when: "You want multiple isolated agents (workspaces + auth) in one gateway process."
+status: active
+---
+
+# Multi-Agent Routing
+
+Goal: multiple *isolated* agents (separate workspace + `agentDir` + sessions), plus multiple provider accounts (e.g. two WhatsApps) in one running Gateway. Inbound is routed to an agent via bindings.
+
+## Concepts
+
+- `agentId`: one “brain” (workspace, per-agent auth, per-agent session store).
+- `accountId`: one provider account instance (e.g. WhatsApp account `"personal"` vs `"biz"`).
+- `binding`: routes inbound messages to an `agentId` by `(provider, accountId, peer)` and optionally guild/team ids.
+- Direct chats collapse to `agent::` (per-agent “main”; `session.mainKey`).
+
+## Example: two WhatsApps → two agents
+
+`~/.clawdbot/clawdbot.json` (JSON5):
+
+```js
+{
+ routing: {
+ defaultAgentId: "home",
+
+ agents: {
+ home: {
+ workspace: "~/clawd-home",
+ agentDir: "~/.clawdbot/agents/home/agent",
+ },
+ work: {
+ workspace: "~/clawd-work",
+ 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.
+ agentToAgent: {
+ enabled: false,
+ allow: ["home", "work"],
+ },
+ },
+
+ whatsapp: {
+ accounts: {
+ personal: {
+ // Optional override. Default: ~/.clawdbot/credentials/whatsapp/personal
+ // authDir: "~/.clawdbot/credentials/whatsapp/personal",
+ },
+ biz: {
+ // Optional override. Default: ~/.clawdbot/credentials/whatsapp/biz
+ // authDir: "~/.clawdbot/credentials/whatsapp/biz",
+ },
+ },
+ },
+}
+```
diff --git a/docs/plans/cron-add-hardening.md b/docs/plans/cron-add-hardening.md
index 49056a422..2ba67ea66 100644
--- a/docs/plans/cron-add-hardening.md
+++ b/docs/plans/cron-add-hardening.md
@@ -8,11 +8,11 @@ last_updated: "2026-01-05"
# Cron Add Hardening & Schema Alignment
## Context
-Recent gateway logs show repeated `cron.add` failures with invalid parameters (missing `sessionTarget`, `wakeMode`, `payload`, and malformed `schedule`). This indicates that at least one client (likely the agent tool call path) is sending wrapped or partially specified job payloads. Separately, there is drift between cron channel enums in TypeScript, gateway schema, CLI flags, and UI form types, plus a UI mismatch for `cron.status` (expects `jobCount` while gateway returns `jobs`).
+Recent gateway logs show repeated `cron.add` failures with invalid parameters (missing `sessionTarget`, `wakeMode`, `payload`, and malformed `schedule`). This indicates that at least one client (likely the agent tool call path) is sending wrapped or partially specified job payloads. Separately, there is drift between cron provider enums in TypeScript, gateway schema, CLI flags, and UI form types, plus a UI mismatch for `cron.status` (expects `jobCount` while gateway returns `jobs`).
## Goals
- Stop `cron.add` INVALID_REQUEST spam by normalizing common wrapper payloads and inferring missing `kind` fields.
-- Align cron channel lists across gateway schema, cron types, CLI docs, and UI forms.
+- Align cron provider lists across gateway schema, cron types, CLI docs, and UI forms.
- Make agent cron tool schema explicit so the LLM produces correct job payloads.
- Fix the Control UI cron status job count display.
- Add tests to cover normalization and tool behavior.
@@ -31,18 +31,18 @@ Recent gateway logs show repeated `cron.add` failures with invalid parameters (m
## Proposed Approach
1. **Normalize** incoming `cron.add` payloads (unwrap `data`/`job`, infer `schedule.kind` and `payload.kind`, default `wakeMode` + `sessionTarget` when safe).
2. **Harden** the agent cron tool schema using the canonical gateway `CronAddParamsSchema` and normalize before sending to the gateway.
-3. **Align** channel enums and cron status fields across gateway schema, TS types, CLI descriptions, and UI form controls.
+3. **Align** provider enums and cron status fields across gateway schema, TS types, CLI descriptions, and UI form controls.
4. **Test** normalization in gateway tests and tool behavior in agent tests.
## Multi-phase Execution Plan
### Phase 1 — Schema + type alignment
-- [x] Expand gateway `CronPayloadSchema` channel enum to include `signal` and `imessage`.
-- [x] Update CLI `--channel` descriptions to include `slack` (already supported by gateway).
-- [x] Update UI Cron payload/channel union types to include all supported channels.
+- [x] Expand gateway `CronPayloadSchema` provider enum to include `signal` and `imessage`.
+- [x] Update CLI `--provider` descriptions to include `slack` (already supported by gateway).
+- [x] Update UI Cron payload/provider union types to include all supported providers.
- [x] Fix UI CronStatus type to match gateway (`jobs` instead of `jobCount`).
-- [x] Update cron UI channel select to include Discord/Slack/Signal/iMessage.
-- [x] Update macOS CronJobEditor channel picker + enum to include Slack/Signal/iMessage.
+- [x] Update cron UI provider select to include Discord/Slack/Signal/iMessage.
+- [x] Update macOS CronJobEditor provider picker + enum to include Slack/Signal/iMessage.
- [x] Document cron compatibility normalization policy in [`docs/cron.md`](https://docs.clawd.bot/cron).
### Phase 2 — Input normalization + tooling hardening
@@ -65,8 +65,8 @@ Recent gateway logs show repeated `cron.add` failures with invalid parameters (m
- If errors persist, extend normalization for additional common shapes (e.g., `schedule.at`, `payload.message` without `kind`).
## Optional Follow-ups
-- Manual Control UI smoke: add cron job per channel + verify status job count.
+- Manual Control UI smoke: add cron job per provider + verify status job count.
## Open Questions
- Should `cron.add` accept explicit `state` from clients (currently disallowed by schema)?
-- Should we allow `webchat` as an explicit delivery channel (currently filtered in delivery resolution)?
+- Should we allow `webchat` as an explicit delivery provider (currently filtered in delivery resolution)?
diff --git a/docs/provider-routing.md b/docs/provider-routing.md
new file mode 100644
index 000000000..d0d7f76a3
--- /dev/null
+++ b/docs/provider-routing.md
@@ -0,0 +1,25 @@
+---
+summary: "Routing rules per provider (WhatsApp, Telegram, Discord, web) and shared context"
+read_when:
+ - Changing provider routing or inbox behavior
+---
+# Providers & Routing
+
+Updated: 2026-01-06
+
+Goal: deterministic replies per provider, while supporting multi-agent + multi-account routing.
+
+- **Provider**: provider label (`whatsapp`, `webchat`, `telegram`, `discord`, `signal`, `imessage`, …). Routing is fixed: replies go back to the origin provider; the model doesn’t choose.
+- **AccountId**: provider account instance (e.g. WhatsApp account `"default"` vs `"work"`). Not every provider supports multi-account yet.
+- **AgentId**: one isolated “brain” (workspace + per-agent agentDir + per-agent session store).
+- **Reply context:** inbound replies include `ReplyToId`, `ReplyToBody`, and `ReplyToSender`, and the quoted context is appended to `Body` as a `[Replying to ...]` block.
+- **Canonical direct session (per agent):** direct chats collapse to `agent::` (default `main`). Groups/channels stay isolated per agent:
+ - group: `agent:::group:`
+ - channel/room: `agent:::channel:`
+- **Session store:** per-agent store lives under `~/.clawdbot/agents//sessions/sessions.json` (override via `session.store` with `{agentId}` templating). JSONL transcripts live next to it.
+- **WebChat:** attaches to the selected agent’s main session (so desktop reflects cross-provider history for that agent).
+- **Implementation hints:**
+ - Set `Provider` + `AccountId` in each ingress.
+ - Route inbound to an agent via `routing.bindings` (match on `provider`, `accountId`, plus optional peer/guild/team).
+ - Keep routing deterministic: originate → same provider. Use the gateway WebSocket for sends; avoid side channels.
+ - Do not let the agent emit “send to X” decisions; keep that policy in the host code.
diff --git a/docs/queue.md b/docs/queue.md
index aafda0ceb..e50121229 100644
--- a/docs/queue.md
+++ b/docs/queue.md
@@ -18,7 +18,7 @@ We now serialize command-based auto-replies (WhatsApp Web listener) through a ti
- 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.
-## Queue modes (per surface)
+## Queue modes (per provider)
Inbound messages can steer the current run, wait for a followup turn, or do both:
- `steer`: inject immediately into the current run (cancels pending tool calls after the next tool boundary). If not streaming, falls back to followup.
- `followup`: enqueue for the next agent turn after the current run ends.
@@ -30,12 +30,12 @@ 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
streaming surfaces can look like duplicates. Prefer `collect`/`steer` if you want
one response per inbound message.
-Inline fix: `/queue collect` (per-session) or set `routing.queue.bySurface.discord: "collect"`.
+Inline fix: `/queue collect` (per-session) or set `routing.queue.byProvider.discord: "collect"`.
Defaults (when unset in config):
- All surfaces → `collect`
-Configure globally or per surface via `routing.queue`:
+Configure globally or per provider via `routing.queue`:
```json5
{
@@ -45,7 +45,7 @@ Configure globally or per surface via `routing.queue`:
debounceMs: 1000,
cap: 20,
drop: "summarize",
- bySurface: { discord: "collect" }
+ byProvider: { discord: "collect" }
}
}
}
diff --git a/docs/session-tool.md b/docs/session-tool.md
index 272acac78..51c3319fd 100644
--- a/docs/session-tool.md
+++ b/docs/session-tool.md
@@ -6,7 +6,7 @@ read_when:
# Session Tools
-Goal: small, hard-to-misuse tool surface so agents can list sessions, fetch history, and send to another session.
+Goal: small, hard-to-misuse tool set so agents can list sessions, fetch history, and send to another session.
## Tool Names
- `sessions_list`
@@ -16,7 +16,7 @@ Goal: small, hard-to-misuse tool surface so agents can list sessions, fetch hist
## Key Model
- Main direct chat bucket is always the literal key `"main"`.
-- Group chats use `surface:group:` or `surface:channel:`.
+- Group chats use `:group:` or `:channel:`.
- Cron jobs use `cron:`.
- Hooks use `hook:` unless explicitly set.
- Node bridge uses `node-` unless explicitly set.
@@ -47,7 +47,7 @@ Row shape (JSON):
- `model`, `contextTokens`, `totalTokens`
- `thinkingLevel`, `verboseLevel`, `systemSent`, `abortedLastRun`
- `sendPolicy` (session override if set)
-- `lastChannel`, `lastTo`
+- `lastProvider`, `lastTo`
- `transcriptPath` (best-effort path derived from store dir + sessionId)
- `messages?` (only when `messageLimit > 0`)
@@ -84,17 +84,17 @@ Behavior:
- Max turns is `session.agentToAgent.maxPingPongTurns` (0–5, default 5).
- Once the loop ends, Clawdbot runs the **agent‑to‑agent announce step** (target agent only):
- Reply exactly `ANNOUNCE_SKIP` to stay silent.
- - Any other reply is sent to the target channel.
+ - Any other reply is sent to the target provider.
- Announce step includes the original request + round‑1 reply + latest ping‑pong reply.
## Provider Field
-- For groups, `provider` is the `surface` recorded on the session entry.
-- For direct chats, `provider` maps from `lastChannel`.
+- For groups, `provider` is the provider recorded on the session entry.
+- For direct chats, `provider` maps from `lastProvider`.
- For cron/hook/node, `provider` is `internal`.
- If missing, `provider` is `unknown`.
## Security / Send Policy
-Policy-based blocking by surface/chat type (not per session id).
+Policy-based blocking by provider/chat type (not per session id).
```json
{
@@ -102,7 +102,7 @@ Policy-based blocking by surface/chat type (not per session id).
"sendPolicy": {
"rules": [
{
- "match": { "surface": "discord", "chatType": "group" },
+ "match": { "provider": "discord", "chatType": "group" },
"action": "deny"
}
],
@@ -121,7 +121,7 @@ Enforcement points:
- auto-reply delivery logic
## sessions_spawn
-Spawn a sub-agent run in an isolated session and announce the result back to the requester chat surface.
+Spawn a sub-agent run in an isolated session and announce the result back to the requester chat provider.
Parameters:
- `task` (required)
@@ -131,9 +131,9 @@ Parameters:
Behavior:
- Starts a new `subagent:` session with `deliver: false`.
-- Sub-agents default to the full tool surface **minus session tools** (configurable via `agent.subagents.tools`).
+- Sub-agents default to the full tool set **minus session tools** (configurable via `agent.subagents.tools`).
- Sub-agents are not allowed to call `sessions_spawn` (no sub-agent → sub-agent spawning).
-- After completion (or best-effort wait), Clawdbot runs a sub-agent **announce step** and posts the result to the requester chat surface.
+- After completion (or best-effort wait), 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.
## Sandbox Session Visibility
diff --git a/docs/session.md b/docs/session.md
index 6cc7a3396..03f689c4b 100644
--- a/docs/session.md
+++ b/docs/session.md
@@ -5,7 +5,7 @@ read_when:
---
# Session Management
-Clawdbot treats **one session as primary**. The canonical key is fixed to `main` for direct chats (or `global` when scope is global); no configuration is required. `session.mainKey` is ignored. Older/local sessions can stay on disk, but only the primary key is used for desktop/web chat and direct agent calls.
+Clawdbot treats **one direct-chat session per agent** as primary. Direct chats collapse to `agent::` (default `main`), while group/channel chats get their own keys. `session.mainKey` is honored.
## Gateway is the source of truth
All session state is **owned by the gateway** (the “master” Clawdbot). UI clients (macOS app, WebChat, etc.) must query the gateway for session lists and token counts instead of reading local files.
@@ -15,17 +15,17 @@ All session state is **owned by the gateway** (the “master” Clawdbot). UI cl
## Where state lives
- On the **gateway host**:
- - Store file: `~/.clawdbot/sessions/sessions.json` (legacy: `~/.clawdbot/sessions.json`).
- - Transcripts: `~/.clawdbot/sessions/.jsonl` (one file per session id).
+ - Store file: `~/.clawdbot/agents//sessions/sessions.json` (per agent).
+ - Transcripts: `~/.clawdbot/agents//sessions/.jsonl` (one file per session id).
- The store is a map `sessionKey -> { sessionId, updatedAt, ... }`. Deleting entries is safe; they are recreated on demand.
-- Group entries may include `displayName`, `surface`, `subject`, `room`, and `space` to label sessions in UIs.
+- Group entries may include `displayName`, `provider`, `subject`, `room`, and `space` to label sessions in UIs.
- Clawdbot does **not** read legacy Pi/Tau session folders.
## Mapping transports → session keys
-- Direct chats (WhatsApp, Telegram, Discord, desktop Web Chat) all collapse to the **primary key** so they share context.
-- Multiple phone numbers can map to that same key; they act as transports into the same conversation.
-- Group chats isolate state with `surface:group:` keys (rooms/channels use `surface:channel:`); do not reuse the primary key for groups. (Discord display names show `discord:#`.)
- - Legacy `group::` and `group:` keys are still recognized.
+- Direct chats collapse to the per-agent primary key: `agent::`.
+ - Multiple phone numbers and providers can map to the same agent main key; they act as transports into one conversation.
+- Group chats isolate state: `agent:::group:` (rooms/channels use `agent:::channel:`).
+ - Legacy `group:` keys are still recognized for migration.
- Other sources:
- Cron jobs: `cron:`
- Webhooks: `hook:` (unless explicitly set by the hook)
@@ -44,7 +44,7 @@ Block delivery for specific session types without listing individual ids.
session: {
sendPolicy: {
rules: [
- { action: "deny", match: { surface: "discord", chatType: "group" } },
+ { action: "deny", match: { provider: "discord", chatType: "group" } },
{ action: "deny", match: { keyPrefix: "cron:" } }
],
default: "allow"
@@ -66,8 +66,8 @@ Runtime override (owner only):
scope: "per-sender", // keep group keys separate
idleMinutes: 120,
resetTriggers: ["/new", "/reset"],
- store: "~/.clawdbot/sessions/sessions.json",
- // mainKey is ignored; primary key is fixed to "main"
+ store: "~/.clawdbot/agents/{agentId}/sessions/sessions.json",
+ mainKey: "main",
}
}
```
diff --git a/docs/signal.md b/docs/signal.md
index e0022e2a7..b2970747d 100644
--- a/docs/signal.md
+++ b/docs/signal.md
@@ -108,7 +108,7 @@ If you have a second phone:
2) Launch daemon (HTTP preferred), store PID.
3) Poll `/api/v1/check` until ready.
4) Open SSE stream; parse `event: receive`.
-5) Translate receive payload into Clawdbot surface model.
+5) Translate receive payload into Clawdbot provider model.
6) On SSE disconnect, backoff + reconnect.
## Storage
diff --git a/docs/subagents.md b/docs/subagents.md
index 0d66c85f4..71b805831 100644
--- a/docs/subagents.md
+++ b/docs/subagents.md
@@ -7,7 +7,7 @@ read_when:
# Sub-agents
-Sub-agents are background agent runs spawned from an existing agent run. They run in their own session (`subagent:`) and, when finished, **announce** their result back to the requester chat surface.
+Sub-agents are background agent runs spawned from an existing agent run. They run in their own session (`subagent:`) and, when finished, **announce** their result back to the requester chat provider.
Primary goals:
- Parallelize “research / long task / slow tool” work without blocking the main run.
@@ -19,7 +19,7 @@ Primary goals:
Use `sessions_spawn`:
- Starts a sub-agent run (`deliver: false`, global lane: `subagent`)
-- Then runs an announce step and posts the announce reply to the requester chat surface
+- Then runs an announce step and posts the announce reply to the requester chat provider
Tool params:
- `task` (required)
@@ -32,7 +32,7 @@ Tool params:
Sub-agents report back via an announce step:
- The announce step runs inside the sub-agent session (not the requester session).
- If the sub-agent replies exactly `ANNOUNCE_SKIP`, nothing is posted.
-- Otherwise the announce reply is posted to the requester chat surface via the gateway `send` method.
+- Otherwise the announce reply is posted to the requester chat provider via the gateway `send` method.
## Tool Policy (sub-agent tools)
diff --git a/docs/surface.md b/docs/surface.md
deleted file mode 100644
index fdcaf8871..000000000
--- a/docs/surface.md
+++ /dev/null
@@ -1,20 +0,0 @@
----
-summary: "Routing rules per surface (WhatsApp, Telegram, Discord, web) and shared context"
-read_when:
- - Changing surface routing or inbox behavior
----
-# Surfaces & Routing
-
-Updated: 2025-12-07
-
-Goal: make replies deterministic per channel while keeping one shared context for direct chats.
-
-- **Surfaces** (channel labels): `whatsapp`, `webchat`, `telegram`, `discord`, `imessage`, `voice`, etc. Add `Surface` to inbound `MsgContext` so templates/agents can log which channel a turn came from. Routing is fixed: replies go back to the origin surface; the model doesn’t choose.
-- **Reply context:** inbound replies include `ReplyToId`, `ReplyToBody`, and `ReplyToSender`, and the quoted context is appended to `Body` as a `[Replying to ...]` block.
-- **Canonical direct session:** All direct chats collapse into the single `main` session by default (no config needed). Groups stay `surface:group:` (rooms: `surface:channel:`), so they remain isolated.
-- **Session store:** Keys are resolved via `resolveSessionKey(scope, ctx, mainKey)`; the agent JSONL path lives under `~/.clawdbot/sessions/.jsonl`.
-- **WebChat:** Always attaches to `main`, loads the full session transcript so desktop reflects cross-surface history, and writes new turns back to the same session.
-- **Implementation hints:**
- - Set `Surface` in each ingress (WhatsApp gateway, WebChat bridge, Telegram, Discord, iMessage).
- - Keep routing deterministic: originate → same surface. Use the gateway WebSocket for sends; avoid side channels.
- - Do not let the agent emit “send to X” decisions; keep that policy in the host code.
diff --git a/docs/telegram.md b/docs/telegram.md
index 6fa6426e0..f1f330165 100644
--- a/docs/telegram.md
+++ b/docs/telegram.md
@@ -12,7 +12,7 @@ Status: ready for bot-mode use with grammY (long-polling by default; webhook sup
## Goals
- Let you talk to Clawdbot via a Telegram bot in DMs and groups.
- Share the same `main` session used by WhatsApp/WebChat; groups stay isolated as `telegram:group:`.
-- Keep transport routing deterministic: replies always go back to the surface they arrived on.
+- Keep transport routing deterministic: replies always go back to the provider they arrived on.
## How it will work (Bot API)
1) Create a bot with @BotFather and grab the token.
@@ -37,7 +37,7 @@ Status: ready for bot-mode use with grammY (long-polling by default; webhook sup
## Planned implementation details
- Library: grammY is the only client for send + gateway (fetch fallback removed); grammY throttler is enabled by default to stay under Bot API limits.
-- Inbound normalization: maps Bot API updates to `MsgContext` with `Surface: "telegram"`, `ChatType: direct|group`, `SenderName`, `MediaPath`/`MediaType` when attachments arrive, `Timestamp`, and reply-to metadata (`ReplyToId`, `ReplyToBody`, `ReplyToSender`) when the user replies; reply context is appended to `Body` as a `[Replying to ...]` block (includes `id:` when available); groups require @bot mention or a `routing.groupChat.mentionPatterns` match by default (override per chat in config).
+- Inbound normalization: maps Bot API updates to `MsgContext` with `Provider: "telegram"`, `ChatType: direct|group`, `SenderName`, `MediaPath`/`MediaType` when attachments arrive, `Timestamp`, and reply-to metadata (`ReplyToId`, `ReplyToBody`, `ReplyToSender`) when the user replies; reply context is appended to `Body` as a `[Replying to ...]` block (includes `id:` when available); groups require @bot mention or a `routing.groupChat.mentionPatterns` match by default (override per chat in config).
- Outbound: text and media (photo/video/audio/document) with optional caption; chunked to limits. Typing cue sent best-effort.
- Config: `TELEGRAM_BOT_TOKEN` env or `telegram.botToken` required; `telegram.dmPolicy`, `telegram.groups` (group allowlist + mention defaults), `telegram.allowFrom`, `telegram.groupAllowFrom`, `telegram.groupPolicy`, `telegram.mediaMaxMb`, `telegram.replyToMode`, `telegram.proxy`, `telegram.webhookSecret`, `telegram.webhookUrl`, `telegram.webhookPath` supported.
- Ack reactions are controlled globally via `messages.ackReaction` + `messages.ackReactionScope`.
diff --git a/docs/timezone.md b/docs/timezone.md
index 8a9d0ca6a..3269c610e 100644
--- a/docs/timezone.md
+++ b/docs/timezone.md
@@ -14,7 +14,7 @@ Clawdbot standardizes timestamps so the model sees a **single reference time**.
Inbound messages are wrapped in an envelope like:
```
-[Surface ... 2026-01-05T21:26Z] message text
+[Provider ... 2026-01-05T21:26Z] message text
```
The timestamp in the envelope is **always UTC**, with minutes precision.
diff --git a/docs/tools.md b/docs/tools.md
index 47815a386..d76567ebe 100644
--- a/docs/tools.md
+++ b/docs/tools.md
@@ -203,7 +203,7 @@ Notes:
- `reactions` returns per-emoji user lists (limited to 100 per reaction).
- `discord.actions.*` gates Discord tool actions; `roles` + `moderation` default to `false`.
- `searchMessages` follows the Discord preview spec (limit max 25, channel/author filters accept arrays).
-- The tool is only exposed when the current surface is Discord.
+- The tool is only exposed when the current provider is Discord.
## Parameters (common)
diff --git a/docs/web.md b/docs/web.md
index 087a18f2d..aecc44f47 100644
--- a/docs/web.md
+++ b/docs/web.md
@@ -25,7 +25,7 @@ The UI talks directly to the Gateway WS and supports:
## Webhooks
-When `hooks.enabled=true`, the Gateway also exposes a small webhook surface on the same HTTP server.
+When `hooks.enabled=true`, the Gateway also exposes a small webhook endpoint on the same HTTP server.
See [`docs/configuration.md`](https://docs.clawd.bot/configuration) → `hooks` for auth + payloads.
## Config (default-on)
diff --git a/docs/webhook.md b/docs/webhook.md
index c0b3b1925..d591892f2 100644
--- a/docs/webhook.md
+++ b/docs/webhook.md
@@ -7,7 +7,7 @@ read_when:
# Webhooks
-Gateway can expose a small HTTP webhook surface for external triggers.
+Gateway can expose a small HTTP webhook endpoint for external triggers.
## Enable
@@ -58,7 +58,7 @@ Payload:
"sessionKey": "hook:email:msg-123",
"wakeMode": "now",
"deliver": false,
- "channel": "last",
+ "provider": "last",
"to": "+15551234567",
"thinking": "low",
"timeoutSeconds": 120
@@ -70,8 +70,8 @@ Payload:
- `sessionKey` optional (default random `hook:`)
- `wakeMode` optional: `now` | `next-heartbeat` (default `now`)
- `deliver` optional (default `false`)
-- `channel` optional: `last` | `whatsapp` | `telegram`
-- `to` optional (channel-specific target)
+- `provider` optional: `last` | `whatsapp` | `telegram`
+- `to` optional (provider-specific target)
- `thinking` optional (override)
- `timeoutSeconds` optional
diff --git a/docs/whatsapp.md b/docs/whatsapp.md
index 426516c39..ba614ece7 100644
--- a/docs/whatsapp.md
+++ b/docs/whatsapp.md
@@ -7,10 +7,10 @@ read_when:
Updated: 2025-12-23
-Status: WhatsApp Web via Baileys only. Gateway owns the single session.
+Status: WhatsApp Web via Baileys only. Gateway owns the session(s).
## Goals
-- One WhatsApp identity, one gateway session.
+- Multiple WhatsApp accounts (multi-account) in one Gateway process.
- Deterministic routing: replies return to WhatsApp, no model routing.
- Model sees enough context to understand quoted replies.
@@ -37,9 +37,12 @@ WhatsApp requires a real mobile number for verification. VoIP and virtual number
## Login + credentials
- Login command: `clawdbot login` (QR via Linked Devices).
-- Credentials stored in `~/.clawdbot/credentials/creds.json`.
+- Multi-account login: `clawdbot login --account ` (`` = `accountId`).
+- Default account (when `--account` is omitted): `default` if present, otherwise the first configured account id (sorted).
+- Credentials stored in `~/.clawdbot/credentials/whatsapp//creds.json`.
- Backup copy at `creds.json.bak` (restored on corruption).
-- Logout: `clawdbot logout` deletes creds and session store.
+- Legacy compatibility: older installs stored Baileys files directly in `~/.clawdbot/credentials/`.
+- Logout: `clawdbot logout` (or `--account `) deletes WhatsApp auth state (but keeps shared `oauth.json`).
- Logged-out socket => error instructs re-link.
## Inbound flow (DM + group)
@@ -72,7 +75,7 @@ WhatsApp requires a real mobile number for verification. VoIP and virtual number
- ``
## Groups
-- Groups map to `whatsapp:group:` sessions.
+- Groups map to `agent::whatsapp:group:` sessions.
- Group policy: `whatsapp.groupPolicy = open|disabled|allowlist` (default `open`).
- Activation modes:
- `mention` (default): requires @mention or regex match.
@@ -89,7 +92,7 @@ WhatsApp requires a real mobile number for verification. VoIP and virtual number
## Reply delivery (threading)
- WhatsApp Web sends standard messages (no quoted reply threading in the current gateway).
-- Reply tags are ignored on this surface.
+- Reply tags are ignored on this provider.
## Outbound send (text + media)
- Uses active web listener; error if gateway not running.
@@ -113,7 +116,7 @@ WhatsApp requires a real mobile number for verification. VoIP and virtual number
- **Gateway heartbeat** logs connection health (`web.heartbeatSeconds`, default 60s).
- **Agent heartbeat** is global (`agent.heartbeat.*`) and runs in the main session.
- Uses `HEARTBEAT` prompt + `HEARTBEAT_OK` skip behavior.
- - Delivery defaults to the last used channel (or configured target).
+ - Delivery defaults to the last used provider (or configured target).
## Reconnect behavior
- Backoff policy: `web.reconnect`:
@@ -124,6 +127,7 @@ WhatsApp requires a real mobile number for verification. VoIP and virtual number
## Config quick map
- `whatsapp.dmPolicy` (DM policy: pairing/allowlist/open/disabled).
- `whatsapp.allowFrom` (DM allowlist).
+- `whatsapp.accounts..*` (per-account settings + optional `authDir`).
- `whatsapp.groupAllowFrom` (group sender allowlist).
- `whatsapp.groupPolicy` (group policy).
- `whatsapp.groups` (group allowlist + mention gating defaults; use `"*"` to allow all)
@@ -136,7 +140,7 @@ WhatsApp requires a real mobile number for verification. VoIP and virtual number
- `agent.heartbeat.model` (optional override)
- `agent.heartbeat.target`
- `agent.heartbeat.to`
-- `session.*` (scope, idle, store; `mainKey` is ignored)
+- `session.*` (scope, idle, store, mainKey)
- `web.enabled` (disable provider startup when false)
- `web.heartbeatSeconds`
- `web.reconnect.*`