diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 000000000..46ee3da04
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,28 @@
+---
+name: Bug report
+about: Report a problem or unexpected behavior in Clawdbot.
+title: "[Bug]: "
+labels: bug
+---
+
+## Summary
+What went wrong?
+
+## Steps to reproduce
+1.
+2.
+3.
+
+## Expected behavior
+What did you expect to happen?
+
+## Actual behavior
+What actually happened?
+
+## Environment
+- Clawdbot version:
+- OS:
+- Install method (pnpm/npx/docker/etc):
+
+## Logs or screenshots
+Paste relevant logs or add screenshots (redact secrets).
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 000000000..26c896f06
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,8 @@
+blank_issues_enabled: true
+contact_links:
+ - name: Onboarding
+ url: https://discord.gg/clawd
+ about: New to Clawdbot? Join Discord for setup guidance from Krill in #help.
+ - name: Support
+ url: https://discord.gg/clawd
+ about: Get help from Krill and the community on Discord in #help.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 000000000..742bf184e
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,18 @@
+---
+name: Feature request
+about: Suggest an idea or improvement for Clawdbot.
+title: "[Feature]: "
+labels: enhancement
+---
+
+## Summary
+Describe the problem you are trying to solve or the opportunity you see.
+
+## Proposed solution
+What would you like Clawdbot to do?
+
+## Alternatives considered
+Any other approaches you have considered?
+
+## Additional context
+Links, screenshots, or related issues.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b6577bfcb..062e5736c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -52,32 +52,21 @@ jobs:
exit 1
- name: Setup Node.js
- if: matrix.runtime == 'node'
uses: actions/setup-node@v4
with:
node-version: 24
check-latest: true
- name: Setup Bun
- if: matrix.runtime == 'bun'
uses: oven-sh/setup-bun@v2
with:
- # bun.sh downloads currently fail with:
- # "Failed to list releases from GitHub: 401" -> "Unexpected HTTP response: 400"
- bun-download-url: "https://github.com/oven-sh/bun/releases/latest/download/bun-linux-x64.zip"
-
- - name: Setup Node.js (tooling for bun)
- if: matrix.runtime == 'bun'
- uses: actions/setup-node@v4
- with:
- node-version: 24
- check-latest: true
+ bun-version: latest
- name: Runtime versions
run: |
node -v
npm -v
- if [ "${{ matrix.runtime }}" = "bun" ]; then bun -v; fi
+ bun -v
- name: Capture node path
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
diff --git a/.gitignore b/.gitignore
index 8bc3ebb67..85b83cb81 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,8 @@ node_modules
dist
*.bun-build
pnpm-lock.yaml
+bun.lock
+bun.lockb
coverage
.pnpm-store
.worktrees/
diff --git a/AGENTS.md b/AGENTS.md
index 56ab3d72a..5712f572c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -6,8 +6,12 @@
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
## Build, Test, and Development Commands
+- Runtime baseline: Node **22+** (keep Node + Bun paths working).
- Install deps: `pnpm install`
-- Run CLI in dev: `pnpm clawdbot ...` (tsx entry) or `pnpm dev` for `src/index.ts`.
+- Also supported: `bun install` (keep `pnpm-lock.yaml` + Bun patching in sync when touching deps/patches).
+- Prefer Bun for TypeScript execution (scripts, dev, tests): `bun ` (Telegram also supports `clawdbot telegram pairing ...`).
- Timestamps in agent envelopes are now UTC (compact `YYYY-MM-DDTHH:mmZ`); removed `messages.timestampPrefix`. Add `agent.userTimezone` to tell the model the user’s local time (system prompt only).
- 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.
+- Groups: `whatsapp.groups`, `telegram.groups`, and `imessage.groups` now act as allowlists when set. Add `"*"` to keep allow-all behavior.
### Fixes
+- Heartbeat: default interval now 30m with a new default prompt + HEARTBEAT.md template.
+- Onboarding: write auth profiles to the multi-agent path (`~/.clawdbot/agents/main/agent/`) so the gateway finds credentials on first startup. Thanks @minghinmatthewlam for PR #327.
+- Docs: add missing `ui:install` setup step in the README. Thanks @hugobarauna for PR #300.
+- Build: import tool-display JSON as a module instead of runtime file reads. Thanks @mukhtharcm for PR #312.
+- Browser: fix `browser snapshot`/`browser act` timeouts under Bun by patching Playwright’s CDP WebSocket selection. Thanks @azade-c for PR #307.
+- Browser: add `--browser-profile` flag and honor profile in tabs routes + browser tool. Thanks @jamesgroat for PR #324.
+- Telegram: stop typing after tool results. Thanks @AbhisekBasu1 for PR #322.
+- Messages: stop defaulting ack reactions to 👀 when identity emoji is missing.
+- Auto-reply: require slash for control commands to avoid false triggers in normal text.
+- Auto-reply: flag error payloads and improve Bun socket error messaging. Thanks @emanuelst for PR #331.
+- Commands: unify native + text chat commands behind `commands.*` config (Discord/Slack/Telegram). Thanks @thewilloftheshadow for PR #275.
+- Auto-reply: treat steer during compaction as a follow-up, queued until compaction completes.
+- Auth: lock auth profile refreshes to avoid multi-instance OAuth logouts; keep credentials on refresh failure.
+- Gateway/CLI: stop forcing localhost URL in remote mode so remote gateway config works. Thanks @oswalpalash for PR #293.
+- Onboarding: prompt immediately for OpenAI Codex redirect URL on remote/headless logins.
+- Configure: add OpenAI Codex (ChatGPT OAuth) auth choice (align with onboarding).
+- Doctor: suggest adding the workspace memory system when missing (opt-out via `--no-workspace-suggestions`).
+- Doctor: normalize default workspace path to `~/clawd` (avoid `~/clawdbot`).
+- Workspace: only create `BOOTSTRAP.md` for brand-new workspaces (don’t recreate after deletion).
+- Build: fix duplicate protocol export, align Codex OAuth options, and add proper-lockfile typings.
+- Build: install Bun in the Dockerfile so `pnpm build` can run Bun scripts. Thanks @loukotal for PR #284.
+- Typing indicators: stop typing once the reply dispatcher drains to prevent stuck typing across Discord/Telegram/WhatsApp.
+- Typing indicators: fix a race that could keep the typing indicator stuck after quick replies. Thanks @thewilloftheshadow for PR #270.
+- Google: merge consecutive messages to satisfy strict role alternation for Google provider models. Thanks @Asleep123 for PR #266.
+- Postinstall: handle targetDir symlinks in the install script. Thanks @obviyus for PR #272.
+- WhatsApp/Telegram: add groupPolicy handling for group messages and normalize allowFrom matching (tg/telegram prefixes). Thanks @mneves75.
+- Auto-reply: add configurable ack reactions for inbound messages (default 👀 or `identity.emoji`) with scope controls. Thanks @obviyus for PR #178.
+- Polls: unify WhatsApp + Discord poll sends via the gateway + CLI (`clawdbot poll`). (#123) — thanks @dbhurley
- Onboarding: resolve CLI entrypoint when running via `npx` so gateway daemon install works without a build step.
- Onboarding: when OpenAI Codex OAuth is used, default to `openai-codex/gpt-5.2` and warn if the selected model lacks auth.
- CLI: auto-migrate legacy config entries on command start (same behavior as gateway startup).
- Gateway: add `gateway stop|restart` helpers and surface launchd/systemd/schtasks stop hints when the gateway is already running.
+- Gateway: honor `agent.timeoutSeconds` for `chat.send` and share timeout defaults across chat/cron/auto-reply. Thanks @MSch for PR #229.
- Auth: prioritize OAuth profiles but fall back to API keys when refresh fails; stored profiles now load without explicit auth order.
- Control UI: harden config Form view with schema normalization, map editing, and guardrails to prevent data loss on save.
- Cron: normalize cron.add/update inputs, align channel enums/status fields across gateway/CLI/UI/macOS, and add protocol conformance tests. Thanks @mneves75 for PR #256.
@@ -21,7 +56,9 @@
- Gmail: stop restart loop when `gog gmail watch serve` fails to bind (address already in use).
- Linux: auto-attempt lingering during onboarding (try without sudo, fallback to sudo) and prompt on install/restart to keep the gateway alive after logout/idle. Thanks @tobiasbischoff for PR #237.
- TUI: migrate key handling to the updated pi-tui Key matcher API.
+- TUI: add `/elev` alias for `/elevated`.
- Logging: redact sensitive tokens in verbose tool summaries by default (configurable patterns).
+- macOS: keep app connection settings local in remote mode to avoid overwriting gateway config. Thanks @ngutman for PR #310.
- macOS: prefer gateway config reads/writes in local mode (fall back to disk if the gateway is unavailable).
- macOS: local gateway now connects via tailnet IP when bind mode is `tailnet`/`auto`.
- macOS: Connections settings now use a custom sidebar to avoid toolbar toggle issues, with rounded styling and full-width row hit targets.
@@ -34,32 +71,58 @@
- Auth: when `openai` has no API key but Codex OAuth exists, suggest `openai-codex/gpt-5.2` vs `OPENAI_API_KEY`.
- Docs: clarify auth storage, migration, and OpenAI Codex OAuth onboarding.
- Sandbox: copy inbound media into sandbox workspaces so agent tools can read attachments.
+- Sandbox: enable session tools in sandboxed sessions with spawned-only visibility by default (opt-in `agent.sandbox.sessionToolsVisibility = "all"`).
- Control UI: show a reading indicator bubble while the assistant is responding.
- Control UI: animate reading indicator dots (honors reduced-motion).
- Control UI: stabilize chat streaming during tool runs (no flicker/vanishing text; correct run scoping).
+- Control UI: let config-form enums select empty-string values. Thanks @sreekaransrinath for PR #268.
+- Control UI: scroll chat to bottom on initial load. Thanks @kiranjd for PR #274.
+- Control UI: add Chat focus mode toggle to collapse header + sidebar.
+- Control UI: tighten focus mode spacing (reduce top padding, add comfortable compose inset).
+- Control UI: standardize UI build instructions on `bun run ui:*` (fallback supported).
- Status: show runtime (docker/direct) and move shortcuts to `/help`.
- Status: show model auth source (api-key/oauth).
+- Status: fix zero token counters for Anthropic (Opus) sessions by normalizing usage fields and ignoring empty usage updates.
- Block streaming: avoid splitting Markdown fenced blocks and reopen fences when forced to split.
- Block streaming: preserve leading indentation in block replies (lists, indented fences).
- Docs: document systemd lingering and logged-in session requirements on macOS/Windows.
-- Auto-reply: unify tool/block/final delivery across providers and apply consistent heartbeat/prefix handling. Thanks @MSch for PR #225 (superseded commit 92c953d0749143eb2a3f31f3cd6ad0e8eabf48c3).
+- Auto-reply: centralize tool/block/final dispatch across providers for consistent streaming + heartbeat/prefix handling. Thanks @MSch for PR #225.
- Heartbeat: make HEARTBEAT_OK ack padding configurable across heartbeat and cron delivery. (#238) — thanks @jalehman
+- Skills: emit MEDIA token after Nano Banana Pro image generation. Thanks @Iamadig for PR #271.
- WhatsApp: set sender E.164 for direct chats so owner commands work in DMs.
- Slack: keep auto-replies in the original thread when responding to thread messages. Thanks @scald for PR #251.
+- Slack: send typing status updates via assistant threads. Thanks @thewilloftheshadow for PR #320.
+- Slack: fix Slack provider startup under Bun by using a named import for Bolt `App`. Thanks @snopoke for PR #299.
- Discord: surface missing-permission hints (muted/role overrides) when replies fail.
+- Discord: use channel IDs for DMs instead of user IDs. Thanks @VACInc for PR #261.
- Docs: clarify Slack manifest scopes (current vs optional) with references. Thanks @jarvis-medmatic for PR #235.
- Control UI: avoid Slack config ReferenceError by reading slack config snapshots. Thanks @sreekaransrinath for PR #249.
-- Telegram: honor routing.groupChat.mentionPatterns for group mention gating. Thanks @regenrek for PR #242.
+- Auth: rotate across multiple OAuth profiles with cooldown tracking and email-based profile IDs. Thanks @mukhtharcm for PR #269.
+- Auth: fix multi-account OAuth rotation so round-robin alternates instead of pinning to lastGood. Thanks @mukhtharcm for PR #281.
+- Configure: stop auto-writing `auth.order` for newly added auth profiles (round-robin default unless explicitly pinned).
+- Telegram: honor routing.groupChat.mentionPatterns for group mention gating. Thanks Kevin Kern (@regenrek) for PR #242.
+- Telegram: gate groups via `telegram.groups` allowlist (align with WhatsApp/iMessage). Thanks @kitze for PR #241.
+- Telegram: support media groups (multi-image messages). Thanks @obviyus for PR #220.
+- Telegram/WhatsApp: parse shared locations (pins, places, live) and expose structured ctx fields. Thanks @nachoiacovino for PR #194.
- Auto-reply: block unauthorized `/reset` and infer WhatsApp senders from E.164 inputs.
- Auto-reply: reset corrupted Gemini sessions when function-call ordering breaks. Thanks @VACInc for PR #297.
- Auto-reply: track compaction count in session status; verbose mode announces auto-compactions.
+- Telegram: notify users when inbound media exceeds size limits. Thanks @jarvis-medmatic for PR #283.
- Telegram: send GIF media as animations (auto-play) and improve filename sniffing.
+- Bash tool: inherit gateway PATH so Nix-provided tools resolve during commands. Thanks @joshp123 for PR #202.
+- Delivery chunking: keep Markdown fenced code blocks valid when splitting long replies (close + reopen fences).
+- Auth: prefer OAuth profiles over API keys during round-robin selection (prevents OAuth “lost after one message” when both are configured).
+- Models: extend `clawdbot models` status output with a masked auth overview (profiles, env sources, and OAuth counts).
### Maintenance
+- Agent: add `skipBootstrap` config option. Thanks @onutc for PR #292.
+- UI: add favicon.ico derived from the macOS app icon. Thanks @jeffersonwarrior for PR #305.
+- Tooling: replace tsx with bun for TypeScript execution. Thanks @obviyus for PR #278.
- Deps: bump pi-* stack, Slack SDK, discord-api-types, file-type, zod, and Biome.
- Skills: add CodexBar model usage helper with macOS requirement metadata.
- Skills: add 1Password CLI skill with op examples.
- Lint: organize imports and wrap long lines in reply commands.
+- Refactor: centralize group allowlist/mention policy across providers.
- Deps: update to latest across the repo.
## 2026.1.5-3
@@ -86,6 +149,7 @@
- Agent tools: new `image` tool routed to the image model (when configured).
- Config: default model shorthands (`opus`, `sonnet`, `gpt`, `gpt-mini`, `gemini`, `gemini-flash`).
- Docs: document built-in model shorthands + precedence (user config wins).
+- Bun: optional local install/build workflow without maintaining a Bun lockfile (see `docs/bun.md`).
### Fixes
- Control UI: render Markdown in tool result cards.
@@ -104,11 +168,16 @@
- Env: load global `$CLAWDBOT_STATE_DIR/.env` (`~/.clawdbot/.env`) as a fallback after CWD `.env`.
- Env: optional login-shell env fallback (opt-in; imports expected keys without overriding existing env).
- Agent tools: OpenAI-compatible tool JSON Schemas (fix `browser`, normalize union schemas).
-- Onboarding: when running from source, auto-build missing Control UI assets (`pnpm 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).
- Agent tools: honor `agent.tools` allow/deny policy even when sandbox is off.
- 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.
+- CLI: run `clawdbot agent` via the Gateway by default; use `--local` to force embedded mode.
+
+## 2026.1.5
+
+### Fixes
- Control UI: render Markdown in chat messages (sanitized).
diff --git a/Dockerfile b/Dockerfile
index 10c4f3614..8fc107f10 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,5 +1,9 @@
FROM node:22-bookworm
+# Install Bun (required for build scripts)
+RUN curl -fsSL https://bun.sh/install | bash
+ENV PATH="/root/.bun/bin:${PATH}"
+
RUN corepack enable
WORKDIR /app
diff --git a/README.md b/README.md
index 3ec3328be..23d1827b9 100644
--- a/README.md
+++ b/README.md
@@ -16,109 +16,127 @@
` (then the sender is added to a local allowlist store).
+- Public inbound DMs require an explicit opt-in: set `dmPolicy="open"` and include `"*"` in the provider allowlist (`allowFrom` / `discord.dm.allowFrom` / `slack.dm.allowFrom`).
+
+Run `clawdbot doctor` to surface risky/misconfigured DM policies.
## Highlights
-- **[Local-first Gateway](https://docs.clawdbot.com/gateway)** — single control plane for sessions, providers, tools, and events.
-- **[Multi-surface inbox](https://docs.clawdbot.com/surface)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, WebChat, macOS, iOS/Android.
-- **[Voice Wake](https://docs.clawdbot.com/voicewake) + [Talk Mode](https://docs.clawdbot.com/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs.
-- **[Live Canvas](https://docs.clawdbot.com/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.clawdbot.com/refactor/canvas-a2ui).
-- **[First-class tools](https://docs.clawdbot.com/tools)** — browser, canvas, nodes, cron, sessions, and Discord/Slack actions.
-- **[Companion apps](https://docs.clawdbot.com/macos)** — macOS menu bar app + iOS/Android [nodes](https://docs.clawdbot.com/nodes).
-- **[Onboarding](https://docs.clawdbot.com/wizard) + [skills](https://docs.clawdbot.com/skills)** — wizard-driven setup with bundled/managed/workspace skills.
+- **[Local-first Gateway](https://docs.clawd.bot/gateway)** — single control plane for sessions, providers, tools, and events.
+- **[Multi-provider inbox](https://docs.clawd.bot/surface)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, WebChat, macOS, iOS/Android.
+- **[Multi-agent routing](https://docs.clawd.bot/configuration)** — 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.
+- **[Companion apps](https://docs.clawd.bot/macos)** — macOS menu bar app + iOS/Android [nodes](https://docs.clawd.bot/nodes).
+- **[Onboarding](https://docs.clawd.bot/wizard) + [skills](https://docs.clawd.bot/skills)** — wizard-driven setup with bundled/managed/workspace skills.
## Everything we built so far
### Core platform
-- [Gateway WS control plane](https://docs.clawdbot.com/gateway) with sessions, presence, config, cron, webhooks, [Control UI](https://docs.clawdbot.com/web), and [Canvas host](https://docs.clawdbot.com/refactor/canvas-a2ui).
-- [CLI surface](https://docs.clawdbot.com/agent-send): gateway, agent, send, [wizard](https://docs.clawdbot.com/wizard), and [doctor](https://docs.clawdbot.com/doctor).
-- [Pi agent runtime](https://docs.clawdbot.com/agent) in RPC mode with tool streaming and block streaming.
-- [Session model](https://docs.clawdbot.com/session): `main` for direct chats, group isolation, activation modes, queue modes, reply-back. Group rules: [Groups](https://docs.clawdbot.com/groups).
-- [Media pipeline](https://docs.clawdbot.com/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.clawdbot.com/audio).
+- [Gateway WS control plane](https://docs.clawd.bot/gateway) with sessions, presence, config, cron, webhooks, [Control UI](https://docs.clawd.bot/web), and [Canvas host](https://docs.clawd.bot/mac/canvas#canvas-a2ui).
+- [CLI surface](https://docs.clawd.bot/agent-send): gateway, agent, send, [wizard](https://docs.clawd.bot/wizard), and [doctor](https://docs.clawd.bot/doctor).
+- [Pi agent runtime](https://docs.clawd.bot/agent) in RPC mode with tool streaming and block streaming.
+- [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](https://docs.clawdbot.com/surface): [WhatsApp](https://docs.clawdbot.com/whatsapp) (Baileys), [Telegram](https://docs.clawdbot.com/telegram) (grammY), [Slack](https://docs.clawdbot.com/slack) (Bolt), [Discord](https://docs.clawdbot.com/discord) (discord.js), [Signal](https://docs.clawdbot.com/signal) (signal-cli), [iMessage](https://docs.clawdbot.com/imessage) (imsg), [WebChat](https://docs.clawdbot.com/webchat).
-- [Group routing](https://docs.clawdbot.com/group-messages): mention gating, reply tags, per-surface chunking and routing. Surface rules: [Surface routing](https://docs.clawdbot.com/surface).
+### 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-provider chunking and routing. Provider rules: [Providers](https://docs.clawd.bot/surface).
### Apps + nodes
-- [macOS app](https://docs.clawdbot.com/macos): menu bar control plane, [Voice Wake](https://docs.clawdbot.com/voicewake)/PTT, [Talk Mode](https://docs.clawdbot.com/talk) overlay, [WebChat](https://docs.clawdbot.com/webchat), debug tools, [remote gateway](https://docs.clawdbot.com/remote) control.
-- [iOS node](https://docs.clawdbot.com/ios): [Canvas](https://docs.clawdbot.com/mac/canvas), [Voice Wake](https://docs.clawdbot.com/voicewake), [Talk Mode](https://docs.clawdbot.com/talk), camera, screen recording, Bonjour pairing.
-- [Android node](https://docs.clawdbot.com/android): [Canvas](https://docs.clawdbot.com/mac/canvas), [Talk Mode](https://docs.clawdbot.com/talk), camera, screen recording, optional SMS.
-- [macOS node mode](https://docs.clawdbot.com/nodes): system.run/notify + canvas/camera exposure.
+- [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.
+- [iOS node](https://docs.clawd.bot/ios): [Canvas](https://docs.clawd.bot/mac/canvas), [Voice Wake](https://docs.clawd.bot/voicewake), [Talk Mode](https://docs.clawd.bot/talk), camera, screen recording, Bonjour pairing.
+- [Android node](https://docs.clawd.bot/android): [Canvas](https://docs.clawd.bot/mac/canvas), [Talk Mode](https://docs.clawd.bot/talk), camera, screen recording, optional SMS.
+- [macOS node mode](https://docs.clawd.bot/nodes): system.run/notify + canvas/camera exposure.
### Tools + automation
-- [Browser control](https://docs.clawdbot.com/browser): dedicated clawd Chrome/Chromium, snapshots, actions, uploads, profiles.
-- [Canvas](https://docs.clawdbot.com/mac/canvas): [A2UI](https://docs.clawdbot.com/refactor/canvas-a2ui) push/reset, eval, snapshot.
-- [Nodes](https://docs.clawdbot.com/nodes): camera snap/clip, screen record, [location.get](https://docs.clawdbot.com/location-command), notifications.
-- [Cron + wakeups](https://docs.clawdbot.com/cron); [webhooks](https://docs.clawdbot.com/webhook); [Gmail Pub/Sub](https://docs.clawdbot.com/gmail-pubsub).
-- [Skills platform](https://docs.clawdbot.com/skills): bundled, managed, and workspace skills with install gating + UI.
+- [Browser control](https://docs.clawd.bot/browser): dedicated clawd Chrome/Chromium, snapshots, actions, uploads, profiles.
+- [Canvas](https://docs.clawd.bot/mac/canvas): [A2UI](https://docs.clawd.bot/mac/canvas#canvas-a2ui) push/reset, eval, snapshot.
+- [Nodes](https://docs.clawd.bot/nodes): camera snap/clip, screen record, [location.get](https://docs.clawd.bot/location-command), notifications.
+- [Cron + wakeups](https://docs.clawd.bot/cron); [webhooks](https://docs.clawd.bot/webhook); [Gmail Pub/Sub](https://docs.clawd.bot/gmail-pubsub).
+- [Skills platform](https://docs.clawd.bot/skills): bundled, managed, and workspace skills with install gating + UI.
### Ops + packaging
-- [Control UI](https://docs.clawdbot.com/web) + [WebChat](https://docs.clawdbot.com/webchat) served directly from the Gateway.
-- [Tailscale Serve/Funnel](https://docs.clawdbot.com/tailscale) or [SSH tunnels](https://docs.clawdbot.com/remote) with token/password auth.
-- [Nix mode](https://docs.clawdbot.com/nix) for declarative config; [Docker](https://docs.clawdbot.com/docker)-based installs.
-- [Doctor](https://docs.clawdbot.com/doctor) migrations, [logging](https://docs.clawdbot.com/logging).
+- [Control UI](https://docs.clawd.bot/web) + [WebChat](https://docs.clawd.bot/webchat) served directly from the Gateway.
+- [Tailscale Serve/Funnel](https://docs.clawd.bot/tailscale) or [SSH tunnels](https://docs.clawd.bot/remote) with token/password auth.
+- [Nix mode](https://docs.clawd.bot/nix) for declarative config; [Docker](https://docs.clawd.bot/docker)-based installs.
+- [Doctor](https://docs.clawd.bot/doctor) migrations, [logging](https://docs.clawd.bot/logging).
## How it works (short)
@@ -140,12 +158,12 @@ WhatsApp / Telegram / Slack / Discord / Signal / iMessage / WebChat
## Key subsystems
-- **[Gateway WebSocket network](https://docs.clawdbot.com/architecture)** — single WS control plane for clients, tools, and events (plus ops: [Gateway runbook](https://docs.clawdbot.com/gateway)).
-- **[Tailscale exposure](https://docs.clawdbot.com/tailscale)** — Serve/Funnel for the Gateway dashboard + WS (remote access: [Remote](https://docs.clawdbot.com/remote)).
-- **[Browser control](https://docs.clawdbot.com/browser)** — clawd‑managed Chrome/Chromium with CDP control.
-- **[Canvas + A2UI](https://docs.clawdbot.com/mac/canvas)** — agent‑driven visual workspace (A2UI host: [Canvas/A2UI](https://docs.clawdbot.com/refactor/canvas-a2ui)).
-- **[Voice Wake](https://docs.clawdbot.com/voicewake) + [Talk Mode](https://docs.clawdbot.com/talk)** — always‑on speech and continuous conversation.
-- **[Nodes](https://docs.clawdbot.com/nodes)** — Canvas, camera snap/clip, screen record, `location.get`, notifications, plus macOS‑only `system.run`/`system.notify`.
+- **[Gateway WebSocket network](https://docs.clawd.bot/architecture)** — single WS control plane for clients, tools, and events (plus ops: [Gateway runbook](https://docs.clawd.bot/gateway)).
+- **[Tailscale exposure](https://docs.clawd.bot/tailscale)** — Serve/Funnel for the Gateway dashboard + WS (remote access: [Remote](https://docs.clawd.bot/remote)).
+- **[Browser control](https://docs.clawd.bot/browser)** — clawd‑managed Chrome/Chromium with CDP control.
+- **[Canvas + A2UI](https://docs.clawd.bot/mac/canvas)** — agent‑driven visual workspace (A2UI host: [Canvas/A2UI](https://docs.clawd.bot/mac/canvas#canvas-a2ui)).
+- **[Voice Wake](https://docs.clawd.bot/voicewake) + [Talk Mode](https://docs.clawd.bot/talk)** — always‑on speech and continuous conversation.
+- **[Nodes](https://docs.clawd.bot/nodes)** — Canvas, camera snap/clip, screen record, `location.get`, notifications, plus macOS‑only `system.run`/`system.notify`.
## Tailscale access (Gateway dashboard)
@@ -161,7 +179,7 @@ Notes:
- Funnel refuses to start unless `gateway.auth.mode: "password"` is set.
- Optional: `gateway.tailscale.resetOnExit` to undo Serve/Funnel on shutdown.
-Details: [Tailscale guide](https://docs.clawdbot.com/tailscale) · [Web surfaces](https://docs.clawdbot.com/web)
+Details: [Tailscale guide](https://docs.clawd.bot/tailscale) · [Web surfaces](https://docs.clawd.bot/web)
## Remote Gateway (Linux is great)
@@ -171,7 +189,7 @@ It’s perfectly fine to run the Gateway on a small Linux instance. Clients (mac
- **Device nodes** run device‑local actions (`system.run`, camera, screen recording, notifications) via `node.invoke`.
In short: bash runs where the Gateway lives; device actions run where the device lives.
-Details: [Remote access](https://docs.clawdbot.com/remote) · [Nodes](https://docs.clawdbot.com/nodes) · [Security](https://docs.clawdbot.com/security)
+Details: [Remote access](https://docs.clawd.bot/remote) · [Nodes](https://docs.clawd.bot/nodes) · [Security](https://docs.clawd.bot/security)
## macOS permissions via the Gateway protocol
@@ -186,7 +204,7 @@ Elevated bash (host permissions) is separate from macOS TCC:
- Use `/elevated on|off` to toggle per‑session elevated access when enabled + allowlisted.
- Gateway persists the per‑session toggle via `sessions.patch` (WS method) alongside `thinkingLevel`, `verboseLevel`, `model`, `sendPolicy`, and `groupActivation`.
-Details: [Nodes](https://docs.clawdbot.com/nodes) · [macOS app](https://docs.clawdbot.com/macos) · [Gateway protocol](https://docs.clawdbot.com/architecture)
+Details: [Nodes](https://docs.clawd.bot/nodes) · [macOS app](https://docs.clawd.bot/macos) · [Gateway protocol](https://docs.clawd.bot/architecture)
## Agent to Agent (sessions_* tools)
@@ -195,7 +213,7 @@ Details: [Nodes](https://docs.clawdbot.com/nodes) · [macOS app](https://docs.cl
- `sessions_history` — fetch transcript logs for a session.
- `sessions_send` — message another session; optional reply‑back ping‑pong + announce step (`REPLY_SKIP`, `ANNOUNCE_SKIP`).
-Details: [Session tools](https://docs.clawdbot.com/session-tool)
+Details: [Session tools](https://docs.clawd.bot/session-tool)
## Skills registry (ClawdHub)
@@ -241,13 +259,13 @@ Note: signed builds required for macOS permissions to stick across rebuilds (see
- Voice trigger forwarding + Canvas surface.
- Controlled via `clawdbot nodes …`.
-Runbook: [iOS connect](https://docs.clawdbot.com/ios).
+Runbook: [iOS connect](https://docs.clawd.bot/ios).
### Android node (optional)
- Pairs via the same Bridge + pairing flow as iOS.
- Exposes Canvas, Camera, and Screen capture commands.
-- Runbook: [Android connect](https://docs.clawdbot.com/android).
+- Runbook: [Android connect](https://docs.clawd.bot/android).
## Agent workspace + skills
@@ -267,25 +285,26 @@ Minimal `~/.clawdbot/clawdbot.json` (model + defaults):
}
```
-[Full configuration reference (all keys + examples).](https://docs.clawdbot.com/configuration)
+[Full configuration reference (all keys + examples).](https://docs.clawd.bot/configuration)
## Security model (important)
- **Default:** tools run on the host for the **main** session, so the agent has full access when it’s just you.
- **Group/channel safety:** set `agent.sandbox.mode: "non-main"` to run **non‑main sessions** (groups/channels) inside per‑session Docker sandboxes; bash then runs in Docker for those sessions.
-- **Sandbox defaults:** allowlist `bash`, `process`, `read`, `write`, `edit`; 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.clawdbot.com/security) · [Docker + sandboxing](https://docs.clawdbot.com/docker) · [Sandbox config](https://docs.clawdbot.com/configuration)
+Details: [Security guide](https://docs.clawd.bot/security) · [Docker + sandboxing](https://docs.clawd.bot/docker) · [Sandbox config](https://docs.clawd.bot/configuration)
-### [WhatsApp](https://docs.clawdbot.com/whatsapp)
+### [WhatsApp](https://docs.clawd.bot/whatsapp)
- Link the device: `pnpm clawdbot login` (stores creds in `~/.clawdbot/credentials`).
- Allowlist who can talk to the assistant via `whatsapp.allowFrom`.
+- If `whatsapp.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
-### [Telegram](https://docs.clawdbot.com/telegram)
+### [Telegram](https://docs.clawd.bot/telegram)
- Set `TELEGRAM_BOT_TOKEN` or `telegram.botToken` (env wins).
-- Optional: set `telegram.groups` (with `telegram.groups."*".requireMention`), `telegram.allowFrom`, or `telegram.webhookUrl` as needed.
+- Optional: set `telegram.groups` (with `telegram.groups."*".requireMention`); when set, it is a group allowlist (include `"*"` to allow all). Also `telegram.allowFrom` or `telegram.webhookUrl` as needed.
```json5
{
@@ -295,14 +314,14 @@ Details: [Security guide](https://docs.clawdbot.com/security) · [Docker + sandb
}
```
-### [Slack](https://docs.clawdbot.com/slack)
+### [Slack](https://docs.clawd.bot/slack)
- Set `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` (or `slack.botToken` + `slack.appToken`).
-### [Discord](https://docs.clawdbot.com/discord)
+### [Discord](https://docs.clawd.bot/discord)
- Set `DISCORD_BOT_TOKEN` or `discord.token` (env wins).
-- Optional: set `discord.slashCommand`, `discord.dm.allowFrom`, `discord.guilds`, or `discord.mediaMaxMb` as needed.
+- Optional: set `commands.native`, `commands.text`, or `commands.useAccessGroups`, plus `discord.dm.allowFrom`, `discord.guilds`, or `discord.mediaMaxMb` as needed.
```json5
{
@@ -312,15 +331,16 @@ Details: [Security guide](https://docs.clawdbot.com/security) · [Docker + sandb
}
```
-### [Signal](https://docs.clawdbot.com/signal)
+### [Signal](https://docs.clawd.bot/signal)
- Requires `signal-cli` and a `signal` config section.
-### [iMessage](https://docs.clawdbot.com/imessage)
+### [iMessage](https://docs.clawd.bot/imessage)
- macOS only; Messages must be signed in.
+- If `imessage.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
-### [WebChat](https://docs.clawdbot.com/webchat)
+### [WebChat](https://docs.clawd.bot/webchat)
- Uses the Gateway WebSocket; no separate WebChat port/config.
@@ -339,69 +359,69 @@ Browser control (optional):
## Docs
Use these when you’re past the onboarding flow and want the deeper reference.
-- [Start with the docs index for navigation and “what’s where.”](https://docs.clawdbot.com/)
-- [Read the architecture overview for the gateway + protocol model.](https://docs.clawdbot.com/architecture)
-- [Use the full configuration reference when you need every key and example.](https://docs.clawdbot.com/configuration)
-- [Run the Gateway by the book with the operational runbook.](https://docs.clawdbot.com/gateway)
-- [Learn how the Control UI/Web surfaces work and how to expose them safely.](https://docs.clawdbot.com/web)
-- [Understand remote access over SSH tunnels or tailnets.](https://docs.clawdbot.com/remote)
-- [Follow the onboarding wizard flow for a guided setup.](https://docs.clawdbot.com/wizard)
-- [Wire external triggers via the webhook surface.](https://docs.clawdbot.com/webhook)
-- [Set up Gmail Pub/Sub triggers.](https://docs.clawdbot.com/gmail-pubsub)
-- [Learn the macOS menu bar companion details.](https://docs.clawdbot.com/mac/menu-bar)
-- [Platform guides: Windows](https://docs.clawdbot.com/windows), [Linux](https://docs.clawdbot.com/linux), [macOS](https://docs.clawdbot.com/macos), [iOS](https://docs.clawdbot.com/ios), [Android](https://docs.clawdbot.com/android)
-- [Debug common failures with the troubleshooting guide.](https://docs.clawdbot.com/troubleshooting)
-- [Review security guidance before exposing anything.](https://docs.clawdbot.com/security)
+- [Start with the docs index for navigation and “what’s where.”](https://docs.clawd.bot)
+- [Read the architecture overview for the gateway + protocol model.](https://docs.clawd.bot/architecture)
+- [Use the full configuration reference when you need every key and example.](https://docs.clawd.bot/configuration)
+- [Run the Gateway by the book with the operational runbook.](https://docs.clawd.bot/gateway)
+- [Learn how the Control UI/Web surfaces work and how to expose them safely.](https://docs.clawd.bot/web)
+- [Understand remote access over SSH tunnels or tailnets.](https://docs.clawd.bot/remote)
+- [Follow the onboarding wizard flow for a guided setup.](https://docs.clawd.bot/wizard)
+- [Wire external triggers via the webhook surface.](https://docs.clawd.bot/webhook)
+- [Set up Gmail Pub/Sub triggers.](https://docs.clawd.bot/gmail-pubsub)
+- [Learn the macOS menu bar companion details.](https://docs.clawd.bot/mac/menu-bar)
+- [Platform guides: Windows](https://docs.clawd.bot/windows), [Linux](https://docs.clawd.bot/linux), [macOS](https://docs.clawd.bot/macos), [iOS](https://docs.clawd.bot/ios), [Android](https://docs.clawd.bot/android)
+- [Debug common failures with the troubleshooting guide.](https://docs.clawd.bot/troubleshooting)
+- [Review security guidance before exposing anything.](https://docs.clawd.bot/security)
## Advanced docs (discovery + control)
-- [Discovery + transports](https://docs.clawdbot.com/discovery)
-- [Bonjour/mDNS](https://docs.clawdbot.com/bonjour)
-- [Gateway pairing](https://docs.clawdbot.com/gateway/pairing)
-- [Remote gateway README](https://docs.clawdbot.com/remote-gateway-readme)
-- [Control UI](https://docs.clawdbot.com/control-ui)
-- [Dashboard](https://docs.clawdbot.com/dashboard)
+- [Discovery + transports](https://docs.clawd.bot/discovery)
+- [Bonjour/mDNS](https://docs.clawd.bot/bonjour)
+- [Gateway pairing](https://docs.clawd.bot/gateway/pairing)
+- [Remote gateway README](https://docs.clawd.bot/remote-gateway-readme)
+- [Control UI](https://docs.clawd.bot/control-ui)
+- [Dashboard](https://docs.clawd.bot/dashboard)
## Operations & troubleshooting
-- [Health checks](https://docs.clawdbot.com/health)
-- [Gateway lock](https://docs.clawdbot.com/gateway-lock)
-- [Background process](https://docs.clawdbot.com/background-process)
-- [Browser troubleshooting (Linux)](https://docs.clawdbot.com/browser-linux-troubleshooting)
-- [Logging](https://docs.clawdbot.com/logging)
+- [Health checks](https://docs.clawd.bot/health)
+- [Gateway lock](https://docs.clawd.bot/gateway-lock)
+- [Background process](https://docs.clawd.bot/background-process)
+- [Browser troubleshooting (Linux)](https://docs.clawd.bot/browser-linux-troubleshooting)
+- [Logging](https://docs.clawd.bot/logging)
## Deep dives
-- [Agent loop](https://docs.clawdbot.com/agent-loop)
-- [Presence](https://docs.clawdbot.com/presence)
-- [TypeBox schemas](https://docs.clawdbot.com/typebox)
-- [RPC adapters](https://docs.clawdbot.com/rpc)
-- [Queue](https://docs.clawdbot.com/queue)
+- [Agent loop](https://docs.clawd.bot/agent-loop)
+- [Presence](https://docs.clawd.bot/presence)
+- [TypeBox schemas](https://docs.clawd.bot/typebox)
+- [RPC adapters](https://docs.clawd.bot/rpc)
+- [Queue](https://docs.clawd.bot/queue)
## Workspace & skills
-- [Skills config](https://docs.clawdbot.com/skills-config)
-- [Default AGENTS](https://docs.clawdbot.com/AGENTS.default)
-- [Templates: AGENTS](https://docs.clawdbot.com/templates/AGENTS)
-- [Templates: BOOTSTRAP](https://docs.clawdbot.com/templates/BOOTSTRAP)
-- [Templates: IDENTITY](https://docs.clawdbot.com/templates/IDENTITY)
-- [Templates: SOUL](https://docs.clawdbot.com/templates/SOUL)
-- [Templates: TOOLS](https://docs.clawdbot.com/templates/TOOLS)
-- [Templates: USER](https://docs.clawdbot.com/templates/USER)
+- [Skills config](https://docs.clawd.bot/skills-config)
+- [Default AGENTS](https://docs.clawd.bot/AGENTS.default)
+- [Templates: AGENTS](https://docs.clawd.bot/templates/AGENTS)
+- [Templates: BOOTSTRAP](https://docs.clawd.bot/templates/BOOTSTRAP)
+- [Templates: IDENTITY](https://docs.clawd.bot/templates/IDENTITY)
+- [Templates: SOUL](https://docs.clawd.bot/templates/SOUL)
+- [Templates: TOOLS](https://docs.clawd.bot/templates/TOOLS)
+- [Templates: USER](https://docs.clawd.bot/templates/USER)
## Platform internals
-- [macOS dev setup](https://docs.clawdbot.com/mac/dev-setup)
-- [macOS menu bar](https://docs.clawdbot.com/mac/menu-bar)
-- [macOS voice wake](https://docs.clawdbot.com/mac/voicewake)
-- [iOS node](https://docs.clawdbot.com/ios)
-- [Android node](https://docs.clawdbot.com/android)
-- [Windows app](https://docs.clawdbot.com/windows)
-- [Linux app](https://docs.clawdbot.com/linux)
+- [macOS dev setup](https://docs.clawd.bot/mac/dev-setup)
+- [macOS menu bar](https://docs.clawd.bot/mac/menu-bar)
+- [macOS voice wake](https://docs.clawd.bot/mac/voicewake)
+- [iOS node](https://docs.clawd.bot/ios)
+- [Android node](https://docs.clawd.bot/android)
+- [Windows app](https://docs.clawd.bot/windows)
+- [Linux app](https://docs.clawd.bot/linux)
## Email hooks (Gmail)
-[Gmail Pub/Sub wiring (gcloud + gogcli), hook tokens, and auto-watch behavior are documented here.](https://docs.clawdbot.com/gmail-pubsub)
+[Gmail Pub/Sub wiring (gcloud + gogcli), hook tokens, and auto-watch behavior are documented here.](https://docs.clawd.bot/gmail-pubsub)
Gateway auto-starts the watcher when `hooks.enabled=true` and `hooks.gmail.account` is set; `clawdbot hooks gmail run` is the manual daemon wrapper if you don’t want auto-start.
@@ -431,5 +451,7 @@ Thanks to all clawtributors:
-
+
+
+
diff --git a/apps/macos/Sources/Clawdbot/AppState.swift b/apps/macos/Sources/Clawdbot/AppState.swift
index 02f51aa54..8811ef58f 100644
--- a/apps/macos/Sources/Clawdbot/AppState.swift
+++ b/apps/macos/Sources/Clawdbot/AppState.swift
@@ -416,7 +416,8 @@ final class AppState {
: nil
Task { @MainActor in
- var root = await ConfigStore.load()
+ // Keep app-only connection settings local to avoid overwriting remote gateway config.
+ var root = ClawdbotConfigFile.loadDict()
var gateway = root["gateway"] as? [String: Any] ?? [:]
var changed = false
@@ -446,8 +447,12 @@ final class AppState {
}
guard changed else { return }
- root["gateway"] = gateway
- try? await ConfigStore.save(root)
+ if gateway.isEmpty {
+ root.removeValue(forKey: "gateway")
+ } else {
+ root["gateway"] = gateway
+ }
+ ClawdbotConfigFile.saveDict(root)
}
}
diff --git a/apps/macos/Sources/Clawdbot/Bridge/BridgeServer.swift b/apps/macos/Sources/Clawdbot/Bridge/BridgeServer.swift
index 1ece2507a..60d01459a 100644
--- a/apps/macos/Sources/Clawdbot/Bridge/BridgeServer.swift
+++ b/apps/macos/Sources/Clawdbot/Bridge/BridgeServer.swift
@@ -187,7 +187,7 @@ actor BridgeServer {
thinking: "low",
deliver: false,
to: nil,
- channel: .last))
+ provider: .last))
case "agent.request":
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else {
@@ -205,7 +205,7 @@ actor BridgeServer {
?? "node-\(nodeId)"
let thinking = link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let to = link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
- let channel = GatewayAgentChannel(raw: link.channel)
+ let provider = GatewayAgentProvider(raw: link.channel)
_ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
message: message,
@@ -213,7 +213,7 @@ actor BridgeServer {
thinking: thinking,
deliver: link.deliver,
to: to,
- channel: channel))
+ provider: provider))
default:
break
diff --git a/apps/macos/Sources/Clawdbot/CanvasA2UIActionMessageHandler.swift b/apps/macos/Sources/Clawdbot/CanvasA2UIActionMessageHandler.swift
index 7fdff8afb..6a02cfd8c 100644
--- a/apps/macos/Sources/Clawdbot/CanvasA2UIActionMessageHandler.swift
+++ b/apps/macos/Sources/Clawdbot/CanvasA2UIActionMessageHandler.swift
@@ -79,14 +79,15 @@ final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
GatewayProcessManager.shared.setActive(true)
}
- let result = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
- message: text,
- sessionKey: self.sessionKey,
- thinking: "low",
- deliver: false,
- to: nil,
- channel: .last,
- idempotencyKey: actionId))
+ let result = await GatewayConnection.shared.sendAgent(
+ GatewayAgentInvocation(
+ message: text,
+ sessionKey: self.sessionKey,
+ thinking: "low",
+ deliver: false,
+ to: nil,
+ provider: .last,
+ idempotencyKey: actionId))
await MainActor.run {
guard let webView else { return }
diff --git a/apps/macos/Sources/Clawdbot/CronJobEditor+Helpers.swift b/apps/macos/Sources/Clawdbot/CronJobEditor+Helpers.swift
index c8ef08668..ec07cc5e4 100644
--- a/apps/macos/Sources/Clawdbot/CronJobEditor+Helpers.swift
+++ b/apps/macos/Sources/Clawdbot/CronJobEditor+Helpers.swift
@@ -33,13 +33,13 @@ extension CronJobEditor {
case let .systemEvent(text):
self.payloadKind = .systemEvent
self.systemEventText = text
- case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver):
+ case let .agentTurn(message, thinking, timeoutSeconds, deliver, provider, to, bestEffortDeliver):
self.payloadKind = .agentTurn
self.agentMessage = message
self.thinking = thinking ?? ""
self.timeoutSeconds = timeoutSeconds.map(String.init) ?? ""
self.deliver = deliver ?? false
- self.channel = GatewayAgentChannel(raw: channel)
+ self.provider = GatewayAgentProvider(raw: provider)
self.to = to ?? ""
self.bestEffortDeliver = bestEffortDeliver ?? false
}
@@ -166,7 +166,7 @@ extension CronJobEditor {
if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n }
payload["deliver"] = self.deliver
if self.deliver {
- payload["channel"] = self.channel.rawValue
+ payload["provider"] = self.provider.rawValue
let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines)
if !to.isEmpty { payload["to"] = to }
payload["bestEffortDeliver"] = self.bestEffortDeliver
diff --git a/apps/macos/Sources/Clawdbot/CronJobEditor+Testing.swift b/apps/macos/Sources/Clawdbot/CronJobEditor+Testing.swift
index 803964bcc..75b5ed6b6 100644
--- a/apps/macos/Sources/Clawdbot/CronJobEditor+Testing.swift
+++ b/apps/macos/Sources/Clawdbot/CronJobEditor+Testing.swift
@@ -13,7 +13,7 @@ extension CronJobEditor {
self.payloadKind = .agentTurn
self.agentMessage = "Run diagnostic"
self.deliver = true
- self.channel = .last
+ self.provider = .last
self.to = "+15551230000"
self.thinking = "low"
self.timeoutSeconds = "90"
diff --git a/apps/macos/Sources/Clawdbot/CronJobEditor.swift b/apps/macos/Sources/Clawdbot/CronJobEditor.swift
index 093978ebb..93d2615bf 100644
--- a/apps/macos/Sources/Clawdbot/CronJobEditor.swift
+++ b/apps/macos/Sources/Clawdbot/CronJobEditor.swift
@@ -17,7 +17,7 @@ struct CronJobEditor: View {
static let scheduleKindNote =
"“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression."
static let isolatedPayloadNote =
- "Isolated jobs always run an agent turn. The result can be delivered to a surface, "
+ "Isolated jobs always run an agent turn. The result can be delivered to a provider, "
+ "and a short summary is posted back to your main chat."
static let mainPayloadNote =
"System events are injected into the current main session. Agent turns require an isolated session target."
@@ -42,7 +42,7 @@ struct CronJobEditor: View {
@State var systemEventText: String = ""
@State var agentMessage: String = ""
@State var deliver: Bool = false
- @State var channel: GatewayAgentChannel = .last
+ @State var provider: GatewayAgentProvider = .last
@State var to: String = ""
@State var thinking: String = ""
@State var timeoutSeconds: String = ""
@@ -309,7 +309,7 @@ struct CronJobEditor: View {
}
GridRow {
self.gridLabel("Deliver")
- Toggle("Deliver result to a surface", isOn: self.$deliver)
+ Toggle("Deliver result to a provider", isOn: self.$deliver)
.toggleStyle(.switch)
}
}
@@ -317,15 +317,15 @@ struct CronJobEditor: View {
if self.deliver {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
- self.gridLabel("Channel")
- Picker("", selection: self.$channel) {
- Text("last").tag(GatewayAgentChannel.last)
- Text("whatsapp").tag(GatewayAgentChannel.whatsapp)
- Text("telegram").tag(GatewayAgentChannel.telegram)
- Text("discord").tag(GatewayAgentChannel.discord)
- Text("slack").tag(GatewayAgentChannel.slack)
- Text("signal").tag(GatewayAgentChannel.signal)
- Text("imessage").tag(GatewayAgentChannel.imessage)
+ self.gridLabel("Provider")
+ Picker("", selection: self.$provider) {
+ Text("last").tag(GatewayAgentProvider.last)
+ Text("whatsapp").tag(GatewayAgentProvider.whatsapp)
+ Text("telegram").tag(GatewayAgentProvider.telegram)
+ Text("discord").tag(GatewayAgentProvider.discord)
+ Text("slack").tag(GatewayAgentProvider.slack)
+ Text("signal").tag(GatewayAgentProvider.signal)
+ Text("imessage").tag(GatewayAgentProvider.imessage)
}
.labelsHidden()
.pickerStyle(.segmented)
diff --git a/apps/macos/Sources/Clawdbot/CronModels.swift b/apps/macos/Sources/Clawdbot/CronModels.swift
index 78c009576..388ef8afb 100644
--- a/apps/macos/Sources/Clawdbot/CronModels.swift
+++ b/apps/macos/Sources/Clawdbot/CronModels.swift
@@ -74,12 +74,12 @@ enum CronPayload: Codable, Equatable {
thinking: String?,
timeoutSeconds: Int?,
deliver: Bool?,
- channel: String?,
+ provider: String?,
to: String?,
bestEffortDeliver: Bool?)
enum CodingKeys: String, CodingKey {
- case kind, text, message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver
+ case kind, text, message, thinking, timeoutSeconds, deliver, provider, to, bestEffortDeliver
}
var kind: String {
@@ -101,7 +101,7 @@ enum CronPayload: Codable, Equatable {
thinking: container.decodeIfPresent(String.self, forKey: .thinking),
timeoutSeconds: container.decodeIfPresent(Int.self, forKey: .timeoutSeconds),
deliver: container.decodeIfPresent(Bool.self, forKey: .deliver),
- channel: container.decodeIfPresent(String.self, forKey: .channel),
+ provider: container.decodeIfPresent(String.self, forKey: .provider),
to: container.decodeIfPresent(String.self, forKey: .to),
bestEffortDeliver: container.decodeIfPresent(Bool.self, forKey: .bestEffortDeliver))
default:
@@ -118,12 +118,12 @@ enum CronPayload: Codable, Equatable {
switch self {
case let .systemEvent(text):
try container.encode(text, forKey: .text)
- case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver):
+ case let .agentTurn(message, thinking, timeoutSeconds, deliver, provider, to, bestEffortDeliver):
try container.encode(message, forKey: .message)
try container.encodeIfPresent(thinking, forKey: .thinking)
try container.encodeIfPresent(timeoutSeconds, forKey: .timeoutSeconds)
try container.encodeIfPresent(deliver, forKey: .deliver)
- try container.encodeIfPresent(channel, forKey: .channel)
+ try container.encodeIfPresent(provider, forKey: .provider)
try container.encodeIfPresent(to, forKey: .to)
try container.encodeIfPresent(bestEffortDeliver, forKey: .bestEffortDeliver)
}
diff --git a/apps/macos/Sources/Clawdbot/CronSettings+Rows.swift b/apps/macos/Sources/Clawdbot/CronSettings+Rows.swift
index b815bb234..fc5ceb51f 100644
--- a/apps/macos/Sources/Clawdbot/CronSettings+Rows.swift
+++ b/apps/macos/Sources/Clawdbot/CronSettings+Rows.swift
@@ -206,7 +206,7 @@ extension CronSettings {
Text(text)
.font(.callout)
.textSelection(.enabled)
- case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, _):
+ case let .agentTurn(message, thinking, timeoutSeconds, deliver, provider, to, _):
VStack(alignment: .leading, spacing: 4) {
Text(message)
.font(.callout)
@@ -216,7 +216,7 @@ extension CronSettings {
if let timeoutSeconds { StatusPill(text: "\(timeoutSeconds)s", tint: .secondary) }
if deliver ?? false {
StatusPill(text: "deliver", tint: .secondary)
- if let channel, !channel.isEmpty { StatusPill(text: channel, tint: .secondary) }
+ if let provider, !provider.isEmpty { StatusPill(text: provider, tint: .secondary) }
if let to, !to.isEmpty { StatusPill(text: to, tint: .secondary) }
}
}
diff --git a/apps/macos/Sources/Clawdbot/CronSettings+Testing.swift b/apps/macos/Sources/Clawdbot/CronSettings+Testing.swift
index bf6898016..788ec9fdb 100644
--- a/apps/macos/Sources/Clawdbot/CronSettings+Testing.swift
+++ b/apps/macos/Sources/Clawdbot/CronSettings+Testing.swift
@@ -20,7 +20,7 @@ struct CronSettings_Previews: PreviewProvider {
thinking: "low",
timeoutSeconds: 600,
deliver: true,
- channel: "last",
+ provider: "last",
to: nil,
bestEffortDeliver: true),
isolation: CronIsolation(postToMainPrefix: "Cron"),
@@ -72,7 +72,7 @@ extension CronSettings {
thinking: "low",
timeoutSeconds: 120,
deliver: true,
- channel: "whatsapp",
+ provider: "whatsapp",
to: "+15551234567",
bestEffortDeliver: true),
isolation: CronIsolation(postToMainPrefix: "[cron] "),
diff --git a/apps/macos/Sources/Clawdbot/DeepLinks.swift b/apps/macos/Sources/Clawdbot/DeepLinks.swift
index 0ffa87908..b1960b239 100644
--- a/apps/macos/Sources/Clawdbot/DeepLinks.swift
+++ b/apps/macos/Sources/Clawdbot/DeepLinks.swift
@@ -59,7 +59,7 @@ final class DeepLinkHandler {
}
do {
- let channel = GatewayAgentChannel(raw: link.channel)
+ let provider = GatewayAgentProvider(raw: link.channel)
let explicitSessionKey = link.sessionKey?
.trimmingCharacters(in: .whitespacesAndNewlines)
.nonEmpty
@@ -72,9 +72,9 @@ final class DeepLinkHandler {
message: messagePreview,
sessionKey: resolvedSessionKey,
thinking: link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty,
- deliver: channel.shouldDeliver(link.deliver),
+ deliver: provider.shouldDeliver(link.deliver),
to: link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty,
- channel: channel,
+ provider: provider,
timeoutSeconds: link.timeoutSeconds,
idempotencyKey: UUID().uuidString)
diff --git a/apps/macos/Sources/Clawdbot/GatewayConnection.swift b/apps/macos/Sources/Clawdbot/GatewayConnection.swift
index d176be624..fb1f0a7d6 100644
--- a/apps/macos/Sources/Clawdbot/GatewayConnection.swift
+++ b/apps/macos/Sources/Clawdbot/GatewayConnection.swift
@@ -5,7 +5,7 @@ import OSLog
private let gatewayConnectionLogger = Logger(subsystem: "com.clawdbot", category: "gateway.connection")
-enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable {
+enum GatewayAgentProvider: String, Codable, CaseIterable, Sendable {
case last
case whatsapp
case telegram
@@ -17,7 +17,7 @@ enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable {
init(raw: String?) {
let normalized = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
- self = GatewayAgentChannel(rawValue: normalized) ?? .last
+ self = GatewayAgentProvider(rawValue: normalized) ?? .last
}
var isDeliverable: Bool { self != .webchat }
@@ -31,7 +31,7 @@ struct GatewayAgentInvocation: Sendable {
var thinking: String?
var deliver: Bool = false
var to: String?
- var channel: GatewayAgentChannel = .last
+ var provider: GatewayAgentProvider = .last
var timeoutSeconds: Int?
var idempotencyKey: String = UUID().uuidString
}
@@ -368,7 +368,7 @@ extension GatewayConnection {
"thinking": AnyCodable(invocation.thinking ?? "default"),
"deliver": AnyCodable(invocation.deliver),
"to": AnyCodable(invocation.to ?? ""),
- "channel": AnyCodable(invocation.channel.rawValue),
+ "provider": AnyCodable(invocation.provider.rawValue),
"idempotencyKey": AnyCodable(invocation.idempotencyKey),
]
if let timeout = invocation.timeoutSeconds {
@@ -389,7 +389,7 @@ extension GatewayConnection {
sessionKey: String,
deliver: Bool,
to: String?,
- channel: GatewayAgentChannel = .last,
+ provider: GatewayAgentProvider = .last,
timeoutSeconds: Int? = nil,
idempotencyKey: String = UUID().uuidString) async -> (ok: Bool, error: String?)
{
@@ -399,7 +399,7 @@ extension GatewayConnection {
thinking: thinking,
deliver: deliver,
to: to,
- channel: channel,
+ provider: provider,
timeoutSeconds: timeoutSeconds,
idempotencyKey: idempotencyKey))
}
diff --git a/apps/macos/Sources/Clawdbot/SessionData.swift b/apps/macos/Sources/Clawdbot/SessionData.swift
index b1d8b7886..7ce1dc8fc 100644
--- a/apps/macos/Sources/Clawdbot/SessionData.swift
+++ b/apps/macos/Sources/Clawdbot/SessionData.swift
@@ -9,7 +9,7 @@ struct GatewaySessionDefaultsRecord: Codable {
struct GatewaySessionEntryRecord: Codable {
let key: String
let displayName: String?
- let surface: String?
+ let provider: String?
let subject: String?
let room: String?
let space: String?
@@ -71,7 +71,7 @@ struct SessionRow: Identifiable {
let key: String
let kind: SessionKind
let displayName: String?
- let surface: String?
+ let provider: String?
let subject: String?
let room: String?
let space: String?
@@ -141,7 +141,7 @@ extension SessionRow {
key: "user@example.com",
kind: .direct,
displayName: nil,
- surface: nil,
+ provider: nil,
subject: nil,
room: nil,
space: nil,
@@ -158,7 +158,7 @@ extension SessionRow {
key: "discord:channel:release-squad",
kind: .group,
displayName: "discord:#release-squad",
- surface: "discord",
+ provider: "discord",
subject: nil,
room: "#release-squad",
space: nil,
@@ -175,7 +175,7 @@ extension SessionRow {
key: "global",
kind: .global,
displayName: nil,
- surface: nil,
+ provider: nil,
subject: nil,
room: nil,
space: nil,
@@ -298,7 +298,7 @@ enum SessionLoader {
key: entry.key,
kind: SessionKind.from(key: entry.key),
displayName: entry.displayName,
- surface: entry.surface,
+ provider: entry.provider,
subject: entry.subject,
room: entry.room,
space: entry.space,
diff --git a/apps/macos/Sources/Clawdbot/VoiceWakeForwarder.swift b/apps/macos/Sources/Clawdbot/VoiceWakeForwarder.swift
index 3fd9f827b..a30d389f3 100644
--- a/apps/macos/Sources/Clawdbot/VoiceWakeForwarder.swift
+++ b/apps/macos/Sources/Clawdbot/VoiceWakeForwarder.swift
@@ -37,7 +37,7 @@ enum VoiceWakeForwarder {
var thinking: String = "low"
var deliver: Bool = true
var to: String?
- var channel: GatewayAgentChannel = .last
+ var provider: GatewayAgentProvider = .last
}
@discardableResult
@@ -46,14 +46,14 @@ enum VoiceWakeForwarder {
options: ForwardOptions = ForwardOptions()) async -> Result
{
let payload = Self.prefixedTranscript(transcript)
- let deliver = options.channel.shouldDeliver(options.deliver)
+ let deliver = options.provider.shouldDeliver(options.deliver)
let result = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
message: payload,
sessionKey: options.sessionKey,
thinking: options.thinking,
deliver: deliver,
to: options.to,
- channel: options.channel))
+ provider: options.provider))
if result.ok {
self.logger.info("voice wake forward ok")
diff --git a/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift b/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift
index 49690fbe3..e7566b297 100644
--- a/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift
+++ b/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift
@@ -342,6 +342,7 @@ public struct SendParams: Codable, Sendable {
public let mediaurl: String?
public let gifplayback: Bool?
public let provider: String?
+ public let accountid: String?
public let idempotencykey: String
public init(
@@ -350,6 +351,7 @@ public struct SendParams: Codable, Sendable {
mediaurl: String?,
gifplayback: Bool?,
provider: String?,
+ accountid: String?,
idempotencykey: String
) {
self.to = to
@@ -357,6 +359,7 @@ public struct SendParams: Codable, Sendable {
self.mediaurl = mediaurl
self.gifplayback = gifplayback
self.provider = provider
+ self.accountid = accountid
self.idempotencykey = idempotencykey
}
private enum CodingKeys: String, CodingKey {
@@ -365,6 +368,48 @@ public struct SendParams: Codable, Sendable {
case mediaurl = "mediaUrl"
case gifplayback = "gifPlayback"
case provider
+ case accountid = "accountId"
+ case idempotencykey = "idempotencyKey"
+ }
+}
+
+public struct PollParams: Codable, Sendable {
+ public let to: String
+ public let question: String
+ public let options: [String]
+ public let maxselections: Int?
+ public let durationhours: Int?
+ public let provider: String?
+ public let accountid: String?
+ public let idempotencykey: String
+
+ public init(
+ to: String,
+ question: String,
+ options: [String],
+ maxselections: Int?,
+ durationhours: Int?,
+ provider: String?,
+ accountid: String?,
+ idempotencykey: String
+ ) {
+ self.to = to
+ self.question = question
+ self.options = options
+ self.maxselections = maxselections
+ self.durationhours = durationhours
+ self.provider = provider
+ self.accountid = accountid
+ self.idempotencykey = idempotencykey
+ }
+ private enum CodingKeys: String, CodingKey {
+ case to
+ case question
+ case options
+ case maxselections = "maxSelections"
+ case durationhours = "durationHours"
+ case provider
+ case accountid = "accountId"
case idempotencykey = "idempotencyKey"
}
}
@@ -376,7 +421,7 @@ public struct AgentParams: Codable, Sendable {
public let sessionkey: String?
public let thinking: String?
public let deliver: Bool?
- public let channel: String?
+ public let provider: String?
public let timeout: Int?
public let lane: String?
public let extrasystemprompt: String?
@@ -389,7 +434,7 @@ public struct AgentParams: Codable, Sendable {
sessionkey: String?,
thinking: String?,
deliver: Bool?,
- channel: String?,
+ provider: String?,
timeout: Int?,
lane: String?,
extrasystemprompt: String?,
@@ -401,7 +446,7 @@ public struct AgentParams: Codable, Sendable {
self.sessionkey = sessionkey
self.thinking = thinking
self.deliver = deliver
- self.channel = channel
+ self.provider = provider
self.timeout = timeout
self.lane = lane
self.extrasystemprompt = extrasystemprompt
@@ -414,7 +459,7 @@ public struct AgentParams: Codable, Sendable {
case sessionkey = "sessionKey"
case thinking
case deliver
- case channel
+ case provider
case timeout
case lane
case extrasystemprompt = "extraSystemPrompt"
@@ -618,23 +663,27 @@ public struct SessionsListParams: Codable, Sendable {
public let activeminutes: Int?
public let includeglobal: Bool?
public let includeunknown: Bool?
+ public let spawnedby: String?
public init(
limit: Int?,
activeminutes: Int?,
includeglobal: Bool?,
- includeunknown: Bool?
+ includeunknown: Bool?,
+ spawnedby: String?
) {
self.limit = limit
self.activeminutes = activeminutes
self.includeglobal = includeglobal
self.includeunknown = includeunknown
+ self.spawnedby = spawnedby
}
private enum CodingKeys: String, CodingKey {
case limit
case activeminutes = "activeMinutes"
case includeglobal = "includeGlobal"
case includeunknown = "includeUnknown"
+ case spawnedby = "spawnedBy"
}
}
@@ -644,6 +693,7 @@ public struct SessionsPatchParams: Codable, Sendable {
public let verboselevel: AnyCodable?
public let elevatedlevel: AnyCodable?
public let model: AnyCodable?
+ public let spawnedby: AnyCodable?
public let sendpolicy: AnyCodable?
public let groupactivation: AnyCodable?
@@ -653,6 +703,7 @@ public struct SessionsPatchParams: Codable, Sendable {
verboselevel: AnyCodable?,
elevatedlevel: AnyCodable?,
model: AnyCodable?,
+ spawnedby: AnyCodable?,
sendpolicy: AnyCodable?,
groupactivation: AnyCodable?
) {
@@ -661,6 +712,7 @@ public struct SessionsPatchParams: Codable, Sendable {
self.verboselevel = verboselevel
self.elevatedlevel = elevatedlevel
self.model = model
+ self.spawnedby = spawnedby
self.sendpolicy = sendpolicy
self.groupactivation = groupactivation
}
@@ -670,6 +722,7 @@ public struct SessionsPatchParams: Codable, Sendable {
case verboselevel = "verboseLevel"
case elevatedlevel = "elevatedLevel"
case model
+ case spawnedby = "spawnedBy"
case sendpolicy = "sendPolicy"
case groupactivation = "groupActivation"
}
@@ -980,33 +1033,41 @@ public struct WebLoginStartParams: Codable, Sendable {
public let force: Bool?
public let timeoutms: Int?
public let verbose: Bool?
+ public let accountid: String?
public init(
force: Bool?,
timeoutms: Int?,
- verbose: Bool?
+ verbose: Bool?,
+ accountid: String?
) {
self.force = force
self.timeoutms = timeoutms
self.verbose = verbose
+ self.accountid = accountid
}
private enum CodingKeys: String, CodingKey {
case force
case timeoutms = "timeoutMs"
case verbose
+ case accountid = "accountId"
}
}
public struct WebLoginWaitParams: Codable, Sendable {
public let timeoutms: Int?
+ public let accountid: String?
public init(
- timeoutms: Int?
+ timeoutms: Int?,
+ accountid: String?
) {
self.timeoutms = timeoutms
+ self.accountid = accountid
}
private enum CodingKeys: String, CodingKey {
case timeoutms = "timeoutMs"
+ case accountid = "accountId"
}
}
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/RELEASING.md b/docs/RELEASING.md
index db9b1e009..32f750a17 100644
--- a/docs/RELEASING.md
+++ b/docs/RELEASING.md
@@ -12,12 +12,12 @@ Use `pnpm` (Node 22+) from the repo root. Keep the working tree clean before tag
1) **Version & metadata**
- [ ] Bump `package.json` version (e.g., `1.1.0`).
-- [ ] Update CLI/version strings: `src/cli/program.ts` and the Baileys user agent in `src/provider-web.ts`.
-- [ ] Confirm package metadata (name, description, repository, keywords, license) and `bin` map points to `dist/index.js` for `clawdbot`.
+- [ ] Update CLI/version strings: [`src/cli/program.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/cli/program.ts) and the Baileys user agent in [`src/provider-web.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/provider-web.ts).
+- [ ] Confirm package metadata (name, description, repository, keywords, license) and `bin` map points to [`dist/entry.js`](https://github.com/clawdbot/clawdbot/blob/main/dist/entry.js) for `clawdbot`.
- [ ] If dependencies changed, run `pnpm install` so `pnpm-lock.yaml` is current.
2) **Build & artifacts**
-- [ ] If A2UI inputs changed, run `pnpm canvas:a2ui:bundle` and commit any updated `src/canvas-host/a2ui/a2ui.bundle.js`.
+- [ ] If A2UI inputs changed, run `pnpm canvas:a2ui:bundle` and commit any updated [`src/canvas-host/a2ui/a2ui.bundle.js`](https://github.com/clawdbot/clawdbot/blob/main/src/canvas-host/a2ui/a2ui.bundle.js).
- [ ] `pnpm run build` (regenerates `dist/`).
- [ ] Optional: `npm pack --pack-destination /tmp` after the build; inspect the tarball contents and keep it handy for the GitHub release (do **not** commit it).
@@ -34,11 +34,11 @@ Use `pnpm` (Node 22+) from the repo root. Keep the working tree clean before tag
5) **macOS app (Sparkle)**
- [ ] Build + sign the macOS app, then zip it for distribution.
-- [ ] Generate the Sparkle appcast (HTML notes via `scripts/make_appcast.sh`) and update `appcast.xml`.
+- [ ] Generate the Sparkle appcast (HTML notes via [`scripts/make_appcast.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/make_appcast.sh)) and update `appcast.xml`.
- [ ] Keep the app zip (and optional dSYM zip) ready to attach to the GitHub release.
-- [ ] Follow `docs/mac/release.md` for the exact commands and required env vars.
+- [ ] Follow [`docs/mac/release.md`](https://docs.clawd.bot/mac/release) for the exact commands and required env vars.
- `APP_BUILD` must be numeric + monotonic (no `-beta`) so Sparkle compares versions correctly.
- - If notarizing, use the `clawdbot-notary` keychain profile created from App Store Connect API env vars (see `docs/mac/release.md`).
+ - If notarizing, use the `clawdbot-notary` keychain profile created from App Store Connect API env vars (see [`docs/mac/release.md`](https://docs.clawd.bot/mac/release)).
6) **Publish (npm)**
- [ ] Confirm git status is clean; commit and push as needed.
diff --git a/docs/agent-loop.md b/docs/agent-loop.md
index 69bfe2a24..a60d02138 100644
--- a/docs/agent-loop.md
+++ b/docs/agent-loop.md
@@ -8,8 +8,8 @@ read_when:
Short, exact flow of one agent run. Source of truth: current code in `src/`.
## Entry points
-- Gateway RPC: `agent` and `agent.wait` in `src/gateway/server-methods/agent.ts`.
-- CLI: `agentCommand` in `src/commands/agent.ts`.
+- Gateway RPC: `agent` and `agent.wait` in [`src/gateway/server-methods/agent.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/gateway/server-methods/agent.ts).
+- CLI: `agentCommand` in [`src/commands/agent.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/commands/agent.ts).
## High-level flow
1) `agent` RPC validates params, resolves session (sessionKey/sessionId), persists session metadata, returns `{ runId, acceptedAt }` immediately.
@@ -36,8 +36,8 @@ 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
-- `createAgentEventHandler` in `src/gateway/server-chat.ts`:
+## Chat provider handling
+- `createAgentEventHandler` in [`src/gateway/server-chat.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/gateway/server-chat.ts):
- buffers assistant deltas
- emits chat `delta` messages
- emits chat `final` when **lifecycle end/error** arrives
@@ -53,9 +53,9 @@ Short, exact flow of one agent run. Source of truth: current code in `src/`.
- `agent.wait` timeout (wait-only, does not stop agent)
## Files
-- `src/gateway/server-methods/agent.ts`
-- `src/gateway/server-methods/agent-job.ts`
-- `src/commands/agent.ts`
-- `src/agents/pi-embedded-runner.ts`
-- `src/agents/pi-embedded-subscribe.ts`
-- `src/gateway/server-chat.ts`
+- [`src/gateway/server-methods/agent.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/gateway/server-methods/agent.ts)
+- [`src/gateway/server-methods/agent-job.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/gateway/server-methods/agent-job.ts)
+- [`src/commands/agent.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/commands/agent.ts)
+- [`src/agents/pi-embedded-runner.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/agents/pi-embedded-runner.ts)
+- [`src/agents/pi-embedded-subscribe.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/agents/pi-embedded-subscribe.ts)
+- [`src/gateway/server-chat.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/gateway/server-chat.ts)
diff --git a/docs/agent.md b/docs/agent.md
index f477ca257..44b449748 100644
--- a/docs/agent.md
+++ b/docs/agent.md
@@ -9,13 +9,13 @@ CLAWDBOT runs a single embedded agent runtime derived from **p-mono** (internal
## Workspace (required)
-You must set an agent home directory via `agent.workspace`. CLAWDBOT uses this as the agent’s **only** working directory (`cwd`) for tools and context.
+CLAWDBOT uses a single agent workspace directory (`agent.workspace`) as the agent’s **only** working directory (`cwd`) for tools and context.
Recommended: use `clawdbot setup` to create `~/.clawdbot/clawdbot.json` if missing and initialize the workspace files.
If `agent.sandbox` is enabled, non-main sessions can override this with
per-session workspaces under `agent.sandbox.workspaceRoot` (see
-`docs/configuration.md`).
+[`docs/configuration.md`](https://docs.clawd.bot/configuration)).
## Bootstrap files (injected)
@@ -31,6 +31,14 @@ On the first turn of a new session, CLAWDBOT injects the contents of these files
If a file is missing, CLAWDBOT injects a single “missing file” marker line (and `clawdbot setup` will create a safe default template).
+`BOOTSTRAP.md` is only created for a **brand new workspace** (no other bootstrap files present). If you delete it after completing the ritual, it should not be recreated on later restarts.
+
+To disable bootstrap file creation entirely (for pre-seeded workspaces), set:
+
+```json5
+{ agent: { skipBootstrap: true } }
+```
+
## Built-in tools (internal)
p’s embedded core tools (read/bash/edit/write and related internals) are defined in code and always available. `TOOLS.md` does **not** control which tools exist; it’s guidance for how *you* want them used.
@@ -42,7 +50,7 @@ Clawdbot loads skills from three locations (workspace wins on name conflict):
- Managed/local: `~/.clawdbot/skills`
- Workspace: `/skills`
-Skills can be gated by config/env (see `skills` in `docs/configuration.md`).
+Skills can be gated by config/env (see `skills` in [`docs/configuration.md`](https://docs.clawd.bot/configuration)).
## p-mono integration
@@ -66,7 +74,7 @@ Apply these notes **only** when the user is Peter Steinberger at steipete.
## Sessions
Session transcripts are stored as JSONL at:
-- `~/.clawdbot/sessions/.jsonl`
+- `~/.clawdbot/agents//sessions/.jsonl`
The session ID is stable and chosen by CLAWDBOT.
Legacy Pi/Tau session folders are **not** read.
@@ -81,7 +89,7 @@ message is injected before the next assistant response.
When queue mode is `followup` or `collect`, inbound messages are held until the
current turn ends, then a new agent turn starts with the queued payloads. See
-`docs/queue.md` for mode + debounce/cap behavior.
+[`docs/queue.md`](https://docs.clawd.bot/queue) for mode + debounce/cap behavior.
Block streaming sends completed assistant blocks as soon as they finish; disable
via `agent.blockStreamingDefault: "off"` if you only want the final response.
@@ -99,4 +107,4 @@ At minimum, set:
---
-*Next: [Group Chats](./group-messages.md)* 🦞
+*Next: [Group Chats](https://docs.clawd.bot/group-messages)* 🦞
diff --git a/docs/android.md b/docs/android.md
index ea09269e5..b33760e98 100644
--- a/docs/android.md
+++ b/docs/android.md
@@ -47,7 +47,7 @@ From the gateway machine:
dns-sd -B _clawdbot-bridge._tcp local.
```
-More debugging notes: `docs/bonjour.md`.
+More debugging notes: [`docs/bonjour.md`](https://docs.clawd.bot/bonjour).
#### Tailnet (Vienna ⇄ London) discovery via unicast DNS-SD
@@ -56,7 +56,7 @@ Android NSD/mDNS discovery won’t cross networks. If your Android node and the
1) Set up a DNS-SD zone (example `clawdbot.internal.`) on the gateway host and publish `_clawdbot-bridge._tcp` records.
2) Configure Tailscale split DNS for `clawdbot.internal` pointing at that DNS server.
-Details and example CoreDNS config: `docs/bonjour.md`.
+Details and example CoreDNS config: [`docs/bonjour.md`](https://docs.clawd.bot/bonjour).
### 3) Connect from Android
@@ -80,7 +80,7 @@ clawdbot nodes pending
clawdbot nodes approve
```
-Pairing details: `docs/gateway/pairing.md`.
+Pairing details: [`docs/gateway/pairing.md`](https://docs.clawd.bot/gateway/pairing).
### 5) Verify the node is connected
@@ -130,4 +130,4 @@ Camera commands (foreground only; permission-gated):
- `camera.snap` (jpg)
- `camera.clip` (mp4)
-See `docs/camera.md` for parameters and CLI helpers.
+See [`docs/camera.md`](https://docs.clawd.bot/camera) for parameters and CLI helpers.
diff --git a/docs/architecture.md b/docs/architecture.md
index ee98631ae..2933c38bb 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -15,14 +15,14 @@ Last updated: 2026-01-05
## Implementation snapshot (current code)
-### TypeScript Gateway (`src/gateway/server.ts`)
+### TypeScript Gateway ([`src/gateway/server.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/gateway/server.ts))
- Single HTTP + WebSocket server (default `18789`); bind policy `loopback|lan|tailnet|auto`. Refuses non-loopback binds without auth; Tailscale serve/funnel requires loopback.
- Handshake: first frame must be a `connect` request; AJV validates request + params against TypeBox schemas; protocol negotiated via `minProtocol`/`maxProtocol`.
- `hello-ok` includes snapshot (presence/health/stateVersion/uptime/configPath/stateDir), features (methods/events), policy (max payload/buffer/tick), and `canvasHostUrl` when available.
- Events emitted: `agent`, `chat`, `presence`, `tick`, `health`, `heartbeat`, `cron`, `talk.mode`, `node.pair.requested`, `node.pair.resolved`, `voicewake.changed`, `shutdown`.
- Idempotency keys are required for `send`, `agent`, `chat.send`, and node invokes; the dedupe cache avoids double-sends on reconnects. Payload sizes are capped per connection.
-- Optional node bridge (`src/infra/bridge/server.ts`): TCP JSONL frames (`hello`, `pair-request`, `req/res`, `event`, `invoke`, `ping`). Node connect/disconnect updates presence and flows into the session bus.
-- Control UI + Canvas host: HTTP serves Control UI (base path configurable) and can host the A2UI canvas via `src/canvas-host/server.ts` (live reload). Canvas host URL is advertised to nodes + clients.
+- Optional node bridge ([`src/infra/bridge/server.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/infra/bridge/server.ts)): TCP JSONL frames (`hello`, `pair-request`, `req/res`, `event`, `invoke`, `ping`). Node connect/disconnect updates presence and flows into the session bus.
+- Control UI + Canvas host: HTTP serves Control UI (base path configurable) and can host the A2UI canvas via [`src/canvas-host/server.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/canvas-host/server.ts) (live reload). Canvas host URL is advertised to nodes + clients.
### iOS node (`apps/ios`)
- Discovery + pairing: `BridgeDiscoveryModel` uses `NWBrowser` Bonjour discovery and reads TXT fields for LAN/tailnet host hints plus gateway/bridge/canvas ports.
@@ -46,7 +46,7 @@ Last updated: 2026-01-05
- **Clients (mac app / CLI / web admin)**
- One WS connection per client.
- Send requests (`health`, `status`, `send`, `agent`, `system-presence`, toggles) and subscribe to events (`tick`, `agent`, `presence`, `shutdown`).
- - On macOS, the app can also be invoked via deep links (`clawdbot://agent?...`) which translate into the same Gateway `agent` request path (see `docs/macos.md`).
+ - On macOS, the app can also be invoked via deep links (`clawdbot://agent?...`) which translate into the same Gateway `agent` request path (see [`docs/macos.md`](https://docs.clawd.bot/macos)).
- **Agent process (Pi)**
- Spawned by the Gateway on demand for `agent` calls; streams events back over the same WS connection.
- **WebChat**
diff --git a/docs/assets/terminal.css b/docs/assets/terminal.css
index 00242acb1..23283d651 100644
--- a/docs/assets/terminal.css
+++ b/docs/assets/terminal.css
@@ -70,11 +70,14 @@ html[data-theme="dark"] {
box-sizing: border-box;
}
-html,
-body {
+html {
height: 100%;
}
+body {
+ min-height: 100%;
+}
+
body {
margin: 0;
font-family: var(--font-body);
diff --git a/docs/bonjour.md b/docs/bonjour.md
index 1e44ddd88..099530008 100644
--- a/docs/bonjour.md
+++ b/docs/bonjour.md
@@ -69,7 +69,7 @@ The bridge port (default `18790`) is a plain TCP service. By default it binds to
For a tailnet-only setup, bind it to the Tailscale IP instead:
- Set `bridge.bind: "tailnet"` in `~/.clawdbot/clawdbot.json`.
-- Restart the Gateway (or restart the macOS menubar app via `./scripts/restart-mac.sh` on that machine).
+- Restart the Gateway (or restart the macOS menubar app via [`./scripts/restart-mac.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/restart-mac.sh) on that machine).
This keeps the bridge reachable only from devices on your tailnet (while still listening on loopback for local/SSH port-forwards).
@@ -77,8 +77,8 @@ This keeps the bridge reachable only from devices on your tailnet (while still l
Only the **Node Gateway** (`clawd` / `clawdbot gateway`) advertises Bonjour beacons.
-- Implementation: `src/infra/bonjour.ts`
-- Gateway wiring: `src/gateway/server.ts`
+- Implementation: [`src/infra/bonjour.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/infra/bonjour.ts)
+- Gateway wiring: [`src/gateway/server.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/gateway/server.ts)
## Service types
@@ -136,7 +136,7 @@ The log includes browser state transitions (`ready`, `waiting`, `failed`, `cance
- **Sleep / interface churn**: macOS may temporarily drop mDNS results when switching networks; retry.
- **Browse works but resolve fails (iOS “NoSuchRecord”)**: make sure the advertiser publishes a valid SRV target hostname.
- Implementation detail: `@homebridge/ciao` defaults `hostname` to the *service instance name* when `hostname` is omitted. If your instance name contains spaces/parentheses, some resolvers can fail to resolve the implied A/AAAA record.
- - Fix: set an explicit DNS-safe `hostname` (single label; no `.local`) in `src/infra/bonjour.ts`.
+ - Fix: set an explicit DNS-safe `hostname` (single label; no `.local`) in [`src/infra/bonjour.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/infra/bonjour.ts).
## Escaped instance names (`\\032`)
Bonjour/DNS-SD often escapes bytes in service instance names as decimal `\\DDD` sequences (e.g. spaces become `\\032`).
@@ -155,5 +155,5 @@ Bonjour/DNS-SD often escapes bytes in service instance names as decimal `\\DDD`
## Related docs
-- Discovery policy and transport selection: `docs/discovery.md`
-- Node pairing + approvals: `docs/gateway/pairing.md`
+- Discovery policy and transport selection: [`docs/discovery.md`](https://docs.clawd.bot/discovery)
+- Node pairing + approvals: [`docs/gateway/pairing.md`](https://docs.clawd.bot/gateway/pairing)
diff --git a/docs/browser.md b/docs/browser.md
index 5684d7c17..af162af05 100644
--- a/docs/browser.md
+++ b/docs/browser.md
@@ -189,10 +189,26 @@ All existing endpoints accept optional `?profile=` query parameter:
- `GET /?profile=work` — status for work profile
- `POST /start?profile=work` — start work profile browser
- `GET /tabs?profile=work` — list tabs for work profile
+- `POST /tabs/open?profile=work` — open tab in work profile
- etc.
When `profile` is omitted, uses `browser.defaultProfile` (defaults to "clawd").
+### Agent browser tool
+
+The `browser` tool accepts an optional `profile` parameter for all actions:
+
+```json
+{
+ "action": "open",
+ "targetUrl": "https://example.com",
+ "profile": "work"
+}
+```
+
+This routes the operation to the specified profile's browser instance. Omitting
+`profile` uses the default profile.
+
### Profile naming rules
- Lowercase alphanumeric characters and hyphens only
@@ -221,7 +237,7 @@ The agent should not assume tabs are ephemeral. It should:
## CLI quick reference (one example each)
-All commands accept `--profile ` to target a specific profile (default: `clawd`).
+All commands accept `--browser-profile ` to target a specific profile (default: `clawd`).
Profile management:
- `clawdbot browser profiles`
@@ -290,4 +306,4 @@ Notes:
## Troubleshooting
-For Linux-specific issues (especially Ubuntu with snap Chromium), see [browser-linux-troubleshooting.md](./browser-linux-troubleshooting.md).
+For Linux-specific issues (especially Ubuntu with snap Chromium), see [browser-linux-troubleshooting](https://docs.clawd.bot/browser-linux-troubleshooting).
diff --git a/docs/bun.md b/docs/bun.md
new file mode 100644
index 000000000..e1a4803cd
--- /dev/null
+++ b/docs/bun.md
@@ -0,0 +1,68 @@
+---
+summary: "Bun workflow (preferred): installs, patches, and gotchas vs pnpm"
+read_when:
+ - You want the fastest local dev loop (bun + watch)
+ - You hit Bun install/patch/lifecycle script issues
+---
+
+# Bun
+
+Goal: run this repo with **Bun** (preferred) without losing pnpm patch behavior.
+
+## Status
+
+- Bun is the preferred local runtime for running TypeScript directly (`bun run …`, `bun --watch …`).
+- `pnpm` is still fully supported (and used by some docs tooling).
+- Bun cannot use `pnpm-lock.yaml` and will ignore it.
+
+## Install
+
+Default:
+
+```sh
+bun install
+```
+
+Note: `bun.lock`/`bun.lockb` are gitignored, so there’s no repo churn either way. If you want *no lockfile writes*:
+
+```sh
+bun install --no-save
+```
+
+## Build / Test (Bun)
+
+```sh
+bun run build
+bun run vitest run
+```
+
+## pnpm patchedDependencies under Bun
+
+pnpm supports `package.json#pnpm.patchedDependencies` and records it in `pnpm-lock.yaml`.
+Bun does not support pnpm patches, so we apply them in `postinstall` when Bun is detected:
+
+- [`scripts/postinstall.js`](https://github.com/clawdbot/clawdbot/blob/main/scripts/postinstall.js) runs only for Bun installs and applies every entry from `package.json#pnpm.patchedDependencies` into `node_modules/...` using `git apply` (idempotent).
+
+To add a new patch that works in both pnpm + Bun:
+
+1. Add an entry to `package.json#pnpm.patchedDependencies`
+2. Add the patch file under `patches/`
+3. Run `pnpm install` (updates `pnpm-lock.yaml` patch hash)
+
+## Bun lifecycle scripts (blocked by default)
+
+Bun may block dependency lifecycle scripts unless explicitly trusted (`bun pm untrusted` / `bun pm trust`).
+For this repo, the commonly blocked scripts are not required:
+
+- `@whiskeysockets/baileys` `preinstall`: checks Node major >= 20 (we run Node 22+).
+- `protobufjs` `postinstall`: emits warnings about incompatible version schemes (no build artifacts).
+
+If you hit a real runtime issue that requires these scripts, trust them explicitly:
+
+```sh
+bun pm trust @whiskeysockets/baileys protobufjs
+```
+
+## Caveats
+
+- Some scripts still hardcode pnpm (e.g. `docs:build`, `ui:*`, `protocol:check`). Run those via pnpm for now.
diff --git a/docs/clawd.md b/docs/clawd.md
index 044c89536..1c62d396b 100644
--- a/docs/clawd.md
+++ b/docs/clawd.md
@@ -18,7 +18,7 @@ You’re putting an agent in a position to:
Start conservative:
- Always set `whatsapp.allowFrom` (never run open-to-the-world on your personal Mac).
- Use a dedicated WhatsApp number for the assistant.
-- Keep heartbeats disabled until you trust the setup (omit `agent.heartbeat` or set `agent.heartbeat.every: "0m"`).
+- Heartbeats now default to every 30 minutes. Disable until you trust the setup by setting `agent.heartbeat.every: "0m"`.
## Prerequisites
@@ -81,11 +81,11 @@ clawdbot gateway --port 18789
Now message the assistant number from your allowlisted phone.
-## Give the agent a workspace (AGENTS.md)
+## Give the agent a workspace (AGENTS)
Clawd reads operating instructions and “memory” from its workspace directory.
-By default, Clawdbot uses `~/clawd` as the agent workspace, and will create it (plus starter `AGENTS.md`, `SOUL.md`, `TOOLS.md`) automatically on setup/first agent run.
+By default, Clawdbot uses `~/clawd` as the agent workspace, and will create it (plus starter `AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`) automatically on setup/first agent run. `BOOTSTRAP.md` is only created when the workspace is brand new (it should not come back after you delete it).
Tip: treat this folder like Clawd’s “memory” and make it a git repo (ideally private) so your `AGENTS.md` + memory files are backed up.
@@ -103,6 +103,16 @@ Optional: choose a different workspace with `agent.workspace` (supports `~`).
}
```
+If you already ship your own workspace files from a repo, you can disable bootstrap file creation entirely:
+
+```json5
+{
+ agent: {
+ skipBootstrap: true
+ }
+}
+```
+
## The config that turns it into “an assistant”
CLAWDBOT defaults to a good assistant setup, but you’ll usually want to tune:
@@ -144,14 +154,16 @@ 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.
## Heartbeats (proactive mode)
-When `agent.heartbeat.every` is set to a positive interval, CLAWDBOT periodically runs a heartbeat prompt (default: `HEARTBEAT`).
+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.`
+Set `agent.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.
@@ -191,12 +203,12 @@ Logs live under `/tmp/clawdbot/` (default: `clawdbot-YYYY-MM-DD.log`).
## Next steps
-- WebChat: [WebChat](./webchat.md)
-- Gateway ops: [Gateway runbook](./gateway.md)
-- Cron + wakeups: [Cron + wakeups](./cron.md)
-- macOS menu bar companion: [Clawdbot macOS app](./macos.md)
-- iOS node app: [iOS app](./ios.md)
-- Android node app: [Android app](./android.md)
-- Windows status: [Windows app](./windows.md)
-- Linux status: [Linux app](./linux.md)
-- Security: [Security](./security.md)
+- WebChat: [WebChat](https://docs.clawd.bot/webchat)
+- Gateway ops: [Gateway runbook](https://docs.clawd.bot/gateway)
+- Cron + wakeups: [Cron + wakeups](https://docs.clawd.bot/cron)
+- macOS menu bar companion: [Clawdbot macOS app](https://docs.clawd.bot/macos)
+- iOS node app: [iOS app](https://docs.clawd.bot/ios)
+- Android node app: [Android app](https://docs.clawd.bot/android)
+- Windows status: [Windows app](https://docs.clawd.bot/windows)
+- Linux status: [Linux app](https://docs.clawd.bot/linux)
+- Security: [Security](https://docs.clawd.bot/security)
diff --git a/docs/configuration.md b/docs/configuration.md
index 70eabc4e7..869945fa0 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -9,7 +9,7 @@ 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:
- restrict who can trigger the bot (`whatsapp.allowFrom`, `telegram.allowFrom`, etc.)
-- control group mention behavior (`whatsapp.groups`, `telegram.groups`, `discord.guilds`, `routing.groupChat`)
+- control group allowlists + mention behavior (`whatsapp.groups`, `telegram.groups`, `discord.guilds`, `routing.groupChat`)
- customize message prefixes (`messages`)
- set the agent's workspace (`agent.workspace`)
- tune the embedded agent (`agent`) and session behavior (`session`)
@@ -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 (default agent root override): `CLAWDBOT_AGENT_DIR` (preferred), `PI_CODING_AGENT_DIR` (legacy)
On first use, Clawdbot imports `oauth.json` entries into `auth-profiles.json`.
@@ -131,7 +134,7 @@ rotation order used for failover.
Optional agent identity used for defaults and UX. This is written by the macOS onboarding assistant.
If set, CLAWDBOT derives defaults (only when you haven’t set them explicitly):
-- `messages.responsePrefix` from `identity.emoji`
+- `messages.ackReaction` from `identity.emoji` (falls back to 👀)
- `routing.groupChat.mentionPatterns` from `identity.name` (so “@Samantha” works in groups across Telegram/Slack/Discord/iMessage/WhatsApp)
```json5
@@ -184,20 +187,57 @@ Metadata written by CLI wizards (`onboard`, `configure`, `doctor`, `update`).
}
```
+### `whatsapp.dmPolicy`
+
+Controls how WhatsApp direct chats (DMs) are handled:
+- `"pairing"` (default): unknown senders get a pairing code; owner must approve
+- `"allowlist"`: only allow senders in `whatsapp.allowFrom` (or paired allow store)
+- `"open"`: allow all inbound DMs (**requires** `whatsapp.allowFrom` to include `"*"`)
+- `"disabled"`: ignore all inbound DMs
+
+Pairing approvals:
+- `clawdbot pairing list --provider whatsapp`
+- `clawdbot pairing approve --provider whatsapp `
+
### `whatsapp.allowFrom`
-Allowlist of E.164 phone numbers that may trigger WhatsApp auto-replies (DMs only).
-If empty, the default allowlist is your own WhatsApp number (self-chat mode).
+Allowlist of E.164 phone numbers that may trigger WhatsApp auto-replies (**DMs only**).
+If empty and `whatsapp.dmPolicy="pairing"`, unknown senders will receive a pairing code.
+For groups, use `whatsapp.groupPolicy` + `whatsapp.groupAllowFrom`.
```json5
{
whatsapp: {
+ dmPolicy: "pairing", // pairing | allowlist | open | disabled
allowFrom: ["+15555550123", "+447700900123"],
textChunkLimit: 4000 // optional outbound chunk size (chars)
}
}
```
+### `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.
@@ -218,7 +258,7 @@ Group messages default to **require mention** (either metadata mention or regex
}
```
-Mention gating defaults live per provider (`whatsapp.groups`, `telegram.groups`, `imessage.groups`, `discord.guilds`).
+Mention gating defaults live per provider (`whatsapp.groups`, `telegram.groups`, `imessage.groups`, `discord.guilds`). When `*.groups` is set, it also acts as a group allowlist; include `"*"` to allow all groups.
To respond **only** to specific text triggers (ignoring native @-mentions):
```json5
@@ -237,6 +277,114 @@ To respond **only** to specific text triggers (ignoring native @-mentions):
}
```
+### Group policy (per provider)
+
+Use `*.groupPolicy` to control whether group/room messages are accepted at all:
+
+```json5
+{
+ whatsapp: {
+ groupPolicy: "allowlist",
+ groupAllowFrom: ["+15551234567"]
+ },
+ telegram: {
+ groupPolicy: "allowlist",
+ groupAllowFrom: ["tg:123456789", "@alice"]
+ },
+ signal: {
+ groupPolicy: "allowlist",
+ groupAllowFrom: ["+15551234567"]
+ },
+ imessage: {
+ groupPolicy: "allowlist",
+ groupAllowFrom: ["chat_id:123"]
+ },
+ discord: {
+ groupPolicy: "allowlist",
+ guilds: {
+ "GUILD_ID": {
+ channels: { help: { allow: true } }
+ }
+ }
+ },
+ slack: {
+ groupPolicy: "allowlist",
+ channels: { "#general": { allow: true } }
+ }
+}
+```
+
+Notes:
+- `"open"` (default): groups bypass allowlists; mention-gating still applies.
+- `"disabled"`: block all group/room messages.
+- `"allowlist"`: only allow groups/rooms that match the configured allowlist.
+- WhatsApp/Telegram/Signal/iMessage use `groupAllowFrom` (fallback: explicit `allowFrom`).
+- 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.
@@ -249,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",
@@ -261,6 +409,27 @@ Controls how inbound messages behave when an agent run is already active.
}
```
+### `commands` (chat command handling)
+
+Controls how chat commands are enabled across connectors.
+
+```json5
+{
+ commands: {
+ native: false, // register native commands when supported
+ text: true, // parse slash commands in chat messages
+ useAccessGroups: true // enforce access-group allowlists/policies for commands
+ }
+}
+```
+
+Notes:
+- Text commands must be sent as a **standalone** message and use the leading `/` (no plain-text aliases).
+- `commands.text: false` disables parsing chat messages for commands.
+- `commands.native: true` registers native commands on supported connectors (Discord/Slack/Telegram). Platforms without native commands still rely on text commands.
+- `commands.native: false` skips native registration; Discord/Telegram clear previously registered commands on startup. Slack commands are managed in the Slack app.
+- `commands.useAccessGroups: false` allows commands to bypass access-group allowlists/policies.
+
### `web` (WhatsApp web provider)
WhatsApp runs through the gateway’s web provider. It starts automatically when a linked session exists.
@@ -292,8 +461,9 @@ Set `telegram.enabled: false` to disable automatic startup.
telegram: {
enabled: true,
botToken: "your-bot-token",
- requireMention: true,
- allowFrom: ["123456789"],
+ dmPolicy: "pairing", // pairing | allowlist | open | disabled
+ allowFrom: ["tg:123456789"], // optional; "open" requires ["*"]
+ groups: { "*": { requireMention: true } },
mediaMaxMb: 5,
proxy: "socks5://localhost:9050",
webhookUrl: "https://example.com/telegram-webhook",
@@ -331,15 +501,10 @@ Configure the Discord bot by setting the bot token and optional gating:
moderation: false
},
replyToMode: "off", // off | first | all
- slashCommand: { // user-installed app slash commands
- enabled: true,
- name: "clawd",
- sessionPrefix: "discord:slash",
- ephemeral: true
- },
dm: {
enabled: true, // disable all DMs when false
- allowFrom: ["1234567890", "steipete"], // optional DM allowlist (ids or names)
+ policy: "pairing", // pairing | allowlist | open | disabled
+ allowFrom: ["1234567890", "steipete"], // optional DM allowlist ("open" requires ["*"])
groupEnabled: false, // enable group DMs
groupChannels: ["clawd-dm"] // optional group DM allowlist
},
@@ -380,7 +545,8 @@ Slack runs in Socket Mode and requires both a bot token and app token:
appToken: "xapp-...",
dm: {
enabled: true,
- allowFrom: ["U123", "U456", "*"],
+ policy: "pairing", // pairing | allowlist | open | disabled
+ allowFrom: ["U123", "U456", "*"], // optional; "open" requires ["*"]
groupEnabled: false,
groupChannels: ["G123"]
},
@@ -435,6 +601,7 @@ Clawdbot spawns `imsg rpc` (JSON-RPC over stdio). No daemon or port required.
enabled: true,
cliPath: "imsg",
dbPath: "~/Library/Messages/chat.db",
+ dmPolicy: "pairing", // pairing | allowlist | open | disabled
allowFrom: ["+15555550123", "user@example.com", "chat_id:123"],
includeAttachments: false,
mediaMaxMb: 16,
@@ -464,6 +631,18 @@ Default: `~/clawd`.
If `agent.sandbox` is enabled, non-main sessions can override this with their
own per-session workspaces under `agent.sandbox.workspaceRoot`.
+### `agent.skipBootstrap`
+
+Disables automatic creation of the workspace bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, and `BOOTSTRAP.md`).
+
+Use this for pre-seeded deployments where your workspace files come from a repo.
+
+```json5
+{
+ agent: { skipBootstrap: true }
+}
+```
+
### `agent.userTimezone`
Sets the user’s timezone for **system prompt context** (not for timestamps in
@@ -477,13 +656,15 @@ message envelopes). If unset, Clawdbot uses the host timezone at runtime.
### `messages`
-Controls inbound/outbound prefixes.
+Controls inbound/outbound prefixes and optional ack reactions.
```json5
{
messages: {
messagePrefix: "[clawdbot]",
- responsePrefix: "🦞"
+ responsePrefix: "🦞",
+ ackReaction: "👀",
+ ackReactionScope: "group-mentions"
}
}
```
@@ -491,6 +672,16 @@ Controls inbound/outbound prefixes.
`responsePrefix` is applied to **all outbound replies** (tool summaries, block
streaming, final replies) across providers unless already present.
+`ackReaction` sends a best-effort emoji reaction to acknowledge inbound messages
+on providers that support reactions (Slack/Discord/Telegram). Defaults to the
+configured `identity.emoji` when set, otherwise `"👀"`. Set it to `""` to disable.
+
+`ackReactionScope` controls when reactions fire:
+- `group-mentions` (default): only when a group/room requires mentions **and** the bot was mentioned
+- `group-all`: all group/room messages
+- `direct`: direct messages only
+- `all`: all messages
+
### `talk`
Defaults for Talk mode (macOS/iOS/Android). Voice IDs fall back to `ELEVENLABS_VOICE_ID` or `SAG_VOICE_ID` when unset.
@@ -595,14 +786,17 @@ Z.AI models are available as `zai/` (e.g. `zai/glm-4.7`) and require
`ZAI_API_KEY` (or legacy `Z_AI_API_KEY`) in the environment.
`agent.heartbeat` configures periodic heartbeat runs:
-- `every`: duration string (`ms`, `s`, `m`, `h`); default unit minutes. Omit or set
- `0m` to disable.
+- `every`: duration string (`ms`, `s`, `m`, `h`); default unit minutes. Default:
+ `30m`. 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`.
-- `to`: optional recipient override (E.164 for WhatsApp, chat id for Telegram).
-- `prompt`: optional override for the heartbeat body (default: `HEARTBEAT`).
+- `target`: optional delivery provider (`last`, `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`, `none`). Default: `last`.
+- `to`: optional recipient override (provider-specific id, e.g. E.164 for WhatsApp, chat id for Telegram).
+- `prompt`: optional override for the heartbeat body (default: `Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`).
- `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery (default: 30).
+Heartbeats run full agent turns. Shorter intervals burn more tokens; adjust `every`
+and/or `model` accordingly.
+
`agent.bash` configures background bash defaults:
- `backgroundMs`: time before auto-background (ms, default 10000)
- `timeoutSec`: auto-kill after this runtime (seconds, default 1800)
@@ -624,7 +818,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)
@@ -661,7 +855,7 @@ Defaults (if enabled):
- Debian bookworm-slim based image
- workspace per session under `~/.clawdbot/sandboxes`
- auto-prune: idle > 24h OR age > 7d
-- tools: allow only `bash`, `process`, `read`, `write`, `edit` (deny wins)
+- tools: allow only `bash`, `process`, `read`, `write`, `edit`, `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn` (deny wins)
- optional sandboxed browser (Chromium + CDP, noVNC observer)
- hardening knobs: `network`, `user`, `pidsLimit`, `memory`, `cpus`, `ulimits`, `seccompProfile`, `apparmorProfile`
@@ -707,7 +901,7 @@ Defaults (if enabled):
enableNoVnc: true
},
tools: {
- allow: ["bash", "process", "read", "write", "edit"],
+ allow: ["bash", "process", "read", "write", "edit", "sessions_list", "sessions_history", "sessions_send", "sessions_spawn"],
deny: ["browser", "canvas", "nodes", "cron", "discord", "gateway"]
},
prune: {
@@ -742,11 +936,11 @@ URL is injected per session.
Clawdbot uses the **pi-coding-agent** model catalog. You can add custom providers
(LiteLLM, local OpenAI-compatible servers, Anthropic proxies, etc.) by writing
-`~/.clawdbot/agent/models.json` or by defining the same schema inside your
+`~/.clawdbot/agents//agent/models.json` or by defining the same schema inside your
Clawdbot config under `models.providers`.
When `models.providers` is present, Clawdbot writes/merges a `models.json` into
-`~/.clawdbot/agent/` on startup:
+`~/.clawdbot/agents//agent/` on startup:
- default behavior: **merge** (keeps existing providers, overrides on name)
- set `models.mode: "replace"` to overwrite the file contents
@@ -832,7 +1026,7 @@ Notes:
`google-generative-ai`
- Use `authHeader: true` + `headers` for custom auth needs.
- Override the agent config root with `CLAWDBOT_AGENT_DIR` (or `PI_CODING_AGENT_DIR`)
- if you want `models.json` stored elsewhere.
+ if you want `models.json` stored elsewhere (default: `~/.clawdbot/agents/main/agent`).
### `session`
@@ -844,15 +1038,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"
}
@@ -861,9 +1058,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)
@@ -1085,7 +1283,7 @@ Convenience flags (CLI):
- `clawdbot --dev …` → uses `~/.clawdbot-dev` + shifts ports from base `19001`
- `clawdbot --profile …` → uses `~/.clawdbot-` (port via config/env/flags)
-See `docs/gateway.md` for the derived port mapping (gateway/bridge/browser/canvas).
+See [`docs/gateway.md`](https://docs.clawd.bot/gateway) for the derived port mapping (gateway/bridge/browser/canvas).
Example:
```bash
@@ -1096,7 +1294,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`
@@ -1133,7 +1331,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"`).
@@ -1263,7 +1461,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 |
@@ -1277,11 +1475,11 @@ 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)
-Cron is a Gateway-owned scheduler for wakeups and scheduled jobs. See [Cron + wakeups](./cron.md) for the full RFC and CLI examples.
+Cron is a Gateway-owned scheduler for wakeups and scheduled jobs. See [Cron + wakeups](https://docs.clawd.bot/cron) for the full RFC and CLI examples.
```json5
{
@@ -1294,4 +1492,4 @@ Cron is a Gateway-owned scheduler for wakeups and scheduled jobs. See [Cron + wa
---
-*Next: [Agent Runtime](./agent.md)* 🦞
+*Next: [Agent Runtime](https://docs.clawd.bot/agent)* 🦞
diff --git a/docs/control-api.md b/docs/control-api.md
deleted file mode 100644
index 1708f6543..000000000
--- a/docs/control-api.md
+++ /dev/null
@@ -1,49 +0,0 @@
----
-summary: "Deprecated newline-delimited control channel API (pre-gateway)"
-read_when:
- - Maintaining legacy control channel support
----
-# Control channel API (newline-delimited JSON)
-
-**Deprecated (historical):** superseded by the WebSocket Gateway protocol (`clawdbot gateway`, see `docs/architecture.md` and `docs/gateway.md`).
-Current builds use a WebSocket server on `ws://127.0.0.1:18789` and do **not** expose this TCP control channel.
-
-Legacy endpoint (if present in an older build): `127.0.0.1:18789` (TCP, localhost only), typically reached via SSH port forward in remote mode.
-
-## Frame format
-Each line is a JSON object. Two shapes exist:
-- **Request**: `{ "type": "request", "id": "", "method": "health" | "status" | "last-heartbeat" | "set-heartbeats" | "ping", "params"?: { ... } }`
-- **Response**: `{ "type": "response", "id": "", "ok": true, "payload"?: { ... } }` or `{ "type": "response", "id": "", "ok": false, "error": "message" }`
-- **Event**: `{ "type": "event", "event": "heartbeat" | "gateway-status" | "log", "payload": { ... } }`
-
-## Methods
-- `ping`: sanity check. Payload: `{ pong: true, ts }`.
-- `health`: returns the gateway health snapshot (same shape as `clawdbot health --json`).
-- `status`: shorter summary (linked/authAge/heartbeatSeconds, session counts).
-- `last-heartbeat`: returns the most recent heartbeat event the gateway has seen.
-- `set-heartbeats { enabled: boolean }`: toggle heartbeat scheduling.
-
-## Events
-- `heartbeat` payload:
- ```json
- {
- "ts": 1765224052664,
- "status": "sent" | "ok-empty" | "ok-token" | "skipped" | "failed",
- "to": "+15551234567",
- "preview": "Heartbeat OK",
- "hasMedia": false,
- "durationMs": 1025,
- "reason": "" // only on failed/skipped
- }
- ```
-- `gateway-status` payload: `{ "state": "starting" | "running" | "restarting" | "failed" | "stopped", "pid"?: number, "reason"?: string }`
-- `log` payload: arbitrary log line; optional, can be disabled.
-
-## Suggested client flow
-1) Connect (or reconnect) → send `ping`.
-2) Send `health` and `last-heartbeat` to populate UI.
-3) Listen for `event` frames; update UI in real time.
-4) For user toggles, send `set-heartbeats` and await response.
-
-## Backward compatibility
-- If the control channel is unavailable: that’s expected on modern builds. Use the Gateway WS protocol instead.
diff --git a/docs/control-ui.md b/docs/control-ui.md
index 315e1415c..0e4fe0c32 100644
--- a/docs/control-ui.md
+++ b/docs/control-ui.md
@@ -63,21 +63,21 @@ Paste the token into the UI settings (sent as `connect.params.auth.token`).
The Gateway serves static files from `dist/control-ui`. Build them with:
```bash
-pnpm ui:install
-pnpm ui:build
+bun run ui:install
+bun run ui:build
```
Optional absolute base (when you want fixed asset URLs):
```bash
-CLAWDBOT_CONTROL_UI_BASE_PATH=/clawdbot/ pnpm ui:build
+CLAWDBOT_CONTROL_UI_BASE_PATH=/clawdbot/ bun run ui:build
```
For local development (separate dev server):
```bash
-pnpm ui:install
-pnpm ui:dev
+bun run ui:install
+bun run ui:dev
```
Then point the UI at your Gateway WS URL (e.g. `ws://127.0.0.1:18789`).
diff --git a/docs/cron.md b/docs/cron.md
index 374086e68..5c76c8b8d 100644
--- a/docs/cron.md
+++ b/docs/cron.md
@@ -14,9 +14,9 @@ Last updated: 2025-12-13
## Context
Clawdbot already has:
-- A **gateway heartbeat runner** that runs the agent with `HEARTBEAT` and suppresses `HEARTBEAT_OK` (`src/infra/heartbeat-runner.ts`).
-- A lightweight, in-memory **system event queue** (`enqueueSystemEvent`) that is injected into the next **main session** turn (`drainSystemEvents` in `src/auto-reply/reply.ts`).
-- A WebSocket **Gateway** daemon that is intended to be always-on (`docs/gateway.md`).
+- A **gateway heartbeat runner** that runs the agent with the configured heartbeat prompt (default: `Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`) and suppresses `HEARTBEAT_OK` ([`src/infra/heartbeat-runner.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/infra/heartbeat-runner.ts)).
+- A lightweight, in-memory **system event queue** (`enqueueSystemEvent`) that is injected into the next **main session** turn (`drainSystemEvents` in [`src/auto-reply/reply.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/auto-reply/reply.ts)).
+- A WebSocket **Gateway** daemon that is intended to be always-on ([`docs/gateway.md`](https://docs.clawd.bot/gateway)).
This RFC adds a small “cron job system” so Clawd can schedule future work and reliably wake itself up:
- **Delayed**: run on the *next* normal heartbeat tick
@@ -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,14 +173,14 @@ 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:`.
### “Run in parallel to main”
-Clawdbot currently serializes command execution through a global in-process queue (`src/process/command-queue.ts`) to avoid collisions.
+Clawdbot currently serializes command execution through a global in-process queue ([`src/process/command-queue.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/process/command-queue.ts)) to avoid collisions.
To support isolated cron jobs running “in parallel”, we should introduce **lanes** (keyed queues) plus a global concurrency cap:
- Lane `"main"`: inbound auto-replies + main heartbeat.
@@ -198,7 +198,7 @@ We need a way for the Gateway (or the scheduler) to request an immediate heartbe
Design:
- `startHeartbeatRunner` owns the real heartbeat execution and installs a wake handler.
-- Wake hook lives in `src/infra/heartbeat-wake.ts`:
+- Wake hook lives in [`src/infra/heartbeat-wake.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/infra/heartbeat-wake.ts):
- `setHeartbeatWakeHandler(fn | null)` installed by the heartbeat runner
- `requestHeartbeatNow({ reason, coalesceMs? })`
- If the handler is absent, the request is stored as “pending”; the next time the handler is installed, it runs once.
@@ -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/dashboard.md b/docs/dashboard.md
index e859fb7c7..e44a1c72b 100644
--- a/docs/dashboard.md
+++ b/docs/dashboard.md
@@ -9,9 +9,9 @@ The Gateway dashboard is the browser Control UI served at `/` by default
(override with `gateway.controlUi.basePath`).
Key references:
-- `docs/control-ui.md` for usage and UI capabilities.
-- `docs/tailscale.md` for Serve/Funnel automation.
-- `docs/web.md` for bind modes and security notes.
+- [`docs/control-ui.md`](https://docs.clawd.bot/control-ui) for usage and UI capabilities.
+- [`docs/tailscale.md`](https://docs.clawd.bot/tailscale) for Serve/Funnel automation.
+- [`docs/web.md`](https://docs.clawd.bot/web) for bind modes and security notes.
Authentication is enforced at the WebSocket handshake via `connect.params.auth`
-(token or password). See `gateway.auth` in `docs/configuration.md`.
+(token or password). See `gateway.auth` in [`docs/configuration.md`](https://docs.clawd.bot/configuration).
diff --git a/docs/discord.md b/docs/discord.md
index a96bfff0b..6b006c068 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.
@@ -23,13 +23,18 @@ Status: ready for DM and guild text channels via the official Discord bot gatewa
- If you prefer env vars, still add `discord: { enabled: true }` to `~/.clawdbot/clawdbot.json` and set `DISCORD_BOT_TOKEN`.
5. Direct chats: use `user:` (or a `<@id>` mention) when delivering; all turns land in the shared `main` session.
6. Guild channels: use `channel:` for delivery. Mentions are required by default and can be set per guild or per channel.
-7. Optional DM control: set `discord.dm.enabled = false` to ignore all DMs, or `discord.dm.allowFrom` to allow specific users (ids or names). Use `discord.dm.groupEnabled` + `discord.dm.groupChannels` to allow group DMs.
-8. Optional guild rules: set `discord.guilds` keyed by guild id (preferred) or slug, with per-channel rules.
-9. Optional slash commands: enable `discord.slashCommand` to accept user-installed app commands (ephemeral replies). Slash invocations respect the same DM/guild allowlists.
-10. 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.
-11. 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.
-12. Slash commands use isolated session keys (`${sessionPrefix}:${userId}`) rather than the shared `main` session.
+7. Direct chats: secure by default via `discord.dm.policy` (default: `"pairing"`). Unknown senders get a pairing code; approve via `clawdbot pairing approve --provider discord `.
+ - To keep old “open to anyone” behavior: set `discord.dm.policy="open"` and `discord.dm.allowFrom=["*"]`.
+ - To hard-allowlist: set `discord.dm.policy="allowlist"` and list senders in `discord.dm.allowFrom`.
+ - To ignore all DMs: set `discord.dm.enabled=false` or `discord.dm.policy="disabled"`.
+8. Group DMs are ignored by default; enable via `discord.dm.groupEnabled` and optionally restrict by `discord.dm.groupChannels`.
+9. Optional guild rules: set `discord.guilds` keyed by guild id (preferred) or slug, with per-channel rules.
+10. Optional native commands: set `commands.native: true` to register native commands in Discord; set `commands.native: false` to clear previously registered native commands. Text commands are controlled by `commands.text` and must be sent as standalone `/...` messages. Use `commands.useAccessGroups: false` to bypass access-group checks for commands.
+ - Full command list + config: https://docs.clawd.bot/slash-commands
+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 provider is Discord.
+13. Native commands use isolated session keys (`discord:slash:${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.
Note: Slugs are lowercase with spaces replaced by `-`. Channel names are slugged without the leading `#`.
@@ -59,7 +64,7 @@ In your app: **OAuth2** → **URL Generator**
**Scopes**
- ✅ `bot`
-- ✅ `applications.commands` (only if you want slash commands; otherwise leave unchecked)
+- ✅ `applications.commands` (required for native commands)
**Bot Permissions** (minimal baseline)
- ✅ View Channels
@@ -138,7 +143,7 @@ Notes:
- The bot lacks channel permissions (View/Send/Read History), or
- Your config requires mentions and you didn’t mention it, or
- Your guild/channel allowlist denies the channel/user.
-- **DMs don’t work**: `discord.dm.enabled` may be `false` or `discord.dm.allowFrom` doesn’t include you.
+- **DMs don’t work**: `discord.dm.enabled=false`, `discord.dm.policy="disabled"`, or you haven’t been approved yet (`discord.dm.policy="pairing"`).
## Capabilities & limits
- DMs and guild text channels (threads are treated as separate channels; voice not supported).
@@ -155,6 +160,7 @@ Notes:
discord: {
enabled: true,
token: "abc.123",
+ groupPolicy: "open",
mediaMaxMb: 8,
actions: {
reactions: true,
@@ -174,14 +180,9 @@ Notes:
moderation: false
},
replyToMode: "off",
- slashCommand: {
- enabled: true,
- name: "clawd",
- sessionPrefix: "discord:slash",
- ephemeral: true
- },
dm: {
enabled: true,
+ policy: "pairing", // pairing | allowlist | open | disabled
allowFrom: ["123456789012345678", "steipete"],
groupEnabled: false,
groupChannels: ["clawd-dm"]
@@ -203,10 +204,15 @@ Notes:
}
```
+Ack reactions are controlled globally via `messages.ackReaction` +
+`messages.ackReactionScope`.
+
- `dm.enabled`: set `false` to ignore all DMs (default `true`).
-- `dm.allowFrom`: DM allowlist (user ids or names). Omit or set to `["*"]` to allow any DM sender.
+- `dm.policy`: DM access control (`pairing` recommended). `"open"` requires `dm.allowFrom=["*"]`.
+- `dm.allowFrom`: DM allowlist (user ids or names). Used by `dm.policy="allowlist"` and for `dm.policy="open"` validation.
- `dm.groupEnabled`: enable group DMs (default `false`).
- `dm.groupChannels`: optional allowlist for group DM channel ids or slugs.
+- `groupPolicy`: controls guild channel handling (`open|disabled|allowlist`); `allowlist` requires channel allowlists.
- `guilds`: per-guild rules keyed by guild id (preferred) or slug.
- `guilds."*"`: default per-guild settings applied when no explicit entry exists.
- `guilds..slug`: optional friendly slug used for display names.
@@ -214,7 +220,6 @@ Notes:
- `guilds..channels`: channel rules (keys are channel slugs or ids).
- `guilds..requireMention`: per-guild mention requirement (overridable per channel).
- `guilds..reactionNotifications`: reaction system event mode (`off`, `own`, `all`, `allowlist`).
-- `slashCommand`: optional config for user-installed slash commands (ephemeral responses).
- `mediaMaxMb`: clamp inbound media saved to disk.
- `historyLimit`: number of recent guild messages to include as context when replying to a mention (default 20, `0` disables).
- `actions`: per-action tool gates; omit to allow all (set `false` to disable).
@@ -268,11 +273,9 @@ Allowlist matching notes:
- Use `*` to allow any sender/channel.
- When `guilds..channels` is present, channels not listed are denied by default.
-Slash command notes:
-- Register a chat input command in Discord with at least one string option (e.g., `prompt`).
-- The first non-empty string option is treated as the prompt.
-- Slash commands honor the same allowlists as DMs/guild messages (`discord.dm.allowFrom`, `discord.guilds`, per-channel rules).
-- Clawdbot will auto-register `/clawd` (or the configured name) if it doesn't already exist.
+Native command notes:
+- The registered commands mirror Clawdbot’s chat commands.
+- Native commands honor the same allowlists as DMs/guild messages (`discord.dm.allowFrom`, `discord.guilds`, per-channel rules).
## Tool actions
The agent can call `discord` with actions like:
diff --git a/docs/discovery.md b/docs/discovery.md
index bbe782ab8..bc7f8a4fb 100644
--- a/docs/discovery.md
+++ b/docs/discovery.md
@@ -42,7 +42,7 @@ Target direction:
- The **gateway** advertises its bridge via Bonjour.
- Clients browse and show a “pick a gateway” list, then store the chosen endpoint.
-Troubleshooting and beacon details: `docs/bonjour.md`.
+Troubleshooting and beacon details: [`docs/bonjour.md`](https://docs.clawd.bot/bonjour).
#### Current implementation
@@ -77,7 +77,7 @@ If the gateway can detect it is running under Tailscale, it publishes `tailnetDn
When there is no direct route (or direct is disabled), clients can always connect via SSH by forwarding the loopback gateway port.
-See `docs/remote.md`.
+See [`docs/remote.md`](https://docs.clawd.bot/remote).
## Transport selection (client policy)
@@ -92,7 +92,7 @@ Recommended client behavior:
The gateway is the source of truth for node/client admission.
-- Pairing requests are created/approved/rejected in the gateway (see `docs/gateway/pairing.md`).
+- Pairing requests are created/approved/rejected in the gateway (see [`docs/gateway/pairing.md`](https://docs.clawd.bot/gateway/pairing)).
- The bridge enforces:
- auth (token / keypair)
- scopes/ACLs (bridge is not a raw proxy to every gateway method)
diff --git a/docs/docker.md b/docs/docker.md
index c587233c6..5950c3fb4 100644
--- a/docs/docker.md
+++ b/docs/docker.md
@@ -68,7 +68,7 @@ pnpm test:docker:qr
### Notes
- Gateway bind defaults to `lan` for container use.
-- The gateway container is the source of truth for sessions (`~/.clawdbot/sessions`).
+- The gateway container is the source of truth for sessions (`~/.clawdbot/agents//sessions/`).
## Per-session Agent Sandbox (host gateway + Docker tools)
@@ -88,7 +88,7 @@ container. The gateway stays on your host, but the tool execution is isolated:
- Workspace per session under `~/.clawdbot/sandboxes`
- Auto-prune: idle > 24h OR age > 7d
- Network: `none` by default (explicitly opt-in if you need egress)
-- Default allow: `bash`, `process`, `read`, `write`, `edit`
+- Default allow: `bash`, `process`, `read`, `write`, `edit`, `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`
- Default deny: `browser`, `canvas`, `nodes`, `cron`, `discord`, `gateway`
### Enable sandboxing
@@ -124,7 +124,7 @@ container. The gateway stays on your host, but the tool execution is isolated:
extraHosts: ["internal.service:10.0.0.5"]
},
tools: {
- allow: ["bash", "process", "read", "write", "edit"],
+ allow: ["bash", "process", "read", "write", "edit", "sessions_list", "sessions_history", "sessions_send", "sessions_spawn"],
deny: ["browser", "canvas", "nodes", "cron", "discord", "gateway"]
},
prune: {
@@ -252,7 +252,7 @@ Example:
## Troubleshooting
-- Image missing: build with `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 `agent.sandbox.docker.image`.
- 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
mounted workspace ownership (or chown the workspace folder).
diff --git a/docs/docs.json b/docs/docs.json
index 26ceef64b..8802512e9 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -23,23 +23,25 @@
"navigation": {
"groups": [
{
- "group": "Getting Started",
+ "group": "Start Here",
"pages": [
+ "getting-started",
+ "wizard",
"index",
+ "setup",
+ "pairing",
+ "faq",
+ "clawd",
"showcase",
"hubs",
- "onboarding",
- "clawd",
- "faq"
+ "onboarding"
]
},
{
- "group": "Installation",
+ "group": "Install Options",
"pages": [
- "wizard",
"nix",
- "docker",
- "setup"
+ "docker"
]
},
{
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 f51fe824d..2614ebe36 100644
--- a/docs/faq.md
+++ b/docs/faq.md
@@ -3,7 +3,7 @@ summary: "Frequently asked questions about Clawdbot setup, configuration, and us
---
# FAQ 🦞
-Common questions from the community. For detailed configuration, see [configuration.md](./configuration.md).
+Common questions from the community. For detailed configuration, see [Configuration](https://docs.clawd.bot/configuration).
## Installation & Setup
@@ -14,12 +14,15 @@ Everything lives under `~/.clawdbot/`:
| Path | Purpose |
|------|---------|
| `~/.clawdbot/clawdbot.json` | Main config (JSON5) |
-| `~/.clawdbot/credentials/oauth.json` | OAuth credentials (Anthropic/OpenAI, etc.) |
-| `~/.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/credentials/oauth.json` | Legacy OAuth import (copied into auth profiles on first use) |
+| `~/.clawdbot/agents//agent/auth-profiles.json` | Auth profiles (OAuth + API keys) |
+| `~/.clawdbot/agents//agent/auth.json` | Runtime auth cache (managed automatically) |
+| `~/.clawdbot/credentials/` | Provider auth state (e.g. `whatsapp//creds.json`) |
+| `~/.clawdbot/agents/` | Per-agent state (agentDir + sessions) |
+| `~/.clawdbot/agents//sessions/` | Conversation history & state (per agent) |
+| `~/.clawdbot/agents//sessions/sessions.json` | Session metadata (per agent) |
+
+Legacy single-agent path: `~/.clawdbot/agent/*` (migrated by `clawdbot doctor`).
Your **workspace** (AGENTS.md, memory files, skills) is separate — configured via `agent.workspace` in your config (default: `~/clawd`).
@@ -37,7 +40,7 @@ Some features are platform-specific:
### What are the minimum system requirements?
-**Basically nothing!** The gateway is very lightweight — all heavy compute happens on Anthropic's servers.
+**Basically nothing!** The gateway is very lightweight — heavy compute happens on your model provider’s servers (Anthropic/OpenAI/etc.).
- **RAM:** 512MB-1GB is enough (community member runs on 1GB VPS!)
- **CPU:** 1 core is fine for personal use
@@ -119,7 +122,7 @@ They're **separate billing**! An API key does NOT use your subscription.
pnpm clawdbot login
```
-**If OAuth fails** (headless/container): Do OAuth on a normal machine, then copy `~/.clawdbot/credentials/oauth.json` to your server. The auth is just a JSON file.
+**If OAuth fails** (headless/container): Do OAuth on a normal machine, then copy `~/.clawdbot/agents//agent/auth-profiles.json` (and `auth.json` if present) to your server. Legacy installs can still import `~/.clawdbot/credentials/oauth.json` on first use.
### How are env vars loaded?
@@ -141,15 +144,15 @@ Or set `CLAWDBOT_LOAD_SHELL_ENV=1` (timeout: `CLAWDBOT_SHELL_ENV_TIMEOUT_MS=1500
### Does enterprise OAuth work?
-**Not currently.** Enterprise accounts use SSO which requires a different auth flow that pi-coding-agent doesn't support yet.
+**Not currently.** Enterprise accounts use SSO which requires a different auth flow that Clawdbot’s OAuth login doesn’t support yet.
-**Workaround:** Ask your enterprise admin to provision an API key via the Anthropic console, then use that with `ANTHROPIC_API_KEY`.
+**Workaround:** Ask your enterprise admin to provision an API key (Anthropic or OpenAI) and use it via `ANTHROPIC_API_KEY` or `OPENAI_API_KEY`.
### OAuth callback not working (containers/headless)?
OAuth needs the callback to reach the machine running the CLI. Options:
-1. **Copy auth manually** — Run OAuth on your laptop, copy `~/.clawdbot/credentials/oauth.json` to the container.
+1. **Copy auth manually** — Run OAuth on your laptop, copy `~/.clawdbot/agents//agent/auth-profiles.json` (and `auth.json` if present) to the container. Legacy flow: copy `~/.clawdbot/credentials/oauth.json` to trigger import.
2. **SSH tunnel** — `ssh -L 18789:localhost:18789 user@server`
3. **Tailscale** — Put both machines on your tailnet.
@@ -191,7 +194,15 @@ OAuth needs the callback to reach the machine running the CLI. Options:
### Can I run Clawdbot in Docker?
-There's no official Docker setup yet, but it works. Key considerations:
+Yes — Docker is optional but supported. Recommended: run the setup script:
+
+```bash
+./docker-setup.sh
+```
+
+It builds the image, runs onboarding + login, and starts Docker Compose. For manual steps and sandbox notes, see `docs/docker.md`.
+
+Key considerations:
- **WhatsApp login:** QR code works in terminal — no display needed.
- **Persistence:** Mount `~/.clawdbot/` and your workspace as volumes.
@@ -218,7 +229,7 @@ pnpm clawdbot gateway
bash /app/start.sh
```
-Docker support is on the roadmap — PRs welcome!
+For more detail, see `docs/docker.md`.
### Can I run Clawdbot headless on a VPS?
@@ -290,7 +301,7 @@ Per-group activation can be changed by the owner:
- `/activation mention` — respond only when mentioned (default)
- `/activation always` — respond to all messages
-See [groups.md](./groups.md) for details.
+See [Groups](https://docs.clawd.bot/groups) for details.
---
@@ -298,7 +309,7 @@ See [groups.md](./groups.md) for details.
### How much context can Clawdbot handle?
-Claude Opus has a 200k token context window, and Clawdbot uses **autocompaction** — older conversation gets summarized to stay under the limit.
+Context window depends on the model. Clawdbot uses **autocompaction** — older conversation gets summarized to stay under the limit.
Practical tips:
- Keep `AGENTS.md` focused, not bloated.
@@ -327,7 +338,7 @@ cat ~/.clawdbot/clawdbot.json | grep workspace
- **Telegram** — Via Bot API (grammY).
- **Discord** — Bot integration.
- **iMessage** — Via `imsg` CLI (macOS only).
-- **Signal** — Via `signal-cli` (see [signal.md](./signal.md)).
+- **Signal** — Via `signal-cli` (see [Signal](https://docs.clawd.bot/signal)).
- **WebChat** — Browser-based chat UI.
### Discord: Bot works in channels but not DMs?
@@ -365,7 +376,7 @@ If you send an image but your Clawd doesn't "see" it, check these:
Not all models support images! Check `agent.model` in your config:
-- ✅ Vision: `claude-opus-4-5`, `claude-sonnet-4-5`, `claude-haiku-4-5`, `gpt-5.2`, `gpt-4o`, `gemini-pro`
+- ✅ Vision (examples): `anthropic/claude-opus-4-5`, `anthropic/claude-sonnet-4-5`, `anthropic/claude-haiku-4-5`, `openai/gpt-5.2`, `openai/gpt-4o`, `google/gemini-3-pro-preview`, `google/gemini-3-flash-preview`
- ❌ No vision: Most local LLMs (Llama, Mistral), older models, text-only configs
**2. Is media being downloaded?**
@@ -486,24 +497,16 @@ Headless/system services are not configured out of the box.
The gateway runs under a supervisor that auto-restarts it. You need to stop the supervisor, not just kill the process.
-**macOS (launchd)**
+**macOS (Clawdbot.app)**
+
+- Quit the menu bar app to stop the gateway.
+- For debugging, restart via the app (or `scripts/restart-mac.sh` when working in the repo).
+- To inspect launchd state: `launchctl print gui/$UID | grep clawdbot`
+
+**macOS (CLI launchd service, if installed)**
```bash
-# Check if running
-launchctl list | grep clawdbot
-
-# Stop (disable does NOT stop a running job)
clawdbot gateway stop
-
-# Stop and disable
-launchctl disable gui/$UID/com.clawdbot.gateway
-launchctl bootout gui/$UID/com.clawdbot.gateway
-
-# Re-enable later
-launchctl enable gui/$UID/com.clawdbot.gateway
-launchctl bootstrap gui/$UID ~/Library/LaunchAgents/com.clawdbot.gateway.plist
-
-# Or just restart
clawdbot gateway restart
```
@@ -535,8 +538,11 @@ pm2 delete clawdbot
launchctl disable gui/$UID/com.clawdbot.gateway
launchctl bootout gui/$UID/com.clawdbot.gateway 2>/dev/null
-# Linux: stop systemd service
-sudo systemctl disable --now clawdbot
+# Linux: stop systemd user service
+systemctl --user disable --now clawdbot-gateway.service
+
+# Linux (system-wide unit, if installed)
+sudo systemctl disable --now clawdbot-gateway.service
# Kill any remaining processes
pkill -f "clawdbot"
@@ -544,10 +550,10 @@ pkill -f "clawdbot"
# Remove data
trash ~/.clawdbot
-# Remove repo and re-clone
-trash ~/clawdbot
-git clone https://github.com/clawdbot/clawdbot.git
-cd clawdbot && pnpm install && pnpm build
+# Remove repo and re-clone (adjust path if you cloned elsewhere)
+trash ~/Projects/clawdbot
+git clone https://github.com/clawdbot/clawdbot.git ~/Projects/clawdbot
+cd ~/Projects/clawdbot && pnpm install && pnpm build
pnpm clawdbot onboard
```
@@ -559,17 +565,21 @@ Quick reference (send these in chat):
| Command | Action |
|---------|--------|
+| `/help` | Show available commands |
| `/status` | Health + session info |
| `/new` or `/reset` | Reset the session |
-| `/compact` | Compact session context |
-
-Slash commands are owner-only (gated by `whatsapp.allowFrom` and command authorization on other surfaces).
+| `/compact [notes]` | Compact session context |
+| `/restart` | Restart Clawdbot |
+| `/activation mention\|always` | Group activation (owner-only) |
| `/think ` | Set thinking level (off\|minimal\|low\|medium\|high) |
| `/verbose on\|off` | Toggle verbose mode |
| `/elevated on\|off` | Toggle elevated bash mode (approved senders only) |
-| `/activation mention\|always` | Group activation (owner-only) |
| `/model ` | Switch AI model (see below) |
-| `/queue instant\|batch\|serial` | Message queuing mode |
+| `/queue ` | Queue mode (see below) |
+
+Slash commands are owner-only (gated by `whatsapp.allowFrom` and command authorization on other surfaces).
+Commands are only recognized when the entire message is the command (slash required; no plain-text aliases).
+Full list + config: https://docs.clawd.bot/slash-commands
### How do I switch models on the fly?
@@ -615,8 +625,8 @@ If you don't want to use Anthropic directly, you can use alternative providers:
```json5
{
agent: {
- model: { primary: "openrouter/anthropic/claude-sonnet-4" },
- models: { "openrouter/anthropic/claude-sonnet-4": {} },
+ model: { primary: "openrouter/anthropic/claude-sonnet-4-5" },
+ models: { "openrouter/anthropic/claude-sonnet-4-5": {} },
env: { OPENROUTER_API_KEY: "sk-or-..." }
}
}
@@ -647,11 +657,8 @@ If you get weird errors after switching models, try `/think off` and `/new` to r
### How do I stop/cancel a running task?
-Send `/stop` to immediately abort the current agent run. Other stop words also work:
-- `/stop`
-- `/abort`
-- `/esc`
-- `/exit`
+Send one of these **as a standalone message** (no slash): `stop`, `abort`, `esc`, `wait`, `exit`.
+These are abort triggers, not slash commands.
For background processes (like Codex), use:
```
@@ -660,8 +667,10 @@ process action:kill sessionId:XXX
You can also configure `routing.queue.mode` to control how new messages interact with running tasks:
- `steer` — New messages redirect the current task
-- `interrupt` — Kills current run, starts fresh
-- `collect` — Queues messages for after
+- `followup` — Run messages one at a time
+- `collect` — Batch messages, reply once after things settle
+- `steer-backlog` — Steer now, process backlog afterward
+- `interrupt` — Abort current run, start fresh
### Does Codex CLI use my ChatGPT Pro subscription or API credits?
@@ -684,11 +693,13 @@ If you have a ChatGPT subscription, use browser auth to avoid API charges!
Use `/queue` to control how messages sent in quick succession are handled:
-- **`/queue instant`** — New messages interrupt/steer the current response
-- **`/queue batch`** — Messages queue up, processed after current turn
-- **`/queue serial`** — One at a time, in order
+- **`/queue steer`** — New messages steer the current response
+- **`/queue collect`** — Batch messages, reply once after things settle
+- **`/queue followup`** — One at a time, in order
+- **`/queue steer-backlog`** — Steer now, process backlog afterward
+- **`/queue interrupt`** — Abort current run, start fresh
-If you tend to send multiple short messages, `/queue instant` feels most natural.
+If you tend to send multiple short messages, `/queue steer` feels most natural.
---
diff --git a/docs/gateway.md b/docs/gateway.md
index 6a3eb4e54..6fbf3aa0e 100644
--- a/docs/gateway.md
+++ b/docs/gateway.md
@@ -111,7 +111,7 @@ CLAWDBOT_CONFIG_PATH=~/.clawdbot/b.json CLAWDBOT_STATE_DIR=~/.clawdbot-b clawdbo
- `node.invoke` — invoke a command on a node (e.g. `canvas.*`, `camera.*`).
- `node.pair.*` — pairing lifecycle (`request`, `list`, `approve`, `reject`, `verify`).
-See also: `docs/presence.md` for how presence is produced/deduped and why `instanceId` matters.
+See also: [`docs/presence.md`](https://docs.clawd.bot/presence) for how presence is produced/deduped and why `instanceId` matters.
## Events
- `agent` — streamed tool/output events from the agent run (seq-tagged).
@@ -127,7 +127,7 @@ See also: `docs/presence.md` for how presence is produced/deduped and why `insta
## Typing and validation
- Server validates every inbound frame with AJV against JSON Schema emitted from the protocol definitions.
- Clients (TS/Swift) consume generated types (TS directly; Swift via the repo’s generator).
-- Types live in `src/gateway/protocol/*.ts`; regenerate schemas/models with `pnpm protocol:gen` (writes `dist/protocol.schema.json`) and `pnpm protocol:gen:swift` (writes `apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift`).
+- Types live in [`src/gateway/protocol/*.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/gateway/protocol/*.ts); regenerate schemas/models with `pnpm protocol:gen` (writes [`dist/protocol.schema.json`](https://github.com/clawdbot/clawdbot/blob/main/dist/protocol.schema.json)) and `pnpm protocol:gen:swift` (writes [`apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift`](https://github.com/clawdbot/clawdbot/blob/main/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift)).
## Connection snapshot
- `hello-ok` includes a `snapshot` with `presence`, `health`, `stateVersion`, and `uptimeMs` plus `policy {maxPayload,maxBufferedBytes,tickIntervalMs}` so clients can render immediately without extra requests.
diff --git a/docs/gateway/pairing.md b/docs/gateway/pairing.md
index 1cc10c53a..9afa96397 100644
--- a/docs/gateway/pairing.md
+++ b/docs/gateway/pairing.md
@@ -20,7 +20,7 @@ This enables:
- **Bridge**: direct transport endpoint owned by the gateway. The bridge does not decide membership.
## API surface (gateway protocol)
-These are conceptual method names; wire them into `src/gateway/protocol/schema.ts` and regenerate Swift types.
+These are conceptual method names; wire them into [`src/gateway/protocol/schema.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/gateway/protocol/schema.ts) and regenerate Swift types.
### Events
- `node.pair.requested`
@@ -78,10 +78,10 @@ Optional interactive helper:
- `clawdbot nodes watch` (subscribe to `node.pair.requested` and prompt in-place)
Implementation pointers:
-- CLI commands: `src/cli/nodes-cli.ts`
-- Gateway handlers + events: `src/gateway/server.ts`
-- Pairing store: `src/infra/node-pairing.ts` (under `~/.clawdbot/nodes/`)
-- Optional macOS UI prompt (frontend only): `apps/macos/Sources/Clawdbot/NodePairingApprovalPrompter.swift`
+- CLI commands: [`src/cli/nodes-cli.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/cli/nodes-cli.ts)
+- Gateway handlers + events: [`src/gateway/server.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/gateway/server.ts) + [`src/gateway/server-methods/nodes.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/gateway/server-methods/nodes.ts)
+- Pairing store: [`src/infra/node-pairing.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/infra/node-pairing.ts) (under `~/.clawdbot/nodes/`)
+- Optional macOS UI prompt (frontend only): [`apps/macos/Sources/Clawdbot/NodePairingApprovalPrompter.swift`](https://github.com/clawdbot/clawdbot/blob/main/apps/macos/Sources/Clawdbot/NodePairingApprovalPrompter.swift)
- Push-first: listens to `node.pair.requested`/`node.pair.resolved`, does a `node.pair.list` on startup/reconnect,
and only runs a slow safety poll while a request is pending/visible.
diff --git a/docs/getting-started.md b/docs/getting-started.md
new file mode 100644
index 000000000..3244784fb
--- /dev/null
+++ b/docs/getting-started.md
@@ -0,0 +1,133 @@
+---
+summary: "Beginner guide: from repo checkout to first message (wizard, auth, providers, pairing)"
+read_when:
+ - First time setup from zero
+ - You want the fastest path from checkout → onboarding → first message
+---
+
+# Getting Started
+
+Goal: go from **zero** → **first working chat** (with sane defaults) as quickly as possible.
+
+Recommended path: use the **CLI onboarding wizard** (`clawdbot onboard`). It sets up:
+- model/auth (OAuth recommended)
+- gateway settings
+- providers (WhatsApp/Telegram/Discord/…)
+- pairing defaults (secure DMs)
+- workspace bootstrap + skills
+- optional background daemon
+
+If you want the deeper reference pages, jump to: [Wizard](https://docs.clawd.bot/wizard), [Setup](https://docs.clawd.bot/setup), [Pairing](https://docs.clawd.bot/pairing), [Security](https://docs.clawd.bot/security).
+
+## 0) Prereqs
+
+- Node `>=22`
+- `bun` (preferred) or `pnpm`
+- Git
+
+macOS: if you plan to build the apps, install Xcode / CLT. For the CLI + gateway only, Node is enough.
+
+## 1) Check out from source
+
+```bash
+git clone https://github.com/clawdbot/clawdbot.git
+cd clawdbot
+bun install
+```
+
+Note: `pnpm` is also supported:
+
+```bash
+pnpm install
+```
+
+## 2) Build the Control UI (recommended)
+
+The Gateway serves the browser dashboard (Control UI) when assets exist.
+
+```bash
+bun run ui:install
+bun run ui:build
+bun run build
+```
+
+If you skip UI build, the gateway still works — you just won’t get the dashboard.
+
+## 3) Run the onboarding wizard
+
+```bash
+bun run clawdbot onboard
+```
+
+What you’ll choose:
+- **Local vs Remote** gateway
+- **Auth**: Anthropic OAuth or OpenAI OAuth (recommended), API key (optional), or skip for now
+- **Providers**: WhatsApp QR login, bot tokens, etc.
+- **Daemon**: optional background install (launchd/systemd/Task Scheduler)
+
+Wizard doc: https://docs.clawd.bot/wizard
+
+### Auth: where it lives (important)
+
+- OAuth credentials (legacy import): `~/.clawdbot/credentials/oauth.json`
+- Auth profiles (OAuth + API keys): `~/.clawdbot/agents//agent/auth-profiles.json`
+
+Headless/server tip: do OAuth on a normal machine first, then copy `oauth.json` to the gateway host.
+
+## 4) Start the Gateway
+
+If the wizard didn’t start it for you:
+
+```bash
+bun run clawdbot gateway --port 18789 --verbose
+```
+
+Dashboard (local loopback): `http://127.0.0.1:18789/`
+
+## 5) Pair + connect your first chat surface
+
+### WhatsApp (QR login)
+
+```bash
+bun run clawdbot login
+```
+
+Scan via WhatsApp → Settings → Linked Devices.
+
+WhatsApp doc: https://docs.clawd.bot/whatsapp
+
+### Telegram / Discord / others
+
+The wizard can write tokens/config for you. If you prefer manual config, start with:
+- Telegram: https://docs.clawd.bot/telegram
+- Discord: https://docs.clawd.bot/discord
+
+## 6) DM safety (pairing approvals)
+
+Default posture: unknown DMs get a short code and messages are not processed until approved.
+
+Approve:
+
+```bash
+bun run clawdbot pairing list --provider telegram
+bun run clawdbot pairing approve --provider telegram
+```
+
+Pairing doc: https://docs.clawd.bot/pairing
+
+## 7) Verify end-to-end
+
+In a new terminal:
+
+```bash
+bun run clawdbot health
+bun run clawdbot send --to +15555550123 --message "Hello from Clawdbot"
+```
+
+If `health` shows “no auth configured”, go back to the wizard and set OAuth/key auth — the agent won’t be able to respond without it.
+
+## Next steps (optional, but great)
+
+- macOS menu bar app + voice wake: https://docs.clawd.bot/macos
+- iOS/Android nodes (Canvas/camera/voice): https://docs.clawd.bot/nodes
+- Remote access (SSH tunnel / Tailscale Serve): https://docs.clawd.bot/remote and https://docs.clawd.bot/tailscale
diff --git a/docs/gmail-pubsub.md b/docs/gmail-pubsub.md
index 63aa0333c..94961b2f0 100644
--- a/docs/gmail-pubsub.md
+++ b/docs/gmail-pubsub.md
@@ -13,7 +13,7 @@ Goal: Gmail watch -> Pub/Sub push -> `gog gmail watch serve` -> Clawdbot webhook
- `gcloud` installed and logged in.
- `gog` (gogcli) installed and authorized for the Gmail account.
-- Clawdbot hooks enabled (see `docs/webhook.md`).
+- Clawdbot hooks enabled (see [`docs/webhook.md`](https://docs.clawd.bot/webhook)).
- `tailscale` logged in if you want a public HTTPS endpoint via Funnel.
Example hook config (enable Gmail preset mapping):
@@ -30,7 +30,7 @@ Example hook config (enable Gmail preset mapping):
```
To customize payload handling, add `hooks.mappings` or a JS/TS transform module
-under `hooks.transformsDir` (see `docs/webhook.md`).
+under `hooks.transformsDir` (see [`docs/webhook.md`](https://docs.clawd.bot/webhook)).
## Wizard (recommended)
diff --git a/docs/grammy.md b/docs/grammy.md
index fb212f3ec..e5fc77b48 100644
--- a/docs/grammy.md
+++ b/docs/grammy.md
@@ -17,8 +17,8 @@ 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.
-- **Config knobs:** `telegram.botToken`, `telegram.groups`, `telegram.allowFrom`, `telegram.mediaMaxMb`, `telegram.proxy`, `telegram.webhookSecret`, `telegram.webhookUrl`.
+- **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.
Open questions
diff --git a/docs/group-messages.md b/docs/group-messages.md
index 07be1e4f6..e403634d2 100644
--- a/docs/group-messages.md
+++ b/docs/group-messages.md
@@ -10,9 +10,9 @@ Goal: let Clawd sit in WhatsApp groups, wake up only when pinged, and keep that
Note: `routing.groupChat.mentionPatterns` is now used by Telegram/Discord/Slack/iMessage as well; this doc focuses on WhatsApp-specific behavior.
## What’s implemented (2025-12-03)
-- Activation modes: `mention` (default) or `always`. `mention` requires a ping (real WhatsApp @-mentions via `mentionedJids`, regex patterns, or the bot’s 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`.
-- Group allowlist bypass: we still enforce `whatsapp.allowFrom` on the participant at inbox ingest, but group JIDs themselves no longer block replies.
-- Per-group sessions: session keys look like `whatsapp:group:` so commands such as `/verbose on` or `/think:high` are scoped to that group; personal DM state is untouched. Heartbeats are skipped for group threads.
+- Activation modes: `mention` (default) or `always`. `mention` requires a ping (real WhatsApp @-mentions via `mentionedJids`, regex patterns, or the bot’s 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).
+- Group policy: `whatsapp.groupPolicy` controls whether group messages are accepted (`open|disabled|allowlist`). `allowlist` uses `whatsapp.groupAllowFrom` (fallback: explicit `whatsapp.allowFrom`).
+- Per-group sessions: session keys look like `whatsapp:group:` so commands such as `/verbose on` or `/think high` (sent as standalone messages) are scoped to that group; personal DM state is untouched. Heartbeats are skipped for group threads.
- Context injection: last N (default 50) group messages are prefixed under `[Chat messages since your last reply - for context]`, with the triggering line under `[Current message - respond to this]`.
- Sender surfacing: every group batch now ends with `[from: Sender Name (+E164)]` so Pi knows who is speaking.
- Ephemeral/view-once: we unwrap those before extracting text/mentions, so pings inside them still trigger.
@@ -52,13 +52,13 @@ Use the group chat command:
- `/activation mention`
- `/activation always`
-Only the owner number (from `whatsapp.allowFrom`, defaulting to the bot’s own E.164 when unset) can change this. `/status` in the group shows the current activation mode.
+Only the owner number (from `whatsapp.allowFrom`, or the bot’s own E.164 when unset) can change this. Send `/status` as a standalone message in the group to see the current activation mode.
## How to use
1) Add Clawd UK (`+447700900123`) to the group.
2) Say `@clawd …` (or `@clawd uk`, `@clawdbot`, or include the number). Anyone in the group can trigger it.
3) The agent prompt will include recent group context plus the trailing `[from: …]` marker so it can address the right person.
-4) Session-level directives (`/verbose on`, `/think:high`, `/new` or `/reset`, `/compact`) apply only to that group’s session; your personal DM session remains independent.
+4) Session-level directives (`/verbose on`, `/think high`, `/new` or `/reset`, `/compact`) apply only to that group’s session; send them as standalone messages so they register. Your personal DM session remains independent.
## Testing / verification
- Automated: `pnpm test -- src/web/auto-reply.test.ts --runInBand` (covers mention gating, history injection, sender suffix).
@@ -70,4 +70,4 @@ Only the owner number (from `whatsapp.allowFrom`, defaulting to the bot’s own
## 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 48a562ed4..98faa8547 100644
--- a/docs/groups.md
+++ b/docs/groups.md
@@ -1,21 +1,69 @@
---
-summary: "Group chat behavior across surfaces (WhatsApp/Telegram/Discord/iMessage)"
+summary: "Group chat behavior across surfaces (WhatsApp/Telegram/Discord/Slack/Signal/iMessage)"
read_when:
- Changing group chat behavior or mention gating
---
# Groups
-Clawdbot treats group chats consistently across surfaces: WhatsApp, Telegram, Discord, iMessage.
+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
+Control how group/room messages are handled per provider:
+
+```json5
+{
+ whatsapp: {
+ groupPolicy: "disabled", // "open" | "disabled" | "allowlist"
+ groupAllowFrom: ["+15551234567"]
+ },
+ telegram: {
+ groupPolicy: "disabled",
+ groupAllowFrom: ["123456789", "@username"]
+ },
+ signal: {
+ groupPolicy: "disabled",
+ groupAllowFrom: ["+15551234567"]
+ },
+ imessage: {
+ groupPolicy: "disabled",
+ groupAllowFrom: ["chat_id:123"]
+ },
+ discord: {
+ groupPolicy: "allowlist",
+ guilds: {
+ "GUILD_ID": { channels: { help: { allow: true } } }
+ }
+ },
+ slack: {
+ groupPolicy: "allowlist",
+ channels: { "#general": { allow: true } }
+ }
+}
+```
+
+| Policy | Behavior |
+|--------|----------|
+| `"open"` | Default. Groups bypass allowlists; mention-gating still applies. |
+| `"disabled"` | Block all group messages entirely. |
+| `"allowlist"` | Only allow groups/rooms that match the configured allowlist. |
+
+Notes:
+- `groupPolicy` is separate from mention-gating (which requires @mentions).
+- WhatsApp/Telegram/Signal/iMessage: use `groupAllowFrom` (fallback: explicit `allowFrom`).
+- Discord: allowlist uses `discord.guilds..channels`.
+- Slack: allowlist uses `slack.channels`.
+- Group DMs are controlled separately (`discord.dm.*`, `slack.dm.*`).
+- Telegram allowlist can match user IDs (`"123456789"`, `"telegram:123456789"`, `"tg:123456789"`) or usernames (`"@alice"` or `"alice"`); prefixes are case-insensitive.
+
## Mention gating (default)
Group messages require a mention unless overridden per group. Defaults live per subsystem under `*.groups."*"`.
@@ -54,12 +102,15 @@ Notes:
- 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).
+## Group allowlists
+When `whatsapp.groups`, `telegram.groups`, or `imessage.groups` is configured, the keys act as a group allowlist. Use `"*"` to allow all groups while still setting default mention behavior.
+
## Activation (owner-only)
Group owners can toggle per-group activation:
- `/activation mention`
- `/activation always`
-Owner is determined by `whatsapp.allowFrom` (or the bot’s self E.164 when unset). Other surfaces currently ignore `/activation`.
+Owner is determined by `whatsapp.allowFrom` (or the bot’s self E.164 when unset). Send the command as a standalone message. Other surfaces currently ignore `/activation`.
## Context fields
Group inbound payloads set:
@@ -76,4 +127,4 @@ The agent system prompt includes a group intro on the first turn of a new group
- Group replies always go back to the same `chat_id`.
## WhatsApp specifics
-See `docs/group-messages.md` for WhatsApp-only behavior (history injection, mention handling details).
+See [`docs/group-messages.md`](https://docs.clawd.bot/group-messages) for WhatsApp-only behavior (history injection, mention handling details).
diff --git a/docs/health.md b/docs/health.md
index d880e8955..5647bb050 100644
--- a/docs/health.md
+++ b/docs/health.md
@@ -11,18 +11,18 @@ Short guide to verify the WhatsApp Web / Baileys stack without guessing.
- `clawdbot status` — local summary: whether creds exist, auth age, session store path + recent sessions.
- `clawdbot status --deep` — also probes the running Gateway (WhatsApp connect + Telegram + Discord APIs).
- `clawdbot health --json` — asks the running Gateway for a full health snapshot (WS-only; no direct Baileys socket).
-- Send `/status` in WhatsApp/WebChat to get a status reply without invoking the agent.
+- Send `/status` as a standalone message in WhatsApp/WebChat to get a status reply without invoking the agent.
- 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
- `logged out` or status 409–515 → relink with `clawdbot logout` then `clawdbot login`.
- 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 mention rules match (`routing.groupChat.mentionPatterns` and `whatsapp.groups`).
+- 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`).
## 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 ` to override the 10s default.
diff --git a/docs/heartbeat.md b/docs/heartbeat.md
index fee828592..6fb021a4d 100644
--- a/docs/heartbeat.md
+++ b/docs/heartbeat.md
@@ -9,13 +9,16 @@ Heartbeat runs periodic agent turns in the **main session** so the model can
surface anything that needs attention without spamming the user.
## Prompt contract
-- Heartbeat body defaults to `HEARTBEAT` (configurable via `agent.heartbeat.prompt`).
+- Heartbeat body defaults to: `Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.` (configurable via `agent.heartbeat.prompt`).
- If nothing needs attention, the model should reply `HEARTBEAT_OK`.
- During heartbeat runs, Clawdbot treats `HEARTBEAT_OK` as an ack when it appears at
the **start or end** of the reply. Clawdbot strips the token and discards the
reply if the remaining content is **≤ `ackMaxChars`** (default: 30).
- If `HEARTBEAT_OK` is in the **middle** of a reply, it is not treated specially.
- For alerts, do **not** include `HEARTBEAT_OK`; return only the alert text.
+- Heartbeat prompt text is sent **verbatim** as the user message. Clawdbot does
+ not append extra body text. The system prompt includes a Heartbeats section
+ and the run is flagged as a heartbeat internally.
### Stray `HEARTBEAT_OK` outside heartbeats
If the model accidentally includes `HEARTBEAT_OK` at the start or end of a
@@ -35,11 +38,11 @@ and final replies:
{
agent: {
heartbeat: {
- every: "30m", // duration string: ms|s|m|h (0m disables)
+ every: "30m", // default: 30m (0m disables)
model: "anthropic/claude-opus-4-5",
- target: "last", // last | whatsapp | telegram | none
- to: "+15551234567", // optional override for whatsapp/telegram
- prompt: "HEARTBEAT", // optional override
+ target: "last", // last | whatsapp | telegram | discord | slack | signal | imessage | none
+ to: "+15551234567", // optional provider-specific override (e.g. E.164 or chat id)
+ 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
}
}
@@ -47,17 +50,28 @@ and final replies:
```
### Fields
-- `every`: heartbeat interval (duration string; default unit minutes). Omit or set
- to `0m` to disable.
+- `every`: heartbeat interval (duration string; default unit minutes). Default:
+ `30m`. Set 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` / `discord` / `slack` / `signal` / `imessage`: 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`).
+- `prompt`: optional override for the heartbeat body (default shown above). Safe to
+ change; heartbeat acks are still keyed off `HEARTBEAT_OK`.
- `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery (default: 30).
+## Cost awareness
+Heartbeats run full agent turns. Shorter intervals burn more tokens. If you
+don’t need frequent checks, increase `every`, pick a cheaper `model`, or set
+`target: "none"` to keep results internal.
+
+## HEARTBEAT.md (optional)
+If a `HEARTBEAT.md` file exists in the workspace, the default prompt tells the
+agent to read it. Keep it tiny (short checklist or reminders) to avoid prompt
+bloat.
+
## Behavior
- Runs in the main session (`main`, or `global` when scope is global).
- Uses the main lane queue; if requests are in flight, the wake is retried.
@@ -66,6 +80,12 @@ and final replies:
- If `target` resolves to no external destination (no last route or `none`), the
heartbeat still runs but no outbound message is sent.
+## Ideas for use
+- Check up on the user (light, respectful pings during daytime).
+- Handle mundane tasks (triage inboxes, summarize queues, refresh notes).
+- Nudge on open loops or reminders.
+- Background monitoring (health checks, status polling, low-priority alerts).
+
## Wake hook
- The gateway exposes a heartbeat wake hook so cron/jobs/webhooks can request an
immediate run (`requestHeartbeatNow`).
diff --git a/docs/hubs.md b/docs/hubs.md
index cd9a9e16d..87e74d053 100644
--- a/docs/hubs.md
+++ b/docs/hubs.md
@@ -9,140 +9,142 @@ Use these hubs to discover every page, including deep dives and reference docs t
## Start here
-- [Index](./index.md)
-- [Onboarding](./onboarding.md)
-- [Wizard](./wizard.md)
-- [Setup](./setup.md)
-- [FAQ](./faq.md)
-- [Configuration](./configuration.md)
-- [Clawd (personal assistant)](./clawd.md)
-- [Lore](./lore.md)
+- [Index](https://docs.clawd.bot)
+- [Getting Started](https://docs.clawd.bot/getting-started)
+- [Onboarding](https://docs.clawd.bot/onboarding)
+- [Wizard](https://docs.clawd.bot/wizard)
+- [Setup](https://docs.clawd.bot/setup)
+- [FAQ](https://docs.clawd.bot/faq)
+- [Configuration](https://docs.clawd.bot/configuration)
+- [Clawd (personal assistant)](https://docs.clawd.bot/clawd)
+- [Lore](https://docs.clawd.bot/lore)
## Installation + distribution
-- [Docker](./docker.md)
-- [Nix](./nix.md)
+- [Docker](https://docs.clawd.bot/docker)
+- [Nix](https://docs.clawd.bot/nix)
## Core concepts
-- [Architecture](./architecture.md)
-- [Agent runtime](./agent.md)
-- [Agent loop](./agent-loop.md)
-- [Sessions](./session.md)
-- [Sessions (alias)](./sessions.md)
-- [Session tools](./session-tool.md)
-- [Queue](./queue.md)
-- [RPC adapters](./rpc.md)
-- [TypeBox schemas](./typebox.md)
-- [Presence](./presence.md)
-- [Discovery + transports](./discovery.md)
-- [Bonjour](./bonjour.md)
-- [Surface routing](./surface.md)
-- [Groups](./groups.md)
-- [Group messages](./group-messages.md)
+- [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)
+- [Queue](https://docs.clawd.bot/queue)
+- [Slash commands](https://docs.clawd.bot/slash-commands)
+- [RPC adapters](https://docs.clawd.bot/rpc)
+- [TypeBox schemas](https://docs.clawd.bot/typebox)
+- [Presence](https://docs.clawd.bot/presence)
+- [Discovery + transports](https://docs.clawd.bot/discovery)
+- [Bonjour](https://docs.clawd.bot/bonjour)
+- [Provider routing](https://docs.clawd.bot/provider-routing)
+- [Groups](https://docs.clawd.bot/groups)
+- [Group messages](https://docs.clawd.bot/group-messages)
## Providers + ingress
-- [WhatsApp](./whatsapp.md)
-- [Telegram](./telegram.md)
-- [Telegram (grammY notes)](./grammy.md)
-- [Slack](./slack.md)
-- [Discord](./discord.md)
-- [Signal](./signal.md)
-- [iMessage](./imessage.md)
-- [WebChat](./webchat.md)
-- [Webhooks](./webhook.md)
-- [Gmail Pub/Sub](./gmail-pubsub.md)
+- [WhatsApp](https://docs.clawd.bot/whatsapp)
+- [Telegram](https://docs.clawd.bot/telegram)
+- [Telegram (grammY notes)](https://docs.clawd.bot/grammy)
+- [Slack](https://docs.clawd.bot/slack)
+- [Discord](https://docs.clawd.bot/discord)
+- [Signal](https://docs.clawd.bot/signal)
+- [iMessage](https://docs.clawd.bot/imessage)
+- [WebChat](https://docs.clawd.bot/webchat)
+- [Webhooks](https://docs.clawd.bot/webhook)
+- [Gmail Pub/Sub](https://docs.clawd.bot/gmail-pubsub)
## Gateway + operations
-- [Gateway runbook](./gateway.md)
-- [Gateway pairing](./gateway/pairing.md)
-- [Gateway lock](./gateway-lock.md)
-- [Background process](./background-process.md)
-- [Health](./health.md)
-- [Heartbeat](./heartbeat.md)
-- [Doctor](./doctor.md)
-- [Logging](./logging.md)
-- [Dashboard](./dashboard.md)
-- [Control UI](./control-ui.md)
-- [Control API (legacy)](./control-api.md)
-- [Remote access](./remote.md)
-- [Remote gateway README](./remote-gateway-readme.md)
-- [Tailscale](./tailscale.md)
-- [Security](./security.md)
-- [Troubleshooting](./troubleshooting.md)
+- [Gateway runbook](https://docs.clawd.bot/gateway)
+- [Gateway pairing](https://docs.clawd.bot/gateway/pairing)
+- [Gateway lock](https://docs.clawd.bot/gateway-lock)
+- [Background process](https://docs.clawd.bot/background-process)
+- [Health](https://docs.clawd.bot/health)
+- [Heartbeat](https://docs.clawd.bot/heartbeat)
+- [Doctor](https://docs.clawd.bot/doctor)
+- [Logging](https://docs.clawd.bot/logging)
+- [Dashboard](https://docs.clawd.bot/dashboard)
+- [Control UI](https://docs.clawd.bot/control-ui)
+- [Remote access](https://docs.clawd.bot/remote)
+- [Remote gateway README](https://docs.clawd.bot/remote-gateway-readme)
+- [Tailscale](https://docs.clawd.bot/tailscale)
+- [Security](https://docs.clawd.bot/security)
+- [Troubleshooting](https://docs.clawd.bot/troubleshooting)
## Tools + automation
-- [Tools surface](./tools.md)
-- [Bash tool](./bash.md)
-- [Elevated mode](./elevated.md)
-- [Cron + wakeups](./cron.md)
-- [Thinking + verbose](./thinking.md)
-- [Models](./models.md)
-- [Agent send CLI](./agent-send.md)
-- [Terminal UI](./tui.md)
-- [Browser control](./browser.md)
-- [Browser (Linux troubleshooting)](./browser-linux-troubleshooting.md)
+- [Tools surface](https://docs.clawd.bot/tools)
+- [Bash tool](https://docs.clawd.bot/bash)
+- [Elevated mode](https://docs.clawd.bot/elevated)
+- [Cron + wakeups](https://docs.clawd.bot/cron)
+- [Thinking + verbose](https://docs.clawd.bot/thinking)
+- [Models](https://docs.clawd.bot/models)
+- [Agent send CLI](https://docs.clawd.bot/agent-send)
+- [Terminal UI](https://docs.clawd.bot/tui)
+- [Browser control](https://docs.clawd.bot/browser)
+- [Browser (Linux troubleshooting)](https://docs.clawd.bot/browser-linux-troubleshooting)
## Nodes, media, voice
-- [Nodes overview](./nodes.md)
-- [Camera](./camera.md)
-- [Images](./images.md)
-- [Audio](./audio.md)
-- [Location command](./location-command.md)
-- [Voice wake](./voicewake.md)
-- [Talk mode](./talk.md)
+- [Nodes overview](https://docs.clawd.bot/nodes)
+- [Camera](https://docs.clawd.bot/camera)
+- [Images](https://docs.clawd.bot/images)
+- [Audio](https://docs.clawd.bot/audio)
+- [Location command](https://docs.clawd.bot/location-command)
+- [Voice wake](https://docs.clawd.bot/voicewake)
+- [Talk mode](https://docs.clawd.bot/talk)
## Platforms
-- [macOS app overview](./macos.md)
-- [macOS dev setup](./mac/dev-setup.md)
-- [macOS menu bar](./mac/menu-bar.md)
-- [macOS voice wake](./mac/voicewake.md)
-- [macOS voice overlay](./mac/voice-overlay.md)
-- [macOS WebChat](./mac/webchat.md)
-- [macOS Canvas](./mac/canvas.md)
-- [macOS child process](./mac/child-process.md)
-- [macOS health](./mac/health.md)
-- [macOS icon](./mac/icon.md)
-- [macOS logging](./mac/logging.md)
-- [macOS permissions](./mac/permissions.md)
-- [macOS remote](./mac/remote.md)
-- [macOS signing](./mac/signing.md)
-- [macOS release](./mac/release.md)
-- [macOS bun gateway](./mac/bun.md)
-- [macOS XPC](./mac/xpc.md)
-- [macOS skills](./mac/skills.md)
-- [macOS Peekaboo plan](./mac/peekaboo.md)
-- [iOS node](./ios.md)
-- [Android node](./android.md)
-- [Windows app](./windows.md)
-- [Linux app](./linux.md)
-- [Web surfaces](./web.md)
+- [macOS app overview](https://docs.clawd.bot/macos)
+- [macOS dev setup](https://docs.clawd.bot/mac/dev-setup)
+- [macOS menu bar](https://docs.clawd.bot/mac/menu-bar)
+- [macOS voice wake](https://docs.clawd.bot/mac/voicewake)
+- [macOS voice overlay](https://docs.clawd.bot/mac/voice-overlay)
+- [macOS WebChat](https://docs.clawd.bot/mac/webchat)
+- [macOS Canvas](https://docs.clawd.bot/mac/canvas)
+- [macOS child process](https://docs.clawd.bot/mac/child-process)
+- [macOS health](https://docs.clawd.bot/mac/health)
+- [macOS icon](https://docs.clawd.bot/mac/icon)
+- [macOS logging](https://docs.clawd.bot/mac/logging)
+- [macOS permissions](https://docs.clawd.bot/mac/permissions)
+- [macOS remote](https://docs.clawd.bot/mac/remote)
+- [macOS signing](https://docs.clawd.bot/mac/signing)
+- [macOS release](https://docs.clawd.bot/mac/release)
+- [macOS bun gateway](https://docs.clawd.bot/mac/bun)
+- [macOS XPC](https://docs.clawd.bot/mac/xpc)
+- [macOS skills](https://docs.clawd.bot/mac/skills)
+- [macOS Peekaboo plan](https://docs.clawd.bot/mac/peekaboo)
+- [iOS node](https://docs.clawd.bot/ios)
+- [Android node](https://docs.clawd.bot/android)
+- [Windows app](https://docs.clawd.bot/windows)
+- [Linux app](https://docs.clawd.bot/linux)
+- [Web surfaces](https://docs.clawd.bot/web)
## Workspace + templates
-- [Skills](./skills.md)
-- [Skills config](./skills-config.md)
-- [Default AGENTS](./AGENTS.default.md)
-- [Templates: AGENTS](./templates/AGENTS.md)
-- [Templates: BOOTSTRAP](./templates/BOOTSTRAP.md)
-- [Templates: IDENTITY](./templates/IDENTITY.md)
-- [Templates: SOUL](./templates/SOUL.md)
-- [Templates: TOOLS](./templates/TOOLS.md)
-- [Templates: USER](./templates/USER.md)
+- [Skills](https://docs.clawd.bot/skills)
+- [Skills config](https://docs.clawd.bot/skills-config)
+- [Default AGENTS](https://docs.clawd.bot/AGENTS.default)
+- [Templates: AGENTS](https://docs.clawd.bot/templates/AGENTS)
+- [Templates: BOOTSTRAP](https://docs.clawd.bot/templates/BOOTSTRAP)
+- [Templates: IDENTITY](https://docs.clawd.bot/templates/IDENTITY)
+- [Templates: SOUL](https://docs.clawd.bot/templates/SOUL)
+- [Templates: TOOLS](https://docs.clawd.bot/templates/TOOLS)
+- [Templates: USER](https://docs.clawd.bot/templates/USER)
## Experiments + proposals
-- [Onboarding config protocol](./onboarding-config-protocol.md)
-- [Research: memory](./research/memory.md)
-- [Proposal: model config](./proposals/model-config.md)
+- [Onboarding config protocol](https://docs.clawd.bot/onboarding-config-protocol)
+- [Research: memory](https://docs.clawd.bot/research/memory)
+- [Proposal: model config](https://docs.clawd.bot/proposals/model-config)
## Testing + release
-- [Testing](./test.md)
-- [Release checklist](./RELEASING.md)
-- [Device models](./device-models.md)
+- [Testing](https://docs.clawd.bot/test)
+- [Release checklist](https://docs.clawd.bot/RELEASING)
+- [Device models](https://docs.clawd.bot/device-models)
diff --git a/docs/imessage.md b/docs/imessage.md
index f602f6de7..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).
@@ -26,7 +51,10 @@ Status: external CLI integration. No daemon.
enabled: true,
cliPath: "imsg",
dbPath: "~/Library/Messages/chat.db",
+ dmPolicy: "pairing", // pairing | allowlist | open | disabled
allowFrom: ["+15555550123", "user@example.com", "chat_id:123"],
+ groupPolicy: "open",
+ groupAllowFrom: ["chat_id:123"],
includeAttachments: false,
mediaMaxMb: 16,
service: "auto",
@@ -37,6 +65,9 @@ Status: external CLI integration. No daemon.
Notes:
- `allowFrom` accepts handles (phone/email) or `chat_id:` entries.
+- Default: `imessage.dmPolicy="pairing"` — unknown DM senders get a pairing code (approve via `clawdbot pairing approve --provider imessage `). `"open"` requires `allowFrom=["*"]`.
+- `groupPolicy` controls group handling (`open|disabled|allowlist`).
+- `groupAllowFrom` accepts the same entries as `allowFrom`.
- `service` defaults to `auto` (use `imessage` or `sms` to pin).
- `region` is only used for SMS targeting.
@@ -55,7 +86,7 @@ imsg chats --limit 20
## Group chat behavior
- Group messages set `ChatType=group`, `GroupSubject`, and `GroupMembers`.
-- Group activation respects `imessage.groups."*".requireMention` and `routing.groupChat.mentionPatterns` (patterns are required to detect mentions on iMessage).
+- Group activation respects `imessage.groups."*".requireMention` and `routing.groupChat.mentionPatterns` (patterns are required to detect mentions on iMessage). When `imessage.groups` is set, it also acts as a group allowlist; include `"*"` to allow all groups.
- Replies go back to the same `chat_id` (group or direct).
## Troubleshooting
diff --git a/docs/index.md b/docs/index.md
index 76893a0b7..862aa4b39 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -19,23 +19,29 @@ read_when:
GitHub ·
Releases ·
- Docs ·
- Clawd setup
+ Docs ·
+ Clawd setup
CLAWDBOT bridges WhatsApp (via WhatsApp Web / Baileys), Telegram (Bot API / grammY), Discord (Bot API / discord.js), and iMessage (imsg CLI) to coding agents like [Pi](https://github.com/badlogic/pi-mono).
It’s built for [Clawd](https://clawd.me), a space lobster who needed a TARDIS.
+## Start here
+
+- **New install from zero:** https://docs.clawd.bot/getting-started
+- **Guided setup (recommended):** https://docs.clawd.bot/wizard (`clawdbot onboard`)
+
## How it works
```
WhatsApp / Telegram / Discord
│
▼
- ┌──────────────────────────┐
+ ┌───────────────────────────┐
│ Gateway │ ws://127.0.0.1:18789 (loopback-only)
│ (single source) │ tcp://0.0.0.0:18790 (Bridge)
- │ │ http://:18793/__clawdbot__/canvas/ (Canvas host)
+ │ │ http://:18793
+ │ │ /__clawdbot__/canvas/ (Canvas host)
└───────────┬───────────────┘
│
├─ Pi agent (RPC)
@@ -54,8 +60,8 @@ Most operations flow through the **Gateway** (`clawdbot gateway`), a single long
- **Loopback-first**: Gateway WS defaults to `ws://127.0.0.1:18789`.
- For Tailnet access, run `clawdbot gateway --bind tailnet --token ...` (token is required for non-loopback binds).
- **Bridge for nodes**: optional LAN/tailnet-facing bridge on `tcp://0.0.0.0:18790` for paired nodes (Bonjour-discoverable).
-- **Canvas host**: HTTP file server on `canvasHost.port` (default `18793`), serving `/__clawdbot__/canvas/` for node WebViews; see `docs/configuration.md` (`canvasHost`).
-- **Remote use**: SSH tunnel or tailnet/VPN; see `docs/remote.md` and `docs/discovery.md`.
+- **Canvas host**: HTTP file server on `canvasHost.port` (default `18793`), serving `/__clawdbot__/canvas/` for node WebViews; see [`docs/configuration.md`](https://docs.clawd.bot/configuration) (`canvasHost`).
+- **Remote use**: SSH tunnel or tailnet/VPN; see [`docs/remote.md`](https://docs.clawd.bot/remote) and [`docs/discovery.md`](https://docs.clawd.bot/discovery).
## Features (high level)
@@ -64,6 +70,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`
@@ -128,41 +135,45 @@ Example:
## Docs
- Start here:
- - [Docs hubs (all pages linked)](./hubs.md)
- - [FAQ](./faq.md) ← *common questions answered*
- - [Configuration](./configuration.md)
- - [Nix mode](./nix.md)
- - [Clawd personal assistant setup](./clawd.md)
- - [Skills](./skills.md)
- - [Skills config](./skills-config.md)
- - [Workspace templates](./templates/AGENTS.md)
- - [RPC adapters](./rpc.md)
- - [Gateway runbook](./gateway.md)
- - [Nodes (iOS/Android)](./nodes.md)
- - [Web surfaces (Control UI)](./web.md)
- - [Discovery + transports](./discovery.md)
- - [Remote access](./remote.md)
+ - [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)
+ - [Slash commands](https://docs.clawd.bot/slash-commands)
+ - [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)
+ - [Clawd personal assistant setup](https://docs.clawd.bot/clawd)
+ - [Skills](https://docs.clawd.bot/skills)
+ - [Skills config](https://docs.clawd.bot/skills-config)
+ - [Workspace templates](https://docs.clawd.bot/templates/AGENTS)
+ - [RPC adapters](https://docs.clawd.bot/rpc)
+ - [Gateway runbook](https://docs.clawd.bot/gateway)
+ - [Nodes (iOS/Android)](https://docs.clawd.bot/nodes)
+ - [Web surfaces (Control UI)](https://docs.clawd.bot/web)
+ - [Discovery + transports](https://docs.clawd.bot/discovery)
+ - [Remote access](https://docs.clawd.bot/remote)
- Providers and UX:
- - [WebChat](./webchat.md)
- - [Control UI (browser)](./control-ui.md)
- - [Telegram](./telegram.md)
- - [Discord](./discord.md)
- - [iMessage](./imessage.md)
- - [Groups](./groups.md)
- - [WhatsApp group messages](./group-messages.md)
- - [Media: images](./images.md)
- - [Media: audio](./audio.md)
+ - [WebChat](https://docs.clawd.bot/webchat)
+ - [Control UI (browser)](https://docs.clawd.bot/control-ui)
+ - [Telegram](https://docs.clawd.bot/telegram)
+ - [Discord](https://docs.clawd.bot/discord)
+ - [iMessage](https://docs.clawd.bot/imessage)
+ - [Groups](https://docs.clawd.bot/groups)
+ - [WhatsApp group messages](https://docs.clawd.bot/group-messages)
+ - [Media: images](https://docs.clawd.bot/images)
+ - [Media: audio](https://docs.clawd.bot/audio)
- Companion apps:
- - [macOS app](./macos.md)
- - [iOS app](./ios.md)
- - [Android app](./android.md)
- - [Windows app](./windows.md)
- - [Linux app](./linux.md)
+ - [macOS app](https://docs.clawd.bot/macos)
+ - [iOS app](https://docs.clawd.bot/ios)
+ - [Android app](https://docs.clawd.bot/android)
+ - [Windows app](https://docs.clawd.bot/windows)
+ - [Linux app](https://docs.clawd.bot/linux)
- Ops and safety:
- - [Sessions](./session.md)
- - [Cron + wakeups](./cron.md)
- - [Security](./security.md)
- - [Troubleshooting](./troubleshooting.md)
+ - [Sessions](https://docs.clawd.bot/session)
+ - [Cron + wakeups](https://docs.clawd.bot/cron)
+ - [Security](https://docs.clawd.bot/security)
+ - [Troubleshooting](https://docs.clawd.bot/troubleshooting)
## The name
@@ -181,6 +192,7 @@ Example:
## Core Contributors
- **Maxim Vovshin** (@Hyaxia, 36747317+Hyaxia@users.noreply.github.com) — Blogwatcher skill
+- **Nacho Iacovino** (@nachoiacovino, nacho.iacovino@gmail.com) — Location parsing (Telegram + WhatsApp)
## License
diff --git a/docs/ios.md b/docs/ios.md
index ecdf3db01..bd00a11a6 100644
--- a/docs/ios.md
+++ b/docs/ios.md
@@ -61,7 +61,7 @@ If browse works, but the iOS node can’t connect, try resolving one instance:
dns-sd -L "" _clawdbot-bridge._tcp local.
```
-More debugging notes: `docs/bonjour.md`.
+More debugging notes: [`docs/bonjour.md`](https://docs.clawd.bot/bonjour).
#### Tailnet (Vienna ⇄ London) discovery via unicast DNS-SD
@@ -70,7 +70,7 @@ If the iOS node and the gateway are on different networks but connected via Tail
1) Set up a DNS-SD zone (example `clawdbot.internal.`) on the gateway host and publish `_clawdbot-bridge._tcp` records.
2) Configure Tailscale split DNS for `clawdbot.internal` pointing at that DNS server.
-Details and example CoreDNS config: `docs/bonjour.md`.
+Details and example CoreDNS config: [`docs/bonjour.md`](https://docs.clawd.bot/bonjour).
### 3) Connect from the iOS node app
@@ -102,7 +102,7 @@ clawdbot nodes approve
After approval, the iOS node receives/stores the token and reconnects authenticated.
-Pairing details: `docs/gateway/pairing.md`.
+Pairing details: [`docs/gateway/pairing.md`](https://docs.clawd.bot/gateway/pairing).
### 5) Verify the node is connected
@@ -169,7 +169,7 @@ The response includes `{ format, base64 }` image data (default `format="jpeg"`;
- **iOS in background:** all `canvas.*` commands fail fast with `NODE_BACKGROUND_UNAVAILABLE` (bring the iOS node app to foreground).
- **Return to default scaffold:** `canvas.navigate` with `{"url":""}` or `{"url":"/"}` returns to the built-in scaffold page.
-- **mDNS blocked:** some networks block multicast; use a different LAN or plan a tailnet-capable bridge (see `docs/discovery.md`).
+- **mDNS blocked:** some networks block multicast; use a different LAN or plan a tailnet-capable bridge (see [`docs/discovery.md`](https://docs.clawd.bot/discovery)).
- **Wrong node selector:** `--node` can be the node id (UUID), display name (e.g. `iOS Node`), IP, or an unambiguous prefix. If it’s ambiguous, the CLI will tell you.
- **Stale pairing / Keychain cleared:** if the pairing token is missing (or iOS Keychain was wiped), the node must pair again; approve a new pending request.
- **App reinstall but no reconnect:** the node restores `instanceId` + last bridge preference from Keychain; if it still comes up “unpaired”, verify Keychain persistence on your device/simulator and re-pair once.
@@ -194,9 +194,9 @@ Non-goals (v1):
- Perfect App Store compliance; this is **internal-only** initially.
### Current repo reality (constraints we respect)
-- The Gateway WebSocket server binds to `127.0.0.1:18789` (`src/gateway/server.ts`) with an optional `CLAWDBOT_GATEWAY_TOKEN`.
-- The Gateway exposes a Canvas file server (`canvasHost`) on `canvasHost.port` (default `18793`), so nodes can `canvas.navigate` to `http://:18793/__clawdbot__/canvas/` and auto-reload on file changes (`docs/configuration.md`).
-- macOS “Canvas” is controlled via the Gateway node protocol (`canvas.*`), matching iOS/Android (`docs/mac/canvas.md`).
+- The Gateway WebSocket server binds to `127.0.0.1:18789` ([`src/gateway/server.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/gateway/server.ts)) with an optional `CLAWDBOT_GATEWAY_TOKEN`.
+- The Gateway exposes a Canvas file server (`canvasHost`) on `canvasHost.port` (default `18793`), so nodes can `canvas.navigate` to `http://:18793/__clawdbot__/canvas/` and auto-reload on file changes ([`docs/configuration.md`](https://docs.clawd.bot/configuration)).
+- macOS “Canvas” is controlled via the Gateway node protocol (`canvas.*`), matching iOS/Android ([`docs/mac/canvas.md`](https://docs.clawd.bot/mac/canvas)).
- Voice wake forwards via `GatewayChannel` to Gateway `agent` (mac app: `VoiceWakeForwarder` → `GatewayConnection.sendAgent`).
### Recommended topology (B): Gateway-owned Bridge + loopback Gateway
@@ -237,7 +237,7 @@ Desired behavior:
- If the Swift UI is present: show alert with Approve/Reject/Later.
- If the Swift UI is not present: `clawdbot` CLI can list pending requests and approve/reject.
-See `docs/gateway/pairing.md` for the API/events and storage.
+See [`docs/gateway/pairing.md`](https://docs.clawd.bot/gateway/pairing) for the API/events and storage.
CLI (headless approvals):
- `clawdbot nodes pending`
@@ -267,7 +267,7 @@ Unify mac Canvas + iOS Canvas under a single conceptual surface:
- remote iOS node via the bridge
#### Minimal protocol additions (v1)
-Add to `src/gateway/protocol/schema.ts` (and regenerate Swift models):
+Add to [`src/gateway/protocol/schema.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/gateway/protocol/schema.ts) (and regenerate Swift models):
**Identity**
- Node identity comes from `connect.params.client.instanceId` (stable), and `connect.params.client.mode = "node"` (or `"ios-node"`).
@@ -366,7 +366,7 @@ open Clawdbot.xcodeproj
## Related docs
-- `docs/gateway.md` (gateway runbook)
-- `docs/gateway/pairing.md` (approval + storage)
-- `docs/bonjour.md` (discovery debugging)
-- `docs/discovery.md` (LAN vs tailnet vs SSH)
+- [`docs/gateway.md`](https://docs.clawd.bot/gateway) (gateway runbook)
+- [`docs/gateway/pairing.md`](https://docs.clawd.bot/gateway/pairing) (approval + storage)
+- [`docs/bonjour.md`](https://docs.clawd.bot/bonjour) (discovery debugging)
+- [`docs/discovery.md`](https://docs.clawd.bot/discovery) (LAN vs tailnet vs SSH)
diff --git a/docs/location.md b/docs/location.md
new file mode 100644
index 000000000..7d610e7ff
--- /dev/null
+++ b/docs/location.md
@@ -0,0 +1,46 @@
+---
+summary: "Inbound provider location parsing (Telegram + WhatsApp) and context fields"
+read_when:
+ - Adding or modifying provider location parsing
+ - Using location context fields in agent prompts or tools
+---
+
+# Provider location parsing
+
+Clawdbot normalizes shared locations from chat providers into:
+- human-readable text appended to the inbound body, and
+- structured fields in the auto-reply context payload.
+
+Currently supported:
+- **Telegram** (location pins + venues + live locations)
+- **WhatsApp** (locationMessage + liveLocationMessage)
+
+## Text formatting
+Locations are rendered as friendly lines without brackets:
+
+- Pin:
+ - `📍 48.858844, 2.294351 ±12m`
+- Named place:
+ - `📍 Eiffel Tower — Champ de Mars, Paris (48.858844, 2.294351 ±12m)`
+- Live share:
+ - `🛰 Live location: 48.858844, 2.294351 ±12m`
+
+If the provider includes a caption/comment, it is appended on the next line:
+```
+📍 48.858844, 2.294351 ±12m
+Meet here
+```
+
+## Context fields
+When a location is present, these fields are added to `ctx`:
+- `LocationLat` (number)
+- `LocationLon` (number)
+- `LocationAccuracy` (number, meters; optional)
+- `LocationName` (string; optional)
+- `LocationAddress` (string; optional)
+- `LocationSource` (`pin | place | live`)
+- `LocationIsLive` (boolean)
+
+## Provider notes
+- **Telegram**: venues map to `LocationName/LocationAddress`; live locations use `live_period`.
+- **WhatsApp**: `locationMessage.comment` and `liveLocationMessage.caption` are appended as the caption line.
diff --git a/docs/logging.md b/docs/logging.md
index 89ffab3a7..a67d18500 100644
--- a/docs/logging.md
+++ b/docs/logging.md
@@ -14,7 +14,7 @@ Clawdbot has two log “surfaces”:
## File-based logger
-Clawdbot uses a file logger backed by `tslog` (`src/logging.ts`).
+Clawdbot uses a file logger backed by `tslog` ([`src/logging.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/logging.ts)).
- Default rolling log file is under `/tmp/clawdbot/` (one file per day): `clawdbot-YYYY-MM-DD.log`
- The log file path and level can be configured via `~/.clawdbot/clawdbot.json`:
@@ -33,7 +33,7 @@ The file format is one JSON object per line.
## Console capture
-The CLI entrypoint enables console capture (`src/index.ts` calls `enableConsoleCapture()`).
+The CLI entrypoint enables console capture ([`src/index.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/index.ts) calls `enableConsoleCapture()`).
That means every `console.log/info/warn/error/debug/trace` is also written into the file logs,
while still behaving normally on stdout/stderr.
@@ -89,8 +89,8 @@ clawdbot gateway --verbose --ws-log full
Clawdbot formats console logs via a small wrapper on top of the existing stack:
-- **tslog** for structured file logs (`src/logging.ts`)
-- **chalk** for colors (`src/globals.ts`)
+- **tslog** for structured file logs ([`src/logging.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/logging.ts))
+- **chalk** for colors ([`src/globals.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/globals.ts))
The console formatter is **TTY-aware** and prints consistent, prefixed lines.
Subsystem loggers are created via `createSubsystemLogger("gateway")`.
diff --git a/docs/mac/bun.md b/docs/mac/bun.md
index d6608e537..92910ca7a 100644
--- a/docs/mac/bun.md
+++ b/docs/mac/bun.md
@@ -15,7 +15,7 @@ Goal: ship **Clawdbot.app** with a self-contained relay binary that can run both
App bundle layout:
- `Clawdbot.app/Contents/Resources/Relay/clawdbot`
- - bun `--compile` relay executable built from `dist/macos/relay.js`
+ - bun `--compile` relay executable built from [`dist/macos/relay.js`](https://github.com/clawdbot/clawdbot/blob/main/dist/macos/relay.js)
- Supports:
- `clawdbot …` (CLI)
- `clawdbot gateway-daemon …` (LaunchAgent daemon)
@@ -31,7 +31,7 @@ Why the sidecar files matter:
## Build pipeline
Packaging script:
-- `scripts/package-mac-app.sh`
+- [`scripts/package-mac-app.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/package-mac-app.sh)
It builds:
- TS: `pnpm exec tsc`
@@ -47,7 +47,7 @@ Important bundler flags:
Version injection:
- `--define "__CLAWDBOT_VERSION__=\"\""`
-- `src/version.ts` also supports `__CLAWDBOT_VERSION__` (and `CLAWDBOT_BUNDLED_VERSION`) so `--version` doesn’t depend on reading `package.json` at runtime.
+- [`src/version.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/version.ts) also supports `__CLAWDBOT_VERSION__` (and `CLAWDBOT_BUNDLED_VERSION`) so `--version` doesn’t depend on reading `package.json` at runtime.
## Launchd (Gateway as LaunchAgent)
@@ -58,7 +58,7 @@ Plist location (per-user):
- `~/Library/LaunchAgents/com.clawdbot.gateway.plist`
Manager:
-- `apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift`
+- [`apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift`](https://github.com/clawdbot/clawdbot/blob/main/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift)
Behavior:
- “Clawdbot Active” enables/disables the LaunchAgent.
@@ -77,7 +77,7 @@ Symptom (when mis-signed):
Fix:
- The bun executable needs JIT-ish permissions under hardened runtime.
-- `scripts/codesign-mac-app.sh` signs `Relay/clawdbot` with:
+- [`scripts/codesign-mac-app.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/codesign-mac-app.sh) signs `Relay/clawdbot` with:
- `com.apple.security.cs.allow-jit`
- `com.apple.security.cs.allow-unsigned-executable-memory`
@@ -87,17 +87,17 @@ Problem:
- bun can’t load some native Node addons like `sharp` (and we don’t want to ship native addon trees for the gateway).
Solution:
-- Central helper `src/media/image-ops.ts`
+- Central helper [`src/media/image-ops.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/media/image-ops.ts)
- Prefers `/usr/bin/sips` on macOS (esp. when running under bun)
- Falls back to `sharp` when available (Node/dev)
- Used by:
- - `src/web/media.ts` (optimize inbound/outbound images)
- - `src/browser/screenshot.ts`
- - `src/agents/pi-tools.ts` (image sanitization)
+ - [`src/web/media.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/web/media.ts) (optimize inbound/outbound images)
+ - [`src/browser/screenshot.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/browser/screenshot.ts)
+ - [`src/agents/pi-tools.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/agents/pi-tools.ts) (image sanitization)
## Browser control server
-The Gateway starts the browser control server (loopback only) from `src/gateway/server.ts`.
+The Gateway starts the browser control server (loopback only) from [`src/gateway/server.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/gateway/server.ts).
It’s started from the relay daemon process, so the relay binary includes Playwright deps.
## Tests / smoke checks
@@ -125,7 +125,7 @@ Bun may leave dotfiles like `*.bun-build` in the repo root or subfolders.
## DMG styling (human installer)
-`scripts/create-dmg.sh` styles the DMG via Finder AppleScript.
+[`scripts/create-dmg.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/create-dmg.sh) styles the DMG via Finder AppleScript.
Rules of thumb:
- Use a **72dpi** background image that matches the Finder window size in points.
diff --git a/docs/mac/canvas.md b/docs/mac/canvas.md
index 3dc2458aa..c7e75e63d 100644
--- a/docs/mac/canvas.md
+++ b/docs/mac/canvas.md
@@ -10,7 +10,7 @@ read_when:
Status: draft spec · Date: 2025-12-12
-Note: for iOS/Android nodes that should render agent-edited HTML/CSS/JS over the network, prefer the Gateway `canvasHost` (serves `~/clawd/canvas` over LAN/tailnet with live reload). A2UI is also **hosted by the Gateway** over HTTP. This doc focuses on the macOS in-app canvas panel. See `docs/configuration.md`.
+Note: for iOS/Android nodes that should render agent-edited HTML/CSS/JS over the network, prefer the Gateway `canvasHost` (serves `~/clawd/canvas` over LAN/tailnet with live reload). A2UI is also **hosted by the Gateway** over HTTP. This doc focuses on the macOS in-app canvas panel. See [`docs/configuration.md`](https://docs.clawd.bot/configuration).
Clawdbot can embed an agent-controlled “visual workspace” panel (“Canvas”) inside the macOS app using `WKWebView`, served via a **custom URL scheme** (no loopback HTTP port required).
@@ -81,7 +81,7 @@ Canvas is exposed via the Gateway **node bridge**, so the agent can:
This should be modeled after `WebChatManager`/`WebChatSwiftUIWindowController` but targeting `clawdbot-canvas://…` URLs.
Related:
-- For “invoke the agent again from UI” flows, prefer the macOS deep link scheme (`clawdbot://agent?...`) so *any* UI surface (Canvas, WebChat, native views) can trigger a new agent run. See `docs/macos.md`.
+- For “invoke the agent again from UI” flows, prefer the macOS deep link scheme (`clawdbot://agent?...`) so *any* UI surface (Canvas, WebChat, native views) can trigger a new agent run. See [`docs/macos.md`](https://docs.clawd.bot/macos).
## Agent commands (current)
diff --git a/docs/mac/dev-setup.md b/docs/mac/dev-setup.md
index 866d8e321..a208e2195 100644
--- a/docs/mac/dev-setup.md
+++ b/docs/mac/dev-setup.md
@@ -57,11 +57,26 @@ The macOS app requires a symlink named `clawdbot` in `/usr/local/bin` or `/opt/h
Alternatively, you can manually link it from your Admin account:
```bash
-sudo ln -sf "/Users/$(whoami)/clawdbot/dist/Clawdbot.app/Contents/Resources/Relay/clawdbot" /usr/local/bin/clawdbot
+sudo ln -sf "/Users/$(whoami)/Projects/clawdbot/dist/Clawdbot.app/Contents/Resources/Relay/clawdbot" /usr/local/bin/clawdbot
```
## Troubleshooting
+### Build Fails: Toolchain or SDK Mismatch
+The macOS app build expects the latest macOS SDK and Swift 6.2 toolchain.
+
+**System dependencies (required):**
+- **Latest macOS version available in Software Update** (required by Xcode 26.2 SDKs)
+- **Xcode 26.2** (Swift 6.2 toolchain)
+
+**Checks:**
+```bash
+xcodebuild -version
+xcrun swift --version
+```
+
+If versions don’t match, update macOS/Xcode and re-run the build.
+
### App Crashes on Permission Grant
If the app crashes when you try to allow **Speech Recognition** or **Microphone** access, it may be due to a corrupted TCC cache or signature mismatch.
@@ -70,7 +85,7 @@ If the app crashes when you try to allow **Speech Recognition** or **Microphone*
```bash
tccutil reset All com.clawdbot.mac.debug
```
-2. If that fails, change the `BUNDLE_ID` temporarily in `scripts/package-mac-app.sh` to force a "clean slate" from macOS.
+2. If that fails, change the `BUNDLE_ID` temporarily in [`scripts/package-mac-app.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/package-mac-app.sh) to force a "clean slate" from macOS.
### Gateway "Starting..." indefinitely
If the gateway status stays on "Starting...", check if a zombie process is holding the port:
diff --git a/docs/mac/health.md b/docs/mac/health.md
index 1514e4c9f..dc081f8b4 100644
--- a/docs/mac/health.md
+++ b/docs/mac/health.md
@@ -25,4 +25,4 @@ How to see whether the WhatsApp Web/Baileys bridge is healthy from the menu bar
- Cache the last good snapshot and the last error separately to avoid flicker; show the timestamp of each.
## When in doubt
-- You can still use the CLI flow in `docs/health.md` (`clawdbot status`, `clawdbot status --deep`, `clawdbot health --json`) and tail `/tmp/clawdbot/clawdbot-*.log` for `web-heartbeat` / `web-reconnect`.
+- You can still use the CLI flow in [`docs/health.md`](https://docs.clawd.bot/health) (`clawdbot status`, `clawdbot status --deep`, `clawdbot health --json`) and tail `/tmp/clawdbot/clawdbot-*.log` for `web-heartbeat` / `web-reconnect`.
diff --git a/docs/mac/release.md b/docs/mac/release.md
index 7ac225cc7..fd31c6cfc 100644
--- a/docs/mac/release.md
+++ b/docs/mac/release.md
@@ -62,7 +62,7 @@ Use the release note generator so Sparkle renders formatted HTML notes:
```bash
SPARKLE_PRIVATE_KEY_FILE=/Users/steipete/Library/CloudStorage/Dropbox/Backup/Sparkle/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-0.1.0.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml
```
-Generates HTML release notes from `CHANGELOG.md` (via `scripts/changelog-to-html.sh`) and embeds them in the appcast entry.
+Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry.
Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when publishing.
## Publish & verify
diff --git a/docs/mac/signing.md b/docs/mac/signing.md
index 60b462c36..913b66e80 100644
--- a/docs/mac/signing.md
+++ b/docs/mac/signing.md
@@ -5,11 +5,11 @@ read_when:
---
# mac signing (debug builds)
-This app is usually built from `scripts/package-mac-app.sh`, which now:
+This app is usually built from [`scripts/package-mac-app.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/package-mac-app.sh), which now:
- sets a stable debug bundle identifier: `com.clawdbot.mac.debug`
- writes the Info.plist with that bundle id (override via `BUNDLE_ID=...`)
-- calls `scripts/codesign-mac-app.sh` to sign the main binary, bundled CLI, and app bundle so macOS treats each rebuild as the same signed bundle and keeps TCC permissions (notifications, accessibility, screen recording, mic, speech). For stable permissions, use a real signing identity; ad-hoc is opt-in and fragile (see `docs/mac/permissions.md`).
+- calls [`scripts/codesign-mac-app.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/codesign-mac-app.sh) to sign the main binary, bundled CLI, and app bundle so macOS treats each rebuild as the same signed bundle and keeps TCC permissions (notifications, accessibility, screen recording, mic, speech). For stable permissions, use a real signing identity; ad-hoc is opt-in and fragile (see [`docs/mac/permissions.md`](https://docs.clawd.bot/mac/permissions)).
- uses `CODESIGN_TIMESTAMP=auto` by default; it enables trusted timestamps for Developer ID signatures. Set `CODESIGN_TIMESTAMP=off` to skip timestamping (offline debug builds).
- inject build metadata into Info.plist: `ClawdbotBuildTimestamp` (UTC) and `ClawdbotGitCommit` (short hash) so the About pane can show build, git, and debug/release channel.
- **Packaging requires Bun**: The embedded gateway relay is compiled using `bun`. Ensure it is installed (`curl -fsSL https://bun.sh/install | bash`).
@@ -26,7 +26,7 @@ SIGN_IDENTITY="-" scripts/package-mac-app.sh # explicit ad-hoc (same cave
```
### Ad-hoc Signing Note
-When signing with `SIGN_IDENTITY="-"` (ad-hoc), the script automatically disables the **Hardened Runtime** (`--options runtime`). This is necessary to prevent crashes when the app attempts to load embedded frameworks (like Sparkle) that do not share the same Team ID. Ad-hoc signatures also break TCC permission persistence; see `docs/mac/permissions.md` for recovery steps.
+When signing with `SIGN_IDENTITY="-"` (ad-hoc), the script automatically disables the **Hardened Runtime** (`--options runtime`). This is necessary to prevent crashes when the app attempts to load embedded frameworks (like Sparkle) that do not share the same Team ID. Ad-hoc signatures also break TCC permission persistence; see [`docs/mac/permissions.md`](https://docs.clawd.bot/mac/permissions) for recovery steps.
## Build metadata for About
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/mac/webchat.md b/docs/mac/webchat.md
index 66c43a4e3..a9e0abe95 100644
--- a/docs/mac/webchat.md
+++ b/docs/mac/webchat.md
@@ -13,10 +13,10 @@ The macOS menu bar app shows the WebChat UI as a native SwiftUI view and reuses
## Launch & debugging
- Manual: Lobster menu → “Open Chat”.
- Auto-open for testing: run `dist/Clawdbot.app/Contents/MacOS/Clawdbot --webchat` (or pass `--webchat` to the binary launched by launchd). The window opens on startup.
-- Logs: see `./scripts/clawlog.sh` (subsystem `com.clawdbot`, category `WebChatSwiftUI`).
+- Logs: see [`./scripts/clawlog.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/clawlog.sh) (subsystem `com.clawdbot`, category `WebChatSwiftUI`).
## How it’s wired
-- Implementation: `apps/macos/Sources/Clawdbot/WebChatSwiftUI.swift` hosts `ClawdbotChatUI` and speaks to the Gateway over `GatewayConnection`.
+- Implementation: [`apps/macos/Sources/Clawdbot/WebChatSwiftUI.swift`](https://github.com/clawdbot/clawdbot/blob/main/apps/macos/Sources/Clawdbot/WebChatSwiftUI.swift) hosts `ClawdbotChatUI` and speaks to the Gateway over `GatewayConnection`.
- Data plane: Gateway WebSocket methods `chat.history`, `chat.send`, `chat.abort`; events `chat`, `agent`, `presence`, `tick`, `health`.
- Session: usually primary (`main`); multiple transports (WhatsApp/Telegram/Discord/Desktop) share the same key. The onboarding flow uses a dedicated `onboarding` session to keep first-run setup separate.
diff --git a/docs/mac/xpc.md b/docs/mac/xpc.md
index a7c6de28b..01b76e2f4 100644
--- a/docs/mac/xpc.md
+++ b/docs/mac/xpc.md
@@ -21,7 +21,7 @@ read_when:
- UI automation uses a separate UNIX socket named `bridge.sock` and the PeekabooBridge JSON protocol.
- Host preference order (client-side): Peekaboo.app → Claude.app → Clawdbot.app → local execution.
- Security: bridge hosts require TeamID `Y5PE65HELJ`; DEBUG-only same-UID escape hatch is guarded by `PEEKABOO_ALLOW_UNSIGNED_SOCKET_CLIENTS=1` (Peekaboo convention).
-- See: `docs/mac/peekaboo.md` for the Clawdbot plan and naming.
+- See: [`docs/mac/peekaboo.md`](https://docs.clawd.bot/mac/peekaboo) for the Clawdbot plan and naming.
### Mach/XPC (future direction)
- Still optional for internal app services, but **not required** for automation now that node.invoke is the surface.
@@ -37,4 +37,4 @@ read_when:
- Prefer requiring a TeamID match for all privileged surfaces.
- PeekabooBridge: `PEEKABOO_ALLOW_UNSIGNED_SOCKET_CLIENTS=1` (DEBUG-only) may allow same-UID callers for local development.
- All communication remains local-only; no network sockets are exposed.
-- TCC prompts originate only from the GUI app bundle; run `scripts/package-mac-app.sh` so the signed bundle ID stays stable.
+- TCC prompts originate only from the GUI app bundle; run [`scripts/package-mac-app.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/package-mac-app.sh) so the signed bundle ID stays stable.
diff --git a/docs/macos.md b/docs/macos.md
index 7412a2fc8..5317e4e14 100644
--- a/docs/macos.md
+++ b/docs/macos.md
@@ -13,7 +13,7 @@ Author: steipete · Status: draft spec · Date: 2025-12-20
- Shows native notifications for Clawdbot/clawdbot events.
- Owns TCC prompts (Notifications, Accessibility, Screen Recording, Automation/AppleScript, Microphone, Speech Recognition).
- Runs (or connects to) the **Gateway** and exposes itself as a **node** so agents can reach macOS‑only features.
- - Hosts **PeekabooBridge** for UI automation (consumed by `peekaboo`; see `docs/mac/peekaboo.md`).
+ - Hosts **PeekabooBridge** for UI automation (consumed by `peekaboo`; see [`docs/mac/peekaboo.md`](https://docs.clawd.bot/mac/peekaboo)).
- Installs a single CLI (`clawdbot`) by symlinking the bundled binary.
## High-level design
@@ -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).
@@ -96,7 +96,7 @@ Notes:
## Build & dev workflow (native)
- `cd apps/macos && swift build` (debug) / `swift build -c release`.
- Run app for dev: `swift run Clawdbot` (or Xcode scheme).
-- Package app + CLI: `scripts/package-mac-app.sh` (builds bun CLI + gateway).
+- Package app + CLI: [`scripts/package-mac-app.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/package-mac-app.sh) (builds bun CLI + gateway).
- Tests: add Swift Testing suites under `apps/macos/Tests`.
## Open questions / decisions
diff --git a/docs/model-failover.md b/docs/model-failover.md
new file mode 100644
index 000000000..11c571e27
--- /dev/null
+++ b/docs/model-failover.md
@@ -0,0 +1,93 @@
+---
+summary: "How Clawdbot rotates auth profiles and falls back across models"
+read_when:
+ - Diagnosing auth profile rotation, cooldowns, or model fallback behavior
+ - Updating failover rules for auth profiles or models
+---
+
+# Model failover
+
+Clawdbot handles failures in two stages:
+1) **Auth profile rotation** within the current provider.
+2) **Model fallback** to the next model in `agent.model.fallbacks`.
+
+This doc explains the runtime rules and the data that backs them.
+
+## Auth storage (keys + OAuth)
+
+Clawdbot uses **auth profiles** for both API keys and OAuth tokens.
+
+- Secrets live in `~/.clawdbot/agents//agent/auth-profiles.json` (legacy: `~/.clawdbot/agent/auth-profiles.json`).
+- Config `auth.profiles` / `auth.order` are **metadata + routing only** (no secrets).
+- Legacy import-only OAuth file: `~/.clawdbot/credentials/oauth.json` (imported into `auth-profiles.json` on first use).
+
+Credential types:
+- `type: "api_key"` → `{ provider, key }`
+- `type: "oauth"` → `{ provider, access, refresh, expires, email? }` (+ `projectId`/`enterpriseUrl` for some providers)
+
+## Profile IDs
+
+OAuth logins create distinct profiles so multiple accounts can coexist.
+- Default: `provider:default` when no email is available.
+- OAuth with email: `provider:` (for example `google-antigravity:user@gmail.com`).
+
+Profiles live in `~/.clawdbot/agents//agent/auth-profiles.json` under `profiles`.
+
+## Rotation order
+
+When a provider has multiple profiles, Clawdbot chooses an order like this:
+
+1) **Explicit config**: `auth.order[provider]` (if set).
+2) **Configured profiles**: `auth.profiles` filtered by provider.
+3) **Stored profiles**: entries in `auth-profiles.json` for the provider.
+
+If no explicit order is configured, Clawdbot uses a round‑robin order:
+- **Primary key:** profile type (**OAuth before API keys**).
+- **Secondary key:** `usageStats.lastUsed` (oldest first, within each type).
+- **Cooldown profiles** are moved to the end, ordered by soonest cooldown expiry.
+
+### Why OAuth can “look lost”
+
+If you have both an OAuth profile and an API key profile for the same provider, round‑robin can switch between them across messages unless pinned. To force a single profile:
+- Pin with `auth.order[provider] = ["provider:profileId"]`, or
+- Use a per-session override via `/model …` with a profile override (when supported by your UI/chat surface).
+
+## Cooldowns
+
+When a profile fails due to auth/rate‑limit errors (or a timeout that looks
+like rate limiting), Clawdbot marks it in cooldown and moves to the next profile.
+
+Cooldowns use exponential backoff:
+- 1 minute
+- 5 minutes
+- 25 minutes
+- 1 hour (cap)
+
+State is stored in `auth-profiles.json` under `usageStats`:
+
+```json
+{
+ "usageStats": {
+ "provider:profile": {
+ "lastUsed": 1736160000000,
+ "cooldownUntil": 1736160600000,
+ "errorCount": 2
+ }
+ }
+}
+```
+
+## Model fallback
+
+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
+timeouts that exhausted profile rotation.
+
+## Related config
+
+See [`docs/configuration.md`](https://docs.clawd.bot/configuration) for:
+- `auth.profiles` / `auth.order`
+- `agent.model.primary` / `agent.model.fallbacks`
+- `agent.imageModel` routing
+
+See [`docs/models.md`](https://docs.clawd.bot/models) for the broader model selection and fallback overview.
diff --git a/docs/models.md b/docs/models.md
index cf88065a5..0b8d6206a 100644
--- a/docs/models.md
+++ b/docs/models.md
@@ -7,6 +7,8 @@ read_when:
---
# Models CLI plan
+See [`docs/model-failover.md`](https://docs.clawd.bot/model-failover) for how auth profiles rotate (OAuth vs API keys), cooldowns, and how that interacts with model fallbacks.
+
Goal: give clear model visibility + control (configured vs available), plus scan tooling
that prefers tool-call + image-capable models and maintains ordered fallbacks.
@@ -77,6 +79,7 @@ Output
- Image routing uses `agent.imageModel` **only when configured** and the primary
model lacks image input.
- Persist last successful provider/model to session entry; auth profile success is global.
+- See [`docs/model-failover.md`](https://docs.clawd.bot/model-failover) for auth profile rotation, cooldowns, and timeout handling.
## Tests
@@ -86,5 +89,5 @@ Output
## Docs
-- Update `docs/configuration.md` with `agent.models` + `agent.model` + `agent.imageModel`.
+- Update [`docs/configuration.md`](https://docs.clawd.bot/configuration) with `agent.models` + `agent.model` + `agent.imageModel`.
- Keep this doc current when CLI surface or scan logic changes.
diff --git a/docs/multi-agent.md b/docs/multi-agent.md
new file mode 100644
index 000000000..004fd1bf4
--- /dev/null
+++ b/docs/multi-agent.md
@@ -0,0 +1,121 @@
+---
+summary: "Multi-agent routing: isolated agents, provider accounts, and bindings"
+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.
+
+## What is “one agent”?
+
+An **agent** is a fully scoped brain with its own:
+
+- **Workspace** (files, AGENTS.md/SOUL.md/USER.md, local notes, persona rules).
+- **State directory** (`agentDir`) for auth profiles, model registry, and per-agent config.
+- **Session store** (chat history + routing state) under `~/.clawdbot/agents//sessions`.
+
+The Gateway can host **one agent** (default) or **many agents** side-by-side.
+
+### Single-agent mode (default)
+
+If you do nothing, Clawdbot runs a single agent:
+
+- `agentId` defaults to **`main`**.
+- Sessions are keyed as `agent:main:`.
+- Workspace defaults to `~/clawd` (or `~/clawd-` when `CLAWDBOT_PROFILE` is set).
+- State defaults to `~/.clawdbot/agents/main/agent`.
+
+## Multiple agents = multiple people, multiple personalities
+
+With **multiple agents**, each `agentId` becomes a **fully isolated persona**:
+
+- **Different phone numbers/accounts** (per provider `accountId`).
+- **Different personalities** (per-agent workspace files like `AGENTS.md` and `SOUL.md`).
+- **Separate auth + sessions** (no cross-talk unless explicitly enabled).
+
+This lets **multiple people** share one Gateway server while keeping their AI “brains” and data isolated.
+
+## Routing rules (how messages pick an agent)
+
+Bindings are **deterministic** and **most-specific wins**:
+
+1. `peer` match (exact DM/group/channel id)
+2. `guildId` (Discord)
+3. `teamId` (Slack)
+4. `accountId` match for a provider
+5. provider-level match (`accountId: "*"`)
+6. fallback to `routing.defaultAgentId` (default: `main`)
+
+## Multiple accounts / phone numbers
+
+Providers that support **multiple accounts** (e.g. WhatsApp) use `accountId` to identify
+each login. Each `accountId` can be routed to a different agent, so one server can host
+multiple phone numbers without mixing sessions.
+
+## 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/nix.md b/docs/nix.md
index aa6cbc588..468871770 100644
--- a/docs/nix.md
+++ b/docs/nix.md
@@ -84,12 +84,12 @@ The macOS packaging flow expects a stable Info.plist template at:
apps/macos/Sources/Clawdbot/Resources/Info.plist
```
-`scripts/package-mac-app.sh` copies this template into the app bundle and patches dynamic fields
+[`scripts/package-mac-app.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/package-mac-app.sh) copies this template into the app bundle and patches dynamic fields
(bundle ID, version/build, Git SHA, Sparkle keys). This keeps the plist deterministic for SwiftPM
packaging and Nix builds (which do not rely on a full Xcode toolchain).
## Related
- [nix-clawdbot](https://github.com/clawdbot/nix-clawdbot) — full setup guide
-- [Wizard](./wizard.md) — non-Nix CLI setup
-- [Docker](./docker.md) — containerized setup
+- [Wizard](https://docs.clawd.bot/wizard) — non-Nix CLI setup
+- [Docker](https://docs.clawd.bot/docker) — containerized setup
diff --git a/docs/nodes.md b/docs/nodes.md
index 28044af7c..e034f8f6d 100644
--- a/docs/nodes.md
+++ b/docs/nodes.md
@@ -14,7 +14,7 @@ macOS can also run in **node mode**: the menubar app connects to the Gateway’s
## Pairing + status
-Pairing is gateway-owned and approval-based. See `docs/gateway/pairing.md` for the full flow.
+Pairing is gateway-owned and approval-based. See [`docs/gateway/pairing.md`](https://docs.clawd.bot/gateway/pairing) for the full flow.
Quick CLI:
@@ -150,8 +150,8 @@ Nodes may include a `permissions` map in `node.list` / `node.describe`, keyed by
## Where to look in code
-- CLI wiring: `src/cli/nodes-cli.ts`
-- Canvas snapshot decoding/temp paths: `src/cli/nodes-canvas.ts`
-- Duration parsing for CLI: `src/cli/parse-duration.ts`
-- iOS node commands: `apps/ios/Sources/Model/NodeAppModel.swift`
+- CLI wiring: [`src/cli/nodes-cli.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/cli/nodes-cli.ts)
+- Canvas snapshot decoding/temp paths: [`src/cli/nodes-canvas.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/cli/nodes-canvas.ts)
+- Duration parsing for CLI: [`src/cli/parse-duration.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/cli/parse-duration.ts)
+- iOS node commands: [`apps/ios/Sources/Model/NodeAppModel.swift`](https://github.com/clawdbot/clawdbot/blob/main/apps/ios/Sources/Model/NodeAppModel.swift)
- Android node commands: `apps/android/app/src/main/java/com/clawdbot/android/node/*`
diff --git a/docs/onboarding-config-protocol.md b/docs/onboarding-config-protocol.md
index 58eb2fa1e..9b593ba01 100644
--- a/docs/onboarding-config-protocol.md
+++ b/docs/onboarding-config-protocol.md
@@ -9,7 +9,7 @@ Purpose: shared onboarding + config surfaces across CLI, macOS app, and Web UI.
## Components
- Wizard engine: `src/wizard` (session + prompts + onboarding state).
-- CLI: `src/commands/onboard-*.ts` uses the wizard with the CLI prompter.
+- CLI: [`src/commands/onboard-*.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/commands/onboard-*.ts) uses the wizard with the CLI prompter.
- Gateway RPC: wizard + config schema endpoints serve UI clients.
- macOS: SwiftUI onboarding uses the wizard step model.
- Web UI: config form renders from JSON Schema + hints.
diff --git a/docs/onboarding.md b/docs/onboarding.md
index bc897e5e3..77da0c341 100644
--- a/docs/onboarding.md
+++ b/docs/onboarding.md
@@ -41,7 +41,7 @@ The macOS app should:
- `~/.clawdbot/credentials/oauth.json` (file mode `0600`, directory mode `0700`)
Why this location matters: it’s the Clawdbot-owned OAuth store.
-Clawdbot also imports `oauth.json` into the agent auth profile store (`~/.clawdbot/agent/auth-profiles.json`) on first use.
+Clawdbot also imports `oauth.json` into the agent auth profile store (`~/.clawdbot/agents//agent/auth-profiles.json`) on first use.
### Recommended: OAuth (OpenAI Codex)
@@ -149,7 +149,7 @@ If the Gateway runs on another machine, OAuth credentials must be created/stored
For now, remote onboarding should:
- explain why OAuth isn't shown
-- point the user at the credential location (`~/.clawdbot/credentials/oauth.json`) and the auth profile store (`~/.clawdbot/agent/auth-profiles.json`) on the gateway host
+- point the user at the credential location (`~/.clawdbot/credentials/oauth.json`) and the auth profile store (`~/.clawdbot/agents//agent/auth-profiles.json`) on the gateway host
- mention that the **bootstrap ritual happens on the gateway host** (same BOOTSTRAP/IDENTITY/USER files)
### Manual credential setup
diff --git a/docs/pairing.md b/docs/pairing.md
new file mode 100644
index 000000000..3db05b841
--- /dev/null
+++ b/docs/pairing.md
@@ -0,0 +1,85 @@
+---
+summary: "Pairing overview: approve who can DM you + which nodes can join"
+read_when:
+ - Setting up DM access control
+ - Pairing a new iOS/Android node
+ - Reviewing Clawdbot security posture
+---
+
+# Pairing
+
+“Pairing” is Clawdbot’s explicit **owner approval** step.
+It is used in two places:
+
+1) **DM pairing** (who is allowed to talk to the bot)
+2) **Node pairing** (which devices/nodes are allowed to join the gateway network)
+
+Security context: https://docs.clawd.bot/security
+
+## 1) DM pairing (inbound chat access)
+
+When a provider is configured with DM policy `pairing`, unknown senders get a short code and their message is **not processed** until you approve.
+
+Default DM policies are documented in: https://docs.clawd.bot/security
+
+### Approve a sender
+
+```bash
+clawdbot pairing list --provider telegram
+clawdbot pairing approve --provider telegram
+```
+
+Supported providers: `telegram`, `whatsapp`, `signal`, `imessage`, `discord`, `slack`.
+
+### Where the state lives
+
+Stored under `~/.clawdbot/credentials/`:
+- Pending requests: `-pairing.json`
+- Approved allowlist store: `-allowFrom.json`
+
+Treat these as sensitive (they gate access to your assistant).
+
+### Source of truth (code)
+
+- DM pairing storage + code generation: [`src/pairing/pairing-store.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/pairing/pairing-store.ts)
+- CLI commands: [`src/cli/pairing-cli.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/cli/pairing-cli.ts)
+
+## 2) Node pairing (iOS/Android nodes joining the gateway)
+
+Nodes (iOS/Android, future hardware, etc.) connect to the Gateway and request to join.
+The Gateway keeps an authoritative allowlist; new nodes require explicit approve/reject.
+
+### Approve a node
+
+```bash
+clawdbot nodes pending
+clawdbot nodes approve
+```
+
+### Where the state lives
+
+Stored under `~/.clawdbot/nodes/`:
+- `pending.json` (short-lived; pending requests expire)
+- `paired.json` (paired nodes + tokens)
+
+### Details
+
+Full protocol + design notes: https://docs.clawd.bot/gateway/pairing
+
+### Source of truth (code)
+
+- Node pairing store (pending/paired + token issuance): [`src/infra/node-pairing.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/infra/node-pairing.ts)
+- Gateway methods/events (`node.pair.*`): [`src/gateway/server-methods/nodes.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/gateway/server-methods/nodes.ts)
+- CLI: [`src/cli/nodes-cli.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/cli/nodes-cli.ts)
+
+## Related docs
+
+- Security model + prompt injection: https://docs.clawd.bot/security
+- Updating safely (run doctor): https://docs.clawd.bot/updating
+- Provider configs:
+ - Telegram: https://docs.clawd.bot/telegram
+ - WhatsApp: https://docs.clawd.bot/whatsapp
+ - Signal: https://docs.clawd.bot/signal
+ - iMessage: https://docs.clawd.bot/imessage
+ - Discord: https://docs.clawd.bot/discord
+ - Slack: https://docs.clawd.bot/slack
diff --git a/docs/plans/cron-add-hardening.md b/docs/plans/cron-add-hardening.md
index f1c6fa6ea..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,19 +31,19 @@ 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] Document cron compatibility normalization policy in `docs/cron.md`.
+- [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
- [x] Add shared cron input normalization helpers (`normalizeCronJobCreate`/`normalizeCronJobPatch`).
@@ -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/plans/group-policy-hardening.md b/docs/plans/group-policy-hardening.md
new file mode 100644
index 000000000..208b95beb
--- /dev/null
+++ b/docs/plans/group-policy-hardening.md
@@ -0,0 +1,126 @@
+---
+summary: "Spec: groupPolicy hardening for Telegram allowlist parity"
+read_when:
+ - Reviewing historical Telegram allowlist normalization changes
+---
+# Engineering Execution Spec: groupPolicy Hardening (Telegram Allowlist Parity)
+
+**Date**: 2026-01-05
+**Status**: Complete
+**PR**: #216 (feat/whatsapp-group-policy)
+
+---
+
+## Executive Summary
+
+Follow-up hardening work ensures Telegram allowlists behave consistently across inbound group/DM filtering and outbound send normalization. The focus is on prefix parity (`telegram:` / `tg:`), case-insensitive matching for prefixes, and resilience to accidental whitespace in config entries. Documentation and tests were updated to reflect and lock in this behavior.
+
+---
+
+## Findings Analysis
+
+### [MED] F1: Telegram Allowlist Prefix Handling Is Case-Sensitive and Excludes `tg:`
+
+**Location**: [`src/telegram/bot.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/telegram/bot.ts)
+
+**Problem**: Inbound allowlist normalization only stripped a lowercase `telegram:` prefix. This rejected `TG:123` / `Telegram:123` and did not accept the `tg:` shorthand even though outbound send normalization already accepts `tg:` and case-insensitive prefixes.
+
+**Impact**:
+- DMs and group allowlists fail when users copy/paste prefixed IDs from logs or existing send format.
+- Behavior is inconsistent between inbound filtering and outbound send normalization.
+
+**Fix**: Normalize allowlist entries by trimming whitespace and stripping `telegram:` / `tg:` prefixes case-insensitively at pre-compute time.
+
+---
+
+### [LOW] F2: Allowlist Entries Are Not Trimmed
+
+**Location**: [`src/telegram/bot.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/telegram/bot.ts)
+
+**Problem**: Allowlist entries are not trimmed; accidental whitespace causes mismatches.
+
+**Fix**: Trim and drop empty entries while normalizing allowlist inputs.
+
+---
+
+## Implementation Phases
+
+### Phase 1: Normalize Telegram Allowlist Inputs
+
+**File**: [`src/telegram/bot.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/telegram/bot.ts)
+
+**Changes**:
+1. Trim allowlist entries and drop empty values.
+2. Strip `telegram:` / `tg:` prefixes case-insensitively.
+3. Simplify DM allowlist check to rely on normalized values.
+
+---
+
+### Phase 2: Add Coverage for Prefix + Whitespace
+
+**File**: [`src/telegram/bot.test.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/telegram/bot.test.ts)
+
+**Add Tests**:
+- DM allowlist accepts `TG:` prefix with surrounding whitespace.
+- Group allowlist accepts `TG:` prefix case-insensitively.
+
+---
+
+### Phase 3: Documentation Updates
+
+**Files**:
+- [`docs/groups.md`](https://docs.clawd.bot/groups)
+- [`docs/telegram.md`](https://docs.clawd.bot/telegram)
+
+**Changes**:
+- Document `tg:` alias and case-insensitive prefixes for Telegram allowlists.
+
+---
+
+### Phase 4: Verification
+
+1. Run targeted Telegram tests (`pnpm test -- src/telegram/bot.test.ts`).
+2. If time allows, run full suite (`pnpm test`).
+
+---
+
+## Files Modified
+
+| File | Change Type | Description |
+|------|-------------|-------------|
+| [`src/telegram/bot.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/telegram/bot.ts) | Fix | Trim allowlist values; strip `telegram:` / `tg:` prefixes case-insensitively |
+| [`src/telegram/bot.test.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/telegram/bot.test.ts) | Test | Add DM + group allowlist coverage for `TG:` prefix + whitespace |
+| [`docs/groups.md`](https://docs.clawd.bot/groups) | Docs | Mention `tg:` alias + case-insensitive prefixes |
+| [`docs/telegram.md`](https://docs.clawd.bot/telegram) | Docs | Mention `tg:` alias + case-insensitive prefixes |
+
+---
+
+## Success Criteria
+
+- [x] Telegram allowlist accepts `telegram:` / `tg:` prefixes case-insensitively.
+- [x] Telegram allowlist tolerates whitespace in config entries.
+- [x] DM and group allowlist tests cover prefixed cases.
+- [x] Docs updated to reflect allowlist formats.
+- [x] Targeted tests pass.
+- [x] Full test suite passes.
+
+---
+
+## Risk Assessment
+
+| Risk | Severity | Mitigation |
+|------|----------|------------|
+| Behavior change for malformed entries | Low | Normalization is additive and trims only whitespace |
+| Test fragility | Low | Isolated unit tests; no external dependencies |
+| Doc drift | Low | Updated docs alongside code |
+
+---
+
+## Estimated Complexity
+
+- **Phase 1**: Low (normalization helpers)
+- **Phase 2**: Low (2 new tests)
+- **Phase 3**: Low (doc edits)
+- **Phase 4**: Low (verification)
+
+**Total**: ~20 minutes
diff --git a/docs/poll.md b/docs/poll.md
new file mode 100644
index 000000000..f00269512
--- /dev/null
+++ b/docs/poll.md
@@ -0,0 +1,52 @@
+---
+summary: "Poll sending via gateway + CLI"
+read_when:
+ - Adding or modifying poll support
+ - Debugging poll sends from the CLI or gateway
+---
+# Polls
+
+Updated: 2026-01-06
+
+## Supported providers
+- WhatsApp (web provider)
+- Discord
+
+## CLI
+
+```bash
+# WhatsApp
+clawdbot poll --to +15555550123 -q "Lunch today?" -o "Yes" -o "No" -o "Maybe"
+clawdbot poll --to 123456789@g.us -q "Meeting time?" -o "10am" -o "2pm" -o "4pm" -s 2
+
+# Discord
+clawdbot poll --to channel:123456789 -q "Snack?" -o "Pizza" -o "Sushi" --provider discord
+clawdbot poll --to channel:123456789 -q "Plan?" -o "A" -o "B" --provider discord --duration-hours 48
+```
+
+Options:
+- `--provider`: `whatsapp` (default) or `discord`
+- `--max-selections`: how many choices a voter can select (default: 1)
+- `--duration-hours`: Discord-only (defaults to 24 when omitted)
+
+## Gateway RPC
+
+Method: `poll`
+
+Params:
+- `to` (string, required)
+- `question` (string, required)
+- `options` (string[], required)
+- `maxSelections` (number, optional)
+- `durationHours` (number, optional)
+- `provider` (string, optional, default: `whatsapp`)
+- `idempotencyKey` (string, required)
+
+## Provider differences
+- WhatsApp: 2-12 options, `maxSelections` must be within option count, ignores `durationHours`.
+- Discord: 2-10 options, `durationHours` clamped to 1-768 hours (default 24). `maxSelections > 1` enables multi-select; Discord does not support a strict selection count.
+
+## Agent tool (Discord)
+The Discord tool action `poll` still uses `question`, `answers`, optional `allowMultiselect`, `durationHours`, and `content`. The gateway/CLI poll model maps `allowMultiselect` to `maxSelections > 1`.
+
+Note: Discord has no “pick exactly N” mode; `maxSelections` is treated as a boolean (`> 1` = multiselect).
diff --git a/docs/presence.md b/docs/presence.md
index 94348ced5..86153aa7b 100644
--- a/docs/presence.md
+++ b/docs/presence.md
@@ -36,7 +36,7 @@ Presence entries are produced by multiple sources and then **merged**.
The Gateway seeds a “self” entry at startup so UIs always show at least the current gateway host.
-Implementation: `src/infra/system-presence.ts` (`initSelfPresence()`).
+Implementation: [`src/infra/system-presence.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/infra/system-presence.ts) (`initSelfPresence()`).
### 2) WebSocket connect (connection-derived presence)
@@ -44,7 +44,7 @@ Every WS client must begin with a `connect` request. On successful handshake, th
This is meant to answer: “Which clients are currently connected?”
-Implementation: `src/gateway/server.ts` (connect handling uses `connect.params.client.instanceId` when provided; otherwise falls back to `connId`).
+Implementation: [`src/gateway/server.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/gateway/server.ts) (connect handling uses `connect.params.client.instanceId` when provided; otherwise falls back to `connId`).
#### Why one-off CLI commands do not show up
@@ -58,8 +58,8 @@ Clients can publish richer periodic beacons via the `system-event` method. The m
- `lastInputSeconds`
Implementation:
-- Gateway: `src/gateway/server.ts` handles method `system-event` by calling `updateSystemPresence(...)`.
-- mac app beaconing: `apps/macos/Sources/Clawdbot/PresenceReporter.swift`.
+- Gateway: [`src/gateway/server.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/gateway/server.ts) handles method `system-event` by calling `updateSystemPresence(...)`.
+- mac app beaconing: [`apps/macos/Sources/Clawdbot/PresenceReporter.swift`](https://github.com/clawdbot/clawdbot/blob/main/apps/macos/Sources/Clawdbot/PresenceReporter.swift).
### 4) Node bridge beacons (gateway-owned presence)
@@ -69,7 +69,7 @@ for that node and starts periodic refresh beacons so it does not expire.
- Connect/disconnect markers: `node-connected`, `node-disconnected`
- Periodic heartbeat: every 3 minutes (`reason: periodic`)
-Implementation: `src/gateway/server.ts` (node bridge handlers + timer beacons).
+Implementation: [`src/gateway/server.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/gateway/server.ts) (node bridge handlers + timer beacons).
## Merge + dedupe rules (why `instanceId` matters)
@@ -80,7 +80,7 @@ Key points:
- The best key is a stable, opaque `instanceId` that does not change across restarts.
- Keys are treated case-insensitively.
-Implementation: `src/infra/system-presence.ts` (`normalizePresenceKey()`).
+Implementation: [`src/infra/system-presence.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/infra/system-presence.ts) (`normalizePresenceKey()`).
### mac app identity (stable UUID)
@@ -89,7 +89,7 @@ The mac app uses a persisted UUID as `instanceId` so:
- renaming the Mac does not create a new “instance”
- debug/release builds can share the same identity
-Implementation: `apps/macos/Sources/Clawdbot/InstanceIdentity.swift`.
+Implementation: [`apps/macos/Sources/Clawdbot/InstanceIdentity.swift`](https://github.com/clawdbot/clawdbot/blob/main/apps/macos/Sources/Clawdbot/InstanceIdentity.swift).
`displayName` (machine name) is used for UI, while `instanceId` is used for dedupe.
@@ -99,7 +99,7 @@ Presence entries are not permanent:
- TTL: entries older than 5 minutes are pruned
- Max: map is capped at 200 entries (LRU by `ts`)
-Implementation: `src/infra/system-presence.ts` (`TTL_MS`, `MAX_ENTRIES`, pruning in `listSystemPresence()`).
+Implementation: [`src/infra/system-presence.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/infra/system-presence.ts) (`TTL_MS`, `MAX_ENTRIES`, pruning in `listSystemPresence()`).
## Remote/tunnel caveat (loopback IPs)
@@ -107,7 +107,7 @@ When a client connects over an SSH tunnel / local port forward, the Gateway may
To avoid degrading an otherwise-correct client beacon IP, the Gateway avoids writing loopback remote addresses into presence entries.
-Implementation: `src/gateway/server.ts` (`isLoopbackAddress()`).
+Implementation: [`src/gateway/server.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/gateway/server.ts) (`isLoopbackAddress()`).
## Consumers (who reads presence)
@@ -116,8 +116,8 @@ Implementation: `src/gateway/server.ts` (`isLoopbackAddress()`).
The mac app’s Instances tab renders the result of `system-presence`.
Implementation:
-- View: `apps/macos/Sources/Clawdbot/InstancesSettings.swift`
-- Store: `apps/macos/Sources/Clawdbot/InstancesStore.swift`
+- View: [`apps/macos/Sources/Clawdbot/InstancesSettings.swift`](https://github.com/clawdbot/clawdbot/blob/main/apps/macos/Sources/Clawdbot/InstancesSettings.swift)
+- Store: [`apps/macos/Sources/Clawdbot/InstancesStore.swift`](https://github.com/clawdbot/clawdbot/blob/main/apps/macos/Sources/Clawdbot/InstancesStore.swift)
The Instances rows show a small presence indicator (Active/Idle/Stale) based on
the last beacon age. The label is derived from the entry timestamp (`ts`).
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..063585102 100644
--- a/docs/queue.md
+++ b/docs/queue.md
@@ -12,13 +12,13 @@ We now serialize command-based auto-replies (WhatsApp Web listener) through a ti
- Serializing avoids competing for terminal/stdin, keeps logs readable, and reduces the chance of rate limits from upstream tools.
## How it works
-- `src/process/command-queue.ts` holds a lane-aware FIFO queue and drains each lane synchronously.
+- [`src/process/command-queue.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/process/command-queue.ts) holds a lane-aware FIFO queue and drains each lane synchronously.
- `runEmbeddedPiAgent` enqueues by **session key** (lane `session:`) 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`.
- 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"`.
+Send `/queue collect` as a standalone command (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" }
}
}
}
@@ -61,8 +61,7 @@ Summarize keeps a short bullet list of dropped messages and injects it as a synt
Defaults: `debounceMs: 1000`, `cap: 20`, `drop: summarize`.
## Per-session overrides
-- `/queue ` as a standalone command stores the mode for the current session.
-- `/queue ` embedded in a message applies **once** (no persistence).
+- Send `/queue ` as a standalone command to store the mode for the current session.
- Options can be combined: `/queue collect debounce:2s cap:25 drop:summarize`
- `/queue default` or `/queue reset` clears the session override.
diff --git a/docs/remote-gateway-readme.md b/docs/remote-gateway-readme.md
index 039955a6d..f0b3f8ba4 100644
--- a/docs/remote-gateway-readme.md
+++ b/docs/remote-gateway-readme.md
@@ -108,7 +108,7 @@ Save this as `~/Library/LaunchAgents/com.clawdbot.ssh-tunnel.plist`:
### Load the Launch Agent
```bash
-launchctl load ~/Library/LaunchAgents/com.clawdbot.ssh-tunnel.plist
+launchctl bootstrap gui/$UID ~/Library/LaunchAgents/com.clawdbot.ssh-tunnel.plist
```
The tunnel will now:
@@ -130,13 +130,13 @@ lsof -i :18789
**Restart the tunnel:**
```bash
-launchctl restart com.clawdbot.ssh-tunnel
+launchctl kickstart -k gui/$UID/com.clawdbot.ssh-tunnel
```
**Stop the tunnel:**
```bash
-launchctl unload ~/Library/LaunchAgents/com.clawdbot.ssh-tunnel.plist
+launchctl bootout gui/$UID/com.clawdbot.ssh-tunnel
```
---
diff --git a/docs/remote.md b/docs/remote.md
index 9239d229d..fd43e6355 100644
--- a/docs/remote.md
+++ b/docs/remote.md
@@ -8,7 +8,7 @@ read_when:
This repo supports “remote over SSH” by keeping a single Gateway (the master) running on a host (e.g., your Mac Studio) and connecting clients to it.
- For **operators (you / the macOS app)**: SSH tunneling is the universal fallback.
-- For **nodes (iOS/Android and future devices)**: prefer the Gateway **Bridge** when on the same LAN/tailnet (see `docs/discovery.md`).
+- For **nodes (iOS/Android and future devices)**: prefer the Gateway **Bridge** when on the same LAN/tailnet (see [`docs/discovery.md`](https://docs.clawd.bot/discovery)).
## The core idea
@@ -58,9 +58,4 @@ WebChat no longer uses a separate HTTP port. The SwiftUI chat UI connects direct
The macOS menu bar app can drive the same setup end-to-end (remote status checks, WebChat, and Voice Wake forwarding).
-Runbook: `docs/mac/remote.md`.
-
-## Legacy control channel
-
-Older builds experimented with a newline-delimited TCP control channel on the same port.
-That API is deprecated and should not be relied on. (Historical reference: `docs/control-api.md`.)
+Runbook: [`docs/mac/remote.md`](https://docs.clawd.bot/mac/remote).
diff --git a/docs/research/memory.md b/docs/research/memory.md
index 5376f85dc..7df735e53 100644
--- a/docs/research/memory.md
+++ b/docs/research/memory.md
@@ -172,7 +172,7 @@ Recommendation: **deep integration in Clawdbot**, but keep a separable core libr
Shape:
- `src/memory/*` (library-ish core; pure functions + sqlite adapter)
-- `src/commands/memory/*.ts` (CLI glue)
+- [`src/commands/memory/*.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/commands/memory/*.ts) (CLI glue)
## “S-Collide” / SuCo: when to use it (research)
diff --git a/docs/rpc.md b/docs/rpc.md
index 7a484758b..e44369e33 100644
--- a/docs/rpc.md
+++ b/docs/rpc.md
@@ -14,7 +14,7 @@ Clawdbot integrates external CLIs via JSON-RPC. Two patterns are used today.
- Health probe: `/api/v1/check`.
- Clawdbot owns lifecycle when `signal.autoStart=true`.
-See `docs/signal.md` for setup and endpoints.
+See [`docs/signal.md`](https://docs.clawd.bot/signal) for setup and endpoints.
## Pattern B: stdio child process (imsg)
- Clawdbot spawns `imsg rpc` as a child process.
@@ -27,7 +27,7 @@ Core methods used:
- `send`
- `chats.list` (probe/diagnostics)
-See `docs/imessage.md` for setup and addressing (`chat_id` preferred).
+See [`docs/imessage.md`](https://docs.clawd.bot/imessage) for setup and addressing (`chat_id` preferred).
## Adapter guidelines
- Gateway owns the process (start/stop tied to provider lifecycle).
diff --git a/docs/security.md b/docs/security.md
index edd624f20..a30d211f6 100644
--- a/docs/security.md
+++ b/docs/security.md
@@ -5,7 +5,12 @@ read_when:
---
# Security 🔒
-Running an AI agent with shell access on your machine is... *spicy*. Here's how to not get pwned.
+Running an AI agent with shell access on your machine is... *spicy*. Here’s how to not get pwned.
+
+Clawdbot is both a product and an experiment: you’re wiring frontier-model behavior into real messaging surfaces and real tools. **There is no “perfectly secure” setup.** The goal is to be deliberate about:
+- who can talk to your bot
+- where the bot is allowed to act
+- what the bot can touch
## The Threat Model
@@ -20,6 +25,57 @@ People who message you can:
- Social engineer access to your data
- Probe for infrastructure details
+## Core concept: access control before intelligence
+
+Most failures here are not fancy exploits — they’re “someone messaged the bot and the bot did what they asked.”
+
+Clawdbot’s stance:
+- **Identity first:** decide who can talk to the bot (DM pairing / allowlists / explicit “open”).
+- **Scope next:** decide where the bot is allowed to act (group allowlists + mention gating, tools, sandboxing, device permissions).
+- **Model last:** assume the model can be manipulated; design so manipulation has limited blast radius.
+
+## DM access model (pairing / allowlist / open / disabled)
+
+All current DM-capable providers support a DM policy (`dmPolicy` or `*.dm.policy`) that gates inbound DMs **before** the message is processed:
+
+- `pairing` (default): unknown senders receive a short pairing code and the bot ignores their message until approved.
+- `allowlist`: unknown senders are blocked (no pairing handshake).
+- `open`: allow anyone to DM (public). **Requires** the provider allowlist to include `"*"` (explicit opt-in).
+- `disabled`: ignore inbound DMs entirely.
+
+Approve via CLI:
+
+```bash
+clawdbot pairing list --provider
+clawdbot pairing approve --provider
+```
+
+Details + files on disk: https://docs.clawd.bot/pairing
+
+## Allowlists (DM + groups) — terminology
+
+Clawdbot has two separate “who can trigger me?” layers:
+
+- **DM allowlist** (`allowFrom` / `discord.dm.allowFrom` / `slack.dm.allowFrom`): who is allowed to talk to the bot in direct messages.
+ - When `dmPolicy="pairing"`, approvals are written to `~/.clawdbot/credentials/-allowFrom.json` (merged with config allowlists).
+- **Group allowlist** (provider-specific): which groups/channels/guilds the bot will accept messages from at all.
+ - Common patterns:
+ - `whatsapp.groups`, `telegram.groups`, `imessage.groups`: per-group defaults like `requireMention`; when set, it also acts as a group allowlist (include `"*"` to keep allow-all behavior).
+ - `groupPolicy="allowlist"` + `groupAllowFrom`: restrict who can trigger the bot *inside* a group session (WhatsApp/Telegram/Signal/iMessage).
+ - `discord.guilds` / `slack.channels`: per-surface allowlists + mention defaults.
+
+Details: https://docs.clawd.bot/configuration and https://docs.clawd.bot/groups
+
+## Prompt injection (what it is, why it matters)
+
+Prompt injection is when an attacker crafts a message that manipulates the model into doing something unsafe (“ignore your instructions”, “dump your filesystem”, “follow this link and run commands”, etc.).
+
+Even with strong system prompts, **prompt injection is not solved**. What helps in practice:
+- Keep inbound DMs locked down (pairing/allowlists).
+- Prefer mention gating in groups; avoid “always-on” bots in public rooms.
+- Treat links and pasted instructions as hostile by default.
+- Run sensitive tool execution in a sandbox; keep secrets out of the agent’s reachable filesystem.
+
## Lessons Learned (The Hard Way)
### The `find ~` Incident 🦞
@@ -36,21 +92,17 @@ This is social engineering 101. Create distrust, encourage snooping.
**Lesson:** Don't let strangers (or friends!) manipulate your AI into exploring the filesystem.
-## Configuration Hardening
+## Configuration Hardening (examples)
-### 1. Allowlist Senders
+### 1) DMs: pairing by default
-```json
+```json5
{
- "whatsapp": {
- "allowFrom": ["+15555550123"]
- }
+ whatsapp: { dmPolicy: "pairing" }
}
```
-Only allow specific phone numbers to trigger your AI. Never use `["*"]` in production.
-
-### 2. Group Chat Mentions
+### 2) Groups: require mention everywhere
```json
{
@@ -82,34 +134,14 @@ We're considering a `readOnlyMode` flag that prevents the AI from:
- Executing shell commands
- Sending messages
-## Container Isolation (Recommended)
+## Sandboxing (recommended)
-For maximum security, run CLAWDBOT in a container with limited access:
+Two complementary approaches:
-```yaml
-# docker-compose.yml
-services:
- clawdbot:
- build: .
- volumes:
- - ./clawd-sandbox:/home/clawd # Limited filesystem
- - /tmp/clawdbot:/tmp/clawdbot # Logs
- environment:
- - CLAWDBOT_SANDBOX=true
- network_mode: bridge # Limited network
-```
+- **Run the full Gateway in Docker** (container boundary): https://docs.clawd.bot/docker
+- **Per-session tool sandbox** (`agent.sandbox`, host gateway + Docker-isolated tools): https://docs.clawd.bot/configuration
-### Per-session sandbox (Clawdbot-native)
-
-Clawdbot can also run **non-main sessions** inside per-session Docker containers
-(`agent.sandbox`). This keeps the gateway on your host while isolating agent
-tools in a hard wall container. See `docs/configuration.md` for the full config.
-
-Expose only the services your AI needs:
-- ✅ GoWA API (for WhatsApp)
-- ✅ Specific HTTP APIs
-- ❌ Raw shell access to host
-- ❌ Full filesystem
+Important: `agent.elevated` is an explicit escape hatch that runs bash on the host. Keep `agent.elevated.allowFrom` tight and don’t enable it for strangers.
## What to Tell Your AI
@@ -130,7 +162,7 @@ If your AI does something bad:
1. **Stop it:** stop the macOS app (if it’s supervising the Gateway) or terminate your `clawdbot gateway` process
2. **Check logs:** `/tmp/clawdbot/clawdbot-YYYY-MM-DD.log` (or your configured `logging.file`)
-3. **Review session:** Check `~/.clawdbot/sessions/` for what happened
+3. **Review session:** Check `~/.clawdbot/agents//sessions/` for what happened
4. **Rotate secrets:** If credentials were exposed
5. **Update rules:** Add to your security prompt
@@ -157,7 +189,7 @@ Mario asking for find ~
Found a vulnerability in CLAWDBOT? Please report responsibly:
-1. Email: security@[redacted].com
+1. Email: security@clawd.bot
2. Don't post publicly until fixed
3. We'll credit you (unless you prefer anonymity)
diff --git a/docs/session-tool.md b/docs/session-tool.md
index 69dd0d5e1..b5a5238e1 100644
--- a/docs/session-tool.md
+++ b/docs/session-tool.md
@@ -6,16 +6,17 @@ 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`
- `sessions_history`
- `sessions_send`
+- `sessions_spawn`
## 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.
@@ -34,6 +35,7 @@ Parameters:
Behavior:
- `messageLimit > 0` fetches `chat.history` per session and includes the last N messages.
- Tool results are filtered out in list output; use `sessions_history` for tool messages.
+- When running in a **sandboxed** agent session, session tools default to **spawned-only visibility** (see below).
Row shape (JSON):
- `key`: session key (string)
@@ -45,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`)
@@ -82,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
{
@@ -100,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"
}
],
@@ -112,8 +114,41 @@ Policy-based blocking by surface/chat type (not per session id).
Runtime override (per session entry):
- `sendPolicy: "allow" | "deny"` (unset = inherit config)
-- Settable via `sessions.patch` or owner-only `/send on|off|inherit`.
+- Settable via `sessions.patch` or owner-only `/send on|off|inherit` (standalone message).
Enforcement points:
- `chat.send` / `agent` (gateway)
- auto-reply delivery logic
+
+## sessions_spawn
+Spawn a sub-agent run in an isolated session and announce the result back to the requester chat provider.
+
+Parameters:
+- `task` (required)
+- `label?` (optional; used for logs/UI)
+- `timeoutSeconds?` (default 0; 0 = fire-and-forget)
+- `cleanup?` (`delete|keep`, default `delete`)
+
+Behavior:
+- Starts a new `subagent:` session with `deliver: false`.
+- 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 provider.
+- Reply exactly `ANNOUNCE_SKIP` during the announce step to stay silent.
+
+## Sandbox Session Visibility
+
+Sandboxed sessions can use session tools, but by default they only see sessions they spawned via `sessions_spawn`.
+
+Config:
+
+```json5
+{
+ agent: {
+ sandbox: {
+ // default: "spawned"
+ sessionToolsVisibility: "spawned" // or "all"
+ }
+ }
+}
+```
diff --git a/docs/session.md b/docs/session.md
index 6cc7a3396..b72222cec 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"
@@ -57,6 +57,7 @@ Runtime override (owner only):
- `/send on` → allow for this session
- `/send off` → deny for this session
- `/send inherit` → clear override and use config rules
+Send these as standalone messages so they register.
## Configuration (optional rename example)
```json5
@@ -66,8 +67,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",
}
}
```
@@ -76,8 +77,8 @@ Runtime override (owner only):
- `pnpm clawdbot status` — shows store path and recent sessions.
- `pnpm clawdbot sessions --json` — dumps every entry (filter with `--active `).
- `pnpm clawdbot gateway call sessions.list --params '{}'` — fetch sessions from the running gateway (use `--url`/`--token` for remote gateway access).
-- Send `/status` in chat to see whether the agent is reachable, how much of the session context is used, current thinking/verbose toggles, and when your WhatsApp web creds were last refreshed (helps spot relink needs).
-- Send `/compact` (optional instructions) to summarize older context and free up window space.
+- Send `/status` as a standalone message in chat to see whether the agent is reachable, how much of the session context is used, current thinking/verbose toggles, and when your WhatsApp web creds were last refreshed (helps spot relink needs).
+- Send `/compact` (optional instructions) as a standalone message to summarize older context and free up window space.
- JSONL transcripts can be opened directly to review full turns.
## Tips
diff --git a/docs/sessions.md b/docs/sessions.md
index 56627b95a..2e048a902 100644
--- a/docs/sessions.md
+++ b/docs/sessions.md
@@ -5,4 +5,4 @@ read_when:
---
# Sessions
-Canonical session management docs live in `docs/session.md`.
+Canonical session management docs live in [`docs/session.md`](https://docs.clawd.bot/session).
diff --git a/docs/setup.md b/docs/setup.md
index d053da7e3..e331fc47e 100644
--- a/docs/setup.md
+++ b/docs/setup.md
@@ -17,7 +17,7 @@ Last updated: 2026-01-01
## Prereqs (from source)
- Node `>=22`
- `pnpm`
-- Docker (optional; only for containerized setup/e2e — see `docs/docker.md`)
+- Docker (optional; only for containerized setup/e2e — see [`docs/docker.md`](https://docs.clawd.bot/docker))
## Tailoring strategy (so updates don’t hurt)
@@ -77,7 +77,7 @@ pnpm install
pnpm gateway:watch
```
-`gateway:watch` runs `src/index.ts gateway --force` and reloads on `src/**/*.ts` changes.
+`gateway:watch` runs `src/entry.ts gateway --force` and reloads on [`src/**/*.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/**/*.ts) changes.
### 2) Point the macOS app at your running Gateway
@@ -102,7 +102,8 @@ pnpm clawdbot health
- **Wrong port:** Gateway WS defaults to `ws://127.0.0.1:18789`; keep app + CLI on the same port.
- **Where state lives:**
- Credentials: `~/.clawdbot/credentials/`
- - Sessions/logs: `~/.clawdbot/sessions/`
+ - Sessions: `~/.clawdbot/agents//sessions/`
+ - Logs: `/tmp/clawdbot/`
## Updating (without wrecking your setup)
@@ -120,12 +121,12 @@ sudo loginctl enable-linger $USER
```
For always-on or multi-user servers, consider a **system** service instead of a
-user service (no lingering needed). See `docs/gateway.md` for the systemd notes.
+user service (no lingering needed). See [`docs/gateway.md`](https://docs.clawd.bot/gateway) for the systemd notes.
## Related docs
-- `docs/gateway.md` (Gateway runbook; flags, supervision, ports)
-- `docs/configuration.md` (config schema + examples)
-- `docs/discord.md` and `docs/telegram.md` (reply tags + replyToMode settings)
-- `docs/clawd.md` (personal assistant setup)
-- `docs/macos.md` (macOS app behavior; gateway lifecycle + “Attach only”)
+- [`docs/gateway.md`](https://docs.clawd.bot/gateway) (Gateway runbook; flags, supervision, ports)
+- [`docs/configuration.md`](https://docs.clawd.bot/configuration) (config schema + examples)
+- [`docs/discord.md`](https://docs.clawd.bot/discord) and [`docs/telegram.md`](https://docs.clawd.bot/telegram) (reply tags + replyToMode settings)
+- [`docs/clawd.md`](https://docs.clawd.bot/clawd) (personal assistant setup)
+- [`docs/macos.md`](https://docs.clawd.bot/macos) (macOS app behavior; gateway lifecycle + “Attach only”)
diff --git a/docs/signal.md b/docs/signal.md
index 7dd4843ac..b2970747d 100644
--- a/docs/signal.md
+++ b/docs/signal.md
@@ -50,8 +50,13 @@ You can still run Clawdbot on your own Signal account if your goal is “respond
httpHost: "127.0.0.1",
httpPort: 8080,
- // Who is allowed to talk to the bot
- allowFrom: ["+15557654321"] // your personal number (or "*")
+ // Who is allowed to talk to the bot (DMs)
+ dmPolicy: "pairing", // pairing | allowlist | open | disabled
+ allowFrom: ["+15557654321"], // your personal number ("open" requires ["*"])
+
+ // Group policy + allowlist
+ groupPolicy: "open",
+ groupAllowFrom: ["+15557654321"]
}
}
```
@@ -60,6 +65,10 @@ You can still run Clawdbot on your own Signal account if your goal is “respond
- Expect `signal.probe.ok=true` and `signal.probe.version`.
5) DM the bot number from your phone; Clawdbot replies.
+## DM pairing
+- Default: `signal.dmPolicy="pairing"` — unknown DM senders get a pairing code.
+- Approve via: `clawdbot pairing approve --provider signal `.
+
## “Do I need a separate number?”
- If you want “I text her and she texts me back”, yes: **use a separate Signal account/number for the bot**.
- Your personal account can run `signal-cli`, but you can’t self-chat (Signal loop protection; Clawdbot ignores sender==account).
@@ -99,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/skills.md b/docs/skills.md
index 0dadb6a10..424a5b833 100644
--- a/docs/skills.md
+++ b/docs/skills.md
@@ -142,6 +142,6 @@ copy). Workspace skills are user-owned and override both on name conflicts.
## Config reference
-See `docs/skills-config.md` for the full configuration schema.
+See [`docs/skills-config.md`](https://docs.clawd.bot/skills-config) for the full configuration schema.
---
diff --git a/docs/slack.md b/docs/slack.md
index d68d4e1a7..a70822116 100644
--- a/docs/slack.md
+++ b/docs/slack.md
@@ -17,7 +17,7 @@ read_when: "Setting up Slack or debugging Slack socket mode"
- `channel_rename`
- `pin_added`, `pin_removed`
5) Invite the bot to channels you want it to read.
-6) Slash Commands → create the `/clawd` command (or your preferred name).
+6) Slash Commands → create `/clawd` if you use `slack.slashCommand`. If you enable `commands.native`, add slash commands for the built-in chat commands (same names as `/help`).
7) App Home → enable the **Messages Tab** so users can DM the bot.
Use the manifest below so scopes and events stay in sync.
@@ -98,6 +98,8 @@ Use this Slack app manifest to create the app quickly (adjust the name/command i
}
```
+If you enable `commands.native`, add one `slash_commands` entry per command you want to expose (matching the `/help` list).
+
## Scopes (current vs optional)
Slack's Conversations API is type-scoped: you only need the scopes for the
conversation types you actually touch (channels, groups, im, mpim). See
@@ -109,12 +111,12 @@ https://api.slack.com/docs/conversations-api for the overview.
- `im:write` (open DMs via `conversations.open` for user DMs)
https://api.slack.com/methods/conversations.open
- `channels:history`, `groups:history`, `im:history`, `mpim:history`
- (`conversations.history` in `src/slack/actions.ts`)
+ (`conversations.history` in [`src/slack/actions.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/slack/actions.ts))
https://api.slack.com/methods/conversations.history
- `channels:read`, `groups:read`, `im:read`, `mpim:read`
- (`conversations.info` in `src/slack/monitor.ts`)
+ (`conversations.info` in [`src/slack/monitor.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/slack/monitor.ts))
https://api.slack.com/methods/conversations.info
-- `users:read` (`users.info` in `src/slack/monitor.ts` + `src/slack/actions.ts`)
+- `users:read` (`users.info` in [`src/slack/monitor.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/slack/monitor.ts) + [`src/slack/actions.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/slack/actions.ts))
https://api.slack.com/methods/users.info
- `reactions:read`, `reactions:write` (`reactions.get` / `reactions.add`)
https://api.slack.com/methods/reactions.get
@@ -145,8 +147,10 @@ Slack uses Socket Mode only (no HTTP webhook server). Provide both tokens:
"enabled": true,
"botToken": "xoxb-...",
"appToken": "xapp-...",
+ "groupPolicy": "open",
"dm": {
"enabled": true,
+ "policy": "pairing",
"allowFrom": ["U123", "U456", "*"],
"groupEnabled": false,
"groupChannels": ["G123"]
@@ -180,10 +184,24 @@ Tokens can also be supplied via env vars:
- `SLACK_BOT_TOKEN`
- `SLACK_APP_TOKEN`
+Ack reactions are controlled globally via `messages.ackReaction` +
+`messages.ackReactionScope`.
+
## Sessions + routing
- DMs share the `main` session (like WhatsApp/Telegram).
- Channels map to `slack:channel:` sessions.
- Slash commands use `slack:slash:` sessions.
+- Native command registration is controlled by `commands.native`; text commands require standalone `/...` messages and can be disabled with `commands.text: false`. Slack slash commands are managed in the Slack app and are not removed automatically. Use `commands.useAccessGroups: false` to bypass access-group checks for commands.
+- Full command list + config: https://docs.clawd.bot/slash-commands
+
+## DM security (pairing)
+- Default: `slack.dm.policy="pairing"` — unknown DM senders get a pairing code.
+- Approve via: `clawdbot pairing approve --provider slack `.
+- To allow anyone: set `slack.dm.policy="open"` and `slack.dm.allowFrom=["*"]`.
+
+## Group policy
+- `slack.groupPolicy` controls channel handling (`open|disabled|allowlist`).
+- `allowlist` requires channels to be listed in `slack.channels`.
## Delivery targets
Use these with cron/CLI sends:
diff --git a/docs/slash-commands.md b/docs/slash-commands.md
new file mode 100644
index 000000000..81cdd12cf
--- /dev/null
+++ b/docs/slash-commands.md
@@ -0,0 +1,53 @@
+---
+summary: "Slash commands: text vs native, config, and supported commands"
+read_when:
+ - Using or configuring chat commands
+ - Debugging command routing or permissions
+---
+# Slash commands
+
+Commands are handled by the Gateway. Send them as a **standalone** message that starts with `/`.
+Inline text like `hello /status` is ignored.
+
+## Config
+
+```json5
+{
+ commands: {
+ native: false,
+ text: true,
+ useAccessGroups: true
+ }
+}
+```
+
+- `commands.text` (default `true`) enables parsing `/...` in chat messages.
+ - On surfaces without native commands (WhatsApp/WebChat/Signal/iMessage), text commands still work even if you set this to `false`.
+- `commands.native` (default `false`) registers native commands on Discord/Slack/Telegram.
+ - `false` clears previously registered commands on Discord/Telegram at startup.
+ - Slack commands are managed in the Slack app and are not removed automatically.
+- `commands.useAccessGroups` (default `true`) enforces allowlists/policies for commands.
+
+## Command list
+
+Text + native (when enabled):
+- `/help`
+- `/status`
+- `/restart`
+- `/activation mention|always` (groups only)
+- `/send on|off|inherit` (owner-only)
+- `/reset` or `/new`
+- `/think ` (aliases: `/thinking`, `/t`)
+- `/verbose on|off` (alias: `/v`)
+- `/elevated on|off` (alias: `/elev`)
+- `/model `
+- `/queue ` (plus options like `debounce:2s cap:25 drop:summarize`)
+
+Text-only:
+- `/compact [instructions]`
+
+## Surface notes
+
+- **Text commands** run in the normal chat session (DMs share `main`, groups have their own session).
+- **Native commands** use isolated sessions: `discord:slash:`, `slack:slash:`, `telegram:slash:`.
+- **Slack:** `slack.slashCommand` is still supported for a single `/clawd`-style command. If you enable `commands.native`, you must create one Slack slash command per built-in command (same names as `/help`).
diff --git a/docs/subagents.md b/docs/subagents.md
new file mode 100644
index 000000000..71b805831
--- /dev/null
+++ b/docs/subagents.md
@@ -0,0 +1,72 @@
+---
+summary: "Sub-agents: spawning isolated agent runs that announce results back to the requester chat"
+read_when:
+ - You want background/parallel work via the agent
+ - You are changing sessions_spawn or sub-agent tool policy
+---
+
+# 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 provider.
+
+Primary goals:
+- Parallelize “research / long task / slow tool” work without blocking the main run.
+- Keep sub-agents isolated by default (session separation + optional sandboxing).
+- Keep the tool surface hard to misuse: sub-agents do **not** get session tools by default.
+- Avoid nested fan-out: sub-agents cannot spawn sub-agents.
+
+## Tool
+
+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 provider
+
+Tool params:
+- `task` (required)
+- `label?` (optional)
+- `timeoutSeconds?` (default `0`; `0` = fire-and-forget)
+- `cleanup?` (`delete|keep`, default `delete`)
+
+## Announce
+
+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 provider via the gateway `send` method.
+
+## Tool Policy (sub-agent tools)
+
+By default, sub-agents get **all tools except session tools**:
+- `sessions_list`
+- `sessions_history`
+- `sessions_send`
+- `sessions_spawn`
+
+Override via config:
+
+```json5
+{
+ agent: {
+ subagents: {
+ maxConcurrent: 1,
+ tools: {
+ // deny wins
+ deny: ["gateway", "cron"],
+ // if allow is set, it becomes allow-only (deny still wins)
+ // allow: ["read", "bash", "process"]
+ }
+ }
+ }
+}
+```
+
+## Concurrency
+
+Sub-agents use a dedicated in-process queue lane:
+- Lane name: `subagent`
+- Concurrency: `agent.subagents.maxConcurrent` (default `1`)
+
+## Limitations
+
+- Sub-agent announce is **best-effort**. If the gateway restarts, pending “announce back” work is lost.
+- Sub-agents still share the same gateway process resources; treat `maxConcurrent` as a safety valve.
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 058a5c36e..5f682d457 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.
@@ -23,9 +23,13 @@ Status: ready for bot-mode use with grammY (long-polling by default; webhook sup
- **Webhook mode** is enabled by setting `telegram.webhookUrl` (optionally `telegram.webhookSecret` / `telegram.webhookPath`).
- The webhook listener currently binds to `0.0.0.0:8787` and serves `POST /telegram-webhook` by default.
- If you need a different public port/host, set `telegram.webhookUrl` to the externally reachable URL and use a reverse proxy to forward to `:8787`.
-4) Direct chats: user sends the first message; all subsequent turns land in the shared `main` session (default, no extra config).
-5) Groups: add the bot, disable privacy mode (or make it admin) so it can read messages; group threads stay on `telegram:group:` and require mention/command by default (override via `telegram.groups`).
-6) Optional allowlist: use `telegram.allowFrom` for direct chats by chat id (`123456789` or `telegram:123456789`).
+4) Direct chats: secure by default — unknown senders are gated by `telegram.dmPolicy` (default: `"pairing"`). The bot responds with a pairing code that the owner must approve before messages are processed. If you really want public inbound DMs: set `telegram.dmPolicy="open"` and `telegram.allowFrom=["*"]`.
+5) Groups: add the bot, disable privacy mode (or make it admin) so it can read messages; group threads stay on `telegram:group:`. When `telegram.groups` is set, it becomes a group allowlist (use `"*"` to allow all). Mention/command gating defaults come from `telegram.groups`.
+6) Allowlist + pairing:
+ - Direct chats: `telegram.allowFrom` (chat ids) or pairing approvals via `clawdbot pairing approve --provider telegram ` (alias: `clawdbot telegram pairing approve `).
+ - Groups: set `telegram.groupPolicy = "allowlist"` and list senders in `telegram.groupAllowFrom` (fallback: explicit `telegram.allowFrom`).
+ - Commands respect group allowlists/policies by default; set `commands.useAccessGroups: false` to bypass.
+7) Native commands: set `commands.native: true` to register `/` commands; set `commands.native: false` to clear previously registered commands.
## Capabilities & limits (Bot API)
- Sees only messages sent after it’s added to a chat; no pre-history access.
@@ -35,9 +39,10 @@ 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.groups`, `telegram.allowFrom`, `telegram.mediaMaxMb`, `telegram.replyToMode`, `telegram.proxy`, `telegram.webhookSecret`, `telegram.webhookUrl`, `telegram.webhookPath` supported.
+- 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`.
- Mention gating precedence (most specific wins): `telegram.groups..requireMention` → `telegram.groups."*".requireMention` → default `true`.
Example config:
@@ -46,12 +51,15 @@ Example config:
telegram: {
enabled: true,
botToken: "123:abc",
+ dmPolicy: "pairing", // pairing | allowlist | open | disabled
replyToMode: "off",
groups: {
- "*": { requireMention: true },
+ "*": { requireMention: true }, // allow all groups
"123456789": { requireMention: false } // group chat id
},
- allowFrom: ["123456789"], // direct chat ids allowed (or "*")
+ allowFrom: ["123456789"], // direct chat ids allowed ("open" requires ["*"])
+ groupPolicy: "allowlist",
+ groupAllowFrom: ["tg:123456789", "@alice"],
mediaMaxMb: 5,
proxy: "socks5://localhost:9050",
webhookSecret: "mysecret",
@@ -60,12 +68,12 @@ Example config:
}
}
```
-- Tests: grammY-based paths in `src/telegram/*.test.ts` cover DM + group gating; add more media and webhook cases as needed.
+- Tests: grammY-based paths in [`src/telegram/*.test.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/telegram/*.test.ts) cover DM + group gating; add more media and webhook cases as needed.
## Group etiquette
- Keep privacy mode off if you expect the bot to read all messages; with privacy on, it only sees commands/mentions.
- Make the bot an admin if you need it to send in restricted groups or channels.
-- Mention the bot (`@yourbot`) or use a `routing.groupChat.mentionPatterns` trigger; per-group overrides live in `telegram.groups` if you want always-on behavior.
+- Mention the bot (`@yourbot`), use a `routing.groupChat.mentionPatterns` trigger, or send a standalone `/...` command. Per-group overrides live in `telegram.groups` if you want always-on behavior; if `telegram.groups` is set, add `"*"` to keep existing allow-all behavior.
## Reply tags
To request a threaded reply, the model can include one tag in its output:
diff --git a/docs/templates/AGENTS.md b/docs/templates/AGENTS.md
index be4345fd7..051f19c00 100644
--- a/docs/templates/AGENTS.md
+++ b/docs/templates/AGENTS.md
@@ -16,18 +16,28 @@ If `BOOTSTRAP.md` exists, that's your birth certificate. Follow it, figure out w
Before doing anything else:
1. Read `SOUL.md` — this is who you are
2. Read `USER.md` — this is who you're helping
-3. Read `memory.md` + today's and yesterday's files in `memory/`
+3. Read `memory/YYYY-MM-DD.md` (today + yesterday) for recent context
+4. **If in MAIN SESSION** (direct chat with your human): Also read `MEMORY.md`
Don't ask permission. Just do it.
## Memory
You wake up fresh each session. These files are your continuity:
-- **Daily notes:** `memory/YYYY-MM-DD.md` (create `memory/` if needed)
-- **Long-term:** `memory.md` for durable facts, preferences, open loops
+- **Daily notes:** `memory/YYYY-MM-DD.md` (create `memory/` if needed) — raw logs of what happened
+- **Long-term:** `MEMORY.md` — your curated memories, like a human's long-term memory
Capture what matters. Decisions, context, things to remember. Skip the secrets unless asked to keep them.
+### 🧠 MEMORY.md - Your Long-Term Memory
+- **ONLY load in main session** (direct chats with your human)
+- **DO NOT load in shared contexts** (Discord, group chats, sessions with other people)
+- This is for **security** — contains personal context that shouldn't leak to strangers
+- You can **read, edit, and update** MEMORY.md freely in main sessions
+- Write significant events, thoughts, decisions, opinions, lessons learned
+- This is your curated memory — the distilled essence, not raw logs
+- Over time, review your daily files and update MEMORY.md with what's worth keeping
+
### 📝 Write It Down - No "Mental Notes"!
- **Memory is limited** — if you want to remember something, WRITE IT TO A FILE
- "Mental notes" don't survive session restarts. Files do.
@@ -105,7 +115,12 @@ Skills provide your tools. When you need one, check its `SKILL.md`. Keep local n
## 💓 Heartbeats - Be Proactive!
-When you receive a `HEARTBEAT` message, don't just reply `HEARTBEAT_OK` every time. Use heartbeats productively!
+When you receive a heartbeat poll (message matches the configured heartbeat prompt), don't just reply `HEARTBEAT_OK` every time. Use heartbeats productively!
+
+Default heartbeat prompt:
+`Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`
+
+You are free to edit `HEARTBEAT.md` with a short checklist or reminders. Keep it small.
**Things to check (rotate through these, 2-4 times per day):**
- **Emails** - Any urgent unread messages?
@@ -141,6 +156,16 @@ When you receive a `HEARTBEAT` message, don't just reply `HEARTBEAT_OK` every ti
- Check on projects (git status, etc.)
- Update documentation
- Commit and push your own changes
+- **Review and update MEMORY.md** (see below)
+
+### 🔄 Memory Maintenance (During Heartbeats)
+Periodically (every few days), use a heartbeat to:
+1. Read through recent `memory/YYYY-MM-DD.md` files
+2. Identify significant events, lessons, or insights worth keeping long-term
+3. Update `MEMORY.md` with distilled learnings
+4. Remove outdated info from MEMORY.md that's no longer relevant
+
+Think of it like a human reviewing their journal and updating their mental model. Daily files are raw notes; MEMORY.md is curated wisdom.
The goal: Be helpful without being annoying. Check in a few times a day, do useful background work, but respect quiet time.
diff --git a/docs/templates/HEARTBEAT.md b/docs/templates/HEARTBEAT.md
new file mode 100644
index 000000000..4d300f421
--- /dev/null
+++ b/docs/templates/HEARTBEAT.md
@@ -0,0 +1,8 @@
+---
+summary: "Workspace template for HEARTBEAT.md"
+read_when:
+ - Bootstrapping a workspace manually
+---
+# HEARTBEAT.md
+
+Keep this file empty unless you want a tiny checklist for heartbeat runs. Keep it small.
diff --git a/docs/test.md b/docs/test.md
index b31a57fbb..c04b7b62e 100644
--- a/docs/test.md
+++ b/docs/test.md
@@ -11,10 +11,10 @@ read_when:
## Model latency bench (local keys)
-Script: `scripts/bench-model.ts`
+Script: [`scripts/bench-model.ts`](https://github.com/clawdbot/clawdbot/blob/main/scripts/bench-model.ts)
Usage:
-- `source ~/.profile && pnpm tsx scripts/bench-model.ts --runs 10`
+- `source ~/.profile && bun scripts/bench-model.ts --runs 10`
- Optional env: `MINIMAX_API_KEY`, `MINIMAX_BASE_URL`, `MINIMAX_MODEL`, `ANTHROPIC_API_KEY`
- Default prompt: “Reply with a single word: ok. No punctuation or extra text.”
diff --git a/docs/thinking.md b/docs/thinking.md
index 3ed9a9aa1..86f697581 100644
--- a/docs/thinking.md
+++ b/docs/thinking.md
@@ -35,10 +35,10 @@ read_when:
- When verbose is on, agents that emit structured tool results (Pi, other JSON agents) send each tool result back as its own metadata-only message, prefixed with ` : ` when available (path/command); the tool output itself is not forwarded. These tool summaries are sent as soon as each tool finishes (separate bubbles), not as streaming deltas. If you toggle `/verbose on|off` while a run is in-flight, subsequent tool bubbles honor the new setting.
## Related
-- Elevated mode docs live in `docs/elevated.md`.
+- Elevated mode docs live in [`docs/elevated.md`](https://docs.clawd.bot/elevated).
## Heartbeats
-- Heartbeat probe body is `HEARTBEAT`. Inline directives in a heartbeat message apply as usual (but avoid changing session defaults from heartbeats).
+- Heartbeat probe body is the configured heartbeat prompt (default: `Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`). Inline directives in a heartbeat message apply as usual (but avoid changing session defaults from heartbeats).
## Web chat UI
- The web chat thinking selector mirrors the session's stored level from the inbound session store/config when the page loads.
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..f19c4d028 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)
@@ -247,17 +247,17 @@ Tools are exposed to the model in **two parallel channels**:
2) **Provider tool schema**: the actual function/tool declarations sent to the model API.
In pi-mono:
-- System prompt builder: `packages/coding-agent/src/core/system-prompt.ts`
+- System prompt builder: [`packages/coding-agent/src/core/system-prompt.ts`](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/src/core/system-prompt.ts)
- Builds the `Available tools:` list from `toolDescriptions`.
- Appends skills and project context.
- Tool schemas passed to providers:
- - OpenAI: `packages/ai/src/providers/openai-responses.ts` (`convertTools`)
- - Anthropic: `packages/ai/src/providers/anthropic.ts` (`convertTools`)
- - Gemini: `packages/ai/src/providers/google-shared.ts` (`convertTools`)
+ - OpenAI: [`packages/ai/src/providers/openai-responses.ts`](https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/providers/openai-responses.ts) (`convertTools`)
+ - Anthropic: [`packages/ai/src/providers/anthropic.ts`](https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/providers/anthropic.ts) (`convertTools`)
+ - Gemini: [`packages/ai/src/providers/google-shared.ts`](https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/providers/google-shared.ts) (`convertTools`)
- Tool execution loop:
- - Agent loop: `packages/ai/src/agent/agent-loop.ts`
+ - Agent loop: [`packages/ai/src/agent/agent-loop.ts`](https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/agent/agent-loop.ts)
- Validates tool arguments and executes tools, then appends `toolResult` messages.
In Clawdbot:
-- System prompt append: `src/agents/system-prompt.ts`
-- Tool list injected via `createClawdbotCodingTools()` in `src/agents/pi-tools.ts`
+- System prompt append: [`src/agents/system-prompt.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/agents/system-prompt.ts)
+- Tool list injected via `createClawdbotCodingTools()` in [`src/agents/pi-tools.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/agents/pi-tools.ts)
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
index c8cbcab88..f119b5029 100644
--- a/docs/troubleshooting.md
+++ b/docs/troubleshooting.md
@@ -14,7 +14,7 @@ When your CLAWDBOT misbehaves, here's how to fix it.
The agent was interrupted mid-response.
**Causes:**
-- User sent `stop`, `abort`, `esc`, or `exit`
+- User sent `stop`, `abort`, `esc`, `wait`, or `exit`
- Timeout exceeded
- Process crashed
@@ -50,7 +50,7 @@ Known issue: When you send an image with ONLY a mention (no other text), WhatsAp
**Check 1:** Is the session file there?
```bash
-ls -la ~/.clawdbot/sessions/
+ls -la ~/.clawdbot/agents//sessions/
```
**Check 2:** Is `idleMinutes` too short?
@@ -146,7 +146,7 @@ tccutil reset All com.clawdbot.mac.debug
```
**Fix 2: Force New Bundle ID**
-If resetting doesn't work, change the `BUNDLE_ID` in `scripts/package-mac-app.sh` (e.g., add a `.test` suffix) and rebuild. This forces macOS to treat it as a new app.
+If resetting doesn't work, change the `BUNDLE_ID` in [`scripts/package-mac-app.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/package-mac-app.sh) (e.g., add a `.test` suffix) and rebuild. This forces macOS to treat it as a new app.
### Gateway stuck on "Starting..."
@@ -168,7 +168,7 @@ clawdbot gateway stop
```
**Fix 2: Check embedded gateway**
-Ensure the gateway relay was properly bundled. Run `./scripts/package-mac-app.sh` and ensure `bun` is installed.
+Ensure the gateway relay was properly bundled. Run [`./scripts/package-mac-app.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/package-mac-app.sh) and ensure `bun` is installed.
## Debug Mode
@@ -188,7 +188,7 @@ clawdbot login --verbose
| Log | Location |
|-----|----------|
| Main logs (default) | `/tmp/clawdbot/clawdbot-YYYY-MM-DD.log` |
-| Session files | `~/.clawdbot/sessions/` |
+| Session files | `~/.clawdbot/agents//sessions/` |
| Media cache | `~/.clawdbot/media/` |
| Credentials | `~/.clawdbot/credentials/` |
@@ -254,4 +254,4 @@ Then set in config:
}
```
-**Full guide:** See [browser-linux-troubleshooting.md](./browser-linux-troubleshooting.md)
+**Full guide:** See [browser-linux-troubleshooting](https://docs.clawd.bot/browser-linux-troubleshooting)
diff --git a/docs/tui.md b/docs/tui.md
index de0479788..2a668160e 100644
--- a/docs/tui.md
+++ b/docs/tui.md
@@ -52,6 +52,7 @@ Use SSH tunneling or Tailscale to reach the Gateway WS.
- `/think `
- `/verbose `
- `/elevated `
+- `/elev `
- `/activation `
- `/deliver `
- `/new` or `/reset`
@@ -65,6 +66,6 @@ Use SSH tunneling or Tailscale to reach the Gateway WS.
- It registers as a Gateway client with `mode: "tui"` for presence and debugging.
## Files
-- CLI: `src/cli/tui-cli.ts`
-- Runner: `src/tui/tui.ts`
-- Gateway client: `src/tui/gateway-chat.ts`
+- CLI: [`src/cli/tui-cli.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/cli/tui-cli.ts)
+- Runner: [`src/tui/tui.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/tui/tui.ts)
+- Gateway client: [`src/tui/gateway-chat.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/tui/gateway-chat.ts)
diff --git a/docs/typebox.md b/docs/typebox.md
index d02db5435..cc192d271 100644
--- a/docs/typebox.md
+++ b/docs/typebox.md
@@ -7,12 +7,12 @@ read_when:
Last updated: 2025-12-09
-We use TypeBox schemas in `src/gateway/protocol/schema.ts` as the single source of truth for the Gateway control plane (connect/req/res/event frames and payloads). All derived artifacts should be generated from these schemas, not edited by hand.
+We use TypeBox schemas in [`src/gateway/protocol/schema.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/gateway/protocol/schema.ts) as the single source of truth for the Gateway control plane (connect/req/res/event frames and payloads). All derived artifacts should be generated from these schemas, not edited by hand.
## Current pipeline
-- **TypeBox → JSON Schema**: `pnpm protocol:gen` writes `dist/protocol.schema.json` (draft-07) and runs AJV in the server tests.
-- **TypeBox → Swift**: `pnpm protocol:gen:swift` generates `apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift`.
+- **TypeBox → JSON Schema**: `pnpm protocol:gen` writes [`dist/protocol.schema.json`](https://github.com/clawdbot/clawdbot/blob/main/dist/protocol.schema.json) (draft-07) and runs AJV in the server tests.
+- **TypeBox → Swift**: `pnpm protocol:gen:swift` generates [`apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift`](https://github.com/clawdbot/clawdbot/blob/main/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift).
## Problem
diff --git a/docs/updating.md b/docs/updating.md
new file mode 100644
index 000000000..8c31091c3
--- /dev/null
+++ b/docs/updating.md
@@ -0,0 +1,138 @@
+---
+summary: "Updating Clawdbot safely (npm or source), plus rollback strategy"
+read_when:
+ - Updating Clawdbot
+ - Something breaks after an update
+---
+
+# Updating
+
+Clawdbot is moving fast (pre “1.0”). Treat updates like shipping infra: update → run checks → restart → verify.
+
+## Before you update
+
+- Know how you installed: **npm** (global) vs **from source** (git clone).
+- Know how your Gateway is running: **foreground terminal** vs **supervised service** (launchd/systemd/Scheduled Task).
+- Snapshot your tailoring:
+ - Config: `~/.clawdbot/clawdbot.json`
+ - Credentials: `~/.clawdbot/credentials/`
+ - Workspace: `~/clawd`
+
+## Update (npm install)
+
+Global install (pick one):
+
+```bash
+npm i -g clawdbot@latest
+```
+
+```bash
+pnpm add -g clawdbot@latest
+```
+
+Then:
+
+```bash
+clawdbot doctor
+clawdbot gateway restart
+clawdbot health
+```
+
+Notes:
+- If your Gateway runs as a service, `clawdbot gateway restart` is preferred over killing PIDs.
+- If you’re pinned to a specific version, see “Rollback / pinning” below.
+
+## Update (from source)
+
+From the repo checkout:
+
+```bash
+git pull
+pnpm install
+pnpm build
+pnpm ui:install
+pnpm ui:build
+pnpm clawdbot doctor
+pnpm clawdbot health
+```
+
+Notes:
+- `pnpm build` matters when you run the packaged `clawdbot` binary ([`dist/entry.js`](https://github.com/clawdbot/clawdbot/blob/main/dist/entry.js)) or use Node to run `dist/`.
+- If you run directly from TypeScript (`pnpm clawdbot ...` / `bun run clawdbot ...`), a rebuild is usually unnecessary, but **config migrations still apply** → run doctor.
+
+## Always run: `clawdbot doctor`
+
+Doctor is the “safe update” command. It’s intentionally boring: repair + migrate + warn.
+
+Typical things it does:
+- Migrate deprecated config keys / legacy config file locations.
+- Audit DM policies and warn on risky “open” settings.
+- Check Gateway health and can offer to restart.
+- Detect and migrate older gateway services (launchd/systemd/schtasks) to current Clawdbot services.
+- On Linux, ensure systemd user lingering (so the Gateway survives logout).
+
+Details: https://docs.clawd.bot/doctor
+
+## Start / stop / restart the Gateway
+
+CLI (works regardless of OS):
+
+```bash
+clawdbot gateway stop
+clawdbot gateway restart
+clawdbot gateway --port 18789
+```
+
+If you’re supervised:
+- macOS launchd (app-bundled LaunchAgent): `launchctl kickstart -k gui/$UID/com.clawdbot.gateway`
+- Linux systemd user service: `systemctl --user restart clawdbot-gateway.service`
+- Windows: restart the `Clawdbot Gateway` Scheduled Task (Task Scheduler)
+
+Runbook + exact service labels: https://docs.clawd.bot/gateway
+
+## Rollback / pinning (when something breaks)
+
+### Pin (npm)
+
+Install a known-good version:
+
+```bash
+npm i -g clawdbot@2026.1.5-3
+```
+
+Then restart + re-run doctor:
+
+```bash
+clawdbot doctor
+clawdbot gateway restart
+```
+
+### Pin (source) by date
+
+Pick a commit from a date (example: “state of main as of 2026-01-01”):
+
+```bash
+git fetch origin
+git checkout "$(git rev-list -n 1 --before=\"2026-01-01\" origin/main)"
+```
+
+Then reinstall deps + restart:
+
+```bash
+pnpm install
+pnpm build
+pnpm clawdbot gateway restart
+```
+
+If you want to go back to latest later:
+
+```bash
+git checkout main
+git pull
+```
+
+## If you’re stuck
+
+- Run `clawdbot doctor` again and read the output carefully (it often tells you the fix).
+- Check: https://docs.clawd.bot/troubleshooting
+- Ask in Discord: https://discord.gg/clawd
diff --git a/docs/web.md b/docs/web.md
index aeb0967f1..aecc44f47 100644
--- a/docs/web.md
+++ b/docs/web.md
@@ -25,8 +25,8 @@ 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.
-See `docs/configuration.md` → `hooks` for auth + payloads.
+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)
@@ -110,6 +110,6 @@ Open:
The Gateway serves static files from `dist/control-ui`. Build them with:
```bash
-pnpm ui:install
-pnpm ui:build
+bun run ui:install
+bun run ui:build
```
diff --git a/docs/webchat.md b/docs/webchat.md
index e36f08da1..46db1b999 100644
--- a/docs/webchat.md
+++ b/docs/webchat.md
@@ -30,5 +30,5 @@ Updated: 2025-12-17
- No fallback transport; the Gateway WS is required.
## Dev notes
-- macOS glue: `apps/macos/Sources/Clawdbot/WebChatSwiftUI.swift` + `apps/macos/Sources/Clawdbot/WebChatManager.swift`.
-- Remote tunnel helper: `apps/macos/Sources/Clawdbot/RemotePortTunnel.swift`.
+- macOS glue: [`apps/macos/Sources/Clawdbot/WebChatSwiftUI.swift`](https://github.com/clawdbot/clawdbot/blob/main/apps/macos/Sources/Clawdbot/WebChatSwiftUI.swift) + [`apps/macos/Sources/Clawdbot/WebChatManager.swift`](https://github.com/clawdbot/clawdbot/blob/main/apps/macos/Sources/Clawdbot/WebChatManager.swift).
+- Remote tunnel helper: [`apps/macos/Sources/Clawdbot/RemotePortTunnel.swift`](https://github.com/clawdbot/clawdbot/blob/main/apps/macos/Sources/Clawdbot/RemotePortTunnel.swift).
diff --git a/docs/webhook.md b/docs/webhook.md
index ae17f8925..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
@@ -91,7 +91,7 @@ Mapping options (summary):
- `hooks.mappings` lets you define `match`, `action`, and templates in config.
- `hooks.transformsDir` + `transform.module` loads a JS/TS module for custom logic.
- Use `match.source` to keep a generic ingest endpoint (payload-driven routing).
-- TS transforms require a TS loader (e.g. `tsx`) or precompiled `.js` at runtime.
+- TS transforms require a TS loader (e.g. `bun`) or precompiled `.js` at runtime.
- `clawdbot hooks gmail setup` writes `hooks.gmail` config for `clawdbot hooks gmail run`.
## Responses
diff --git a/docs/whatsapp.md b/docs/whatsapp.md
index 0594fb583..e23c597bc 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)
@@ -47,8 +50,12 @@ WhatsApp requires a real mobile number for verification. VoIP and virtual number
- Inbox listeners are detached on shutdown to avoid accumulating event handlers in tests/restarts.
- Status/broadcast chats are ignored.
- Direct chats use E.164; groups use group JID.
-- **Allowlist**: `whatsapp.allowFrom` enforced for direct chats only.
- - If `whatsapp.allowFrom` is empty, default allowlist = self number (self-chat mode).
+- **DM policy**: `whatsapp.dmPolicy` controls direct chat access (default: `pairing`).
+ - Pairing: unknown senders get a pairing code (approve via `clawdbot pairing approve --provider whatsapp `).
+ - Open: requires `whatsapp.allowFrom` to include `"*"`.
+ - Self messages are always allowed; “self-chat mode” still requires `whatsapp.allowFrom` to include your own number.
+- **Group policy**: `whatsapp.groupPolicy` controls group handling (`open|disabled|allowlist`).
+ - `allowlist` uses `whatsapp.groupAllowFrom` (fallback: explicit `whatsapp.allowFrom`).
- **Self-chat mode**: avoids auto read receipts and ignores mention JIDs.
- Read receipts sent for non-self-chat DMs.
@@ -68,11 +75,12 @@ 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.
- `always`: always triggers.
-- `/activation mention|always` is owner-only.
+- `/activation mention|always` is owner-only and must be sent as a standalone message.
- Owner = `whatsapp.allowFrom` (or self E.164 if unset).
- **History injection**:
- Recent messages (default 50) inserted under:
@@ -84,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.
@@ -107,8 +115,8 @@ WhatsApp requires a real mobile number for verification. VoIP and virtual number
## Heartbeats
- **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).
+ - 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).
## Reconnect behavior
- Backoff policy: `web.reconnect`:
@@ -117,8 +125,12 @@ WhatsApp requires a real mobile number for verification. VoIP and virtual number
- Logged-out => stop and require re-link.
## Config quick map
+- `whatsapp.dmPolicy` (DM policy: pairing/allowlist/open/disabled).
- `whatsapp.allowFrom` (DM allowlist).
-- `whatsapp.groups` (group mention gating defaults/overrides)
+- `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)
- `routing.groupChat.mentionPatterns`
- `routing.groupChat.historyLimit`
- `messages.messagePrefix` (inbound prefix)
@@ -128,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.*`
@@ -136,9 +148,9 @@ WhatsApp requires a real mobile number for verification. VoIP and virtual number
## Logs + troubleshooting
- Subsystems: `whatsapp/inbound`, `whatsapp/outbound`, `web-heartbeat`, `web-reconnect`.
- Log file: `/tmp/clawdbot/clawdbot-YYYY-MM-DD.log` (configurable).
-- Troubleshooting guide: `docs/troubleshooting.md`.
+- Troubleshooting guide: [`docs/troubleshooting.md`](https://docs.clawd.bot/troubleshooting).
## Tests
-- `src/web/auto-reply.test.ts` (mention gating, history injection, reply flow)
-- `src/web/monitor-inbox.test.ts` (inbound parsing + reply context)
-- `src/web/outbound.test.ts` (send mapping + media)
+- [`src/web/auto-reply.test.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/web/auto-reply.test.ts) (mention gating, history injection, reply flow)
+- [`src/web/monitor-inbox.test.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/web/monitor-inbox.test.ts) (inbound parsing + reply context)
+- [`src/web/outbound.test.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/web/outbound.test.ts) (send mapping + media)
diff --git a/docs/wizard.md b/docs/wizard.md
index 883f4e003..76ac67534 100644
--- a/docs/wizard.md
+++ b/docs/wizard.md
@@ -54,7 +54,7 @@ It does **not** install or change anything on the remote host.
- **Minimax M2.1 (LM Studio)**: config is auto‑written for the LM Studio endpoint.
- **Skip**: no auth configured yet.
- Wizard runs a model check and warns if the configured model is unknown or missing auth.
- - OAuth credentials live in `~/.clawdbot/credentials/oauth.json`; auth profiles live in `~/.clawdbot/agent/auth-profiles.json` (API keys + OAuth).
+ - OAuth credentials live in `~/.clawdbot/credentials/oauth.json`; auth profiles live in `~/.clawdbot/agents//agent/auth-profiles.json` (API keys + OAuth).
3) **Workspace**
- Default `~/clawd` (configurable).
@@ -71,6 +71,7 @@ It does **not** install or change anything on the remote host.
- Discord: bot token.
- Signal: optional `signal-cli` install + account config.
- iMessage: local `imsg` CLI path + DB access.
+ - DM security: default is pairing (unknown DMs get a pairing code). Approve via `clawdbot pairing approve --provider `.
6) **Daemon install**
- macOS: LaunchAgent
@@ -155,12 +156,12 @@ Typical fields in `~/.clawdbot/clawdbot.json`:
- `wizard.lastRunCommand`
- `wizard.lastRunMode`
-WhatsApp credentials go to `~/.clawdbot/credentials/`.
-Sessions are stored under `~/.clawdbot/sessions/`.
+WhatsApp credentials go under `~/.clawdbot/credentials/whatsapp//`.
+Sessions are stored under `~/.clawdbot/agents//sessions/`.
## Related docs
-- macOS app onboarding: `docs/onboarding.md`
-- Config reference: `docs/configuration.md`
-- Providers: `docs/whatsapp.md`, `docs/telegram.md`, `docs/discord.md`, `docs/signal.md`, `docs/imessage.md`
-- Skills: `docs/skills.md`, `docs/skills-config.md`
+- macOS app onboarding: [`docs/onboarding.md`](https://docs.clawd.bot/onboarding)
+- Config reference: [`docs/configuration.md`](https://docs.clawd.bot/configuration)
+- Providers: [`docs/whatsapp.md`](https://docs.clawd.bot/whatsapp), [`docs/telegram.md`](https://docs.clawd.bot/telegram), [`docs/discord.md`](https://docs.clawd.bot/discord), [`docs/signal.md`](https://docs.clawd.bot/signal), [`docs/imessage.md`](https://docs.clawd.bot/imessage)
+- Skills: [`docs/skills.md`](https://docs.clawd.bot/skills), [`docs/skills-config.md`](https://docs.clawd.bot/skills-config)
diff --git a/package.json b/package.json
index 5ddc0c3a7..8dd1f0b7c 100644
--- a/package.json
+++ b/package.json
@@ -44,19 +44,20 @@
"LICENSE"
],
"scripts": {
- "dev": "tsx src/entry.ts",
- "docs:list": "tsx scripts/docs-list.ts",
+ "dev": "bun src/entry.ts",
+ "postinstall": "node scripts/postinstall.js",
+ "docs:list": "bun scripts/docs-list.ts",
"docs:dev": "cd docs && mint dev",
"docs:build": "cd docs && pnpm dlx mint broken-links",
- "build": "tsc -p tsconfig.json && tsx scripts/canvas-a2ui-copy.ts",
- "release:check": "tsx scripts/release-check.ts",
- "ui:install": "pnpm -C ui install",
- "ui:dev": "pnpm -C ui dev",
- "ui:build": "pnpm -C ui build",
- "start": "tsx src/entry.ts",
- "clawdbot": "tsx src/entry.ts",
- "gateway:watch": "tsx watch --clear-screen=false --include 'src/**/*.ts' src/entry.ts gateway --force",
- "clawdbot:rpc": "tsx src/entry.ts agent --mode rpc --json",
+ "build": "tsc -p tsconfig.json && bun scripts/canvas-a2ui-copy.ts",
+ "release:check": "bun scripts/release-check.ts",
+ "ui:install": "node scripts/ui.js install",
+ "ui:dev": "node scripts/ui.js dev",
+ "ui:build": "node scripts/ui.js build",
+ "start": "bun src/entry.ts",
+ "clawdbot": "bun src/entry.ts",
+ "gateway:watch": "bun --watch src/entry.ts gateway --force",
+ "clawdbot:rpc": "bun src/entry.ts agent --mode rpc --json",
"lint": "biome check src test && oxlint --type-aware src test --ignore-pattern src/canvas-host/a2ui/a2ui.bundle.js",
"lint:swift": "swiftlint lint --config .swiftlint.yml && (cd apps/ios && swiftlint lint --config .swiftlint.yml)",
"lint:fix": "biome check --write --unsafe src && biome format --write src",
@@ -64,12 +65,12 @@
"format:swift": "swiftformat --lint --config .swiftformat apps/macos/Sources apps/ios/Sources apps/shared/ClawdbotKit/Sources",
"format:fix": "biome format src --write",
"test": "vitest",
- "test:force": "tsx scripts/test-force.ts",
+ "test:force": "bun scripts/test-force.ts",
"test:coverage": "vitest run --coverage",
"test:e2e": "vitest run --config vitest.e2e.config.ts",
"test:docker:qr": "bash scripts/e2e/qr-import-docker.sh",
- "protocol:gen": "tsx scripts/protocol-gen.ts",
- "protocol:gen:swift": "tsx scripts/protocol-gen-swift.ts",
+ "protocol:gen": "bun scripts/protocol-gen.ts",
+ "protocol:gen:swift": "bun scripts/protocol-gen-swift.ts",
"protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && git diff --exit-code -- dist/protocol.schema.json apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift",
"canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh"
},
@@ -81,13 +82,14 @@
},
"packageManager": "pnpm@10.23.0",
"dependencies": {
+ "@buape/carbon": "^0.13.0",
"@clack/prompts": "^0.11.0",
"@grammyjs/transformer-throttler": "^1.2.1",
"@homebridge/ciao": "^1.3.4",
- "@mariozechner/pi-agent-core": "^0.36.0",
- "@mariozechner/pi-ai": "^0.36.0",
- "@mariozechner/pi-coding-agent": "^0.36.0",
- "@mariozechner/pi-tui": "^0.36.0",
+ "@mariozechner/pi-agent-core": "^0.37.2",
+ "@mariozechner/pi-ai": "^0.37.2",
+ "@mariozechner/pi-coding-agent": "^0.37.2",
+ "@mariozechner/pi-tui": "^0.37.2",
"@sinclair/typebox": "0.34.46",
"@slack/bolt": "^4.6.0",
"@slack/web-api": "^7.13.0",
@@ -101,7 +103,6 @@
"croner": "^9.1.0",
"detect-libc": "^2.1.2",
"discord-api-types": "^0.38.37",
- "discord.js": "^14.25.1",
"dotenv": "^17.2.3",
"express": "^5.2.1",
"file-type": "^21.3.0",
@@ -109,6 +110,7 @@
"json5": "^2.2.3",
"long": "5.3.2",
"playwright-core": "1.57.0",
+ "proper-lockfile": "^4.1.2",
"qrcode-terminal": "^0.12.0",
"sharp": "^0.34.5",
"tslog": "^4.10.2",
@@ -125,6 +127,7 @@
"@types/express": "^5.0.6",
"@types/markdown-it": "^14.1.2",
"@types/node": "^25.0.3",
+ "@types/proper-lockfile": "^4.1.4",
"@types/qrcode-terminal": "^0.12.2",
"@types/ws": "^8.18.1",
"@vitest/coverage-v8": "^4.0.16",
@@ -139,7 +142,6 @@
"quicktype-core": "^23.2.6",
"rolldown": "1.0.0-beta.58",
"signal-utils": "^0.21.1",
- "tsx": "^4.21.0",
"typescript": "^5.9.3",
"vitest": "^4.0.16",
"wireit": "^0.14.12"
@@ -150,7 +152,8 @@
},
"patchedDependencies": {
"@mariozechner/pi-ai": "patches/@mariozechner__pi-ai.patch",
- "qrcode-terminal": "patches/qrcode-terminal.patch"
+ "qrcode-terminal": "patches/qrcode-terminal.patch",
+ "playwright-core@1.57.0": "patches/playwright-core@1.57.0.patch"
}
},
"vitest": {
@@ -184,5 +187,10 @@
"apps/macos/.build/**",
"dist/Clawdbot.app/**"
]
+ },
+ "patchedDependencies": {
+ "@mariozechner/pi-ai": "patches/@mariozechner__pi-ai.patch",
+ "qrcode-terminal": "patches/qrcode-terminal.patch",
+ "playwright-core@1.57.0": "patches/playwright-core@1.57.0.patch"
}
}
diff --git a/patches/@mariozechner__pi-ai.patch b/patches/@mariozechner__pi-ai.patch
index b4cdf8e51..aa03fc55a 100644
--- a/patches/@mariozechner__pi-ai.patch
+++ b/patches/@mariozechner__pi-ai.patch
@@ -1,8 +1,52 @@
diff --git a/dist/providers/google-shared.js b/dist/providers/google-shared.js
-index 7bc0a9f5d6241f191cd607ecb37b3acac8d58267..76166a34784cbc0718d4b9bd1fa6336a6dd394ec 100644
+index 7bc0a9f5d6241f191cd607ecb37b3acac8d58267..56866774e47444b5d333961c9b20fce582363124 100644
--- a/dist/providers/google-shared.js
+++ b/dist/providers/google-shared.js
-@@ -51,9 +51,19 @@ export function convertMessages(model, context) {
+@@ -10,13 +10,27 @@ import { transformMessages } from "./transorm-messages.js";
+ export function convertMessages(model, context) {
+ const contents = [];
+ const transformedMessages = transformMessages(context.messages, model);
++
++ /**
++ * Helper to add content while merging consecutive messages of the same role.
++ * Gemini/Cloud Code Assist requires strict role alternation (user/model/user/model).
++ * Consecutive messages of the same role cause "function call turn" errors.
++ */
++ function addContent(role, parts) {
++ if (parts.length === 0) return;
++ const lastContent = contents[contents.length - 1];
++ if (lastContent?.role === role) {
++ // Merge into existing message of same role
++ lastContent.parts.push(...parts);
++ } else {
++ contents.push({ role, parts });
++ }
++ }
++
+ for (const msg of transformedMessages) {
+ if (msg.role === "user") {
+ if (typeof msg.content === "string") {
+- contents.push({
+- role: "user",
+- parts: [{ text: sanitizeSurrogates(msg.content) }],
+- });
++ addContent("user", [{ text: sanitizeSurrogates(msg.content) }]);
+ }
+ else {
+ const parts = msg.content.map((item) => {
+@@ -35,10 +49,7 @@ export function convertMessages(model, context) {
+ const filteredParts = !model.input.includes("image") ? parts.filter((p) => p.text !== undefined) : parts;
+ if (filteredParts.length === 0)
+ continue;
+- contents.push({
+- role: "user",
+- parts: filteredParts,
+- });
++ addContent("user", filteredParts);
+ }
+ }
+ else if (msg.role === "assistant") {
+@@ -51,9 +62,19 @@ export function convertMessages(model, context) {
parts.push({ text: sanitizeSurrogates(block.text) });
}
else if (block.type === "thinking") {
@@ -25,7 +69,7 @@ index 7bc0a9f5d6241f191cd607ecb37b3acac8d58267..76166a34784cbc0718d4b9bd1fa6336a
parts.push({
thought: true,
text: sanitizeSurrogates(block.thinking),
-@@ -61,6 +71,7 @@ export function convertMessages(model, context) {
+@@ -61,6 +82,7 @@ export function convertMessages(model, context) {
});
}
else {
@@ -33,7 +77,44 @@ index 7bc0a9f5d6241f191cd607ecb37b3acac8d58267..76166a34784cbc0718d4b9bd1fa6336a
parts.push({
text: `\n${sanitizeSurrogates(block.thinking)}\n `,
});
-@@ -146,6 +157,77 @@ export function convertMessages(model, context) {
+@@ -85,10 +107,7 @@ export function convertMessages(model, context) {
+ }
+ if (parts.length === 0)
+ continue;
+- contents.push({
+- role: "model",
+- parts,
+- });
++ addContent("model", parts);
+ }
+ else if (msg.role === "toolResult") {
+ // Extract text and image content
+@@ -125,27 +144,94 @@ export function convertMessages(model, context) {
+ }
+ // Cloud Code Assist API requires all function responses to be in a single user turn.
+ // Check if the last content is already a user turn with function responses and merge.
++ // Use addContent for proper role alternation handling.
+ const lastContent = contents[contents.length - 1];
+ if (lastContent?.role === "user" && lastContent.parts?.some((p) => p.functionResponse)) {
+ lastContent.parts.push(functionResponsePart);
+ }
+ else {
+- contents.push({
+- role: "user",
+- parts: [functionResponsePart],
+- });
++ addContent("user", [functionResponsePart]);
+ }
+ // For older models, add images in a separate user message
++ // Note: This may create consecutive user messages, but addContent will merge them
+ if (hasImages && !supportsMultimodalFunctionResponse) {
+- contents.push({
+- role: "user",
+- parts: [{ text: "Tool result image:" }, ...imageParts],
+- });
++ addContent("user", [{ text: "Tool result image:" }, ...imageParts]);
+ }
+ }
}
return contents;
}
@@ -111,7 +192,7 @@ index 7bc0a9f5d6241f191cd607ecb37b3acac8d58267..76166a34784cbc0718d4b9bd1fa6336a
/**
* Convert tools to Gemini function declarations format.
*/
-@@ -157,7 +239,7 @@ export function convertTools(tools) {
+@@ -157,7 +243,7 @@ export function convertTools(tools) {
functionDeclarations: tools.map((tool) => ({
name: tool.name,
description: tool.description,
diff --git a/patches/@mariozechner__pi-coding-agent@0.32.3.patch b/patches/@mariozechner__pi-coding-agent@0.32.3.patch
deleted file mode 100644
index a56be808a..000000000
--- a/patches/@mariozechner__pi-coding-agent@0.32.3.patch
+++ /dev/null
@@ -1,17 +0,0 @@
-diff --git a/dist/config.js b/dist/config.js
-index 7caa66d2676933b102431ec8d92c571eb9d6d82c..77103b9d9573e56c26014c8c7c918e1f853afcdc 100644
---- a/dist/config.js
-+++ b/dist/config.js
-@@ -10,8 +10,11 @@ const __dirname = dirname(__filename);
- /**
- * Detect if we're running as a Bun compiled binary.
- * Bun binaries have import.meta.url containing "$bunfs", "~BUN", or "%7EBUN" (Bun's virtual filesystem path)
-+ * Some packaging workflows keep import.meta.url as a file:// path, so fall back to execPath next to package.json.
- */
--export const isBunBinary = import.meta.url.includes("$bunfs") || import.meta.url.includes("~BUN") || import.meta.url.includes("%7EBUN");
-+const bunBinaryByUrl = import.meta.url.includes("$bunfs") || import.meta.url.includes("~BUN") || import.meta.url.includes("%7EBUN");
-+const bunBinaryByExecPath = existsSync(join(dirname(process.execPath), "package.json"));
-+export const isBunBinary = bunBinaryByUrl || bunBinaryByExecPath;
- // =============================================================================
- // Package Asset Paths (shipped with executable)
- // =============================================================================
diff --git a/patches/playwright-core@1.57.0.patch b/patches/playwright-core@1.57.0.patch
new file mode 100644
index 000000000..97e5ad3ae
--- /dev/null
+++ b/patches/playwright-core@1.57.0.patch
@@ -0,0 +1,13 @@
+diff --git a/lib/utilsBundle.js b/lib/utilsBundle.js
+index 7dd8831f29c19f2e20468508b77b0a3f9d204ae6..c50a1ac2b3439a5b2fbf8afa61c369360710071f 100644
+--- a/lib/utilsBundle.js
++++ b/lib/utilsBundle.js
+@@ -59,7 +59,7 @@ const program = require("./utilsBundleImpl").program;
+ const ProgramOption = require("./utilsBundleImpl").ProgramOption;
+ const progress = require("./utilsBundleImpl").progress;
+ const SocksProxyAgent = require("./utilsBundleImpl").SocksProxyAgent;
+-const ws = require("./utilsBundleImpl").ws;
++const ws = "Bun" in globalThis ? require("ws") : require("./utilsBundleImpl").ws;
+ const wsServer = require("./utilsBundleImpl").wsServer;
+ const wsReceiver = require("./utilsBundleImpl").wsReceiver;
+ const wsSender = require("./utilsBundleImpl").wsSender;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ce9e10913..cdaa899e7 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -9,8 +9,11 @@ overrides:
patchedDependencies:
'@mariozechner/pi-ai':
- hash: 628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5
+ hash: b49275c3e2023970d8248ababef6df60e093e58a3ba3127c2ba4de1df387d06a
path: patches/@mariozechner__pi-ai.patch
+ playwright-core@1.57.0:
+ hash: 66f1f266424dbe354068aaa5bba87bfb0e1d7d834a938c25dd70d43cdf1c1b02
+ path: patches/playwright-core@1.57.0.patch
qrcode-terminal:
hash: ed82029850dbdf551f5df1de320945af52b8ea8500cc7bd4f39258e7a3d92e12
path: patches/qrcode-terminal.patch
@@ -19,6 +22,9 @@ importers:
.:
dependencies:
+ '@buape/carbon':
+ specifier: ^0.13.0
+ version: 0.13.0(@types/react@19.2.7)(hono@4.11.3)
'@clack/prompts':
specifier: ^0.11.0
version: 0.11.0
@@ -29,17 +35,17 @@ importers:
specifier: ^1.3.4
version: 1.3.4
'@mariozechner/pi-agent-core':
- specifier: ^0.36.0
- version: 0.36.0(ws@8.19.0)(zod@4.3.5)
+ specifier: ^0.37.2
+ version: 0.37.2(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-ai':
- specifier: ^0.36.0
- version: 0.36.0(patch_hash=628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5)(ws@8.19.0)(zod@4.3.5)
+ specifier: ^0.37.2
+ version: 0.37.2(patch_hash=b49275c3e2023970d8248ababef6df60e093e58a3ba3127c2ba4de1df387d06a)(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-coding-agent':
- specifier: ^0.36.0
- version: 0.36.0(ws@8.19.0)(zod@4.3.5)
+ specifier: ^0.37.2
+ version: 0.37.2(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-tui':
- specifier: ^0.36.0
- version: 0.36.0
+ specifier: ^0.37.2
+ version: 0.37.2
'@sinclair/typebox':
specifier: 0.34.46
version: 0.34.46
@@ -79,9 +85,6 @@ importers:
discord-api-types:
specifier: ^0.38.37
version: 0.38.37
- discord.js:
- specifier: ^14.25.1
- version: 14.25.1
dotenv:
specifier: ^17.2.3
version: 17.2.3
@@ -102,7 +105,10 @@ importers:
version: 5.3.2
playwright-core:
specifier: 1.57.0
- version: 1.57.0
+ version: 1.57.0(patch_hash=66f1f266424dbe354068aaa5bba87bfb0e1d7d834a938c25dd70d43cdf1c1b02)
+ proper-lockfile:
+ specifier: ^4.1.2
+ version: 4.1.2
qrcode-terminal:
specifier: ^0.12.0
version: 0.12.0(patch_hash=ed82029850dbdf551f5df1de320945af52b8ea8500cc7bd4f39258e7a3d92e12)
@@ -146,6 +152,9 @@ importers:
'@types/node':
specifier: ^25.0.3
version: 25.0.3
+ '@types/proper-lockfile':
+ specifier: ^4.1.4
+ version: 4.1.4
'@types/qrcode-terminal':
specifier: ^0.12.2
version: 0.12.2
@@ -188,9 +197,6 @@ importers:
signal-utils:
specifier: ^0.21.1
version: 0.21.1(signal-polyfill@0.2.2)
- tsx:
- specifier: ^4.21.0
- version: 4.21.0
typescript:
specifier: ^5.9.3
version: 5.9.3
@@ -325,6 +331,9 @@ packages:
'@borewit/text-codec@0.2.1':
resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==}
+ '@buape/carbon@0.13.0':
+ resolution: {integrity: sha512-N52sGIJj832IezL+JmekC4gE7cCORj8r8mCJ1vsHOZiyr3O2pvsUA930E1j+rjStkd67TLxURPRMrpyqAFveIg==}
+
'@cacheable/memory@2.0.7':
resolution: {integrity: sha512-RbxnxAMf89Tp1dLhXMS7ceft/PGsDl1Ip7T20z5nZ+pwIAsQ1p2izPjVG69oCLv/jfQ7HDPHTWK0c9rcAWXN3A==}
@@ -341,6 +350,9 @@ packages:
'@clack/prompts@0.11.0':
resolution: {integrity: sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==}
+ '@cloudflare/workers-types@4.20250513.0':
+ resolution: {integrity: sha512-TXaQyWLqhxEmi/DHx+VSaHZ4DHF/uJCPVv/hRyC7M/eWBo/I7mBtAkUEsrhqcKKO9oCeeRUHUHoeRLh5Gd96Gg==}
+
'@crosscopy/clipboard-darwin-arm64@0.2.8':
resolution: {integrity: sha512-Y36ST9k5JZgtDE6SBT45bDNkPKBHd4UEIZgWnC0iC4kAWwdjPmsZ8Mn8e5W0YUKowJ/BDcO+EGm2tVTPQOQKXg==}
engines: {node: '>= 10'}
@@ -392,34 +404,6 @@ packages:
resolution: {integrity: sha512-0qRWscafAHzQ+DdfXX+YgPN2KDTIzWBNfN5Q6z1CgCWsRxtkwK8HfQUc00xIejfRWSGWPIxcCTg82hvg06bodg==}
engines: {node: '>= 10'}
- '@discordjs/builders@1.13.1':
- resolution: {integrity: sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w==}
- engines: {node: '>=16.11.0'}
-
- '@discordjs/collection@1.5.3':
- resolution: {integrity: sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==}
- engines: {node: '>=16.11.0'}
-
- '@discordjs/collection@2.1.1':
- resolution: {integrity: sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==}
- engines: {node: '>=18'}
-
- '@discordjs/formatters@0.6.2':
- resolution: {integrity: sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==}
- engines: {node: '>=16.11.0'}
-
- '@discordjs/rest@2.6.0':
- resolution: {integrity: sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w==}
- engines: {node: '>=18'}
-
- '@discordjs/util@1.2.0':
- resolution: {integrity: sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==}
- engines: {node: '>=18'}
-
- '@discordjs/ws@1.2.3':
- resolution: {integrity: sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==}
- engines: {node: '>=16.11.0'}
-
'@emnapi/core@1.8.1':
resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==}
@@ -619,6 +603,12 @@ packages:
resolution: {integrity: sha512-qK6ZgGx0wwOubq/MY6eTbhApQHBUQCvCOsTYpQE01uLvfA2/Prm6egySHlZouKaina1RPuDwfLhCmsRCxwHj3Q==}
hasBin: true
+ '@hono/node-server@1.18.2':
+ resolution: {integrity: sha512-icgNvC0vRYivzyuSSaUv9ttcwtN8fDyd1k3AOIBDJgYd84tXRZSS6na8X54CY/oYoFTNhEmZraW/Rb9XYwX4KA==}
+ engines: {node: '>=18.14.1'}
+ peerDependencies:
+ hono: ^4
+
'@img/colour@1.0.0':
resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==}
engines: {node: '>=18'}
@@ -804,22 +794,22 @@ packages:
peerDependencies:
lit: ^3.3.1
- '@mariozechner/pi-agent-core@0.36.0':
- resolution: {integrity: sha512-86BI1/j/MLxQHSWRXVLz8+NuSmDvLQebNb40+lFDI9XI9YBh8+r5fkYgU43u4j2TvANZ7iW6SFFnhWhzy8y6dg==}
+ '@mariozechner/pi-agent-core@0.37.2':
+ resolution: {integrity: sha512-GAN1lDVmlY1yH/FCfvpH29f2WBoqqMQkda7zKthOJO9l8tagxnlCWtq078CjzUGYlTDhKSf388XlOuDByBGYLA==}
engines: {node: '>=20.0.0'}
- '@mariozechner/pi-ai@0.36.0':
- resolution: {integrity: sha512-xkzTgvdMzAZ/L/TgMH8z9Zi+aH0EWc54l5ygiafwvCgDk7xvfbylQG6pa9yn5zEn9T4NF9byJNk+nMHnycZvMQ==}
+ '@mariozechner/pi-ai@0.37.2':
+ resolution: {integrity: sha512-IhhvlPrgkdrlbS7QnV+qJPmlzKyae/aI1kenclG18/dXCypxUU50OuzGoVwrXvXw/RIHRwodhd7w4IH38Z7W4Q==}
engines: {node: '>=20.0.0'}
hasBin: true
- '@mariozechner/pi-coding-agent@0.36.0':
- resolution: {integrity: sha512-lKdpuGE0yVs/96GnDhrPLEEFhRteHRtnkfX04KIBpcsEXXg2vyAlpxtjtZ9nlhYqLLIY7qJRkeyjbhcFFfbAAA==}
+ '@mariozechner/pi-coding-agent@0.37.2':
+ resolution: {integrity: sha512-wRFqcyY76h4mONO1si2oAn9WVKnhmVV28dPHjQXVPrl7uSwMCLn+Fcde/nmbL29pYfiU1il4GmUR+iSyoxBUVQ==}
engines: {node: '>=20.0.0'}
hasBin: true
- '@mariozechner/pi-tui@0.36.0':
- resolution: {integrity: sha512-4n+nmTd36q0AVCbqWmjtTHTjIEwlGayKKhc+4QbpN9U3Z9jyQQa8Za1P2OHRmi6Jeu+ISuf4VBDvgmgCaxPZYg==}
+ '@mariozechner/pi-tui@0.37.2':
+ resolution: {integrity: sha512-XNV+jEeWJxQ8U3r5njRotVs6DnEIunkLHSA4nnF4OaRRgrcsafD8M4Pm/3RywSucclVK8P7+KoGiBB2Lokkmuw==}
engines: {node: '>=20.0.0'}
'@mistralai/mistralai@1.10.0':
@@ -1146,18 +1136,6 @@ packages:
cpu: [x64]
os: [win32]
- '@sapphire/async-queue@1.5.5':
- resolution: {integrity: sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==}
- engines: {node: '>=v14.0.0', npm: '>=7.0.0'}
-
- '@sapphire/shapeshift@4.0.0':
- resolution: {integrity: sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==}
- engines: {node: '>=v16'}
-
- '@sapphire/snowflake@3.5.3':
- resolution: {integrity: sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==}
- engines: {node: '>=v14.0.0', npm: '>=7.0.0'}
-
'@sinclair/typebox@0.34.46':
resolution: {integrity: sha512-kiW7CtS/NkdvTUjkjUJo7d5JsFfbJ14YjdhDk9KoEgK6nFjKNXZPrX0jfLA8ZlET4cFLHxOZ/0vFKOP+bOxIOQ==}
@@ -1224,6 +1202,9 @@ packages:
'@types/body-parser@1.19.6':
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
+ '@types/bun@1.2.23':
+ resolution: {integrity: sha512-le8ueOY5b6VKYf19xT3McVbXqLqmxzPXHsQT/q9JHgikJ2X22wyTW3g3ohz2ZMnp7dod6aduIiq8A14Xyimm0A==}
+
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
@@ -1269,9 +1250,15 @@ packages:
'@types/node@10.17.60':
resolution: {integrity: sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==}
+ '@types/node@22.19.3':
+ resolution: {integrity: sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==}
+
'@types/node@25.0.3':
resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==}
+ '@types/proper-lockfile@4.1.4':
+ resolution: {integrity: sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==}
+
'@types/qrcode-terminal@0.12.2':
resolution: {integrity: sha512-v+RcIEJ+Uhd6ygSQ0u5YYY7ZM+la7GgPbs0V/7l/kFs2uO4S8BcIUEMoP7za4DNIqNnUD5npf0A/7kBhrCKG5Q==}
@@ -1281,9 +1268,15 @@ packages:
'@types/range-parser@1.2.7':
resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
+ '@types/react@19.2.7':
+ resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==}
+
'@types/retry@0.12.0':
resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==}
+ '@types/retry@0.12.5':
+ resolution: {integrity: sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==}
+
'@types/send@1.2.1':
resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==}
@@ -1350,10 +1343,6 @@ packages:
'@vitest/utils@4.0.16':
resolution: {integrity: sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==}
- '@vladfrangu/async_event_emitter@2.4.7':
- resolution: {integrity: sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==}
- engines: {node: '>=v14.0.0', npm: '>=7.0.0'}
-
'@wasm-audio-decoders/common@9.0.7':
resolution: {integrity: sha512-WRaUuWSKV7pkttBygml/a6dIEpatq2nnZGFIoPTc5yPLkxL6Wk4YaslPM98OPQvWacvNZ+Py9xROGDtrFBDzag==}
@@ -1525,6 +1514,11 @@ packages:
buffer@6.0.3:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
+ bun-types@1.2.23:
+ resolution: {integrity: sha512-R9f0hKAZXgFU3mlrA0YpE/fiDvwV0FT9rORApt2aQVWSuJDzZOyB5QLc0N/4HF57CS8IXJ6+L5E4W1bW6NS2Aw==}
+ peerDependencies:
+ '@types/react': ^19
+
bytes@3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'}
@@ -1635,6 +1629,9 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
+ csstype@3.2.3:
+ resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+
curve25519-js@0.0.4:
resolution: {integrity: sha512-axn2UMEnkhyDUPWOwVKBMVIzSQy2ejH2xRGy1wq81dqRwApXfIzfbE3hIX0ZRFBIihf/KDqK158DLwESu4AK1w==}
@@ -1674,13 +1671,12 @@ packages:
resolution: {integrity: sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==}
engines: {node: '>=0.3.1'}
+ discord-api-types@0.38.29:
+ resolution: {integrity: sha512-+5BfrjLJN1hrrcK0MxDQli6NSv5lQH7Y3/qaOfk9+k7itex8RkA/UcevVMMLe8B4IKIawr4ITBTb2fBB2vDORg==}
+
discord-api-types@0.38.37:
resolution: {integrity: sha512-Cv47jzY1jkGkh5sv0bfHYqGgKOWO1peOrGMkDFM4UmaGMOTgOW8QSexhvixa9sVOiz8MnVOBryWYyw/CEVhj7w==}
- discord.js@14.25.1:
- resolution: {integrity: sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g==}
- engines: {node: '>=18'}
-
docx-preview@0.3.7:
resolution: {integrity: sha512-Lav69CTA/IYZPJTsKH7oYeoZjyg96N0wEJMNslGJnZJ+dMUZK85Lt5ASC79yUlD48ecWjuv+rkcmFt6EVPV0Xg==}
@@ -1954,6 +1950,10 @@ packages:
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
engines: {node: '>=12.0.0'}
+ hono@4.11.3:
+ resolution: {integrity: sha512-PmQi306+M/ct/m5s66Hrg+adPnkD5jiO6IjA7WhWw0gSBSo1EcRegwuI1deZ+wd5pzCGynCcn2DprnE4/yEV4w==}
+ engines: {node: '>=16.9.0'}
+
hookified@1.15.0:
resolution: {integrity: sha512-51w+ZZGt7Zw5q7rM3nC4t3aLn/xvKDETsXqMczndvwyVQhAHfUmUuFBRFcos8Iyebtk7OAE9dL26wFNzZVVOkw==}
@@ -2207,9 +2207,6 @@ packages:
lodash.once@4.1.1:
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
- lodash.snakecase@4.1.1:
- resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==}
-
lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
@@ -2236,9 +2233,6 @@ packages:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true
- magic-bytes.js@1.12.1:
- resolution: {integrity: sha512-ThQLOhN86ZkJ7qemtVRGYM+gRgR8GEXNli9H/PMvpnZsE44Xfh3wx9kGJaldg314v85m+bFW6WBMaVHJc/c3zA==}
-
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -2891,9 +2885,6 @@ packages:
ts-algebra@2.0.0:
resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==}
- ts-mixer@6.0.4:
- resolution: {integrity: sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==}
-
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -2929,13 +2920,12 @@ packages:
resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==}
engines: {node: '>=18'}
+ undici-types@6.21.0:
+ resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
+
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
- undici@6.21.3:
- resolution: {integrity: sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==}
- engines: {node: '>=18.17'}
-
undici@7.18.0:
resolution: {integrity: sha512-CfPufgPFHCYu0W4h1NiKW9+tNJ39o3kWm7Cm29ET1enSJx+AERfz7A2wAr26aY0SZbYzZlTBQtcHy15o60VZfQ==}
engines: {node: '>=20.18.1'}
@@ -3076,6 +3066,18 @@ packages:
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
+ ws@8.18.3:
+ resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==}
+ engines: {node: '>=10.0.0'}
+ peerDependencies:
+ bufferutil: ^4.0.1
+ utf-8-validate: '>=5.0.2'
+ peerDependenciesMeta:
+ bufferutil:
+ optional: true
+ utf-8-validate:
+ optional: true
+
ws@8.19.0:
resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==}
engines: {node: '>=10.0.0'}
@@ -3185,6 +3187,22 @@ snapshots:
'@borewit/text-codec@0.2.1': {}
+ '@buape/carbon@0.13.0(@types/react@19.2.7)(hono@4.11.3)':
+ dependencies:
+ '@types/node': 22.19.3
+ discord-api-types: 0.38.29
+ optionalDependencies:
+ '@cloudflare/workers-types': 4.20250513.0
+ '@hono/node-server': 1.18.2(hono@4.11.3)
+ '@types/bun': 1.2.23(@types/react@19.2.7)
+ '@types/ws': 8.18.1
+ ws: 8.18.3
+ transitivePeerDependencies:
+ - '@types/react'
+ - bufferutil
+ - hono
+ - utf-8-validate
+
'@cacheable/memory@2.0.7':
dependencies:
'@cacheable/utils': 2.3.3
@@ -3214,6 +3232,9 @@ snapshots:
picocolors: 1.1.1
sisteransi: 1.0.5
+ '@cloudflare/workers-types@4.20250513.0':
+ optional: true
+
'@crosscopy/clipboard-darwin-arm64@0.2.8':
optional: true
@@ -3249,55 +3270,6 @@ snapshots:
'@crosscopy/clipboard-win32-arm64-msvc': 0.2.8
'@crosscopy/clipboard-win32-x64-msvc': 0.2.8
- '@discordjs/builders@1.13.1':
- dependencies:
- '@discordjs/formatters': 0.6.2
- '@discordjs/util': 1.2.0
- '@sapphire/shapeshift': 4.0.0
- discord-api-types: 0.38.37
- fast-deep-equal: 3.1.3
- ts-mixer: 6.0.4
- tslib: 2.8.1
-
- '@discordjs/collection@1.5.3': {}
-
- '@discordjs/collection@2.1.1': {}
-
- '@discordjs/formatters@0.6.2':
- dependencies:
- discord-api-types: 0.38.37
-
- '@discordjs/rest@2.6.0':
- dependencies:
- '@discordjs/collection': 2.1.1
- '@discordjs/util': 1.2.0
- '@sapphire/async-queue': 1.5.5
- '@sapphire/snowflake': 3.5.3
- '@vladfrangu/async_event_emitter': 2.4.7
- discord-api-types: 0.38.37
- magic-bytes.js: 1.12.1
- tslib: 2.8.1
- undici: 6.21.3
-
- '@discordjs/util@1.2.0':
- dependencies:
- discord-api-types: 0.38.37
-
- '@discordjs/ws@1.2.3':
- dependencies:
- '@discordjs/collection': 2.1.1
- '@discordjs/rest': 2.6.0
- '@discordjs/util': 1.2.0
- '@sapphire/async-queue': 1.5.5
- '@types/ws': 8.18.1
- '@vladfrangu/async_event_emitter': 2.4.7
- discord-api-types: 0.38.37
- tslib: 2.8.1
- ws: 8.19.0
- transitivePeerDependencies:
- - bufferutil
- - utf-8-validate
-
'@emnapi/core@1.8.1':
dependencies:
'@emnapi/wasi-threads': 1.1.0
@@ -3428,6 +3400,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@hono/node-server@1.18.2(hono@4.11.3)':
+ dependencies:
+ hono: 4.11.3
+ optional: true
+
'@img/colour@1.0.0': {}
'@img/sharp-darwin-arm64@0.34.5':
@@ -3588,10 +3565,10 @@ snapshots:
transitivePeerDependencies:
- tailwindcss
- '@mariozechner/pi-agent-core@0.36.0(ws@8.19.0)(zod@4.3.5)':
+ '@mariozechner/pi-agent-core@0.37.2(ws@8.19.0)(zod@4.3.5)':
dependencies:
- '@mariozechner/pi-ai': 0.36.0(patch_hash=628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5)(ws@8.19.0)(zod@4.3.5)
- '@mariozechner/pi-tui': 0.36.0
+ '@mariozechner/pi-ai': 0.37.2(patch_hash=b49275c3e2023970d8248ababef6df60e093e58a3ba3127c2ba4de1df387d06a)(ws@8.19.0)(zod@4.3.5)
+ '@mariozechner/pi-tui': 0.37.2
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
- bufferutil
@@ -3600,7 +3577,7 @@ snapshots:
- ws
- zod
- '@mariozechner/pi-ai@0.36.0(patch_hash=628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5)(ws@8.19.0)(zod@4.3.5)':
+ '@mariozechner/pi-ai@0.37.2(patch_hash=b49275c3e2023970d8248ababef6df60e093e58a3ba3127c2ba4de1df387d06a)(ws@8.19.0)(zod@4.3.5)':
dependencies:
'@anthropic-ai/sdk': 0.71.2(zod@4.3.5)
'@google/genai': 1.34.0
@@ -3620,12 +3597,12 @@ snapshots:
- ws
- zod
- '@mariozechner/pi-coding-agent@0.36.0(ws@8.19.0)(zod@4.3.5)':
+ '@mariozechner/pi-coding-agent@0.37.2(ws@8.19.0)(zod@4.3.5)':
dependencies:
'@crosscopy/clipboard': 0.2.8
- '@mariozechner/pi-agent-core': 0.36.0(ws@8.19.0)(zod@4.3.5)
- '@mariozechner/pi-ai': 0.36.0(patch_hash=628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5)(ws@8.19.0)(zod@4.3.5)
- '@mariozechner/pi-tui': 0.36.0
+ '@mariozechner/pi-agent-core': 0.37.2(ws@8.19.0)(zod@4.3.5)
+ '@mariozechner/pi-ai': 0.37.2(patch_hash=b49275c3e2023970d8248ababef6df60e093e58a3ba3127c2ba4de1df387d06a)(ws@8.19.0)(zod@4.3.5)
+ '@mariozechner/pi-tui': 0.37.2
chalk: 5.6.2
cli-highlight: 2.1.11
diff: 8.0.2
@@ -3633,6 +3610,7 @@ snapshots:
glob: 11.1.0
jiti: 2.6.1
marked: 15.0.12
+ proper-lockfile: 4.1.2
sharp: 0.34.5
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
@@ -3642,7 +3620,7 @@ snapshots:
- ws
- zod
- '@mariozechner/pi-tui@0.36.0':
+ '@mariozechner/pi-tui@0.37.2':
dependencies:
'@types/mime-types': 2.1.4
chalk: 5.6.2
@@ -3859,15 +3837,6 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.54.0':
optional: true
- '@sapphire/async-queue@1.5.5': {}
-
- '@sapphire/shapeshift@4.0.0':
- dependencies:
- fast-deep-equal: 3.1.3
- lodash: 4.17.21
-
- '@sapphire/snowflake@3.5.3': {}
-
'@sinclair/typebox@0.34.46': {}
'@slack/bolt@4.6.0(@types/express@5.0.6)':
@@ -3984,6 +3953,13 @@ snapshots:
'@types/connect': 3.4.38
'@types/node': 25.0.3
+ '@types/bun@1.2.23(@types/react@19.2.7)':
+ dependencies:
+ bun-types: 1.2.23(@types/react@19.2.7)
+ transitivePeerDependencies:
+ - '@types/react'
+ optional: true
+
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
@@ -4034,18 +4010,33 @@ snapshots:
'@types/node@10.17.60': {}
+ '@types/node@22.19.3':
+ dependencies:
+ undici-types: 6.21.0
+
'@types/node@25.0.3':
dependencies:
undici-types: 7.16.0
+ '@types/proper-lockfile@4.1.4':
+ dependencies:
+ '@types/retry': 0.12.5
+
'@types/qrcode-terminal@0.12.2': {}
'@types/qs@6.14.0': {}
'@types/range-parser@1.2.7': {}
+ '@types/react@19.2.7':
+ dependencies:
+ csstype: 3.2.3
+ optional: true
+
'@types/retry@0.12.0': {}
+ '@types/retry@0.12.5': {}
+
'@types/send@1.2.1':
dependencies:
'@types/node': 25.0.3
@@ -4162,8 +4153,6 @@ snapshots:
'@vitest/pretty-format': 4.0.16
tinyrainbow: 3.0.3
- '@vladfrangu/async_event_emitter@2.4.7': {}
-
'@wasm-audio-decoders/common@9.0.7':
dependencies:
'@eshaz/web-worker': 1.2.2
@@ -4358,6 +4347,12 @@ snapshots:
base64-js: 1.5.1
ieee754: 1.2.1
+ bun-types@1.2.23(@types/react@19.2.7):
+ dependencies:
+ '@types/node': 25.0.3
+ '@types/react': 19.2.7
+ optional: true
+
bytes@3.1.2: {}
cacheable@2.3.1:
@@ -4473,6 +4468,9 @@ snapshots:
shebang-command: 2.0.0
which: 2.0.2
+ csstype@3.2.3:
+ optional: true
+
curve25519-js@0.0.4: {}
data-uri-to-buffer@4.0.1: {}
@@ -4494,26 +4492,9 @@ snapshots:
diff@8.0.2: {}
- discord-api-types@0.38.37: {}
+ discord-api-types@0.38.29: {}
- discord.js@14.25.1:
- dependencies:
- '@discordjs/builders': 1.13.1
- '@discordjs/collection': 1.5.3
- '@discordjs/formatters': 0.6.2
- '@discordjs/rest': 2.6.0
- '@discordjs/util': 1.2.0
- '@discordjs/ws': 1.2.3
- '@sapphire/snowflake': 3.5.3
- discord-api-types: 0.38.37
- fast-deep-equal: 3.1.3
- lodash.snakecase: 4.1.1
- magic-bytes.js: 1.12.1
- tslib: 2.8.1
- undici: 6.21.3
- transitivePeerDependencies:
- - bufferutil
- - utf-8-validate
+ discord-api-types@0.38.37: {}
docx-preview@0.3.7:
dependencies:
@@ -4773,6 +4754,7 @@ snapshots:
get-tsconfig@4.13.0:
dependencies:
resolve-pkg-maps: 1.0.0
+ optional: true
glob-parent@5.1.2:
dependencies:
@@ -4851,6 +4833,9 @@ snapshots:
highlight.js@11.11.1: {}
+ hono@4.11.3:
+ optional: true
+
hookified@1.15.0: {}
html-escaper@2.0.2: {}
@@ -5092,8 +5077,6 @@ snapshots:
lodash.once@4.1.1: {}
- lodash.snakecase@4.1.1: {}
-
lodash@4.17.21: {}
long@4.0.0: {}
@@ -5111,8 +5094,6 @@ snapshots:
lz-string@1.5.0:
optional: true
- magic-bytes.js@1.12.1: {}
-
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -5377,11 +5358,11 @@ snapshots:
dependencies:
pngjs: 7.0.0
- playwright-core@1.57.0: {}
+ playwright-core@1.57.0(patch_hash=66f1f266424dbe354068aaa5bba87bfb0e1d7d834a938c25dd70d43cdf1c1b02): {}
playwright@1.57.0:
dependencies:
- playwright-core: 1.57.0
+ playwright-core: 1.57.0(patch_hash=66f1f266424dbe354068aaa5bba87bfb0e1d7d834a938c25dd70d43cdf1c1b02)
optionalDependencies:
fsevents: 2.3.2
@@ -5534,7 +5515,8 @@ snapshots:
require-from-string@2.0.2: {}
- resolve-pkg-maps@1.0.0: {}
+ resolve-pkg-maps@1.0.0:
+ optional: true
retry@0.12.0: {}
@@ -5844,8 +5826,6 @@ snapshots:
ts-algebra@2.0.0: {}
- ts-mixer@6.0.4: {}
-
tslib@2.8.1: {}
tslog@4.10.2: {}
@@ -5858,6 +5838,7 @@ snapshots:
get-tsconfig: 4.13.0
optionalDependencies:
fsevents: 2.3.3
+ optional: true
type-is@2.0.1:
dependencies:
@@ -5875,9 +5856,9 @@ snapshots:
uint8array-extras@1.5.0: {}
- undici-types@7.16.0: {}
+ undici-types@6.21.0: {}
- undici@6.21.3: {}
+ undici-types@7.16.0: {}
undici@7.18.0: {}
@@ -5998,6 +5979,9 @@ snapshots:
wrappy@1.0.2: {}
+ ws@8.18.3:
+ optional: true
+
ws@8.19.0: {}
y18n@5.0.8: {}
diff --git a/scripts/docs-list.ts b/scripts/docs-list.ts
old mode 100644
new mode 100755
index a631726cf..7fad2594a
--- a/scripts/docs-list.ts
+++ b/scripts/docs-list.ts
@@ -1,4 +1,4 @@
-#!/usr/bin/env tsx
+#!/usr/bin/env bun
import { readdirSync, readFileSync } from 'node:fs';
import { join, relative } from 'node:path';
diff --git a/scripts/package-mac-app.sh b/scripts/package-mac-app.sh
index 7c7fe1b1f..b60a6cb75 100755
--- a/scripts/package-mac-app.sh
+++ b/scripts/package-mac-app.sh
@@ -146,8 +146,8 @@ else
fi
if [[ "${SKIP_UI_BUILD:-0}" != "1" ]]; then
- echo "🖥 Building Control UI (pnpm ui:build)"
- (cd "$ROOT_DIR" && pnpm ui:build)
+ echo "🖥 Building Control UI (ui:build)"
+ (cd "$ROOT_DIR" && node scripts/ui.js build)
else
echo "🖥 Skipping Control UI build (SKIP_UI_BUILD=1)"
fi
diff --git a/scripts/postinstall.js b/scripts/postinstall.js
new file mode 100644
index 000000000..f849c02fd
--- /dev/null
+++ b/scripts/postinstall.js
@@ -0,0 +1,110 @@
+import { spawnSync } from "node:child_process";
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+function isBunInstall() {
+ const ua = process.env.npm_config_user_agent ?? "";
+ return ua.includes("bun/");
+}
+
+function getRepoRoot() {
+ const here = path.dirname(fileURLToPath(import.meta.url));
+ return path.resolve(here, "..");
+}
+
+function run(cmd, args, opts = {}) {
+ const res = spawnSync(cmd, args, { stdio: "inherit", ...opts });
+ if (typeof res.status === "number") return res.status;
+ return 1;
+}
+
+function applyPatchIfNeeded(opts) {
+ const patchPath = path.resolve(opts.patchPath);
+ if (!fs.existsSync(patchPath)) {
+ throw new Error(`missing patch: ${patchPath}`);
+ }
+
+ let targetDir = path.resolve(opts.targetDir);
+ if (!fs.existsSync(targetDir) || !fs.statSync(targetDir).isDirectory()) {
+ console.warn(`[postinstall] skip missing target: ${targetDir}`);
+ return;
+ }
+
+ // Resolve symlinks to avoid "beyond a symbolic link" errors from git apply
+ // (bun/pnpm use symlinks in node_modules)
+ targetDir = fs.realpathSync(targetDir);
+
+ const gitArgsBase = ["apply", "--unsafe-paths", "--whitespace=nowarn"];
+ const reverseCheck = [
+ ...gitArgsBase,
+ "--reverse",
+ "--check",
+ "--directory",
+ targetDir,
+ patchPath,
+ ];
+ const forwardCheck = [
+ ...gitArgsBase,
+ "--check",
+ "--directory",
+ targetDir,
+ patchPath,
+ ];
+ const apply = [...gitArgsBase, "--directory", targetDir, patchPath];
+
+ // Already applied?
+ if (run("git", reverseCheck, { stdio: "ignore" }) === 0) {
+ return;
+ }
+
+ if (run("git", forwardCheck, { stdio: "ignore" }) !== 0) {
+ throw new Error(`patch does not apply cleanly: ${path.basename(patchPath)}`);
+ }
+
+ const status = run("git", apply);
+ if (status !== 0) {
+ throw new Error(`failed applying patch: ${path.basename(patchPath)}`);
+ }
+}
+
+function extractPackageName(key) {
+ if (key.startsWith("@")) {
+ const idx = key.indexOf("@", 1);
+ if (idx === -1) return key;
+ return key.slice(0, idx);
+ }
+ const idx = key.lastIndexOf("@");
+ if (idx <= 0) return key;
+ return key.slice(0, idx);
+}
+
+function main() {
+ if (!isBunInstall()) return;
+
+ const repoRoot = getRepoRoot();
+ process.chdir(repoRoot);
+
+ const pkgPath = path.join(repoRoot, "package.json");
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
+ const patched = pkg?.pnpm?.patchedDependencies ?? {};
+
+ // Bun does not support pnpm.patchedDependencies. Apply these patch files to
+ // node_modules packages as a best-effort compatibility layer.
+ for (const [key, relPatchPath] of Object.entries(patched)) {
+ if (typeof relPatchPath !== "string" || !relPatchPath.trim()) continue;
+ const pkgName = extractPackageName(String(key));
+ if (!pkgName) continue;
+ applyPatchIfNeeded({
+ targetDir: path.join("node_modules", ...pkgName.split("/")),
+ patchPath: relPatchPath,
+ });
+ }
+}
+
+try {
+ main();
+} catch (err) {
+ console.error(String(err));
+ process.exit(1);
+}
diff --git a/scripts/release-check.ts b/scripts/release-check.ts
old mode 100644
new mode 100755
index d9e0b43a3..3863a9d11
--- a/scripts/release-check.ts
+++ b/scripts/release-check.ts
@@ -1,4 +1,4 @@
-#!/usr/bin/env tsx
+#!/usr/bin/env bun
import { execSync } from "node:child_process";
diff --git a/scripts/test-force.ts b/scripts/test-force.ts
old mode 100644
new mode 100755
index 9845fbd69..4e6e3bf9e
--- a/scripts/test-force.ts
+++ b/scripts/test-force.ts
@@ -1,4 +1,4 @@
-#!/usr/bin/env tsx
+#!/usr/bin/env bun
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
diff --git a/scripts/ui.js b/scripts/ui.js
new file mode 100644
index 000000000..8296491b7
--- /dev/null
+++ b/scripts/ui.js
@@ -0,0 +1,130 @@
+#!/usr/bin/env node
+import { spawn, spawnSync } from "node:child_process";
+import fs from "node:fs";
+import { createRequire } from "node:module";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const here = path.dirname(fileURLToPath(import.meta.url));
+const repoRoot = path.resolve(here, "..");
+const uiDir = path.join(repoRoot, "ui");
+
+function usage() {
+ // keep this tiny; it's invoked from npm scripts too
+ process.stderr.write(
+ "Usage: node scripts/ui.js [...args]\n",
+ );
+}
+
+function which(cmd) {
+ try {
+ const key = process.platform === "win32" ? "Path" : "PATH";
+ const paths = (process.env[key] ?? process.env.PATH ?? "")
+ .split(path.delimiter)
+ .filter(Boolean);
+ const extensions =
+ process.platform === "win32"
+ ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM")
+ .split(";")
+ .filter(Boolean)
+ : [""];
+ for (const entry of paths) {
+ for (const ext of extensions) {
+ const candidate = path.join(entry, process.platform === "win32" ? `${cmd}${ext}` : cmd);
+ try {
+ if (fs.existsSync(candidate)) return candidate;
+ } catch {
+ // ignore
+ }
+ }
+ }
+ } catch {
+ // ignore
+ }
+ return null;
+}
+
+function resolveRunner() {
+ const bun = which("bun");
+ if (bun) return { cmd: bun, kind: "bun" };
+ const pnpm = which("pnpm");
+ if (pnpm) return { cmd: pnpm, kind: "pnpm" };
+ return null;
+}
+
+function run(cmd, args) {
+ const child = spawn(cmd, args, {
+ cwd: uiDir,
+ stdio: "inherit",
+ env: process.env,
+ });
+ child.on("exit", (code, signal) => {
+ if (signal) process.exit(1);
+ process.exit(code ?? 1);
+ });
+}
+
+function runSync(cmd, args) {
+ const result = spawnSync(cmd, args, {
+ cwd: uiDir,
+ stdio: "inherit",
+ env: process.env,
+ });
+ if (result.signal) process.exit(1);
+ if ((result.status ?? 1) !== 0) process.exit(result.status ?? 1);
+}
+
+function depsInstalled() {
+ try {
+ const require = createRequire(path.join(uiDir, "package.json"));
+ require.resolve("vite");
+ require.resolve("dompurify");
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+const [, , action, ...rest] = process.argv;
+if (!action) {
+ usage();
+ process.exit(2);
+}
+
+const runner = resolveRunner();
+if (!runner) {
+ process.stderr.write(
+ "Missing UI runner: install bun or pnpm, then retry.\n",
+ );
+ process.exit(1);
+}
+
+const script =
+ action === "install"
+ ? null
+ : action === "dev"
+ ? "dev"
+ : action === "build"
+ ? "build"
+ : action === "test"
+ ? "test"
+ : null;
+
+if (action !== "install" && !script) {
+ usage();
+ process.exit(2);
+}
+
+if (runner.kind === "bun") {
+ if (action === "install") run(runner.cmd, ["install", ...rest]);
+ else {
+ if (!depsInstalled()) runSync(runner.cmd, ["install"]);
+ run(runner.cmd, ["run", script, ...rest]);
+ }
+} else {
+ if (action === "install") run(runner.cmd, ["install", ...rest]);
+ else {
+ if (!depsInstalled()) runSync(runner.cmd, ["install"]);
+ run(runner.cmd, ["run", script, ...rest]);
+ }
+}
diff --git a/skills/nano-banana-pro/SKILL.md b/skills/nano-banana-pro/SKILL.md
index 814ce326b..a36c21f64 100644
--- a/skills/nano-banana-pro/SKILL.md
+++ b/skills/nano-banana-pro/SKILL.md
@@ -26,4 +26,5 @@ API key
Notes
- Resolutions: `1K` (default), `2K`, `4K`.
- Use timestamps in filenames: `yyyy-mm-dd-hh-mm-ss-name.png`.
+- The script prints a `MEDIA:` line for Clawdbot to auto-attach on supported chat providers.
- Do not read the image back; report the saved path only.
diff --git a/skills/nano-banana-pro/scripts/generate_image.py b/skills/nano-banana-pro/scripts/generate_image.py
index b3dbf30ba..48dd9e9e5 100755
--- a/skills/nano-banana-pro/scripts/generate_image.py
+++ b/skills/nano-banana-pro/scripts/generate_image.py
@@ -154,6 +154,8 @@ def main():
if image_saved:
full_path = output_path.resolve()
print(f"\nImage saved: {full_path}")
+ # Clawdbot parses MEDIA tokens and will attach the file on supported providers.
+ print(f"MEDIA: {full_path}")
else:
print("Error: No image was generated in the response.", file=sys.stderr)
sys.exit(1)
diff --git a/src/agents/agent-paths.test.ts b/src/agents/agent-paths.test.ts
new file mode 100644
index 000000000..e70f93717
--- /dev/null
+++ b/src/agents/agent-paths.test.ts
@@ -0,0 +1,58 @@
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+
+import { afterEach, describe, expect, it } from "vitest";
+
+import { resolveClawdbotAgentDir } from "./agent-paths.js";
+
+describe("resolveClawdbotAgentDir", () => {
+ const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
+ const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR;
+ const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
+ let tempStateDir: string | null = null;
+
+ afterEach(async () => {
+ if (tempStateDir) {
+ await fs.rm(tempStateDir, { recursive: true, force: true });
+ tempStateDir = null;
+ }
+ if (previousStateDir === undefined) {
+ delete process.env.CLAWDBOT_STATE_DIR;
+ } else {
+ process.env.CLAWDBOT_STATE_DIR = previousStateDir;
+ }
+ if (previousAgentDir === undefined) {
+ delete process.env.CLAWDBOT_AGENT_DIR;
+ } else {
+ process.env.CLAWDBOT_AGENT_DIR = previousAgentDir;
+ }
+ if (previousPiAgentDir === undefined) {
+ delete process.env.PI_CODING_AGENT_DIR;
+ } else {
+ process.env.PI_CODING_AGENT_DIR = previousPiAgentDir;
+ }
+ });
+
+ it("defaults to the multi-agent path when no overrides are set", async () => {
+ tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
+ process.env.CLAWDBOT_STATE_DIR = tempStateDir;
+ delete process.env.CLAWDBOT_AGENT_DIR;
+ delete process.env.PI_CODING_AGENT_DIR;
+
+ const resolved = resolveClawdbotAgentDir();
+
+ expect(resolved).toBe(path.join(tempStateDir, "agents", "main", "agent"));
+ });
+
+ it("honors CLAWDBOT_AGENT_DIR overrides", async () => {
+ tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
+ const override = path.join(tempStateDir, "agent");
+ process.env.CLAWDBOT_AGENT_DIR = override;
+ delete process.env.PI_CODING_AGENT_DIR;
+
+ const resolved = resolveClawdbotAgentDir();
+
+ expect(resolved).toBe(path.resolve(override));
+ });
+});
diff --git a/src/agents/agent-paths.ts b/src/agents/agent-paths.ts
index 2fe019e75..1dd54ea81 100644
--- a/src/agents/agent-paths.ts
+++ b/src/agents/agent-paths.ts
@@ -1,14 +1,21 @@
import path from "node:path";
-import { resolveConfigDir, resolveUserPath } from "../utils.js";
+import { resolveStateDir } from "../config/paths.js";
+import { DEFAULT_AGENT_ID } from "../routing/session-key.js";
+import { resolveUserPath } from "../utils.js";
export function resolveClawdbotAgentDir(): string {
- const defaultAgentDir = path.join(resolveConfigDir(), "agent");
const override =
process.env.CLAWDBOT_AGENT_DIR?.trim() ||
- process.env.PI_CODING_AGENT_DIR?.trim() ||
- defaultAgentDir;
- return resolveUserPath(override);
+ process.env.PI_CODING_AGENT_DIR?.trim();
+ if (override) return resolveUserPath(override);
+ const defaultAgentDir = path.join(
+ resolveStateDir(),
+ "agents",
+ DEFAULT_AGENT_ID,
+ "agent",
+ );
+ return resolveUserPath(defaultAgentDir);
}
export function ensureClawdbotAgentEnv(): string {
diff --git a/src/agents/agent-scope.ts b/src/agents/agent-scope.ts
new file mode 100644
index 000000000..e462abbcf
--- /dev/null
+++ b/src/agents/agent-scope.ts
@@ -0,0 +1,64 @@
+import os from "node:os";
+import path from "node:path";
+
+import type { ClawdbotConfig } from "../config/config.js";
+import { resolveStateDir } from "../config/paths.js";
+import {
+ DEFAULT_AGENT_ID,
+ normalizeAgentId,
+ parseAgentSessionKey,
+} from "../routing/session-key.js";
+import { resolveUserPath } from "../utils.js";
+import { DEFAULT_AGENT_WORKSPACE_DIR } from "./workspace.js";
+
+export function resolveAgentIdFromSessionKey(
+ sessionKey?: string | null,
+): string {
+ const parsed = parseAgentSessionKey(sessionKey);
+ return normalizeAgentId(parsed?.agentId ?? DEFAULT_AGENT_ID);
+}
+
+export function resolveAgentConfig(
+ cfg: ClawdbotConfig,
+ agentId: string,
+): { workspace?: string; agentDir?: string } | undefined {
+ const id = normalizeAgentId(agentId);
+ const agents = cfg.routing?.agents;
+ if (!agents || typeof agents !== "object") return undefined;
+ const entry = agents[id];
+ if (!entry || typeof entry !== "object") return undefined;
+ return {
+ workspace:
+ typeof entry.workspace === "string" ? entry.workspace : undefined,
+ agentDir: typeof entry.agentDir === "string" ? entry.agentDir : undefined,
+ };
+}
+
+export function resolveAgentWorkspaceDir(cfg: ClawdbotConfig, agentId: string) {
+ const id = normalizeAgentId(agentId);
+ const configured = resolveAgentConfig(cfg, id)?.workspace?.trim();
+ if (configured) return resolveUserPath(configured);
+ if (id === DEFAULT_AGENT_ID) {
+ const legacy = cfg.agent?.workspace?.trim();
+ if (legacy) return resolveUserPath(legacy);
+ return DEFAULT_AGENT_WORKSPACE_DIR;
+ }
+ return path.join(os.homedir(), `clawd-${id}`);
+}
+
+export function resolveAgentDir(cfg: ClawdbotConfig, agentId: string) {
+ const id = normalizeAgentId(agentId);
+ const configured = resolveAgentConfig(cfg, id)?.agentDir?.trim();
+ if (configured) return resolveUserPath(configured);
+ const root = resolveStateDir(process.env, os.homedir);
+ return path.join(root, "agents", id, "agent");
+}
+
+/**
+ * Resolve the agent directory for the default agent without requiring config.
+ * Used by onboarding when writing auth profiles before config is fully set up.
+ */
+export function resolveDefaultAgentDir(): string {
+ const root = resolveStateDir(process.env, os.homedir);
+ return path.join(root, "agents", DEFAULT_AGENT_ID, "agent");
+}
diff --git a/src/agents/auth-profiles.test.ts b/src/agents/auth-profiles.test.ts
index 493f2c09d..f7fecba89 100644
--- a/src/agents/auth-profiles.test.ts
+++ b/src/agents/auth-profiles.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
type AuthProfileStore,
+ calculateAuthProfileCooldownMs,
resolveAuthProfileOrder,
} from "./auth-profiles.js";
@@ -49,13 +50,20 @@ describe("resolveAuthProfileOrder", () => {
expect(order).toContain("anthropic:default");
});
- it("prioritizes last-good profile when no preferred override", () => {
+ it("does not prioritize lastGood over round-robin ordering", () => {
const order = resolveAuthProfileOrder({
cfg,
- store: { ...store, lastGood: { anthropic: "anthropic:work" } },
+ store: {
+ ...store,
+ lastGood: { anthropic: "anthropic:work" },
+ usageStats: {
+ "anthropic:default": { lastUsed: 100 },
+ "anthropic:work": { lastUsed: 200 },
+ },
+ },
provider: "anthropic",
});
- expect(order[0]).toBe("anthropic:work");
+ expect(order[0]).toBe("anthropic:default");
});
it("uses explicit profiles when order is missing", () => {
@@ -105,4 +113,87 @@ describe("resolveAuthProfileOrder", () => {
});
expect(order).toEqual(["anthropic:oauth", "anthropic:default"]);
});
+
+ it("orders by lastUsed when no explicit order exists", () => {
+ const order = resolveAuthProfileOrder({
+ store: {
+ version: 1,
+ profiles: {
+ "anthropic:a": {
+ type: "oauth",
+ provider: "anthropic",
+ access: "access-token",
+ refresh: "refresh-token",
+ expires: Date.now() + 60_000,
+ },
+ "anthropic:b": {
+ type: "api_key",
+ provider: "anthropic",
+ key: "sk-b",
+ },
+ "anthropic:c": {
+ type: "api_key",
+ provider: "anthropic",
+ key: "sk-c",
+ },
+ },
+ usageStats: {
+ "anthropic:a": { lastUsed: 200 },
+ "anthropic:b": { lastUsed: 100 },
+ "anthropic:c": { lastUsed: 300 },
+ },
+ },
+ provider: "anthropic",
+ });
+ expect(order).toEqual(["anthropic:a", "anthropic:b", "anthropic:c"]);
+ });
+
+ it("pushes cooldown profiles to the end, ordered by cooldown expiry", () => {
+ const now = Date.now();
+ const order = resolveAuthProfileOrder({
+ store: {
+ version: 1,
+ profiles: {
+ "anthropic:ready": {
+ type: "api_key",
+ provider: "anthropic",
+ key: "sk-ready",
+ },
+ "anthropic:cool1": {
+ type: "oauth",
+ provider: "anthropic",
+ access: "access-token",
+ refresh: "refresh-token",
+ expires: now + 60_000,
+ },
+ "anthropic:cool2": {
+ type: "api_key",
+ provider: "anthropic",
+ key: "sk-cool",
+ },
+ },
+ usageStats: {
+ "anthropic:ready": { lastUsed: 50 },
+ "anthropic:cool1": { cooldownUntil: now + 5_000 },
+ "anthropic:cool2": { cooldownUntil: now + 1_000 },
+ },
+ },
+ provider: "anthropic",
+ });
+ expect(order).toEqual([
+ "anthropic:ready",
+ "anthropic:cool2",
+ "anthropic:cool1",
+ ]);
+ });
+});
+
+describe("auth profile cooldowns", () => {
+ it("applies exponential backoff with a 1h cap", () => {
+ expect(calculateAuthProfileCooldownMs(1)).toBe(60_000);
+ expect(calculateAuthProfileCooldownMs(2)).toBe(5 * 60_000);
+ expect(calculateAuthProfileCooldownMs(3)).toBe(25 * 60_000);
+ expect(calculateAuthProfileCooldownMs(4)).toBe(60 * 60_000);
+ expect(calculateAuthProfileCooldownMs(5)).toBe(60 * 60_000);
+ });
});
diff --git a/src/agents/auth-profiles.ts b/src/agents/auth-profiles.ts
index f9999f17e..8b0b31866 100644
--- a/src/agents/auth-profiles.ts
+++ b/src/agents/auth-profiles.ts
@@ -6,6 +6,7 @@ import {
type OAuthCredentials,
type OAuthProvider,
} from "@mariozechner/pi-ai";
+import lockfile from "proper-lockfile";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveOAuthPath } from "../config/paths.js";
@@ -31,22 +32,31 @@ export type OAuthCredential = OAuthCredentials & {
export type AuthProfileCredential = ApiKeyCredential | OAuthCredential;
+/** Per-profile usage statistics for round-robin and cooldown tracking */
+export type ProfileUsageStats = {
+ lastUsed?: number;
+ cooldownUntil?: number;
+ errorCount?: number;
+};
+
export type AuthProfileStore = {
version: number;
profiles: Record;
lastGood?: Record;
+ /** Usage statistics per profile for round-robin rotation */
+ usageStats?: Record;
};
type LegacyAuthStore = Record;
-function resolveAuthStorePath(): string {
- const agentDir = resolveClawdbotAgentDir();
- return path.join(agentDir, AUTH_PROFILE_FILENAME);
+function resolveAuthStorePath(agentDir?: string): string {
+ const resolved = resolveUserPath(agentDir ?? resolveClawdbotAgentDir());
+ return path.join(resolved, AUTH_PROFILE_FILENAME);
}
-function resolveLegacyAuthStorePath(): string {
- const agentDir = resolveClawdbotAgentDir();
- return path.join(agentDir, LEGACY_AUTH_FILENAME);
+function resolveLegacyAuthStorePath(agentDir?: string): string {
+ const resolved = resolveUserPath(agentDir ?? resolveClawdbotAgentDir());
+ return path.join(resolved, LEGACY_AUTH_FILENAME);
}
function loadJsonFile(pathname: string): unknown {
@@ -68,6 +78,84 @@ function saveJsonFile(pathname: string, data: unknown) {
fs.chmodSync(pathname, 0o600);
}
+function ensureAuthStoreFile(pathname: string) {
+ if (fs.existsSync(pathname)) return;
+ const payload: AuthProfileStore = {
+ version: AUTH_STORE_VERSION,
+ profiles: {},
+ };
+ saveJsonFile(pathname, payload);
+}
+
+function buildOAuthApiKey(
+ provider: OAuthProvider,
+ credentials: OAuthCredentials,
+): string {
+ const needsProjectId =
+ provider === "google-gemini-cli" || provider === "google-antigravity";
+ return needsProjectId
+ ? JSON.stringify({
+ token: credentials.access,
+ projectId: credentials.projectId,
+ })
+ : credentials.access;
+}
+
+async function refreshOAuthTokenWithLock(params: {
+ profileId: string;
+ provider: OAuthProvider;
+ agentDir?: string;
+}): Promise<{ apiKey: string; newCredentials: OAuthCredentials } | null> {
+ const authPath = resolveAuthStorePath(params.agentDir);
+ ensureAuthStoreFile(authPath);
+
+ let release: (() => Promise) | undefined;
+ try {
+ release = await lockfile.lock(authPath, {
+ retries: {
+ retries: 10,
+ factor: 2,
+ minTimeout: 100,
+ maxTimeout: 10_000,
+ randomize: true,
+ },
+ stale: 30_000,
+ });
+
+ const store = ensureAuthProfileStore(params.agentDir);
+ const cred = store.profiles[params.profileId];
+ if (!cred || cred.type !== "oauth") return null;
+
+ if (Date.now() < cred.expires) {
+ return {
+ apiKey: buildOAuthApiKey(cred.provider, cred),
+ newCredentials: cred,
+ };
+ }
+
+ const oauthCreds: Record = {
+ [cred.provider]: cred,
+ };
+ const result = await getOAuthApiKey(cred.provider, oauthCreds);
+ if (!result) return null;
+ store.profiles[params.profileId] = {
+ ...cred,
+ ...result.newCredentials,
+ type: "oauth",
+ };
+ saveAuthProfileStore(store, params.agentDir);
+ return result;
+ } finally {
+ if (release) {
+ try {
+ await release();
+ } catch {
+ // ignore unlock errors
+ }
+ }
+ }
+}
+
function coerceLegacyStore(raw: unknown): LegacyAuthStore | null {
if (!raw || typeof raw !== "object") return null;
const record = raw as Record;
@@ -105,6 +193,10 @@ function coerceAuthStore(raw: unknown): AuthProfileStore | null {
record.lastGood && typeof record.lastGood === "object"
? (record.lastGood as Record)
: undefined,
+ usageStats:
+ record.usageStats && typeof record.usageStats === "object"
+ ? (record.usageStats as Record)
+ : undefined,
};
}
@@ -170,13 +262,13 @@ export function loadAuthProfileStore(): AuthProfileStore {
return { version: AUTH_STORE_VERSION, profiles: {} };
}
-export function ensureAuthProfileStore(): AuthProfileStore {
- const authPath = resolveAuthStorePath();
+export function ensureAuthProfileStore(agentDir?: string): AuthProfileStore {
+ const authPath = resolveAuthStorePath(agentDir);
const raw = loadJsonFile(authPath);
const asStore = coerceAuthStore(raw);
if (asStore) return asStore;
- const legacyRaw = loadJsonFile(resolveLegacyAuthStorePath());
+ const legacyRaw = loadJsonFile(resolveLegacyAuthStorePath(agentDir));
const legacy = coerceLegacyStore(legacyRaw);
const store: AuthProfileStore = {
version: AUTH_STORE_VERSION,
@@ -216,12 +308,16 @@ export function ensureAuthProfileStore(): AuthProfileStore {
return store;
}
-export function saveAuthProfileStore(store: AuthProfileStore): void {
- const authPath = resolveAuthStorePath();
+export function saveAuthProfileStore(
+ store: AuthProfileStore,
+ agentDir?: string,
+): void {
+ const authPath = resolveAuthStorePath(agentDir);
const payload = {
version: AUTH_STORE_VERSION,
profiles: store.profiles,
lastGood: store.lastGood ?? undefined,
+ usageStats: store.usageStats ?? undefined,
} satisfies AuthProfileStore;
saveJsonFile(authPath, payload);
}
@@ -229,10 +325,11 @@ export function saveAuthProfileStore(store: AuthProfileStore): void {
export function upsertAuthProfile(params: {
profileId: string;
credential: AuthProfileCredential;
+ agentDir?: string;
}): void {
- const store = ensureAuthProfileStore();
+ const store = ensureAuthProfileStore(params.agentDir);
store.profiles[params.profileId] = params.credential;
- saveAuthProfileStore(store);
+ saveAuthProfileStore(store, params.agentDir);
}
export function listProfilesForProvider(
@@ -244,6 +341,93 @@ export function listProfilesForProvider(
.map(([id]) => id);
}
+/**
+ * Check if a profile is currently in cooldown (due to rate limiting or errors).
+ */
+export function isProfileInCooldown(
+ store: AuthProfileStore,
+ profileId: string,
+): boolean {
+ const stats = store.usageStats?.[profileId];
+ if (!stats?.cooldownUntil) return false;
+ return Date.now() < stats.cooldownUntil;
+}
+
+/**
+ * Mark a profile as successfully used. Resets error count and updates lastUsed.
+ */
+export function markAuthProfileUsed(params: {
+ store: AuthProfileStore;
+ profileId: string;
+ agentDir?: string;
+}): void {
+ const { store, profileId, agentDir } = params;
+ if (!store.profiles[profileId]) return;
+
+ store.usageStats = store.usageStats ?? {};
+ store.usageStats[profileId] = {
+ ...store.usageStats[profileId],
+ lastUsed: Date.now(),
+ errorCount: 0,
+ cooldownUntil: undefined,
+ };
+ saveAuthProfileStore(store, agentDir);
+}
+
+export function calculateAuthProfileCooldownMs(errorCount: number): number {
+ const normalized = Math.max(1, errorCount);
+ return Math.min(
+ 60 * 60 * 1000, // 1 hour max
+ 60 * 1000 * 5 ** Math.min(normalized - 1, 3),
+ );
+}
+
+/**
+ * Mark a profile as failed/rate-limited. Applies exponential backoff cooldown.
+ * Cooldown times: 1min, 5min, 25min, max 1 hour.
+ */
+export function markAuthProfileCooldown(params: {
+ store: AuthProfileStore;
+ profileId: string;
+ agentDir?: string;
+}): void {
+ const { store, profileId, agentDir } = params;
+ if (!store.profiles[profileId]) return;
+
+ store.usageStats = store.usageStats ?? {};
+ const existing = store.usageStats[profileId] ?? {};
+ const errorCount = (existing.errorCount ?? 0) + 1;
+
+ // Exponential backoff: 1min, 5min, 25min, capped at 1h
+ const backoffMs = calculateAuthProfileCooldownMs(errorCount);
+
+ store.usageStats[profileId] = {
+ ...existing,
+ errorCount,
+ cooldownUntil: Date.now() + backoffMs,
+ };
+ saveAuthProfileStore(store, agentDir);
+}
+
+/**
+ * Clear cooldown for a profile (e.g., manual reset).
+ */
+export function clearAuthProfileCooldown(params: {
+ store: AuthProfileStore;
+ profileId: string;
+ agentDir?: string;
+}): void {
+ const { store, profileId, agentDir } = params;
+ if (!store.usageStats?.[profileId]) return;
+
+ store.usageStats[profileId] = {
+ ...store.usageStats[profileId],
+ errorCount: 0,
+ cooldownUntil: undefined,
+ };
+ saveAuthProfileStore(store, agentDir);
+}
+
export function resolveAuthProfileOrder(params: {
cfg?: ClawdbotConfig;
store: AuthProfileStore;
@@ -257,19 +441,14 @@ export function resolveAuthProfileOrder(params: {
.filter(([, profile]) => profile.provider === provider)
.map(([profileId]) => profileId)
: [];
- const lastGood = store.lastGood?.[provider];
const baseOrder =
configuredOrder ??
(explicitProfiles.length > 0
? explicitProfiles
: listProfilesForProvider(store, provider));
if (baseOrder.length === 0) return [];
- const order =
- configuredOrder && configuredOrder.length > 0
- ? baseOrder
- : orderProfilesByMode(baseOrder, store);
- const filtered = order.filter((profileId) => {
+ const filtered = baseOrder.filter((profileId) => {
const cred = store.profiles[profileId];
return cred ? cred.provider === provider : true;
});
@@ -277,41 +456,86 @@ export function resolveAuthProfileOrder(params: {
for (const entry of filtered) {
if (!deduped.includes(entry)) deduped.push(entry);
}
- if (preferredProfile && deduped.includes(preferredProfile)) {
- const rest = deduped.filter((entry) => entry !== preferredProfile);
- if (lastGood && rest.includes(lastGood)) {
+
+ // If user specified explicit order in config, respect it exactly
+ if (configuredOrder && configuredOrder.length > 0) {
+ // Still put preferredProfile first if specified
+ if (preferredProfile && deduped.includes(preferredProfile)) {
return [
preferredProfile,
- lastGood,
- ...rest.filter((entry) => entry !== lastGood),
+ ...deduped.filter((e) => e !== preferredProfile),
];
}
- return [preferredProfile, ...rest];
+ return deduped;
}
- if (lastGood && deduped.includes(lastGood)) {
- return [lastGood, ...deduped.filter((entry) => entry !== lastGood)];
+
+ // Otherwise, use round-robin: sort by lastUsed (oldest first)
+ // preferredProfile goes first if specified (for explicit user choice)
+ // lastGood is NOT prioritized - that would defeat round-robin
+ const sorted = orderProfilesByMode(deduped, store);
+
+ if (preferredProfile && sorted.includes(preferredProfile)) {
+ return [preferredProfile, ...sorted.filter((e) => e !== preferredProfile)];
}
- return deduped;
+
+ return sorted;
}
function orderProfilesByMode(
order: string[],
store: AuthProfileStore,
): string[] {
- const scored = order.map((profileId) => {
+ const now = Date.now();
+
+ // Partition into available and in-cooldown
+ const available: string[] = [];
+ const inCooldown: string[] = [];
+
+ for (const profileId of order) {
+ if (isProfileInCooldown(store, profileId)) {
+ inCooldown.push(profileId);
+ } else {
+ available.push(profileId);
+ }
+ }
+
+ // Sort available profiles by lastUsed (oldest first = round-robin)
+ // Then by lastUsed (oldest first = round-robin within type)
+ const scored = available.map((profileId) => {
const type = store.profiles[profileId]?.type;
- const score = type === "oauth" ? 0 : type === "api_key" ? 1 : 2;
- return { profileId, score };
+ const typeScore = type === "oauth" ? 0 : type === "api_key" ? 1 : 2;
+ const lastUsed = store.usageStats?.[profileId]?.lastUsed ?? 0;
+ return { profileId, typeScore, lastUsed };
});
- return scored
- .sort((a, b) => a.score - b.score)
+
+ // Primary sort: type preference (oauth > api_key).
+ // Secondary sort: lastUsed (oldest first for round-robin within type).
+ const sorted = scored
+ .sort((a, b) => {
+ // First by type (oauth > api_key)
+ if (a.typeScore !== b.typeScore) return a.typeScore - b.typeScore;
+ // Then by lastUsed (oldest first)
+ return a.lastUsed - b.lastUsed;
+ })
.map((entry) => entry.profileId);
+
+ // Append cooldown profiles at the end (sorted by cooldown expiry, soonest first)
+ const cooldownSorted = inCooldown
+ .map((profileId) => ({
+ profileId,
+ cooldownUntil: store.usageStats?.[profileId]?.cooldownUntil ?? now,
+ }))
+ .sort((a, b) => a.cooldownUntil - b.cooldownUntil)
+ .map((entry) => entry.profileId);
+
+ return [...sorted, ...cooldownSorted];
}
export async function resolveApiKeyForProfile(params: {
cfg?: ClawdbotConfig;
store: AuthProfileStore;
profileId: string;
+ agentDir?: string;
}): Promise<{ apiKey: string; provider: string; email?: string } | null> {
const { cfg, store, profileId } = params;
const cred = store.profiles[profileId];
@@ -323,35 +547,55 @@ export async function resolveApiKeyForProfile(params: {
if (cred.type === "api_key") {
return { apiKey: cred.key, provider: cred.provider, email: cred.email };
}
+ if (Date.now() < cred.expires) {
+ return {
+ apiKey: buildOAuthApiKey(cred.provider, cred),
+ provider: cred.provider,
+ email: cred.email,
+ };
+ }
- const oauthCreds: Record = {
- [cred.provider]: cred,
- };
- const result = await getOAuthApiKey(cred.provider, oauthCreds);
- if (!result) return null;
- store.profiles[profileId] = {
- ...cred,
- ...result.newCredentials,
- type: "oauth",
- };
- saveAuthProfileStore(store);
- return {
- apiKey: result.apiKey,
- provider: cred.provider,
- email: cred.email,
- };
+ try {
+ const result = await refreshOAuthTokenWithLock({
+ profileId,
+ provider: cred.provider,
+ agentDir: params.agentDir,
+ });
+ if (!result) return null;
+ return {
+ apiKey: result.apiKey,
+ provider: cred.provider,
+ email: cred.email,
+ };
+ } catch (error) {
+ const refreshedStore = ensureAuthProfileStore(params.agentDir);
+ const refreshed = refreshedStore.profiles[profileId];
+ if (refreshed?.type === "oauth" && Date.now() < refreshed.expires) {
+ return {
+ apiKey: buildOAuthApiKey(refreshed.provider, refreshed),
+ provider: refreshed.provider,
+ email: refreshed.email ?? cred.email,
+ };
+ }
+ const message = error instanceof Error ? error.message : String(error);
+ throw new Error(
+ `OAuth token refresh failed for ${cred.provider}: ${message}. ` +
+ "Please try again or re-authenticate.",
+ );
+ }
}
export function markAuthProfileGood(params: {
store: AuthProfileStore;
provider: string;
profileId: string;
+ agentDir?: string;
}): void {
- const { store, provider, profileId } = params;
+ const { store, provider, profileId, agentDir } = params;
const profile = store.profiles[profileId];
if (!profile || profile.provider !== provider) return;
store.lastGood = { ...store.lastGood, [provider]: profileId };
- saveAuthProfileStore(store);
+ saveAuthProfileStore(store, agentDir);
}
export function resolveAuthStorePathForDisplay(): string {
diff --git a/src/agents/bash-tools.ts b/src/agents/bash-tools.ts
index f92057046..b8985756d 100644
--- a/src/agents/bash-tools.ts
+++ b/src/agents/bash-tools.ts
@@ -36,6 +36,7 @@ const DEFAULT_MAX_OUTPUT = clampNumber(
150_000,
);
const DEFAULT_PATH =
+ process.env.PATH ??
"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
const stringEnum = (
diff --git a/src/agents/clawdbot-tools.sessions.test.ts b/src/agents/clawdbot-tools.sessions.test.ts
index 10b6b5b34..c7df1cf79 100644
--- a/src/agents/clawdbot-tools.sessions.test.ts
+++ b/src/agents/clawdbot-tools.sessions.test.ts
@@ -36,14 +36,14 @@ describe("sessions tools", () => {
kind: "direct",
sessionId: "s-main",
updatedAt: 10,
- lastChannel: "whatsapp",
+ lastProvider: "whatsapp",
},
{
key: "discord:group:dev",
kind: "group",
sessionId: "s-group",
updatedAt: 11,
- surface: "discord",
+ provider: "discord",
displayName: "discord:g-dev",
},
{
@@ -196,7 +196,7 @@ describe("sessions tools", () => {
const tool = createClawdbotTools({
agentSessionKey: requesterKey,
- agentSurface: "discord",
+ agentProvider: "discord",
}).find((candidate) => candidate.name === "sessions_send");
expect(tool).toBeDefined();
if (!tool) throw new Error("missing sessions_send tool");
@@ -340,7 +340,7 @@ describe("sessions tools", () => {
const tool = createClawdbotTools({
agentSessionKey: requesterKey,
- agentSurface: "discord",
+ agentProvider: "discord",
}).find((candidate) => candidate.name === "sessions_send");
expect(tool).toBeDefined();
if (!tool) throw new Error("missing sessions_send tool");
diff --git a/src/agents/clawdbot-tools.subagents.test.ts b/src/agents/clawdbot-tools.subagents.test.ts
new file mode 100644
index 000000000..0d1d61f0a
--- /dev/null
+++ b/src/agents/clawdbot-tools.subagents.test.ts
@@ -0,0 +1,204 @@
+import { describe, expect, it, vi } from "vitest";
+
+const callGatewayMock = vi.fn();
+vi.mock("../gateway/call.js", () => ({
+ callGateway: (opts: unknown) => callGatewayMock(opts),
+}));
+
+vi.mock("../config/config.js", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ loadConfig: () => ({
+ session: {
+ mainKey: "main",
+ scope: "per-sender",
+ },
+ }),
+ resolveGatewayPort: () => 18789,
+ };
+});
+
+import { createClawdbotTools } from "./clawdbot-tools.js";
+
+describe("subagents", () => {
+ it("sessions_spawn announces back to the requester group provider", async () => {
+ callGatewayMock.mockReset();
+ const calls: Array<{ method?: string; params?: unknown }> = [];
+ let agentCallCount = 0;
+ let lastWaitedRunId: string | undefined;
+ const replyByRunId = new Map();
+ let sendParams: { to?: string; provider?: string; message?: string } = {};
+ let deletedKey: string | undefined;
+
+ callGatewayMock.mockImplementation(async (opts: unknown) => {
+ const request = opts as { method?: string; params?: unknown };
+ calls.push(request);
+ if (request.method === "agent") {
+ agentCallCount += 1;
+ const runId = `run-${agentCallCount}`;
+ const params = request.params as
+ | { message?: string; sessionKey?: string }
+ | undefined;
+ const message = params?.message ?? "";
+ const reply =
+ message === "Sub-agent announce step." ? "announce now" : "result";
+ replyByRunId.set(runId, reply);
+ return {
+ runId,
+ status: "accepted",
+ acceptedAt: 1000 + agentCallCount,
+ };
+ }
+ if (request.method === "agent.wait") {
+ const params = request.params as { runId?: string } | undefined;
+ lastWaitedRunId = params?.runId;
+ return { runId: params?.runId ?? "run-1", status: "ok" };
+ }
+ if (request.method === "chat.history") {
+ const text =
+ (lastWaitedRunId && replyByRunId.get(lastWaitedRunId)) ?? "";
+ return {
+ messages: [{ role: "assistant", content: [{ type: "text", text }] }],
+ };
+ }
+ if (request.method === "send") {
+ const params = request.params as
+ | { to?: string; provider?: string; message?: string }
+ | undefined;
+ sendParams = {
+ to: params?.to,
+ provider: params?.provider,
+ message: params?.message,
+ };
+ return { messageId: "m-announce" };
+ }
+ if (request.method === "sessions.delete") {
+ const params = request.params as { key?: string } | undefined;
+ deletedKey = params?.key;
+ return { ok: true };
+ }
+ return {};
+ });
+
+ const tool = createClawdbotTools({
+ agentSessionKey: "discord:group:req",
+ agentProvider: "discord",
+ }).find((candidate) => candidate.name === "sessions_spawn");
+ if (!tool) throw new Error("missing sessions_spawn tool");
+
+ const result = await tool.execute("call1", {
+ task: "do thing",
+ timeoutSeconds: 1,
+ });
+ expect(result.details).toMatchObject({ status: "ok", reply: "result" });
+
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ const agentCalls = calls.filter((call) => call.method === "agent");
+ expect(agentCalls).toHaveLength(2);
+ const first = agentCalls[0]?.params as
+ | { lane?: string; deliver?: boolean; sessionKey?: string }
+ | undefined;
+ expect(first?.lane).toBe("subagent");
+ expect(first?.deliver).toBe(false);
+ expect(first?.sessionKey?.startsWith("agent:main:subagent:")).toBe(true);
+
+ expect(sendParams).toMatchObject({
+ provider: "discord",
+ to: "channel:req",
+ message: "announce now",
+ });
+ expect(deletedKey?.startsWith("agent:main:subagent:")).toBe(true);
+ });
+
+ it("sessions_spawn resolves main announce target from sessions.list", async () => {
+ callGatewayMock.mockReset();
+ const calls: Array<{ method?: string; params?: unknown }> = [];
+ let agentCallCount = 0;
+ let lastWaitedRunId: string | undefined;
+ const replyByRunId = new Map();
+ let sendParams: { to?: string; provider?: string; message?: string } = {};
+
+ callGatewayMock.mockImplementation(async (opts: unknown) => {
+ const request = opts as { method?: string; params?: unknown };
+ calls.push(request);
+ if (request.method === "sessions.list") {
+ return {
+ sessions: [
+ {
+ key: "main",
+ lastProvider: "whatsapp",
+ lastTo: "+123",
+ },
+ ],
+ };
+ }
+ if (request.method === "agent") {
+ agentCallCount += 1;
+ const runId = `run-${agentCallCount}`;
+ const params = request.params as
+ | { message?: string; sessionKey?: string }
+ | undefined;
+ const message = params?.message ?? "";
+ const reply =
+ message === "Sub-agent announce step." ? "hello from sub" : "done";
+ replyByRunId.set(runId, reply);
+ return {
+ runId,
+ status: "accepted",
+ acceptedAt: 2000 + agentCallCount,
+ };
+ }
+ if (request.method === "agent.wait") {
+ const params = request.params as { runId?: string } | undefined;
+ lastWaitedRunId = params?.runId;
+ return { runId: params?.runId ?? "run-1", status: "ok" };
+ }
+ if (request.method === "chat.history") {
+ const text =
+ (lastWaitedRunId && replyByRunId.get(lastWaitedRunId)) ?? "";
+ return {
+ messages: [{ role: "assistant", content: [{ type: "text", text }] }],
+ };
+ }
+ if (request.method === "send") {
+ const params = request.params as
+ | { to?: string; provider?: string; message?: string }
+ | undefined;
+ sendParams = {
+ to: params?.to,
+ provider: params?.provider,
+ message: params?.message,
+ };
+ return { messageId: "m1" };
+ }
+ if (request.method === "sessions.delete") {
+ return { ok: true };
+ }
+ return {};
+ });
+
+ const tool = createClawdbotTools({
+ agentSessionKey: "main",
+ agentProvider: "whatsapp",
+ }).find((candidate) => candidate.name === "sessions_spawn");
+ if (!tool) throw new Error("missing sessions_spawn tool");
+
+ const result = await tool.execute("call2", {
+ task: "do thing",
+ timeoutSeconds: 1,
+ });
+ expect(result.details).toMatchObject({ status: "ok", reply: "done" });
+
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ expect(sendParams).toMatchObject({
+ provider: "whatsapp",
+ to: "+123",
+ message: "hello from sub",
+ });
+ });
+});
diff --git a/src/agents/clawdbot-tools.ts b/src/agents/clawdbot-tools.ts
index cd655ea36..447a098b0 100644
--- a/src/agents/clawdbot-tools.ts
+++ b/src/agents/clawdbot-tools.ts
@@ -10,15 +10,21 @@ import { createNodesTool } from "./tools/nodes-tool.js";
import { createSessionsHistoryTool } from "./tools/sessions-history-tool.js";
import { createSessionsListTool } from "./tools/sessions-list-tool.js";
import { createSessionsSendTool } from "./tools/sessions-send-tool.js";
+import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js";
import { createSlackTool } from "./tools/slack-tool.js";
export function createClawdbotTools(options?: {
browserControlUrl?: string;
agentSessionKey?: string;
- agentSurface?: string;
+ agentProvider?: string;
+ agentDir?: string;
+ sandboxed?: boolean;
config?: ClawdbotConfig;
}): AnyAgentTool[] {
- const imageTool = createImageTool({ config: options?.config });
+ const imageTool = createImageTool({
+ config: options?.config,
+ agentDir: options?.agentDir,
+ });
return [
createBrowserTool({ defaultControlUrl: options?.browserControlUrl }),
createCanvasTool(),
@@ -27,11 +33,23 @@ export function createClawdbotTools(options?: {
createDiscordTool(),
createSlackTool(),
createGatewayTool(),
- createSessionsListTool(),
- createSessionsHistoryTool(),
+ createSessionsListTool({
+ agentSessionKey: options?.agentSessionKey,
+ sandboxed: options?.sandboxed,
+ }),
+ createSessionsHistoryTool({
+ agentSessionKey: options?.agentSessionKey,
+ sandboxed: options?.sandboxed,
+ }),
createSessionsSendTool({
agentSessionKey: options?.agentSessionKey,
- agentSurface: options?.agentSurface,
+ agentProvider: options?.agentProvider,
+ sandboxed: options?.sandboxed,
+ }),
+ createSessionsSpawnTool({
+ agentSessionKey: options?.agentSessionKey,
+ agentProvider: options?.agentProvider,
+ sandboxed: options?.sandboxed,
}),
...(imageTool ? [imageTool] : []),
];
diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts
index 0564381e4..1716f7800 100644
--- a/src/agents/model-auth.ts
+++ b/src/agents/model-auth.ts
@@ -31,15 +31,17 @@ export async function resolveApiKeyForProvider(params: {
profileId?: string;
preferredProfile?: string;
store?: AuthProfileStore;
+ agentDir?: string;
}): Promise<{ apiKey: string; profileId?: string; source: string }> {
const { provider, cfg, profileId, preferredProfile } = params;
- const store = params.store ?? ensureAuthProfileStore();
+ const store = params.store ?? ensureAuthProfileStore(params.agentDir);
if (profileId) {
const resolved = await resolveApiKeyForProfile({
cfg,
store,
profileId,
+ agentDir: params.agentDir,
});
if (!resolved) {
throw new Error(`No credentials found for profile "${profileId}".`);
@@ -63,6 +65,7 @@ export async function resolveApiKeyForProvider(params: {
cfg,
store,
profileId: candidate,
+ agentDir: params.agentDir,
});
if (resolved) {
return {
@@ -146,6 +149,7 @@ export async function getApiKeyForModel(params: {
profileId?: string;
preferredProfile?: string;
store?: AuthProfileStore;
+ agentDir?: string;
}): Promise<{ apiKey: string; profileId?: string; source: string }> {
return resolveApiKeyForProvider({
provider: params.model.provider,
@@ -153,5 +157,6 @@ export async function getApiKeyForModel(params: {
profileId: params.profileId,
preferredProfile: params.preferredProfile,
store: params.store,
+ agentDir: params.agentDir,
});
}
diff --git a/src/agents/models-config.ts b/src/agents/models-config.ts
index b3fa0dae7..2887554f5 100644
--- a/src/agents/models-config.ts
+++ b/src/agents/models-config.ts
@@ -2,10 +2,7 @@ import fs from "node:fs/promises";
import path from "node:path";
import { type ClawdbotConfig, loadConfig } from "../config/config.js";
-import {
- ensureClawdbotAgentEnv,
- resolveClawdbotAgentDir,
-} from "./agent-paths.js";
+import { resolveClawdbotAgentDir } from "./agent-paths.js";
type ModelsConfig = NonNullable;
@@ -26,15 +23,21 @@ async function readJson(pathname: string): Promise {
export async function ensureClawdbotModelsJson(
config?: ClawdbotConfig,
+ agentDirOverride?: string,
): Promise<{ agentDir: string; wrote: boolean }> {
const cfg = config ?? loadConfig();
const providers = cfg.models?.providers;
if (!providers || Object.keys(providers).length === 0) {
- return { agentDir: resolveClawdbotAgentDir(), wrote: false };
+ const agentDir = agentDirOverride?.trim()
+ ? agentDirOverride.trim()
+ : resolveClawdbotAgentDir();
+ return { agentDir, wrote: false };
}
const mode = cfg.models?.mode ?? DEFAULT_MODE;
- const agentDir = ensureClawdbotAgentEnv();
+ const agentDir = agentDirOverride?.trim()
+ ? agentDirOverride.trim()
+ : resolveClawdbotAgentDir();
const targetPath = path.join(agentDir, "models.json");
let mergedProviders = providers;
diff --git a/src/agents/pi-embedded-block-chunker.ts b/src/agents/pi-embedded-block-chunker.ts
index 9aa97afad..f5473c182 100644
--- a/src/agents/pi-embedded-block-chunker.ts
+++ b/src/agents/pi-embedded-block-chunker.ts
@@ -1,17 +1,15 @@
+import {
+ findFenceSpanAt,
+ isSafeFenceBreak,
+ parseFenceSpans,
+} from "../markdown/fences.js";
+
export type BlockReplyChunking = {
minChars: number;
maxChars: number;
breakPreference?: "paragraph" | "newline" | "sentence";
};
-type FenceSpan = {
- start: number;
- end: number;
- openLine: string;
- marker: string;
- indent: string;
-};
-
type FenceSplit = {
closeFenceLine: string;
reopenFenceLine: string;
@@ -123,7 +121,10 @@ export class EmbeddedBlockChunker {
if (preference === "paragraph") {
let paragraphIdx = buffer.indexOf("\n\n");
while (paragraphIdx !== -1) {
- if (paragraphIdx >= minChars && isSafeBreak(fenceSpans, paragraphIdx)) {
+ if (
+ paragraphIdx >= minChars &&
+ isSafeFenceBreak(fenceSpans, paragraphIdx)
+ ) {
return { index: paragraphIdx };
}
paragraphIdx = buffer.indexOf("\n\n", paragraphIdx + 2);
@@ -133,7 +134,10 @@ export class EmbeddedBlockChunker {
if (preference === "paragraph" || preference === "newline") {
let newlineIdx = buffer.indexOf("\n");
while (newlineIdx !== -1) {
- if (newlineIdx >= minChars && isSafeBreak(fenceSpans, newlineIdx)) {
+ if (
+ newlineIdx >= minChars &&
+ isSafeFenceBreak(fenceSpans, newlineIdx)
+ ) {
return { index: newlineIdx };
}
newlineIdx = buffer.indexOf("\n", newlineIdx + 1);
@@ -147,7 +151,7 @@ export class EmbeddedBlockChunker {
const at = match.index ?? -1;
if (at < minChars) continue;
const candidate = at + 1;
- if (isSafeBreak(fenceSpans, candidate)) {
+ if (isSafeFenceBreak(fenceSpans, candidate)) {
sentenceIdx = candidate;
}
}
@@ -168,7 +172,7 @@ export class EmbeddedBlockChunker {
if (preference === "paragraph") {
let paragraphIdx = window.lastIndexOf("\n\n");
while (paragraphIdx >= minChars) {
- if (isSafeBreak(fenceSpans, paragraphIdx)) {
+ if (isSafeFenceBreak(fenceSpans, paragraphIdx)) {
return { index: paragraphIdx };
}
paragraphIdx = window.lastIndexOf("\n\n", paragraphIdx - 1);
@@ -178,7 +182,7 @@ export class EmbeddedBlockChunker {
if (preference === "paragraph" || preference === "newline") {
let newlineIdx = window.lastIndexOf("\n");
while (newlineIdx >= minChars) {
- if (isSafeBreak(fenceSpans, newlineIdx)) {
+ if (isSafeFenceBreak(fenceSpans, newlineIdx)) {
return { index: newlineIdx };
}
newlineIdx = window.lastIndexOf("\n", newlineIdx - 1);
@@ -192,7 +196,7 @@ export class EmbeddedBlockChunker {
const at = match.index ?? -1;
if (at < minChars) continue;
const candidate = at + 1;
- if (isSafeBreak(fenceSpans, candidate)) {
+ if (isSafeFenceBreak(fenceSpans, candidate)) {
sentenceIdx = candidate;
}
}
@@ -200,13 +204,13 @@ export class EmbeddedBlockChunker {
}
for (let i = window.length - 1; i >= minChars; i--) {
- if (/\s/.test(window[i]) && isSafeBreak(fenceSpans, i)) {
+ if (/\s/.test(window[i]) && isSafeFenceBreak(fenceSpans, i)) {
return { index: i };
}
}
if (buffer.length >= maxChars) {
- if (isSafeBreak(fenceSpans, maxChars)) return { index: maxChars };
+ if (isSafeFenceBreak(fenceSpans, maxChars)) return { index: maxChars };
const fence = findFenceSpanAt(fenceSpans, maxChars);
if (fence) {
return {
@@ -229,76 +233,3 @@ function stripLeadingNewlines(value: string): string {
while (i < value.length && value[i] === "\n") i++;
return i > 0 ? value.slice(i) : value;
}
-
-function parseFenceSpans(buffer: string): FenceSpan[] {
- const spans: FenceSpan[] = [];
- let open:
- | {
- start: number;
- markerChar: string;
- markerLen: number;
- openLine: string;
- marker: string;
- indent: string;
- }
- | undefined;
- let offset = 0;
- while (offset <= buffer.length) {
- const nextNewline = buffer.indexOf("\n", offset);
- const lineEnd = nextNewline === -1 ? buffer.length : nextNewline;
- const line = buffer.slice(offset, lineEnd);
- const match = line.match(/^( {0,3})(`{3,}|~{3,})(.*)$/);
- if (match) {
- const indent = match[1];
- const marker = match[2];
- const markerChar = marker[0];
- const markerLen = marker.length;
- if (!open) {
- open = {
- start: offset,
- markerChar,
- markerLen,
- openLine: line,
- marker,
- indent,
- };
- } else if (
- open.markerChar === markerChar &&
- markerLen >= open.markerLen
- ) {
- const end = nextNewline === -1 ? buffer.length : nextNewline + 1;
- spans.push({
- start: open.start,
- end,
- openLine: open.openLine,
- marker: open.marker,
- indent: open.indent,
- });
- open = undefined;
- }
- }
- if (nextNewline === -1) break;
- offset = nextNewline + 1;
- }
- if (open) {
- spans.push({
- start: open.start,
- end: buffer.length,
- openLine: open.openLine,
- marker: open.marker,
- indent: open.indent,
- });
- }
- return spans;
-}
-
-function findFenceSpanAt(
- spans: FenceSpan[],
- index: number,
-): FenceSpan | undefined {
- return spans.find((span) => index > span.start && index < span.end);
-}
-
-function isSafeBreak(spans: FenceSpan[], index: number): boolean {
- return !findFenceSpanAt(spans, index);
-}
diff --git a/src/agents/pi-embedded-runner.ts b/src/agents/pi-embedded-runner.ts
index cc27f95ef..a80b6a982 100644
--- a/src/agents/pi-embedded-runner.ts
+++ b/src/agents/pi-embedded-runner.ts
@@ -12,6 +12,7 @@ import {
SettingsManager,
type Skill,
} from "@mariozechner/pi-coding-agent";
+import { resolveHeartbeatPrompt } from "../auto-reply/heartbeat.js";
import type { ThinkLevel, VerboseLevel } from "../auto-reply/thinking.js";
import { formatToolAggregate } from "../auto-reply/tool-meta.js";
import type { ClawdbotConfig } from "../config/config.js";
@@ -24,7 +25,11 @@ import {
} from "../process/command-queue.js";
import { resolveUserPath } from "../utils.js";
import { resolveClawdbotAgentDir } from "./agent-paths.js";
-import { markAuthProfileGood } from "./auth-profiles.js";
+import {
+ markAuthProfileCooldown,
+ markAuthProfileGood,
+ markAuthProfileUsed,
+} from "./auth-profiles.js";
import type { BashElevatedDefaults } from "./bash-tools.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js";
import {
@@ -61,6 +66,7 @@ import {
type SkillSnapshot,
} from "./skills.js";
import { buildAgentSystemPromptAppend } from "./system-prompt.js";
+import { normalizeUsage, type UsageLike } from "./usage.js";
import { loadWorkspaceBootstrapFiles } from "./workspace.js";
export type EmbeddedPiAgentMeta = {
@@ -94,6 +100,7 @@ export type EmbeddedPiRunResult = {
mediaUrl?: string;
mediaUrls?: string[];
replyToId?: string;
+ isError?: boolean;
}>;
meta: EmbeddedPiRunMeta;
};
@@ -113,6 +120,7 @@ export type EmbeddedPiCompactResult = {
type EmbeddedPiQueueHandle = {
queueMessage: (text: string) => Promise;
isStreaming: () => boolean;
+ isCompacting: () => boolean;
abort: () => void;
};
@@ -212,6 +220,7 @@ export function queueEmbeddedPiMessage(
const handle = ACTIVE_EMBEDDED_RUNS.get(sessionId);
if (!handle) return false;
if (!handle.isStreaming()) return false;
+ if (handle.isCompacting()) return false;
void handle.queueMessage(text);
return true;
}
@@ -329,9 +338,10 @@ function resolvePromptSkills(
export async function compactEmbeddedPiSession(params: {
sessionId: string;
sessionKey?: string;
- surface?: string;
+ messageProvider?: string;
sessionFile: string;
workspaceDir: string;
+ agentDir?: string;
config?: ClawdbotConfig;
skillsSnapshot?: SkillSnapshot;
provider?: string;
@@ -360,7 +370,7 @@ export async function compactEmbeddedPiSession(params: {
(params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER;
const modelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL;
await ensureClawdbotModelsJson(params.config);
- const agentDir = resolveClawdbotAgentDir();
+ const agentDir = params.agentDir ?? resolveClawdbotAgentDir();
const { model, error, authStorage, modelRegistry } = resolveModel(
provider,
modelId,
@@ -434,8 +444,9 @@ export async function compactEmbeddedPiSession(params: {
elevated: params.bashElevated,
},
sandbox,
- surface: params.surface,
+ messageProvider: params.messageProvider,
sessionKey: params.sessionKey ?? params.sessionId,
+ agentDir,
config: params.config,
});
const machineName = await getMachineDisplayName();
@@ -459,6 +470,9 @@ export async function compactEmbeddedPiSession(params: {
extraSystemPrompt: params.extraSystemPrompt,
ownerNumbers: params.ownerNumbers,
reasoningTagHint,
+ heartbeatPrompt: resolveHeartbeatPrompt(
+ params.config?.agent?.heartbeat?.prompt,
+ ),
runtimeInfo,
sandboxInfo,
toolNames: tools.map((tool) => tool.name),
@@ -538,9 +552,10 @@ export async function compactEmbeddedPiSession(params: {
export async function runEmbeddedPiAgent(params: {
sessionId: string;
sessionKey?: string;
- surface?: string;
+ messageProvider?: string;
sessionFile: string;
workspaceDir: string;
+ agentDir?: string;
config?: ClawdbotConfig;
skillsSnapshot?: SkillSnapshot;
prompt: string;
@@ -595,7 +610,7 @@ export async function runEmbeddedPiAgent(params: {
(params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER;
const modelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL;
await ensureClawdbotModelsJson(params.config);
- const agentDir = resolveClawdbotAgentDir();
+ const agentDir = params.agentDir ?? resolveClawdbotAgentDir();
const { model, error, authStorage, modelRegistry } = resolveModel(
provider,
modelId,
@@ -604,7 +619,7 @@ export async function runEmbeddedPiAgent(params: {
if (!model) {
throw new Error(error ?? `Unknown model: ${provider}/${modelId}`);
}
- const authStore = ensureAuthProfileStore();
+ const authStore = ensureAuthProfileStore(agentDir);
const explicitProfileId = params.authProfileId?.trim();
const profileOrder = resolveAuthProfileOrder({
cfg: params.config,
@@ -672,7 +687,7 @@ export async function runEmbeddedPiAgent(params: {
attemptedThinking.add(thinkLevel);
log.debug(
- `embedded run start: runId=${params.runId} sessionId=${params.sessionId} provider=${provider} model=${modelId} thinking=${thinkLevel} surface=${params.surface ?? "unknown"}`,
+ `embedded run start: runId=${params.runId} sessionId=${params.sessionId} provider=${provider} model=${modelId} thinking=${thinkLevel} messageProvider=${params.messageProvider ?? "unknown"}`,
);
await fs.mkdir(resolvedWorkspace, { recursive: true });
@@ -728,8 +743,9 @@ export async function runEmbeddedPiAgent(params: {
elevated: params.bashElevated,
},
sandbox,
- surface: params.surface,
+ messageProvider: params.messageProvider,
sessionKey: params.sessionKey ?? params.sessionId,
+ agentDir,
config: params.config,
});
const machineName = await getMachineDisplayName();
@@ -753,6 +769,9 @@ export async function runEmbeddedPiAgent(params: {
extraSystemPrompt: params.extraSystemPrompt,
ownerNumbers: params.ownerNumbers,
reasoningTagHint,
+ heartbeatPrompt: resolveHeartbeatPrompt(
+ params.config?.agent?.heartbeat?.prompt,
+ ),
runtimeInfo,
sandboxInfo,
toolNames: tools.map((tool) => tool.name),
@@ -806,25 +825,13 @@ export async function runEmbeddedPiAgent(params: {
session.agent.replaceMessages(prior);
}
let aborted = Boolean(params.abortSignal?.aborted);
- const abortRun = () => {
+ let timedOut = false;
+ const abortRun = (isTimeout = false) => {
aborted = true;
+ if (isTimeout) timedOut = true;
void session.abort();
};
- const queueHandle: EmbeddedPiQueueHandle = {
- queueMessage: async (text: string) => {
- await session.steer(text);
- },
- isStreaming: () => session.isStreaming,
- abort: abortRun,
- };
- ACTIVE_EMBEDDED_RUNS.set(params.sessionId, queueHandle);
-
- const {
- assistantTexts,
- toolMetas,
- unsubscribe,
- waitForCompactionRetry,
- } = subscribeEmbeddedPiSession({
+ const subscription = subscribeEmbeddedPiSession({
session,
runId: params.runId,
verboseLevel: params.verboseLevel,
@@ -837,6 +844,22 @@ export async function runEmbeddedPiAgent(params: {
onAgentEvent: params.onAgentEvent,
enforceFinalTag: params.enforceFinalTag,
});
+ const {
+ assistantTexts,
+ toolMetas,
+ unsubscribe,
+ waitForCompactionRetry,
+ } = subscription;
+
+ const queueHandle: EmbeddedPiQueueHandle = {
+ queueMessage: async (text: string) => {
+ await session.steer(text);
+ },
+ isStreaming: () => session.isStreaming,
+ isCompacting: () => subscription.isCompacting(),
+ abort: abortRun,
+ };
+ ACTIVE_EMBEDDED_RUNS.set(params.sessionId, queueHandle);
let abortWarnTimer: NodeJS.Timeout | undefined;
const abortTimer = setTimeout(
@@ -844,7 +867,7 @@ export async function runEmbeddedPiAgent(params: {
log.warn(
`embedded run timeout: runId=${params.runId} sessionId=${params.sessionId} timeoutMs=${params.timeoutMs}`,
);
- abortRun();
+ abortRun(true);
if (!abortWarnTimer) {
abortWarnTimer = setTimeout(() => {
if (!session.isStreaming) return;
@@ -949,7 +972,24 @@ export async function runEmbeddedPiAgent(params: {
(params.config?.agent?.model?.fallbacks?.length ?? 0) > 0;
const authFailure = isAuthAssistantError(lastAssistant);
const rateLimitFailure = isRateLimitAssistantError(lastAssistant);
- if (!aborted && (authFailure || rateLimitFailure)) {
+
+ // Treat timeout as potential rate limit (Antigravity hangs on rate limit)
+ const shouldRotate =
+ (!aborted && (authFailure || rateLimitFailure)) || timedOut;
+
+ if (shouldRotate) {
+ // Mark current profile for cooldown before rotating
+ if (lastProfileId) {
+ markAuthProfileCooldown({
+ store: authStore,
+ profileId: lastProfileId,
+ });
+ if (timedOut) {
+ log.warn(
+ `Profile ${lastProfileId} timed out (possible rate limit). Trying next account...`,
+ );
+ }
+ }
const rotated = await advanceAuthProfile();
if (rotated) {
continue;
@@ -960,35 +1000,34 @@ export async function runEmbeddedPiAgent(params: {
(lastAssistant
? formatAssistantErrorText(lastAssistant)
: "") ||
- (rateLimitFailure
- ? "LLM request rate limited."
- : "LLM request unauthorized.");
+ (timedOut
+ ? "LLM request timed out."
+ : rateLimitFailure
+ ? "LLM request rate limited."
+ : "LLM request unauthorized.");
throw new Error(message);
}
}
- const usage = lastAssistant?.usage;
+ const usage = normalizeUsage(lastAssistant?.usage as UsageLike);
const agentMeta: EmbeddedPiAgentMeta = {
sessionId: sessionIdUsed,
provider: lastAssistant?.provider ?? provider,
model: lastAssistant?.model ?? model.id,
- usage: usage
- ? {
- input: usage.input,
- output: usage.output,
- cacheRead: usage.cacheRead,
- cacheWrite: usage.cacheWrite,
- total: usage.totalTokens,
- }
- : undefined,
+ usage,
};
- const replyItems: Array<{ text: string; media?: string[] }> = [];
+ const replyItems: Array<{
+ text: string;
+ media?: string[];
+ isError?: boolean;
+ }> = [];
const errorText = lastAssistant
? formatAssistantErrorText(lastAssistant)
: undefined;
- if (errorText) replyItems.push({ text: errorText });
+
+ if (errorText) replyItems.push({ text: errorText, isError: true });
const inlineToolResults =
params.verboseLevel === "on" &&
@@ -1021,6 +1060,7 @@ export async function runEmbeddedPiAgent(params: {
text: item.text?.trim() ? item.text.trim() : undefined,
mediaUrls: item.media?.length ? item.media : undefined,
mediaUrl: item.media?.[0],
+ isError: item.isError,
}))
.filter(
(p) =>
@@ -1036,6 +1076,8 @@ export async function runEmbeddedPiAgent(params: {
provider,
profileId: lastProfileId,
});
+ // Track usage for round-robin rotation
+ markAuthProfileUsed({ store: authStore, profileId: lastProfileId });
}
return {
payloads: payloads.length ? payloads : undefined,
diff --git a/src/agents/pi-embedded-subscribe.test.ts b/src/agents/pi-embedded-subscribe.test.ts
index 8c7751c51..c22316357 100644
--- a/src/agents/pi-embedded-subscribe.test.ts
+++ b/src/agents/pi-embedded-subscribe.test.ts
@@ -968,6 +968,7 @@ describe("subscribeEmbeddedPiSession", () => {
});
}
+ expect(subscription.isCompacting()).toBe(true);
expect(subscription.assistantTexts.length).toBe(0);
let resolved = false;
@@ -1004,6 +1005,8 @@ describe("subscribeEmbeddedPiSession", () => {
listener({ type: "auto_compaction_start" });
}
+ expect(subscription.isCompacting()).toBe(true);
+
let resolved = false;
const waitPromise = subscription.waitForCompactionRetry().then(() => {
resolved = true;
@@ -1018,6 +1021,7 @@ describe("subscribeEmbeddedPiSession", () => {
await waitPromise;
expect(resolved).toBe(true);
+ expect(subscription.isCompacting()).toBe(false);
});
it("waits for multiple compaction retries before resolving", async () => {
diff --git a/src/agents/pi-embedded-subscribe.ts b/src/agents/pi-embedded-subscribe.ts
index 330422efd..e87ff74e8 100644
--- a/src/agents/pi-embedded-subscribe.ts
+++ b/src/agents/pi-embedded-subscribe.ts
@@ -604,6 +604,7 @@ export function subscribeEmbeddedPiSession(params: {
assistantTexts,
toolMetas,
unsubscribe,
+ isCompacting: () => compactionInFlight || pendingCompactionRetry > 0,
waitForCompactionRetry: () => {
if (compactionInFlight || pendingCompactionRetry > 0) {
ensureCompactionPromise();
diff --git a/src/agents/pi-tools.test.ts b/src/agents/pi-tools.test.ts
index a9cb42d44..4dd83fdbd 100644
--- a/src/agents/pi-tools.test.ts
+++ b/src/agents/pi-tools.test.ts
@@ -100,22 +100,54 @@ describe("createClawdbotCodingTools", () => {
expect(offenders).toEqual([]);
});
- it("scopes discord tool to discord surface", () => {
- const other = createClawdbotCodingTools({ surface: "whatsapp" });
+ it("scopes discord tool to discord provider", () => {
+ const other = createClawdbotCodingTools({ messageProvider: "whatsapp" });
expect(other.some((tool) => tool.name === "discord")).toBe(false);
- const discord = createClawdbotCodingTools({ surface: "discord" });
+ const discord = createClawdbotCodingTools({ messageProvider: "discord" });
expect(discord.some((tool) => tool.name === "discord")).toBe(true);
});
- it("scopes slack tool to slack surface", () => {
- const other = createClawdbotCodingTools({ surface: "whatsapp" });
+ it("scopes slack tool to slack provider", () => {
+ const other = createClawdbotCodingTools({ messageProvider: "whatsapp" });
expect(other.some((tool) => tool.name === "slack")).toBe(false);
- const slack = createClawdbotCodingTools({ surface: "slack" });
+ const slack = createClawdbotCodingTools({ messageProvider: "slack" });
expect(slack.some((tool) => tool.name === "slack")).toBe(true);
});
+ it("filters session tools for sub-agent sessions by default", () => {
+ const tools = createClawdbotCodingTools({
+ sessionKey: "agent:main:subagent:test",
+ });
+ const names = new Set(tools.map((tool) => tool.name));
+ expect(names.has("sessions_list")).toBe(false);
+ expect(names.has("sessions_history")).toBe(false);
+ expect(names.has("sessions_send")).toBe(false);
+ expect(names.has("sessions_spawn")).toBe(false);
+
+ expect(names.has("read")).toBe(true);
+ expect(names.has("bash")).toBe(true);
+ expect(names.has("process")).toBe(true);
+ });
+
+ it("supports allow-only sub-agent tool policy", () => {
+ const tools = createClawdbotCodingTools({
+ sessionKey: "agent:main:subagent:test",
+ // Intentionally partial config; only fields used by pi-tools are provided.
+ config: {
+ agent: {
+ subagents: {
+ tools: {
+ allow: ["read"],
+ },
+ },
+ },
+ },
+ });
+ expect(tools.map((tool) => tool.name)).toEqual(["read"]);
+ });
+
it("keeps read tool image metadata intact", async () => {
const tools = createClawdbotCodingTools();
const readTool = tools.find((tool) => tool.name === "read");
diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts
index e25afe3d4..2dbfb6452 100644
--- a/src/agents/pi-tools.ts
+++ b/src/agents/pi-tools.ts
@@ -9,6 +9,7 @@ import {
import { Type } from "@sinclair/typebox";
import type { ClawdbotConfig } from "../config/config.js";
import { detectMime } from "../media/mime.js";
+import { isSubagentSessionKey } from "../routing/session-key.js";
import { startWebLoginWithQr, waitForWebLogin } from "../web/login-qr.js";
import {
type BashToolDefaults,
@@ -333,6 +334,23 @@ function normalizeToolNames(list?: string[]) {
return list.map((entry) => entry.trim().toLowerCase()).filter(Boolean);
}
+const DEFAULT_SUBAGENT_TOOL_DENY = [
+ "sessions_list",
+ "sessions_history",
+ "sessions_send",
+ "sessions_spawn",
+];
+
+function resolveSubagentToolPolicy(cfg?: ClawdbotConfig): SandboxToolPolicy {
+ const configured = cfg?.agent?.subagents?.tools;
+ const deny = [
+ ...DEFAULT_SUBAGENT_TOOL_DENY,
+ ...(Array.isArray(configured?.deny) ? configured.deny : []),
+ ];
+ const allow = Array.isArray(configured?.allow) ? configured.allow : undefined;
+ return { allow, deny };
+}
+
function filterToolsByPolicy(
tools: AnyAgentTool[],
policy?: SandboxToolPolicy,
@@ -466,28 +484,31 @@ function createClawdbotReadTool(base: AnyAgentTool): AnyAgentTool {
};
}
-function normalizeSurface(surface?: string): string | undefined {
- const trimmed = surface?.trim().toLowerCase();
+function normalizeMessageProvider(
+ messageProvider?: string,
+): string | undefined {
+ const trimmed = messageProvider?.trim().toLowerCase();
return trimmed ? trimmed : undefined;
}
-function shouldIncludeDiscordTool(surface?: string): boolean {
- const normalized = normalizeSurface(surface);
+function shouldIncludeDiscordTool(messageProvider?: string): boolean {
+ const normalized = normalizeMessageProvider(messageProvider);
if (!normalized) return false;
return normalized === "discord" || normalized.startsWith("discord:");
}
-function shouldIncludeSlackTool(surface?: string): boolean {
- const normalized = normalizeSurface(surface);
+function shouldIncludeSlackTool(messageProvider?: string): boolean {
+ const normalized = normalizeMessageProvider(messageProvider);
if (!normalized) return false;
return normalized === "slack" || normalized.startsWith("slack:");
}
export function createClawdbotCodingTools(options?: {
bash?: BashToolDefaults & ProcessToolDefaults;
- surface?: string;
+ messageProvider?: string;
sandbox?: SandboxContext | null;
sessionKey?: string;
+ agentDir?: string;
config?: ClawdbotConfig;
}): AnyAgentTool[] {
const bashToolName = "bash";
@@ -533,12 +554,14 @@ export function createClawdbotCodingTools(options?: {
...createClawdbotTools({
browserControlUrl: sandbox?.browser?.controlUrl,
agentSessionKey: options?.sessionKey,
- agentSurface: options?.surface,
+ agentProvider: options?.messageProvider,
+ agentDir: options?.agentDir,
+ sandboxed: !!sandbox,
config: options?.config,
}),
];
- const allowDiscord = shouldIncludeDiscordTool(options?.surface);
- const allowSlack = shouldIncludeSlackTool(options?.surface);
+ const allowDiscord = shouldIncludeDiscordTool(options?.messageProvider);
+ const allowSlack = shouldIncludeSlackTool(options?.messageProvider);
const filtered = tools.filter((tool) => {
if (tool.name === "discord") return allowDiscord;
if (tool.name === "slack") return allowSlack;
@@ -553,7 +576,14 @@ export function createClawdbotCodingTools(options?: {
const sandboxed = sandbox
? filterToolsByPolicy(globallyFiltered, sandbox.tools)
: globallyFiltered;
+ const subagentFiltered =
+ isSubagentSessionKey(options?.sessionKey) && options?.sessionKey
+ ? filterToolsByPolicy(
+ sandboxed,
+ resolveSubagentToolPolicy(options.config),
+ )
+ : sandboxed;
// Always normalize tool JSON Schemas before handing them to pi-agent/pi-ai.
// Without this, some providers (notably OpenAI) will reject root-level union schemas.
- return sandboxed.map(normalizeToolParameters);
+ return subagentFiltered.map(normalizeToolParameters);
}
diff --git a/src/agents/sandbox.ts b/src/agents/sandbox.ts
index 6166f3349..15ef13840 100644
--- a/src/agents/sandbox.ts
+++ b/src/agents/sandbox.ts
@@ -114,7 +114,17 @@ const DEFAULT_SANDBOX_CONTAINER_PREFIX = "clawdbot-sbx-";
const DEFAULT_SANDBOX_WORKDIR = "/workspace";
const DEFAULT_SANDBOX_IDLE_HOURS = 24;
const DEFAULT_SANDBOX_MAX_AGE_DAYS = 7;
-const DEFAULT_TOOL_ALLOW = ["bash", "process", "read", "write", "edit"];
+const DEFAULT_TOOL_ALLOW = [
+ "bash",
+ "process",
+ "read",
+ "write",
+ "edit",
+ "sessions_list",
+ "sessions_history",
+ "sessions_send",
+ "sessions_spawn",
+];
const DEFAULT_TOOL_DENY = [
"browser",
"canvas",
@@ -424,7 +434,11 @@ async function dockerContainerState(name: string) {
return { exists: true, running: result.stdout.trim() === "true" };
}
-async function ensureSandboxWorkspace(workspaceDir: string, seedFrom?: string) {
+async function ensureSandboxWorkspace(
+ workspaceDir: string,
+ seedFrom?: string,
+ skipBootstrap?: boolean,
+) {
await fs.mkdir(workspaceDir, { recursive: true });
if (seedFrom) {
const seed = resolveUserPath(seedFrom);
@@ -451,7 +465,10 @@ async function ensureSandboxWorkspace(workspaceDir: string, seedFrom?: string) {
}
}
}
- await ensureAgentWorkspace({ dir: workspaceDir, ensureBootstrapFiles: true });
+ await ensureAgentWorkspace({
+ dir: workspaceDir,
+ ensureBootstrapFiles: !skipBootstrap,
+ });
}
function normalizeDockerLimit(value?: string | number) {
@@ -846,7 +863,11 @@ export async function resolveSandboxContext(params: {
: workspaceRoot;
const seedWorkspace =
params.workspaceDir?.trim() || DEFAULT_AGENT_WORKSPACE_DIR;
- await ensureSandboxWorkspace(workspaceDir, seedWorkspace);
+ await ensureSandboxWorkspace(
+ workspaceDir,
+ seedWorkspace,
+ params.config?.agent?.skipBootstrap,
+ );
const containerName = await ensureSandboxContainer({
sessionKey: rawSessionKey,
@@ -889,7 +910,11 @@ export async function ensureSandboxWorkspaceForSession(params: {
: workspaceRoot;
const seedWorkspace =
params.workspaceDir?.trim() || DEFAULT_AGENT_WORKSPACE_DIR;
- await ensureSandboxWorkspace(workspaceDir, seedWorkspace);
+ await ensureSandboxWorkspace(
+ workspaceDir,
+ seedWorkspace,
+ params.config?.agent?.skipBootstrap,
+ );
return {
workspaceDir,
diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts
index 4528d372d..d5c40f51b 100644
--- a/src/agents/system-prompt.ts
+++ b/src/agents/system-prompt.ts
@@ -9,6 +9,7 @@ export function buildAgentSystemPromptAppend(params: {
toolNames?: string[];
userTimezone?: string;
userTime?: string;
+ heartbeatPrompt?: string;
runtimeInfo?: {
host?: string;
os?: string;
@@ -113,6 +114,10 @@ export function buildAgentSystemPromptAppend(params: {
: undefined;
const userTimezone = params.userTimezone?.trim();
const userTime = params.userTime?.trim();
+ const heartbeatPrompt = params.heartbeatPrompt?.trim();
+ const heartbeatPromptLine = heartbeatPrompt
+ ? `Heartbeat prompt: ${heartbeatPrompt}`
+ : "Heartbeat prompt: (configured)";
const runtimeInfo = params.runtimeInfo;
const runtimeLines: string[] = [];
if (runtimeInfo?.host) runtimeLines.push(`Host: ${runtimeInfo.host}`);
@@ -207,7 +212,8 @@ export function buildAgentSystemPromptAppend(params: {
lines.push(
"## Heartbeats",
- 'If you receive a heartbeat poll (a user message containing just "HEARTBEAT"), and there is nothing that needs attention, reply exactly:',
+ heartbeatPromptLine,
+ "If you receive a heartbeat poll (a user message matching the heartbeat prompt above), and there is nothing that needs attention, reply exactly:",
"HEARTBEAT_OK",
'Clawdbot treats a leading/trailing "HEARTBEAT_OK" as a heartbeat ack (and may discard it).',
'If something needs attention, do NOT include "HEARTBEAT_OK"; reply with the alert text instead.',
diff --git a/src/agents/timeout.ts b/src/agents/timeout.ts
new file mode 100644
index 000000000..65d0eeb9c
--- /dev/null
+++ b/src/agents/timeout.ts
@@ -0,0 +1,35 @@
+import type { ClawdbotConfig } from "../config/config.js";
+
+const DEFAULT_AGENT_TIMEOUT_SECONDS = 600;
+
+const normalizeNumber = (value: unknown): number | undefined =>
+ typeof value === "number" && Number.isFinite(value)
+ ? Math.floor(value)
+ : undefined;
+
+export function resolveAgentTimeoutSeconds(cfg?: ClawdbotConfig): number {
+ const raw = normalizeNumber(cfg?.agent?.timeoutSeconds);
+ const seconds = raw ?? DEFAULT_AGENT_TIMEOUT_SECONDS;
+ return Math.max(seconds, 1);
+}
+
+export function resolveAgentTimeoutMs(opts: {
+ cfg?: ClawdbotConfig;
+ overrideMs?: number | null;
+ overrideSeconds?: number | null;
+ minMs?: number;
+}): number {
+ const minMs = Math.max(normalizeNumber(opts.minMs) ?? 1, 1);
+ const defaultMs = resolveAgentTimeoutSeconds(opts.cfg) * 1000;
+ const overrideMs = normalizeNumber(opts.overrideMs);
+ if (overrideMs !== undefined) {
+ if (overrideMs <= 0) return defaultMs;
+ return Math.max(overrideMs, minMs);
+ }
+ const overrideSeconds = normalizeNumber(opts.overrideSeconds);
+ if (overrideSeconds !== undefined) {
+ if (overrideSeconds <= 0) return defaultMs;
+ return Math.max(overrideSeconds * 1000, minMs);
+ }
+ return Math.max(defaultMs, minMs);
+}
diff --git a/src/agents/tool-display.json b/src/agents/tool-display.json
index 6de42b775..ce3ba7b66 100644
--- a/src/agents/tool-display.json
+++ b/src/agents/tool-display.json
@@ -165,6 +165,11 @@
"title": "Session Send",
"detailKeys": ["sessionKey", "timeoutSeconds"]
},
+ "sessions_spawn": {
+ "emoji": "🧑🔧",
+ "title": "Sub-agent",
+ "detailKeys": ["label", "timeoutSeconds", "cleanup"]
+ },
"whatsapp_login": {
"emoji": "🟢",
"title": "WhatsApp Login",
diff --git a/src/agents/tool-display.ts b/src/agents/tool-display.ts
index 1d531e8c5..9e9a5a9e0 100644
--- a/src/agents/tool-display.ts
+++ b/src/agents/tool-display.ts
@@ -1,6 +1,6 @@
-import fs from "node:fs";
import { redactToolDetail } from "../logging/redact.js";
import { shortenHomeInString } from "../utils.js";
+import TOOL_DISPLAY_JSON from "./tool-display.json" with { type: "json" };
type ToolDisplayActionSpec = {
label?: string;
@@ -30,17 +30,7 @@ export type ToolDisplay = {
detail?: string;
};
-const TOOL_DISPLAY_CONFIG: ToolDisplayConfig = (() => {
- try {
- const raw = fs.readFileSync(
- new URL("./tool-display.json", import.meta.url),
- "utf8",
- );
- return JSON.parse(raw) as ToolDisplayConfig;
- } catch {
- return {};
- }
-})();
+const TOOL_DISPLAY_CONFIG = TOOL_DISPLAY_JSON as ToolDisplayConfig;
const FALLBACK = TOOL_DISPLAY_CONFIG.fallback ?? { emoji: "🧩" };
const TOOL_MAP = TOOL_DISPLAY_CONFIG.tools ?? {};
diff --git a/src/agents/tools/agent-step.ts b/src/agents/tools/agent-step.ts
new file mode 100644
index 000000000..84d5fdff8
--- /dev/null
+++ b/src/agents/tools/agent-step.ts
@@ -0,0 +1,56 @@
+import crypto from "node:crypto";
+
+import { callGateway } from "../../gateway/call.js";
+import { extractAssistantText, stripToolMessages } from "./sessions-helpers.js";
+
+export async function readLatestAssistantReply(params: {
+ sessionKey: string;
+ limit?: number;
+}): Promise {
+ const history = (await callGateway({
+ method: "chat.history",
+ params: { sessionKey: params.sessionKey, limit: params.limit ?? 50 },
+ })) as { messages?: unknown[] };
+ const filtered = stripToolMessages(
+ Array.isArray(history?.messages) ? history.messages : [],
+ );
+ const last = filtered.length > 0 ? filtered[filtered.length - 1] : undefined;
+ return last ? extractAssistantText(last) : undefined;
+}
+
+export async function runAgentStep(params: {
+ sessionKey: string;
+ message: string;
+ extraSystemPrompt: string;
+ timeoutMs: number;
+ lane?: string;
+}): Promise {
+ const stepIdem = crypto.randomUUID();
+ const response = (await callGateway({
+ method: "agent",
+ params: {
+ message: params.message,
+ sessionKey: params.sessionKey,
+ idempotencyKey: stepIdem,
+ deliver: false,
+ lane: params.lane ?? "nested",
+ extraSystemPrompt: params.extraSystemPrompt,
+ },
+ timeoutMs: 10_000,
+ })) as { runId?: string; acceptedAt?: number };
+
+ const stepRunId =
+ typeof response?.runId === "string" && response.runId ? response.runId : "";
+ const resolvedRunId = stepRunId || stepIdem;
+ const stepWaitMs = Math.min(params.timeoutMs, 60_000);
+ const wait = (await callGateway({
+ method: "agent.wait",
+ params: {
+ runId: resolvedRunId,
+ timeoutMs: stepWaitMs,
+ },
+ timeoutMs: stepWaitMs + 2000,
+ })) as { status?: string };
+ if (wait?.status !== "ok") return undefined;
+ return await readLatestAssistantReply({ sessionKey: params.sessionKey });
+}
diff --git a/src/agents/tools/browser-tool.ts b/src/agents/tools/browser-tool.ts
index 882c65a33..8681e3abb 100644
--- a/src/agents/tools/browser-tool.ts
+++ b/src/agents/tools/browser-tool.ts
@@ -118,6 +118,7 @@ const BrowserToolSchema = Type.Object({
Type.Literal("dialog"),
Type.Literal("act"),
]),
+ profile: Type.Optional(Type.String()),
controlUrl: Type.Optional(Type.String()),
targetUrl: Type.Optional(Type.String()),
targetId: Type.Optional(Type.String()),
@@ -161,38 +162,41 @@ export function createBrowserTool(opts?: {
const params = args as Record;
const action = readStringParam(params, "action", { required: true });
const controlUrl = readStringParam(params, "controlUrl");
+ const profile = readStringParam(params, "profile");
const baseUrl = resolveBrowserBaseUrl(
controlUrl ?? opts?.defaultControlUrl,
);
switch (action) {
case "status":
- return jsonResult(await browserStatus(baseUrl));
+ return jsonResult(await browserStatus(baseUrl, { profile }));
case "start":
- await browserStart(baseUrl);
- return jsonResult(await browserStatus(baseUrl));
+ await browserStart(baseUrl, { profile });
+ return jsonResult(await browserStatus(baseUrl, { profile }));
case "stop":
- await browserStop(baseUrl);
- return jsonResult(await browserStatus(baseUrl));
+ await browserStop(baseUrl, { profile });
+ return jsonResult(await browserStatus(baseUrl, { profile }));
case "tabs":
- return jsonResult({ tabs: await browserTabs(baseUrl) });
+ return jsonResult({ tabs: await browserTabs(baseUrl, { profile }) });
case "open": {
const targetUrl = readStringParam(params, "targetUrl", {
required: true,
});
- return jsonResult(await browserOpenTab(baseUrl, targetUrl));
+ return jsonResult(
+ await browserOpenTab(baseUrl, targetUrl, { profile }),
+ );
}
case "focus": {
const targetId = readStringParam(params, "targetId", {
required: true,
});
- await browserFocusTab(baseUrl, targetId);
+ await browserFocusTab(baseUrl, targetId, { profile });
return jsonResult({ ok: true });
}
case "close": {
const targetId = readStringParam(params, "targetId");
- if (targetId) await browserCloseTab(baseUrl, targetId);
- else await browserAct(baseUrl, { kind: "close" });
+ if (targetId) await browserCloseTab(baseUrl, targetId, { profile });
+ else await browserAct(baseUrl, { kind: "close" }, { profile });
return jsonResult({ ok: true });
}
case "snapshot": {
@@ -212,6 +216,7 @@ export function createBrowserTool(opts?: {
format,
targetId,
limit,
+ profile,
});
if (snapshot.format === "ai") {
return {
@@ -233,6 +238,7 @@ export function createBrowserTool(opts?: {
ref,
element,
type,
+ profile,
});
return await imageResultFromFile({
label: "browser:screenshot",
@@ -246,7 +252,11 @@ export function createBrowserTool(opts?: {
});
const targetId = readStringParam(params, "targetId");
return jsonResult(
- await browserNavigate(baseUrl, { url: targetUrl, targetId }),
+ await browserNavigate(baseUrl, {
+ url: targetUrl,
+ targetId,
+ profile,
+ }),
);
}
case "console": {
@@ -257,7 +267,7 @@ export function createBrowserTool(opts?: {
? params.targetId.trim()
: undefined;
return jsonResult(
- await browserConsoleMessages(baseUrl, { level, targetId }),
+ await browserConsoleMessages(baseUrl, { level, targetId, profile }),
);
}
case "pdf": {
@@ -265,7 +275,7 @@ export function createBrowserTool(opts?: {
typeof params.targetId === "string"
? params.targetId.trim()
: undefined;
- const result = await browserPdfSave(baseUrl, { targetId });
+ const result = await browserPdfSave(baseUrl, { targetId, profile });
return {
content: [{ type: "text", text: `FILE:${result.path}` }],
details: result,
@@ -296,6 +306,7 @@ export function createBrowserTool(opts?: {
element,
targetId,
timeoutMs,
+ profile,
}),
);
}
@@ -320,6 +331,7 @@ export function createBrowserTool(opts?: {
promptText,
targetId,
timeoutMs,
+ profile,
}),
);
}
@@ -331,6 +343,7 @@ export function createBrowserTool(opts?: {
const result = await browserAct(
baseUrl,
request as Parameters[1],
+ { profile },
);
return jsonResult(result);
}
diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts
index 38e7ee9e9..95f7a68ba 100644
--- a/src/agents/tools/cron-tool.ts
+++ b/src/agents/tools/cron-tool.ts
@@ -3,11 +3,23 @@ import {
normalizeCronJobCreate,
normalizeCronJobPatch,
} from "../../cron/normalize.js";
-import { CronAddParamsSchema } from "../../gateway/protocol/schema.js";
import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js";
import { callGatewayTool, type GatewayCallOptions } from "./gateway.js";
-const CronJobPatchSchema = Type.Partial(CronAddParamsSchema);
+// NOTE: We use Type.Object({}, { additionalProperties: true }) for job/patch
+// instead of CronAddParamsSchema/CronJobPatchSchema because:
+//
+// 1. CronAddParamsSchema contains nested Type.Union (for schedule, payload, etc.)
+// 2. TypeBox compiles Type.Union to JSON Schema `anyOf`
+// 3. pi-ai's sanitizeSchemaForGoogle() strips `anyOf` from nested properties
+// 4. This leaves empty schemas `{}` which Claude rejects as invalid
+//
+// The actual validation happens at runtime via normalizeCronJobCreate/Patch
+// and the gateway's validateCronAddParams. This schema just needs to accept
+// any object so the AI can pass through the job definition.
+//
+// See: https://github.com/anthropics/anthropic-cookbook/blob/main/misc/tool_use_best_practices.md
+// Claude requires valid JSON Schema 2020-12 with explicit types.
const CronToolSchema = Type.Union([
Type.Object({
@@ -28,7 +40,7 @@ const CronToolSchema = Type.Union([
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
- job: CronAddParamsSchema,
+ job: Type.Object({}, { additionalProperties: true }),
}),
Type.Object({
action: Type.Literal("update"),
@@ -36,7 +48,7 @@ const CronToolSchema = Type.Union([
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
id: Type.String(),
- patch: CronJobPatchSchema,
+ patch: Type.Object({}, { additionalProperties: true }),
}),
Type.Object({
action: Type.Literal("remove"),
diff --git a/src/agents/tools/discord-actions-messaging.ts b/src/agents/tools/discord-actions-messaging.ts
index 63fbe491a..855a72d8f 100644
--- a/src/agents/tools/discord-actions-messaging.ts
+++ b/src/agents/tools/discord-actions-messaging.ts
@@ -126,9 +126,10 @@ export async function handleDiscordMessagingAction(
typeof durationRaw === "number" && Number.isFinite(durationRaw)
? durationRaw
: undefined;
+ const maxSelections = allowMultiselect ? Math.max(2, answers.length) : 1;
await sendPollDiscord(
to,
- { question, answers, allowMultiselect, durationHours },
+ { question, options: answers, maxSelections, durationHours },
{ content },
);
return jsonResult({ ok: true });
diff --git a/src/agents/tools/gateway.test.ts b/src/agents/tools/gateway.test.ts
new file mode 100644
index 000000000..7827a7947
--- /dev/null
+++ b/src/agents/tools/gateway.test.ts
@@ -0,0 +1,35 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { callGatewayTool, resolveGatewayOptions } from "./gateway.js";
+
+const callGatewayMock = vi.fn();
+vi.mock("../../gateway/call.js", () => ({
+ callGateway: (...args: unknown[]) => callGatewayMock(...args),
+}));
+
+describe("gateway tool defaults", () => {
+ beforeEach(() => {
+ callGatewayMock.mockReset();
+ });
+
+ it("leaves url undefined so callGateway can use config", () => {
+ const opts = resolveGatewayOptions();
+ expect(opts.url).toBeUndefined();
+ });
+
+ it("passes through explicit overrides", async () => {
+ callGatewayMock.mockResolvedValueOnce({ ok: true });
+ await callGatewayTool(
+ "health",
+ { gatewayUrl: "ws://example", gatewayToken: "t", timeoutMs: 5000 },
+ {},
+ );
+ expect(callGatewayMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ url: "ws://example",
+ token: "t",
+ timeoutMs: 5000,
+ }),
+ );
+ });
+});
diff --git a/src/agents/tools/gateway.ts b/src/agents/tools/gateway.ts
index ae2ca7744..c0ca1b36b 100644
--- a/src/agents/tools/gateway.ts
+++ b/src/agents/tools/gateway.ts
@@ -9,10 +9,11 @@ export type GatewayCallOptions = {
};
export function resolveGatewayOptions(opts?: GatewayCallOptions) {
+ // Prefer an explicit override; otherwise let callGateway choose based on config.
const url =
typeof opts?.gatewayUrl === "string" && opts.gatewayUrl.trim()
? opts.gatewayUrl.trim()
- : DEFAULT_GATEWAY_URL;
+ : undefined;
const token =
typeof opts?.gatewayToken === "string" && opts.gatewayToken.trim()
? opts.gatewayToken.trim()
diff --git a/src/agents/tools/image-tool.ts b/src/agents/tools/image-tool.ts
index e39841972..b1a7574e8 100644
--- a/src/agents/tools/image-tool.ts
+++ b/src/agents/tools/image-tool.ts
@@ -14,7 +14,6 @@ import { Type } from "@sinclair/typebox";
import type { ClawdbotConfig } from "../../config/config.js";
import { resolveUserPath } from "../../utils.js";
import { loadWebMedia } from "../../web/media.js";
-import { resolveClawdbotAgentDir } from "../agent-paths.js";
import { getApiKeyForModel } from "../model-auth.js";
import { runWithImageModelFallback } from "../model-fallback.js";
import { ensureClawdbotModelsJson } from "../models-config.js";
@@ -78,15 +77,15 @@ function buildImageContext(
async function runImagePrompt(params: {
cfg?: ClawdbotConfig;
+ agentDir: string;
modelOverride?: string;
prompt: string;
base64: string;
mimeType: string;
}): Promise<{ text: string; provider: string; model: string }> {
- const agentDir = resolveClawdbotAgentDir();
- await ensureClawdbotModelsJson(params.cfg);
- const authStorage = discoverAuthStorage(agentDir);
- const modelRegistry = discoverModels(authStorage, agentDir);
+ await ensureClawdbotModelsJson(params.cfg, params.agentDir);
+ const authStorage = discoverAuthStorage(params.agentDir);
+ const modelRegistry = discoverModels(authStorage, params.agentDir);
const result = await runWithImageModelFallback({
cfg: params.cfg,
@@ -104,6 +103,7 @@ async function runImagePrompt(params: {
const apiKeyInfo = await getApiKeyForModel({
model,
cfg: params.cfg,
+ agentDir: params.agentDir,
});
authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey);
const context = buildImageContext(
@@ -130,8 +130,13 @@ async function runImagePrompt(params: {
export function createImageTool(options?: {
config?: ClawdbotConfig;
+ agentDir?: string;
}): AnyAgentTool | null {
if (!ensureImageToolConfigured(options?.config)) return null;
+ const agentDir = options?.agentDir;
+ if (!agentDir?.trim()) {
+ throw new Error("createImageTool requires agentDir when enabled");
+ }
return {
label: "Image",
name: "image",
@@ -175,6 +180,7 @@ export function createImageTool(options?: {
const base64 = media.buffer.toString("base64");
const result = await runImagePrompt({
cfg: options?.config,
+ agentDir,
modelOverride,
prompt: promptRaw,
base64,
diff --git a/src/agents/tools/sessions-announce-target.test.ts b/src/agents/tools/sessions-announce-target.test.ts
new file mode 100644
index 000000000..490a8c4d2
--- /dev/null
+++ b/src/agents/tools/sessions-announce-target.test.ts
@@ -0,0 +1,52 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const callGatewayMock = vi.fn();
+vi.mock("../../gateway/call.js", () => ({
+ callGateway: (opts: unknown) => callGatewayMock(opts),
+}));
+
+import { resolveAnnounceTarget } from "./sessions-announce-target.js";
+
+describe("resolveAnnounceTarget", () => {
+ beforeEach(() => {
+ callGatewayMock.mockReset();
+ });
+
+ it("derives non-WhatsApp announce targets from the session key", async () => {
+ const target = await resolveAnnounceTarget({
+ sessionKey: "agent:main:discord:group:dev",
+ displayKey: "agent:main:discord:group:dev",
+ });
+ expect(target).toEqual({ provider: "discord", to: "channel:dev" });
+ expect(callGatewayMock).not.toHaveBeenCalled();
+ });
+
+ it("hydrates WhatsApp accountId from sessions.list when available", async () => {
+ callGatewayMock.mockResolvedValueOnce({
+ sessions: [
+ {
+ key: "agent:main:whatsapp:group:123@g.us",
+ lastProvider: "whatsapp",
+ lastTo: "123@g.us",
+ lastAccountId: "work",
+ },
+ ],
+ });
+
+ const target = await resolveAnnounceTarget({
+ sessionKey: "agent:main:whatsapp:group:123@g.us",
+ displayKey: "agent:main:whatsapp:group:123@g.us",
+ });
+ expect(target).toEqual({
+ provider: "whatsapp",
+ to: "123@g.us",
+ accountId: "work",
+ });
+ expect(callGatewayMock).toHaveBeenCalledTimes(1);
+ const first = callGatewayMock.mock.calls[0]?.[0] as
+ | { method?: string }
+ | undefined;
+ expect(first).toBeDefined();
+ expect(first?.method).toBe("sessions.list");
+ });
+});
diff --git a/src/agents/tools/sessions-announce-target.ts b/src/agents/tools/sessions-announce-target.ts
new file mode 100644
index 000000000..4a0b66dc9
--- /dev/null
+++ b/src/agents/tools/sessions-announce-target.ts
@@ -0,0 +1,43 @@
+import { callGateway } from "../../gateway/call.js";
+import type { AnnounceTarget } from "./sessions-send-helpers.js";
+import { resolveAnnounceTargetFromKey } from "./sessions-send-helpers.js";
+
+export async function resolveAnnounceTarget(params: {
+ sessionKey: string;
+ displayKey: string;
+}): Promise {
+ const parsed = resolveAnnounceTargetFromKey(params.sessionKey);
+ const parsedDisplay = resolveAnnounceTargetFromKey(params.displayKey);
+ const fallback = parsed ?? parsedDisplay ?? null;
+
+ // Most providers can derive (provider,to) from the session key directly.
+ // WhatsApp is special: we may need lastAccountId from the session store.
+ if (fallback && fallback.provider !== "whatsapp") return fallback;
+
+ try {
+ const list = (await callGateway({
+ method: "sessions.list",
+ params: {
+ includeGlobal: true,
+ includeUnknown: true,
+ limit: 200,
+ },
+ })) as { sessions?: Array