Compare commits

...
Author SHA1 Message Date
Peter Steinberger e8779ac329 fix: handle Anthropic overloaded_error gracefully (#832) (thanks @danielz1z) 2026-01-13 08:32:06 +00:00
danielz1z 36725ce153 fix: handle Anthropic overloaded_error gracefully
When Anthropic's API returns an overloaded_error (temporary capacity issue),
the raw JSON error was being sent to users instead of a friendly message.

Changes:
- Add overloaded error pattern detection
- Return user-friendly message for overloaded errors
- Classify overloaded as failover-worthy (triggers retry logic)
2026-01-13 08:18:59 +00:00
Peter Steinberger 0d537ece10 docs: add minimax coding plan referral 2026-01-13 08:15:44 +00:00
Peter Steinberger f825dd2897 chore(lint): format live profiles test 2026-01-13 08:03:58 +00:00
Peter Steinberger 9faa95d558 fix: recover from compaction overflow 2026-01-13 08:03:11 +00:00
Peter Steinberger 365cbe8d50 docs: clarify Discord requireMention placement 2026-01-13 08:03:11 +00:00
Peter Steinberger 9a322d52e2 docs: update faq and install guidance 2026-01-13 08:03:11 +00:00
Peter Steinberger 89013efbca docs: fix Discord invite link in showcase 2026-01-13 08:03:11 +00:00
Peter Steinberger 3a90335b5a chore(lint): satisfy biome checks 2026-01-13 07:53:38 +00:00
Peter Steinberger 676b64e8a3 chore(changelog): note auth profile order fix 2026-01-13 07:53:38 +00:00
Peter Steinberger 9007920695 fix(auth): drop invalid auth profiles from order 2026-01-13 07:53:38 +00:00
Peter Steinberger 2887376646 test: stabilize gpt-5.2 tool-only live check 2026-01-13 07:51:24 +00:00
Peter Steinberger e48d452c63 docs: credit MiniMax agent-dir fix 2026-01-13 07:30:54 +00:00
Peter Steinberger 165841ae79 fix: suppress experimental sqlite warning at startup 2026-01-13 07:27:32 +00:00
Peter Steinberger 76acdb7ae7 test: silence experimental sqlite warnings in docker 2026-01-13 07:27:32 +00:00
Peter Steinberger dfbe4041f5 fix: skip Control UI asset check when UI is skipped 2026-01-13 07:27:32 +00:00
Peter Steinberger 4fd1a6dec3 fix: harden doctor install checks 2026-01-13 07:25:25 +00:00
Peter Steinberger aa394d0e14 chore: update clawtributors (thanks @vrknetha) 2026-01-13 07:19:53 +00:00
Peter Steinberger d5b17d344b Merge pull request #722 from vrknetha/feat/slash-bash-command
feat(bash): add host-only /bash slash command
2026-01-13 07:18:35 +00:00
Peter Steinberger 8111e18dbd docs: add Opus/MiniMax fallback examples 2026-01-13 07:15:20 +00:00
Peter Steinberger ba7d12f205 refactor: centralize onboarding auth paths 2026-01-13 07:12:20 +00:00
Peter Steinberger ef66ad3b52 docs: group FAQ sections 2026-01-13 07:07:48 +00:00
Peter Steinberger 69e4339af9 Tests: run e2e gateway with node 2026-01-13 07:06:41 +00:00
Peter Steinberger 779904657f Docs: reorder 2025.1.12 changelog 2026-01-13 07:03:44 +00:00
Peter Steinberger cb0f6cefa4 Deps: update Pi + Vitest and add Bedrock docs 2026-01-13 06:57:11 +00:00
Peter Steinberger 25ef01b74a docs: add FAQ table of contents 2026-01-13 06:55:00 +00:00
Peter Steinberger 6db0201fcd Merge pull request #583 from mitschabaude-bot/feat/agent-model-fallbacks
Config: per-agent model fallbacks
2026-01-13 06:54:00 +00:00
Peter Steinberger 40e508823f Merge pull request #444 from grp06/feature/xhigh-thinking-models
Thinking: gate xhigh by model
2026-01-13 06:51:39 +00:00
Peter Steinberger 3368284b2a fix: per-agent model fallbacks (#583) (thanks @mitschabaude-bot) 2026-01-13 06:50:41 +00:00
Gregor's Bot ece01d89fe Docs: document agents.list model fallbacks 2026-01-13 06:50:20 +00:00
Gregor's Bot 9c0c4f50ec Agents: test per-agent model fallbacks override 2026-01-13 06:50:20 +00:00
Gregor's Bot 6729637f61 Config: support per-agent model fallbacks 2026-01-13 06:50:20 +00:00
Peter Steinberger 18d22aa426 fix: gate xhigh by model (#444) (thanks @grp06) 2026-01-13 06:48:41 +00:00
George Pickett a3641526ab Thinking: gate xhigh by model 2026-01-13 06:48:26 +00:00
Peter Steinberger f50e06a1b6 test(tools): cover tool policy helpers 2026-01-13 06:32:59 +00:00
Peter Steinberger 2ae3b45ac1 docs: update changelog for tool schema and profiles 2026-01-13 06:31:12 +00:00
Peter Steinberger 780a43711f feat(tools): add tool profiles and group shorthands 2026-01-13 06:30:20 +00:00
Peter Steinberger d682b604de fix(tools): harden tool schemas for strict providers 2026-01-13 06:30:20 +00:00
104 changed files with 4152 additions and 1267 deletions

No files matched your search

+1
View File
@@ -100,6 +100,7 @@
- **Multi-agent safety:** focus reports on your edits; avoid guard-rail disclaimers unless truly blocked; when multiple agents touch the same file, continue if safe; end with a brief “other files present” note only if relevant.
- Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause.
- Code style: add brief comments for tricky logic; keep files under ~500 LOC when feasible (split/refactor as needed).
- Tool schema guardrails (google-antigravity): avoid `Type.Union` in tool input schemas; no `anyOf`/`oneOf`/`allOf`. Use `stringEnum`/`optionalStringEnum` (Type.Unsafe enum) for string lists, and `Type.Optional(...)` instead of `... | null`. Keep top-level tool schema as `type: "object"` with `properties`.
- When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/agents/main/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdbot variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.
+45 -106
View File
@@ -1,116 +1,55 @@
# Changelog
## 2026.1.13-1
### Changes
- Cron: accept ISO timestamps for one-shot schedules (UTC) and allow optional delete-after-run; wired into CLI + macOS editor.
- Gateway: add Tailscale binary discovery, custom bind mode, and probe auth retry for password changes. (#740 — thanks @jeffersonwarrior)
- Agents: add compaction mode config with optional safeguard summarization for long histories. (#700 — thanks @thewilloftheshadow)
### Fixes
- Gateway: honor `CLAWDBOT_LAUNCHD_LABEL` / `CLAWDBOT_SYSTEMD_UNIT` overrides when checking or restarting the daemon.
## 2026.1.12-4
### Changes
- Models/Moonshot: add Kimi K2 0905 + turbo/thinking variants to the preset + docs. (#818 — thanks @mickahouan)
- Memory: allow custom OpenAI-compatible embedding endpoints for memory search (remote baseUrl/apiKey/headers). (#819 — thanks @mukhtharcm)
- Auth: add Chutes OAuth (PKCE + refresh + onboarding choice). (#726 — thanks @FrieSei)
- Agents: make workspace bootstrap truncation configurable (default 20k) and warn when files are truncated.
### Fixes
- Typing: keep typing indicators alive during tool execution. (#450, #447 — thanks @thewilloftheshadow)
- Cron: coerce enabled patches so disabling jobs persists correctly. (#205 — thanks @thewilloftheshadow)
- Control UI: keep chat scroll position unless user is near the bottom. (#217 — thanks @thewilloftheshadow)
- Fallback: treat credential validation failures ("no credentials found", "no API key found") as auth errors that trigger model fallback. (#822 — thanks @sebslight)
- Telegram: preserve forum topic thread ids, including General topic replies. (#727 — thanks @thewilloftheshadow)
- Telegram: persist polling update offsets across restarts to avoid duplicate updates. (#739 — thanks @thewilloftheshadow)
- Discord: avoid duplicate message/reaction listeners on monitor reloads. (#744 — thanks @thewilloftheshadow)
- System events: include local timestamps when events are injected into prompts. (#245 — thanks @thewilloftheshadow)
- Cron: accept `jobId` aliases for cron update/run/remove params in gateway validation. (#252 — thanks @thewilloftheshadow)
- Models/Google: normalize Gemini 3 model ids to preview variants before runtime selection. (#795 — thanks @thewilloftheshadow)
- Models/Google: strip Gemini CLI tool call/response ids in patched provider handling. (#783 — thanks @ananth-vardhan-cn)
- TUI: keep the last streamed response instead of replacing it with "(no output)". (#747 — thanks @thewilloftheshadow)
- Slack: accept slash commands with or without leading `/` for custom command configs. (#798 — thanks @thewilloftheshadow)
- Onboarding/Configure: refuse to proceed with invalid configs; run `clawdbot doctor` first to avoid wiping custom fields. (#764 — thanks @mukhtharcm)
- Onboarding: quote Windows browser URLs when launching via `cmd start` to preserve OAuth query params. (#794 — thanks @roshanasingh4)
- Gateway/Auth: allow Tailscale Serve identity headers to satisfy token auth when `allowTailscale` is enabled. (#823 — thanks @roshanasingh4)
- Anthropic: merge consecutive user turns (preserve newest metadata) before validation to avoid “Incorrect role information” errors. (#804 — thanks @ThomsenDrake)
- Discord/Slack: centralize reply-thread planning so auto-thread replies stay in the created thread without parent reply refs.
- Telegram: respect account-scoped bindings when webhook mode is enabled. (#821 — thanks @gumadeiras)
- Update: run `clawdbot doctor --non-interactive` during updates to avoid TTY hangs. (#781 — thanks @ronyrus)
- Browser tools: treat explicit `maxChars: 0` as unlimited while keeping the default limit only when omitted. (#796 — thanks @gabriel-trigo)
- Tools: allow Claude/Gemini tool param aliases (`file_path`, `old_string`, `new_string`) while enforcing required params at runtime. (#793 — thanks @hsrvc)
- Gemini: downgrade tool-call history missing `thought_signature` to avoid INVALID_ARGUMENT errors. (#793 — thanks @hsrvc)
- Messaging: enforce context isolation for message tool sends across providers (normalized targets + tests). (#793 — thanks @hsrvc)
- Auto-reply: re-evaluate reasoning tag enforcement on fallback providers to prevent leaked reasoning. (#810 — thanks @mcinteerj)
- Tools/Gemini: drop null-only union variants while cleaning tool schemas to avoid Cloud Code Assist schema errors. (#782 — thanks @AbhisekBasu1)
- Connections UI: polish multi-account account cards in the Connections view. (#816 — thanks @steipete)
- Gemini: strip Claude `msg_*` thought_signature fields from session history to avoid base64 decode errors. (#805 — thanks @marcmarg)
## 2026.1.12-3
### Changes
- Sandbox: drop legacy `memory` tool-policy shorthand; require explicit `group:memory`.
### Fixes
- Telegram: tolerate mocked bots missing native-command APIs (`setMyCommands`, `command`) during tests.
- Auto-reply: fix streaming block reply media handling (no redeclared/use-before-declare vars).
## 2026.1.12-2
### Changes
- Subagents: add config to set default sub-agent model (`agents.defaults.subagents.model` + per-agent override); still overridden by `sessions_spawn.model`.
- Plugins: restore full voice-call plugin parity (Telnyx/Twilio, streaming, inbound policies, tools/CLI).
- Sandbox: support tool-policy groups in `tools.sandbox.tools` (e.g. `group:memory`, `group:fs`) to reduce config churn.
### Fixes
- Models/MiniMax: strip malformed tool invocation XML (`<invoke>...</invoke>` and `</minimax:tool_call>`) from assistant text to prevent tool call leaks into user messages. (#809 — thanks @latitudeki5223)
- Tools/Models: MiniMax vision now uses the Coding Plan VLM endpoint (`/v1/coding_plan/vlm`) so the `image` tool works with MiniMax keys (also accepts `@/path/to/file.png`-style inputs).
- Gateway/macOS: reduce noisy loopback WS "closed before connect" logs during tests.
- Auto-reply: resolve ambiguous `/model` fuzzy matches by picking the best candidate instead of erroring.
## 2026.1.12-1
### Changes
- Heartbeat: raise default `ackMaxChars` to 300 so any `HEARTBEAT_OK` replies with short padding stay internal (fewer noisy heartbeat posts on providers).
- Onboarding: normalize API key inputs (strip `export KEY=...` wrappers) so shell-style entries paste cleanly.
## 2026.1.12 (Unreleased)
## 2025.1.12 (Unreleased)
### Highlights
- Memory: add vector search for agent memories (Markdown-only scope) with SQLite index, chunking, lazy sync + file watch, and per-agent enablement/fallback.
- Memory: add vector search for agent memories (Markdown-only) with SQLite index, chunking, lazy sync + file watch, and per-agent enablement/fallback.
- Plugins: restore full voice-call plugin parity (Telnyx/Twilio, streaming, inbound policies, tools/CLI).
- Models: add Synthetic provider plus Moonshot Kimi K2 0905 + turbo/thinking variants (with docs).
- Cron: one-shot schedules accept ISO timestamps (UTC) with optional delete-after-run; cron jobs can target a specific agent (CLI + macOS/Control UI).
- Agents: add compaction mode config with optional safeguard summarization and per-agent model fallbacks.
### Changes
- Agents: strengthen memory recall guidance (memory_search mandatory for past work/preferences; system prompt injects conditional recall section; memory_get now described as safe snippet fetch).
- Browser: add `scrollintoview` action to scroll refs into view before click/type.
- Memory: embedding providers support OpenAI or local `node-llama-cpp`; config adds defaults + per-agent overrides, provider/fallback metadata surfaced in tools/CLI.
- CLI/Tools: new `clawdbot memory` commands plus `memory_search`/`memory_get` tools returning snippets + line ranges and provider info.
- Runtime: memory index stored under `~/.clawdbot/memory/{agentId}.sqlite` with watch-on-by-default; inline status replies now stay auth-gated while inline prompts continue to the agent.
- Doctor: rebuild Control UI assets when protocol schema is newer to avoid stale UI connect errors. (#786) — thanks @meaningfool.
- Cron: allow jobs to target a specific agent and expose agent selection in the macOS app + Control UI.
- Discord: add `discord.allowBots` to permit bot-authored messages (still ignores its own messages) with docs warning about bot loops. (#802) — thanks @zknicker.
- CLI/Onboarding: `clawdbot dashboard` prints/copies the tokenized Control UI link and opens it; onboarding now auto-opens the dashboard with your token and keeps the link in the summary.
- Commands: native slash commands now default to `"auto"` (on for Discord/Telegram, off for Slack) with per-provider overrides (`discord/telegram/slack.commands.native`) and docs updated.
- Sandbox: allow Docker bind mounts via `docker.binds`; merges global + per-agent binds (per-agent ignored under shared scope) for custom host paths. (#790 — thanks @akonyer)
- Models: add Synthetic provider (Anthropic-compatible) and trim legacy MiniMax M2 from default catalogs. (#811 — thanks @siraht)
### New & Improved
- Memory: add custom OpenAI-compatible embedding endpoints; support OpenAI/local `node-llama-cpp` embeddings with per-agent overrides and provider metadata in tools/CLI.
- Memory: new `clawdbot memory` CLI plus `memory_search`/`memory_get` tools with snippets + line ranges; index stored under `~/.clawdbot/memory/{agentId}.sqlite` with watch-on-by-default.
- Agents: strengthen memory recall guidance; make workspace bootstrap truncation configurable (default 20k) with warnings; add default sub-agent model config.
- Tools/Sandbox: add tool profiles + group shorthands; support tool-policy groups in `tools.sandbox.tools`; drop legacy `memory` shorthand; allow Docker bind mounts via `docker.binds`.
- Tools: add browser `scrollintoview` action; allow Claude/Gemini tool param aliases; allow thinking `xhigh` for GPT-5.2/Codex with safe downgrades.
- Gateway/CLI: add Tailscale binary discovery, custom bind mode, and probe auth retry; add `clawdbot dashboard` auto-open flow; default native slash commands to `"auto"` with per-provider overrides.
- Auth/Onboarding: add Chutes OAuth (PKCE + refresh + onboarding choice); normalize API key inputs; default TUI onboarding to `deliver: false`.
- Providers: add `discord.allowBots`; trim legacy MiniMax M2 from default catalogs; route MiniMax vision to the Coding Plan VLM endpoint (also accepts `@/path/to/file.png` inputs).
- Gateway: allow Tailscale Serve identity headers to satisfy token auth; rebuild Control UI assets when protocol schema is newer.
- Heartbeat: default `ackMaxChars` to 300 so short `HEARTBEAT_OK` replies stay internal.
### Installer
- Install: run `clawdbot doctor --non-interactive` after git installs/updates and nudge daemon restarts when detected.
### Fixes
- Auto-reply: inline `/status` now honors allowlists (authorized stripped + replied inline; unauthorized leaves text for the agent) to match command gating tests.
- Models: normalize `${ENV_VAR}` apiKey config values and auto-fill missing provider `apiKey` from env/auth when custom provider models are configured (fixes MiniMax “Unknown model” on fresh installs).
- Models/Tools: include `MiniMax-VL-01` in implicit MiniMax provider so image pairing uses a real vision model.
- Telegram: show typing indicator in General forum topics. (#779) — thanks @azade-c.
- Discord: keep reasoning italics intact when messages are chunked, so reasoning stays italic across multi-part sends.
- Models: keep explicit GitHub Copilot provider config and honor agent-dir auth profiles for auto-injection. (#705) — thanks @TAGOOZ.
- Auto-reply: restore 300-char heartbeat ack limit and keep >300 char replies instead of dropping them; adjust long heartbeat test content accordingly.
- Gateway: `agents.list` now honors explicit `agents.list` config without pulling stray agents from disk; GitHub Copilot CLI auth path uses the updated provider build.
- Google: apply patched pi-ai `google-gemini-cli` function call handling (strips ids) after upgrading to pi-ai 0.43.0.
- Tools: allow Gemini/Claude-style aliases (`file_path`, `old_string`, `new_string`) in read/write/edit schemas while still enforcing required params at runtime to avoid validation loops.
- Auto-reply: elevated/reasoning toggles now enqueue system events so the model sees the mode change immediately.
- Tools: keep `image` available in sandbox and fail over when image models return empty output (fixes “(no text returned)”).
- Discord: add per-channel `autoThread` to auto-create threads for top-level messages. (#800) — thanks @davidguttman.
- Discord: fix autoThread routing so replies stay in the created thread and avoid reply references. (#807) — thanks @davidguttman.
- Onboarding: TUI defaults to `deliver: false` to avoid cross-provider auto-delivery leaks; onboarding spawns the TUI with explicit `deliver: false`. (#791 — thanks @roshanasingh4)
- Doctor: warn on pnpm workspace mismatches, missing Control UI assets, and missing tsx binaries; offer UI rebuilds.
- Models/Providers: treat credential validation failures as auth errors to trigger fallback; normalize `${ENV_VAR}` apiKey values and auto-fill missing provider keys; preserve explicit GitHub Copilot provider config + agent-dir auth profiles.
- Auth: drop invalid auth profiles from ordering so environment keys can still be used for providers like MiniMax.
- Gemini: normalize Gemini 3 ids to preview variants; strip Gemini CLI tool call/response ids; downgrade missing `thought_signature`; strip Claude `msg_*` thought_signature fields to avoid base64 decode errors.
- Agents: auto-recover from compaction context overflow by resetting the session and retrying; propagate overflow details from embedded runs so callers can recover.
- MiniMax: strip malformed tool invocation XML; include `MiniMax-VL-01` in implicit provider for image pairing.
- Onboarding/Auth: honor `CLAWDBOT_AGENT_DIR` / `PI_CODING_AGENT_DIR` when writing auth profiles (MiniMax). (#829) — thanks @roshanasingh4.
- Anthropic: handle `overloaded_error` with a friendly message and failover classification. (#832) — thanks @danielz1z.
- Anthropic: merge consecutive user turns (preserve newest metadata) before validation to avoid incorrect role errors.
- Messaging: enforce context isolation for message tool sends; keep typing indicators alive during tool execution.
- Auto-reply: `/status` allowlist behavior, reasoning-tag enforcement on fallback, and system-event enqueueing for elevated/reasoning toggles.
- Auto-reply: resolve ambiguous `/model` matches; fix streaming block reply media handling; keep >300 char heartbeat replies instead of dropping.
- Discord/Slack: centralize reply-thread planning; fix autoThread routing + add per-channel autoThread; avoid duplicate listeners; keep reasoning italics intact; allow clearing channel parents via message tool.
- Telegram: preserve forum topic thread ids, persist polling offsets, respect account bindings in webhook mode, and show typing indicator in General topics.
- Slack: accept slash commands with or without leading `/` for custom command configs.
- Cron: persist disabled jobs correctly; accept `jobId` aliases for update/run/remove params.
- Gateway/CLI: honor `CLAWDBOT_LAUNCHD_LABEL` / `CLAWDBOT_SYSTEMD_UNIT` overrides; `agents.list` respects explicit config; reduce noisy loopback WS logs during tests; run `clawdbot doctor --non-interactive` during updates.
- Onboarding/Control UI: refuse invalid configs (run doctor first); quote Windows browser URLs for OAuth; keep chat scroll position unless the user is near the bottom.
- Tools/UI: harden tool input schemas for strict providers; drop null-only union variants for Gemini schema cleanup; treat `maxChars: 0` as unlimited; keep TUI last streamed response instead of "(no output)".
- Connections UI: polish multi-account account cards.
### Maintenance
- Dependencies: bump Pi packages to 0.45.3 and refresh patched pi-ai.
- Testing: update Vitest + browser-playwright to 4.0.17.
- Docs: add Amazon Bedrock provider notes and link from models/FAQ.
## 2026.1.11
+16 -16
View File
@@ -247,7 +247,7 @@ Send these in WhatsApp/Telegram/Slack/Microsoft Teams/WebChat (group commands ar
- `/status` — compact session status (model + tokens, cost when available)
- `/new` or `/reset` — reset the session
- `/compact` — compact session context (summary)
- `/think <level>` — off|minimal|low|medium|high
- `/think <level>` — off|minimal|low|medium|high|xhigh (GPT-5.2 + Codex models only)
- `/verbose on|off`
- `/cost on|off` — append per-response token/cost usage lines
- `/restart` — restart the gateway (owner-only in groups)
@@ -467,21 +467,21 @@ Special thanks to @andrewting19 for the Anthropic OAuth tool-name fix.
Thanks to all clawtributors:
<p align="left">
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a>
<a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a>
<a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a>
<a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a>
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a>
<a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a>
<a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a>
<a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a>
<a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="Sebastian Barrios" title="Sebastian Barrios"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a>
<a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a>
<a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a>
<a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a>
<a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a>
<a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a>
<a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a>
<a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a>
<a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a>
<a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a>
<a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a>
<a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a>
<a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
<a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a>
<a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a>
<a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a>
<a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a>
<a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a>
<a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a>
<a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a>
<a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a>
<a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a>
<a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a>
<a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
</p>
+1 -1
View File
@@ -105,7 +105,7 @@ Isolation options (only for `session=isolated`):
### Model and thinking overrides
Isolated jobs (`agentTurn`) can override the model and thinking level:
- `model`: Provider/model string (e.g., `anthropic/claude-sonnet-4-20250514`) or alias (e.g., `opus`)
- `thinking`: Thinking level (`off`, `minimal`, `low`, `medium`, `high`)
- `thinking`: Thinking level (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`; GPT-5.2 + Codex models only)
Note: You can set `model` on main-session jobs too, but it changes the shared main
session model. We recommend model overrides only for isolated jobs to avoid
+71
View File
@@ -0,0 +1,71 @@
---
summary: "Use Amazon Bedrock (Converse API) models with Clawdbot"
read_when:
- You want to use Amazon Bedrock models with Clawdbot
- You need AWS credential/region setup for model calls
---
# Amazon Bedrock
Clawdbot can use **Amazon Bedrock** models via pi‑ai’s **Bedrock Converse**
streaming provider. Bedrock auth uses the **AWS SDK default credential chain**,
not an API key.
## What pi‑ai supports
- Provider: `amazon-bedrock`
- API: `bedrock-converse-stream`
- Auth: AWS credentials (env vars, shared config, or instance role)
- Region: `AWS_REGION` or `AWS_DEFAULT_REGION` (default: `us-east-1`)
## Setup (manual)
1) Ensure AWS credentials are available on the **gateway host**:
```bash
export AWS_ACCESS_KEY_ID="AKIA..."
export AWS_SECRET_ACCESS_KEY="..."
export AWS_REGION="us-east-1"
# Optional:
export AWS_SESSION_TOKEN="..."
export AWS_PROFILE="your-profile"
```
2) Add a Bedrock provider and model to your config:
```json5
{
models: {
providers: {
"amazon-bedrock": {
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
api: "bedrock-converse-stream",
models: [
{
id: "anthropic.claude-3-7-sonnet-20250219-v1:0",
name: "Claude 3.7 Sonnet (Bedrock)",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 8192
}
]
}
}
},
agents: {
defaults: {
model: { primary: "amazon-bedrock/anthropic.claude-3-7-sonnet-20250219-v1:0" }
}
}
}
```
## Notes
- Bedrock requires **model access** enabled in your AWS account/region.
- If you use profiles, set `AWS_PROFILE` on the gateway host.
- Reasoning support depends on the model; check the Bedrock model card for
current capabilities.
- If you prefer a managed key flow, you can also place an OpenAI‑compatible
proxy in front of Bedrock and configure it as an OpenAI provider instead.
+4 -4
View File
@@ -257,12 +257,12 @@ Options:
- `--tailscale-reset-on-exit`
- `--install-daemon`
- `--no-install-daemon` (alias: `--skip-daemon`)
- `--daemon-runtime <node|bun>`
- `--daemon-runtime <node|bun>` (bun not recommended for WhatsApp/Telegram)
- `--skip-providers`
- `--skip-skills`
- `--skip-health`
- `--skip-ui`
- `--node-manager <npm|pnpm|bun>`
- `--node-manager <npm|pnpm|bun>` (pnpm recommended; bun not recommended for Gateway runtime)
- `--json`
### `configure` / `config`
@@ -398,7 +398,7 @@ Required:
Options:
- `--to <dest>` (for session key and optional delivery)
- `--session-id <id>`
- `--thinking <off|minimal|low|medium|high>`
- `--thinking <off|minimal|low|medium|high|xhigh>` (GPT-5.2 + Codex models only)
- `--verbose <on|off>`
- `--provider <whatsapp|telegram|discord|slack|signal|imessage>`
- `--local`
@@ -549,7 +549,7 @@ Notes:
- `daemon status` supports `--no-probe`, `--deep`, and `--json` for scripting.
- `daemon status` also surfaces legacy or extra gateway services when it can detect them (`--deep` adds system-level scans). Profile-named Clawdbot services are treated as first-class and aren't flagged as "extra".
- `daemon status` prints which config path the CLI uses vs which config the daemon likely uses (service env), plus the resolved probe target URL.
- `daemon install` defaults to Node runtime; use `--runtime bun` only when WhatsApp is disabled.
- `daemon install` defaults to Node runtime; bun is **not recommended** (WhatsApp/Telegram bugs).
- `daemon install` options: `--port`, `--runtime`, `--token`, `--force`.
### `logs`
+2 -2
View File
@@ -9,7 +9,7 @@ read_when:
Safely update a **source checkout** (git install) of Clawdbot.
If you installed via **npm/pnpm/bun** (global install, no git metadata), use the package manager flow in [Updating](/install/updating).
If you installed via **npm/pnpm** (global install, no git metadata), use the package manager flow in [Updating](/install/updating).
## Usage
@@ -32,7 +32,7 @@ High-level:
1. Requires a clean worktree (no uncommitted changes).
2. Fetches and rebases against `@{upstream}`.
3. Installs deps (pnpm/bun/npm depending on the checkout).
3. Installs deps (pnpm preferred; npm fallback).
4. Builds + builds the Control UI.
5. Runs `clawdbot doctor` as the final “safe update” check.
+3
View File
@@ -92,6 +92,9 @@ This maps to:
`*.blockStreaming` is explicitly set to `true`. Telegram can stream drafts
(`telegram.streamMode`) without block replies.
Config location reminder: the `blockStreaming*` defaults live under
`agents.defaults`, not the root config.
## Telegram draft streaming (token-ish)
Telegram is the only provider with draft streaming:
@@ -39,7 +39,7 @@ Add `hooks.gmail.model` config option to specify an optional cheaper model for G
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `hooks.gmail.model` | `string` | (none) | Model to use for Gmail hook processing. Accepts `provider/model` refs or aliases from `agents.defaults.models`. |
| `hooks.gmail.thinking` | `string` | (inherited) | Thinking level override (`off`, `minimal`, `low`, `medium`, `high`). If unset, inherits from `agents.defaults.thinkingDefault` or model's default. |
| `hooks.gmail.thinking` | `string` | (inherited) | Thinking level override (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`; GPT-5.2 + Codex models only). If unset, inherits from `agents.defaults.thinkingDefault` or model's default. |
### Alias Support
+64 -1
View File
@@ -569,7 +569,9 @@ Inbound messages are routed to an agent via bindings.
- `name`: display name for the agent.
- `workspace`: default `~/clawd-<agentId>` (for `main`, falls back to `agents.defaults.workspace`).
- `agentDir`: default `~/.clawdbot/agents/<agentId>/agent`.
- `model`: per-agent default model (provider/model), overrides `agents.defaults.model` for that agent.
- `model`: per-agent default model, overrides `agents.defaults.model` for that agent.
- string form: `"provider/model"`, overrides only `agents.defaults.model.primary`
- object form: `{ primary, fallbacks }` (fallbacks override `agents.defaults.model.fallbacks`; `[]` disables global fallbacks for that agent)
- `identity`: per-agent name/theme/emoji (used for mention patterns + ack reactions).
- `groupChat`: per-agent mention-gating (`mentionPatterns`).
- `sandbox`: per-agent sandbox config (overrides `agents.defaults.sandbox`).
@@ -583,6 +585,7 @@ Inbound messages are routed to an agent via bindings.
- `subagents`: per-agent sub-agent defaults.
- `allowAgents`: allowlist of agent ids for `sessions_spawn` from this agent (`["*"]` = allow any; default: only same agent)
- `tools`: per-agent tool restrictions (applied before sandbox tool policy).
- `profile`: base tool profile (applied before allow/deny)
- `allow`: array of allowed tool names
- `deny`: array of denied tool names (deny wins)
- `agents.defaults`: shared agent defaults (model, workspace, sandbox, etc.).
@@ -1214,6 +1217,27 @@ is already present in `agents.defaults.models`:
If you configure the same alias name (case-insensitive) yourself, your value wins (defaults never override).
Example: Opus 4.5 primary with MiniMax M2.1 fallback (hosted MiniMax):
```json5
{
agents: {
defaults: {
models: {
"anthropic/claude-opus-4-5": { alias: "opus" },
"minimax/MiniMax-M2.1": { alias: "minimax" }
},
model: {
primary: "anthropic/claude-opus-4-5",
fallbacks: ["minimax/MiniMax-M2.1"]
}
}
}
}
```
MiniMax auth: set `MINIMAX_API_KEY` (env) or configure `models.providers.minimax`.
#### `agents.defaults.cliBackends` (CLI fallback)
Optional CLI backends for text-only fallback runs (no tool calls). These are useful as a
@@ -1507,6 +1531,34 @@ Legacy: `tools.bash` is still accepted as an alias.
- `archiveAfterMinutes`: auto-archive sub-agent sessions after N minutes (default 60; set `0` to disable)
- Per-subagent tool policy: `tools.subagents.tools.allow` / `tools.subagents.tools.deny` (deny wins)
`tools.profile` sets a **base tool allowlist** before `tools.allow`/`tools.deny`:
- `minimal`: `session_status` only
- `coding`: `group:fs`, `group:runtime`, `group:sessions`, `group:memory`, `image`
- `messaging`: `group:messaging`, `sessions_list`, `sessions_history`, `sessions_send`, `session_status`
- `full`: no restriction (same as unset)
Per-agent override: `agents.list[].tools.profile`.
Example (messaging-only by default, allow Slack + Discord tools too):
```json5
{
tools: {
profile: "messaging",
allow: ["slack", "discord"]
}
}
```
Example (coding profile, but deny exec/process everywhere):
```json5
{
tools: {
profile: "coding",
deny: ["group:runtime"]
}
}
```
`tools.allow` / `tools.deny` configure a global tool allow/deny policy (deny wins).
This is applied even when the Docker sandbox is **off**.
@@ -1517,6 +1569,17 @@ Example (disable browser/canvas everywhere):
}
```
Tool groups (shorthands) work in **global** and **per-agent** tool policies:
- `group:runtime`: `exec`, `bash`, `process`
- `group:fs`: `read`, `write`, `edit`, `apply_patch`
- `group:sessions`: `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `session_status`
- `group:memory`: `memory_search`, `memory_get`
- `group:ui`: `browser`, `canvas`
- `group:automation`: `cron`, `gateway`
- `group:messaging`: `message`
- `group:nodes`: `nodes`
- `group:clawdbot`: all built-in Clawdbot tools (excludes provider plugins)
`tools.elevated` controls elevated (host) exec access:
- `enabled`: allow elevated mode (default true)
- `allowFrom`: per-provider allowlists (empty = disabled)
+1
View File
@@ -76,6 +76,7 @@ cat ~/.clawdbot/clawdbot.json
- Security warnings for open DM policies.
- Gateway auth warnings when no `gateway.auth.token` is set (local mode; offers token generation).
- systemd linger check on Linux.
- Source install checks (pnpm workspace mismatch, missing UI assets, missing tsx binary).
- Writes updated config + wizard metadata.
## Detailed behavior and rationale
@@ -50,6 +50,7 @@ See [Sandboxing](/gateway/sandboxing) for the full matrix (scope, workspace moun
## Tool policy: which tools exist/are callable
Two layers matter:
- **Tool profile**: `tools.profile` and `agents.list[].tools.profile` (base allowlist)
- **Global/per-agent tool policy**: `tools.allow`/`tools.deny` and `agents.list[].tools.allow`/`agents.list[].tools.deny`
- **Sandbox tool policy** (only applies when sandboxed): `tools.sandbox.tools.allow`/`tools.sandbox.tools.deny` and `agents.list[].tools.sandbox.tools.*`
@@ -59,7 +60,7 @@ Rules of thumb:
### Tool groups (shorthands)
For sandbox tool policy, you can use `group:*` entries that expand to multiple tools:
Tool policies (global, agent, sandbox) support `group:*` entries that expand to multiple tools:
```json5
{
@@ -78,6 +79,11 @@ Available groups:
- `group:fs`: `read`, `write`, `edit`, `apply_patch`
- `group:sessions`: `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `session_status`
- `group:memory`: `memory_search`, `memory_get`
- `group:ui`: `browser`, `canvas`
- `group:automation`: `cron`, `gateway`
- `group:messaging`: `message`
- `group:nodes`: `nodes`
- `group:clawdbot`: all built-in Clawdbot tools (excludes provider plugins)
## Elevated: exec-only “run on host”
+2
View File
@@ -73,6 +73,8 @@ clawdbot gateway --tailscale funnel --auth password
- `tailscale.mode: "funnel"` refuses to start unless auth mode is `password` to avoid public exposure.
- Set `gateway.tailscale.resetOnExit` if you want Clawdbot to undo `tailscale serve`
or `tailscale funnel` configuration on shutdown.
- Serve/Funnel only expose the **Gateway control UI + WS**. Node **bridge** traffic
uses the separate bridge port (default `18790`) and is **not** proxied by Serve.
## Tailscale prerequisites + limits
+6 -3
View File
@@ -1,13 +1,16 @@
---
summary: "Bun workflow (preferred): installs, patches, and gotchas vs pnpm"
summary: "Bun workflow (experimental): 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
# Bun (experimental)
Goal: run this repo with **Bun** (optional) without losing pnpm patch behavior.
Goal: run this repo with **Bun** (optional, not recommended for WhatsApp/Telegram)
without losing pnpm patch behavior.
⚠️ **Not recommended for Gateway runtime** (WhatsApp/Telegram bugs). Use Node for production.
## Status
+1
View File
@@ -30,6 +30,7 @@ What it does (high level):
- `git`: clone/build a source checkout and install a wrapper script
- On Linux: avoid global npm permission errors by switching npm’s prefix to `~/.npm-global` when needed.
- If upgrading an existing install: runs `clawdbot doctor --non-interactive` (best effort).
- For git installs: runs `clawdbot doctor --non-interactive` after install/update (best effort).
- Mitigates `sharp` native install gotchas by defaulting `SHARP_IGNORE_GLOBAL_LIBVIPS=1` (avoids building against system libvips).
If you *want* `sharp` to link against a globally-installed libvips (or you’re debugging), set:
+23 -11
View File
@@ -9,9 +9,28 @@ read_when:
Clawdbot is moving fast (pre “1.0”). Treat updates like shipping infra: update → run checks → restart → verify.
## Recommended: re-run the website installer (upgrade in place)
The **preferred** update path is to re-run the installer from the website. It
detects existing installs, upgrades in place, and runs `clawdbot doctor` when
needed.
```bash
curl -fsSL https://clawd.bot/install.sh | bash
```
Notes:
- Add `--no-onboard` if you don’t want the onboarding wizard to run again.
- For **source installs**, use:
```bash
curl -fsSL https://clawd.bot/install.sh | bash -s -- --install-method git --no-onboard
```
The installer will `git pull --rebase` **only** if the repo is clean.
- For **global installs**, the script uses `npm install -g clawdbot@latest` under the hood.
## Before you update
- Know how you installed: **global** (npm/pnpm/bun) vs **from source** (git clone).
- Know how you installed: **global** (npm/pnpm) vs **from source** (git clone).
- Know how your Gateway is running: **foreground terminal** vs **supervised service** (launchd/systemd).
- Snapshot your tailoring:
- Config: `~/.clawdbot/clawdbot.json`
@@ -29,10 +48,7 @@ npm i -g clawdbot@latest
```bash
pnpm add -g clawdbot@latest
```
```bash
bun add -g clawdbot@latest
```
We do **not** recommend Bun for the Gateway runtime (WhatsApp/Telegram bugs).
Then:
@@ -59,7 +75,7 @@ It runs a safe-ish update flow:
- Fetches + rebases against the configured upstream.
- Installs deps, builds, builds the Control UI, and runs `clawdbot doctor`.
If you installed via **npm/pnpm/bun** (no git metadata), `clawdbot update` will skip. Use “Update (global install)” instead.
If you installed via **npm/pnpm** (no git metadata), `clawdbot update` will skip. Use “Update (global install)” instead.
## Update (Control UI / RPC)
@@ -93,7 +109,7 @@ 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.
- If you run directly from TypeScript (`pnpm clawdbot ...`), a rebuild is usually unnecessary, but **config migrations still apply** → run doctor.
- Switching between global and git installs is easy: install the other flavor, then run `clawdbot doctor` so the gateway service entrypoint is rewritten to the current install.
## Always run: `clawdbot doctor`
@@ -145,10 +161,6 @@ npm i -g clawdbot@<version>
pnpm add -g clawdbot@<version>
```
```bash
bun add -g clawdbot@<version>
```
Tip: to see the current published version, run `npm view clawdbot version`.
Then restart + re-run doctor:
+34 -5
View File
@@ -106,6 +106,28 @@ For debugging “why is this blocked?”, see [Sandbox vs Tool Policy vs Elevate
---
### Example 2b: Global coding profile + messaging-only agent
```json
{
"tools": { "profile": "coding" },
"agents": {
"list": [
{
"id": "support",
"tools": { "profile": "messaging", "allow": ["slack"] }
}
]
}
}
```
**Result:**
- default agents get coding tools
- `support` agent is messaging-only (+ Slack tool)
---
### Example 3: Different Sandbox Modes per Agent
```json
@@ -165,22 +187,29 @@ agents.list[].sandbox.prune.* > agents.defaults.sandbox.prune.*
### Tool Restrictions
The filtering order is:
1. **Global tool policy** (`tools.allow` / `tools.deny`)
2. **Agent-specific tool policy** (`agents.list[].tools`)
3. **Sandbox tool policy** (`tools.sandbox.tools` or `agents.list[].tools.sandbox.tools`)
4. **Subagent tool policy** (`tools.subagents.tools`, if applicable)
1. **Tool profile** (`tools.profile` or `agents.list[].tools.profile`)
2. **Global tool policy** (`tools.allow` / `tools.deny`)
3. **Agent-specific tool policy** (`agents.list[].tools`)
4. **Sandbox tool policy** (`tools.sandbox.tools` or `agents.list[].tools.sandbox.tools`)
5. **Subagent tool policy** (`tools.subagents.tools`, if applicable)
Each level can further restrict tools, but cannot grant back denied tools from earlier levels.
If `agents.list[].tools.sandbox.tools` is set, it replaces `tools.sandbox.tools` for that agent.
If `agents.list[].tools.profile` is set, it overrides `tools.profile` for that agent.
### Tool groups (shorthands)
Sandbox tool policy supports `group:*` entries that expand to multiple concrete tools:
Tool policies (global, agent, sandbox) support `group:*` entries that expand to multiple concrete tools:
- `group:runtime`: `exec`, `bash`, `process`
- `group:fs`: `read`, `write`, `edit`, `apply_patch`
- `group:sessions`: `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `session_status`
- `group:memory`: `memory_search`, `memory_get`
- `group:ui`: `browser`, `canvas`
- `group:automation`: `cron`, `gateway`
- `group:messaging`: `message`
- `group:nodes`: `nodes`
- `group:clawdbot`: all built-in Clawdbot tools (excludes provider plugins)
### Elevated Mode
`tools.elevated` is the global baseline (sender-based allowlist). `agents.list[].tools.elevated` can further restrict elevated for specific agents (both must allow).
+2 -1
View File
@@ -6,7 +6,8 @@ read_when:
---
# Platforms
Clawdbot core is written in TypeScript, so the CLI + Gateway run anywhere Node or Bun runs.
Clawdbot core is written in TypeScript. **Node is the recommended runtime**.
Bun is not recommended for the Gateway (WhatsApp/Telegram bugs).
Companion apps exist for macOS (menu bar app) and mobile nodes (iOS/Android). Windows and
Linux companion apps are planned, but the Gateway is fully supported today.
+3 -2
View File
@@ -6,7 +6,8 @@ read_when:
---
# Linux App
The Gateway is fully supported on Linux. The core is written in TypeScript, so it runs anywhere Node or Bun runs.
The Gateway is fully supported on Linux. **Node is the recommended runtime**.
Bun is not recommended for the Gateway (WhatsApp/Telegram bugs).
Native Linux companion apps are planned. Contributions are welcome if you want to help build one.
@@ -23,7 +24,7 @@ Step-by-step VPS guide: [exe.dev](/platforms/exe-dev)
## Install
- [Getting Started](/start/getting-started)
- [Install & updates](/install/updating)
- Optional flows: [Bun](/install/bun), [Nix](/install/nix), [Docker](/install/docker)
- Optional flows: [Bun (experimental)](/install/bun), [Nix](/install/nix), [Docker](/install/docker)
## Gateway
- [Gateway runbook](/gateway)
+1 -1
View File
@@ -20,7 +20,7 @@ You need Node 22+ on the Mac, then install `clawdbot` globally:
npm install -g clawdbot@<version>
```
The macOS app’s **Install CLI** button runs the same flow via npm/pnpm/bun.
The macOS app’s **Install CLI** button runs the same flow via npm/pnpm (bun not recommended for Gateway runtime).
## Launchd (Gateway as LaunchAgent)
+1 -1
View File
@@ -18,7 +18,7 @@ node.
- Runs or connects to the Gateway (local or remote).
- Exposes macOS‑only tools (Canvas, Camera, Screen Recording, `system.run`).
- Optionally hosts **PeekabooBridge** for UI automation.
- Installs the global CLI (`clawdbot`) via npm/pnpm/bun on request.
- Installs the global CLI (`clawdbot`) via npm/pnpm on request (bun not recommended for the Gateway runtime).
## Local vs remote mode
+3
View File
@@ -174,6 +174,9 @@ 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.
- **`requireMention: false` but still no replies**:
- `discord.groupPolicy` defaults to **allowlist**; you must either set it to `"open"` or explicitly list the channel under `discord.guilds.<id>.channels`.
- `requireMention` must live under `discord.guilds` (or a specific channel). `discord.requireMention` at the top level is ignored.
- **Permission audits** (`providers status --probe`) only check numeric channel IDs. If you use slugs/names as `discord.guilds.*.channels` keys, the audit can’t verify permissions.
- **DMs don’t work**: `discord.dm.enabled=false`, `discord.dm.policy="disabled"`, or you haven’t been approved yet (`discord.dm.policy="pairing"`).
+44
View File
@@ -72,6 +72,28 @@ Configure via CLI:
}
```
### MiniMax M2.1 as fallback (Opus primary)
**Best for:** keep Opus 4.5 as primary, fail over to MiniMax M2.1.
```json5
{
env: { MINIMAX_API_KEY: "sk-..." },
agents: {
defaults: {
models: {
"anthropic/claude-opus-4-5": { alias: "opus" },
"minimax/MiniMax-M2.1": { alias: "minimax" }
},
model: {
primary: "anthropic/claude-opus-4-5",
fallbacks: ["minimax/MiniMax-M2.1"]
}
}
}
}
```
### Optional: Local via LM Studio (manual)
**Best for:** local inference with LM Studio.
@@ -134,5 +156,27 @@ Use the interactive config wizard to set MiniMax without editing JSON:
- Model refs are `minimax/<model>`.
- Update pricing values in `models.json` if you need exact cost tracking.
- Referral link for MiniMax Coding Plan (10% off): https://platform.minimax.io/subscribe/coding-plan?code=DbXJTRClnb&source=link
- See [/concepts/model-providers](/concepts/model-providers) for provider rules.
- Use `clawdbot models list` and `clawdbot models set minimax/MiniMax-M2.1` to switch.
## Troubleshooting
### “Unknown model: minimax/MiniMax-M2.1”
This usually means the **MiniMax provider isn’t configured** (no provider entry
and no MiniMax auth profile/env key found). A fix for this detection is in
**2026.1.12** (unreleased at the time of writing). Fix by:
- Upgrading to **2026.1.12** (or run from source `main`), then restarting the gateway.
- Running `clawdbot configure` and selecting **MiniMax M2.1**, or
- Adding the `models.providers.minimax` block manually, or
- Setting `MINIMAX_API_KEY` (or a MiniMax auth profile) so the provider can be injected.
Make sure the model id is **case‑sensitive**:
- `minimax/MiniMax-M2.1`
- `minimax/MiniMax-M2.1-lightning`
Then recheck with:
```bash
clawdbot models list
```
+1
View File
@@ -31,6 +31,7 @@ model as `provider/model`.
- [Z.AI](/providers/zai)
- [GLM models](/providers/glm)
- [MiniMax](/providers/minimax)
- [Amazon Bedrock](/bedrock)
For the full provider catalog (xAI, Groq, Mistral, etc.) and advanced configuration,
see [Model providers](/concepts/model-providers).
+2 -1
View File
@@ -310,4 +310,5 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately
- Fix: `clawdbot doctor` (or restart the gateway). If it persists, relink via `providers login` and inspect `clawdbot logs --follow`.
**Bun runtime**
- WhatsApp uses Baileys; run the gateway with **Node** for WhatsApp. (See Getting Started runtime note.)
- Bun is **not recommended**. WhatsApp (Baileys) and Telegram are unreliable on Bun.
Run the gateway with **Node**. (See Getting Started runtime note.)
+468 -7
View File
@@ -5,6 +5,123 @@ summary: "Frequently asked questions about Clawdbot setup, configuration, and us
Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS, multi-agent, OAuth/API keys, model failover). For runtime diagnostics, see [Troubleshooting](/gateway/troubleshooting). For the full config reference, see [Configuration](/gateway/configuration).
## Table of contents
- [What is Clawdbot?](#what-is-clawdbot)
- [What is Clawdbot, in one paragraph?](#what-is-clawdbot-in-one-paragraph)
- [Quick start and first-run setup](#quick-start-and-first-run-setup)
- [What’s the recommended way to install and set up Clawdbot?](#whats-the-recommended-way-to-install-and-set-up-clawdbot)
- [How do I open the dashboard after onboarding?](#how-do-i-open-the-dashboard-after-onboarding)
- [How do I authenticate the dashboard (token) on localhost vs remote?](#how-do-i-authenticate-the-dashboard-token-on-localhost-vs-remote)
- [What runtime do I need?](#what-runtime-do-i-need)
- [What does the onboarding wizard actually do?](#what-does-the-onboarding-wizard-actually-do)
- [How does Anthropic "setup-token" auth work?](#how-does-anthropic-setup-token-auth-work)
- [Do you support Claude subscription auth (Claude Code OAuth)?](#do-you-support-claude-subscription-auth-claude-code-oauth)
- [Is AWS Bedrock supported?](#is-aws-bedrock-supported)
- [How does Codex auth work?](#how-does-codex-auth-work)
- [Is a local model OK for casual chats?](#is-a-local-model-ok-for-casual-chats)
- [How do I keep hosted model traffic in a specific region?](#how-do-i-keep-hosted-model-traffic-in-a-specific-region)
- [Can I use Bun?](#can-i-use-bun)
- [Telegram: what goes in `allowFrom`?](#telegram-what-goes-in-allowfrom)
- [Can multiple people use one WhatsApp number with different Clawdbots?](#can-multiple-people-use-one-whatsapp-number-with-different-clawdbots)
- [Can I run a "fast chat" agent and an "Opus for coding" agent?](#can-i-run-a-fast-chat-agent-and-an-opus-for-coding-agent)
- [Does Homebrew work on Linux?](#does-homebrew-work-on-linux)
- [Can I switch between npm and git installs later?](#can-i-switch-between-npm-and-git-installs-later)
- [Should I run the Gateway on my laptop or a VPS?](#should-i-run-the-gateway-on-my-laptop-or-a-vps)
- [Skills and automation](#skills-and-automation)
- [How do I customize skills without keeping the repo dirty?](#how-do-i-customize-skills-without-keeping-the-repo-dirty)
- [Can I load skills from a custom folder?](#can-i-load-skills-from-a-custom-folder)
- [How can I use different models for different tasks?](#how-can-i-use-different-models-for-different-tasks)
- [How do I install skills on Linux?](#how-do-i-install-skills-on-linux)
- [Do you have a Notion or HeyGen integration?](#do-you-have-a-notion-or-heygen-integration)
- [Sandboxing and memory](#sandboxing-and-memory)
- [Is there a dedicated sandboxing doc?](#is-there-a-dedicated-sandboxing-doc)
- [How do I bind a host folder into the sandbox?](#how-do-i-bind-a-host-folder-into-the-sandbox)
- [How does memory work?](#how-does-memory-work)
- [Does semantic memory search require an OpenAI API key?](#does-semantic-memory-search-require-an-openai-api-key)
- [Where things live on disk](#where-things-live-on-disk)
- [Where does Clawdbot store its data?](#where-does-clawdbot-store-its-data)
- [Where should AGENTS.md / SOUL.md / USER.md / MEMORY.md live?](#where-should-agentsmd--soulmd--usermd--memorymd-live)
- [How do I completely uninstall Clawdbot?](#how-do-i-completely-uninstall-clawdbot)
- [Can agents work outside the workspace?](#can-agents-work-outside-the-workspace)
- [I’m in remote mode — where is the session store?](#im-in-remote-mode-where-is-the-session-store)
- [Config basics](#config-basics)
- [What format is the config? Where is it?](#what-format-is-the-config-where-is-it)
- [I set `gateway.bind: "lan"` (or `"tailnet"`) and now nothing listens / the UI says unauthorized](#i-set-gatewaybind-lan-or-tailnet-and-now-nothing-listens-the-ui-says-unauthorized)
- [Why do I need a token on localhost now?](#why-do-i-need-a-token-on-localhost-now)
- [Do I have to restart after changing config?](#do-i-have-to-restart-after-changing-config)
- [How do I run a central Gateway with specialized workers across devices?](#how-do-i-run-a-central-gateway-with-specialized-workers-across-devices)
- [Can the Clawdbot browser run headless?](#can-the-clawdbot-browser-run-headless)
- [Remote gateways + nodes](#remote-gateways-nodes)
- [How do commands propagate between Telegram, the gateway, and nodes?](#how-do-commands-propagate-between-telegram-the-gateway-and-nodes)
- [Do nodes run a gateway daemon?](#do-nodes-run-a-gateway-daemon)
- [Is there an API / RPC way to apply config?](#is-there-an-api-rpc-way-to-apply-config)
- [What’s a minimal “sane” config for a first install?](#whats-a-minimal-sane-config-for-a-first-install)
- [How do I set up Tailscale on a VPS and connect from my Mac?](#how-do-i-set-up-tailscale-on-a-vps-and-connect-from-my-mac)
- [How do I connect a Mac node to a remote Gateway (Tailscale Serve)?](#how-do-i-connect-a-mac-node-to-a-remote-gateway-tailscale-serve)
- [Env vars and .env loading](#env-vars-and-env-loading)
- [How does Clawdbot load environment variables?](#how-does-clawdbot-load-environment-variables)
- [“I started the Gateway via a daemon and my env vars disappeared.” What now?](#i-started-the-gateway-via-a-daemon-and-my-env-vars-disappeared-what-now)
- [I set `COPILOT_GITHUB_TOKEN`, but models status shows “Shell env: off.” Why?](#i-set-copilot_github_token-but-models-status-shows-shell-env-off-why)
- [Sessions & multiple chats](#sessions-multiple-chats)
- [How do I start a fresh conversation?](#how-do-i-start-a-fresh-conversation)
- [How do I completely reset Clawdbot (but keep it installed)?](#how-do-i-completely-reset-clawdbot-but-keep-it-installed)
- [I’m getting “context too large” errors — how do I reset or compact?](#im-getting-context-too-large-errors-how-do-i-reset-or-compact)
- [Do I need to add a “bot account” to a WhatsApp group?](#do-i-need-to-add-a-bot-account-to-a-whatsapp-group)
- [Why doesn’t Clawdbot reply in a group?](#why-doesnt-clawdbot-reply-in-a-group)
- [Do groups/threads share context with DMs?](#do-groupsthreads-share-context-with-dms)
- [Models: defaults, selection, aliases, switching](#models-defaults-selection-aliases-switching)
- [What is the “default model”?](#what-is-the-default-model)
- [How do I switch models on the fly (without restarting)?](#how-do-i-switch-models-on-the-fly-without-restarting)
- [Why do I see “Model … is not allowed” and then no reply?](#why-do-i-see-model-is-not-allowed-and-then-no-reply)
- [Why do I see “Unknown model: minimax/MiniMax-M2.1”?](#why-do-i-see-unknown-model-minimaxminimax-m21)
- [Can I use MiniMax as my default and OpenAI for complex tasks?](#can-i-use-minimax-as-my-default-and-openai-for-complex-tasks)
- [Are opus / sonnet / gpt built‑in shortcuts?](#are-opus-sonnet-gpt-builtin-shortcuts)
- [How do I define/override model shortcuts (aliases)?](#how-do-i-defineoverride-model-shortcuts-aliases)
- [How do I add models from other providers like OpenRouter or Z.AI?](#how-do-i-add-models-from-other-providers-like-openrouter-or-zai)
- [Model failover and “All models failed”](#model-failover-and-all-models-failed)
- [How does failover work?](#how-does-failover-work)
- [What does this error mean?](#what-does-this-error-mean)
- [Fix checklist for `No credentials found for profile "anthropic:default"`](#fix-checklist-for-no-credentials-found-for-profile-anthropicdefault)
- [Why did it also try Google Gemini and fail?](#why-did-it-also-try-google-gemini-and-fail)
- [Auth profiles: what they are and how to manage them](#auth-profiles-what-they-are-and-how-to-manage-them)
- [What is an auth profile?](#what-is-an-auth-profile)
- [What are typical profile IDs?](#what-are-typical-profile-ids)
- [Can I control which auth profile is tried first?](#can-i-control-which-auth-profile-is-tried-first)
- [OAuth vs API key: what’s the difference?](#oauth-vs-api-key-whats-the-difference)
- [Gateway: ports, “already running”, and remote mode](#gateway-ports-already-running-and-remote-mode)
- [What port does the Gateway use?](#what-port-does-the-gateway-use)
- [Why does `clawdbot daemon status` say `Runtime: running` but `RPC probe: failed`?](#why-does-clawdbot-daemon-status-say-runtime-running-but-rpc-probe-failed)
- [Why does `clawdbot daemon status` show `Config (cli)` and `Config (daemon)` different?](#why-does-clawdbot-daemon-status-show-config-cli-and-config-daemon-different)
- [What does “another gateway instance is already listening” mean?](#what-does-another-gateway-instance-is-already-listening-mean)
- [How do I run Clawdbot in remote mode (client connects to a Gateway elsewhere)?](#how-do-i-run-clawdbot-in-remote-mode-client-connects-to-a-gateway-elsewhere)
- [The Control UI says “unauthorized” (or keeps reconnecting). What now?](#the-control-ui-says-unauthorized-or-keeps-reconnecting-what-now)
- [I set `gateway.bind: "tailnet"` but it can’t bind / nothing listens](#i-set-gatewaybind-tailnet-but-it-cant-bind-nothing-listens)
- [Can I run multiple Gateways on the same host?](#can-i-run-multiple-gateways-on-the-same-host)
- [What does “invalid handshake” / code 1008 mean?](#what-does-invalid-handshake--code-1008-mean)
- [Logging and debugging](#logging-and-debugging)
- [Where are logs?](#where-are-logs)
- [How do I start/stop/restart the Gateway daemon?](#how-do-i-startstoprestart-the-gateway-daemon)
- [What’s the fastest way to get more details when something fails?](#whats-the-fastest-way-to-get-more-details-when-something-fails)
- [Media & attachments](#media-attachments)
- [My skill generated an image/PDF, but nothing was sent](#my-skill-generated-an-imagepdf-but-nothing-was-sent)
- [Security and access control](#security-and-access-control)
- [Is it safe to expose Clawdbot to inbound DMs?](#is-it-safe-to-expose-clawdbot-to-inbound-dms)
- [WhatsApp: will it message my contacts? How does pairing work?](#whatsapp-will-it-message-my-contacts-how-does-pairing-work)
- [Chat commands, aborting tasks, and “it won’t stop”](#chat-commands-aborting-tasks-and-it-wont-stop)
- [How do I stop/cancel a running task?](#how-do-i-stopcancel-a-running-task)
- [Why does it feel like the bot “ignores” rapid‑fire messages?](#why-does-it-feel-like-the-bot-ignores-rapidfire-messages)
- [Common troubleshooting](#common-troubleshooting)
- [“All models failed” — what should I check first?](#all-models-failed-what-should-i-check-first)
- [I’m running on my personal WhatsApp number — why is self-chat weird?](#im-running-on-my-personal-whatsapp-number-why-is-self-chat-weird)
- [WhatsApp logged me out. How do I re‑auth?](#whatsapp-logged-me-out-how-do-i-reauth)
- [Build errors on `main` — what’s the standard fix path?](#build-errors-on-main-whats-the-standard-fix-path)
- [npm install fails (allow-build-scripts / missing tar or yargs). What now?](#npm-install-fails-allow-build-scripts--missing-tar-or-yargs-what-now)
- [How do I switch between git installs and npm installs?](#how-do-i-switch-between-git-installs-and-npm-installs)
- [Telegram block streaming isn’t splitting text between tool calls. Why?](#telegram-block-streaming-isnt-splitting-text-between-tool-calls-why)
- [Discord doesn’t reply in my server even with `requireMention: false`. Why?](#discord-doesnt-reply-in-my-server-even-with-requiremention-false-why)
- [Cloud Code Assist API error: invalid tool schema (400). What now?](#cloud-code-assist-api-error-invalid-tool-schema-400-what-now)
## First 60 seconds if something's broken
1) **Quick status (first check)**
@@ -60,7 +177,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
Clawdbot is a personal AI assistant you run on your own devices. It replies on the messaging surfaces you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, WebChat) and can also do voice + a live Canvas on supported platforms. The **Gateway** is the always‑on control plane; the assistant is the product.
## Quick start and first‑run setup
## Quick start and first-run setup
### What’s the recommended way to install and set up Clawdbot?
@@ -103,7 +220,7 @@ See [Dashboard](/web/dashboard) and [Web surfaces](/web) for bind modes and auth
### What runtime do I need?
Node **>= 22** is required. `pnpm` is recommended; `bun` is optional.
Node **>= 22** is required. `pnpm` is recommended. Bun is **not recommended** for the Gateway.
### What does the onboarding wizard actually do?
@@ -126,9 +243,11 @@ The wizard can run `claude setup-token` on the gateway host (or you run it yours
Yes. Clawdbot can **reuse Claude Code CLI credentials** (OAuth) and also supports **setup-token**. If you have a Claude subscription, we recommend **setup-token** on the gateway host for the most reliable long‑running setup (requires Claude Pro/Max + the `claude` CLI). OAuth reuse is supported, but avoid logging in separately via Clawdbot and Claude Code to prevent token conflicts. See [Anthropic](/providers/anthropic) and [OAuth](/concepts/oauth).
Note: Claude subscription access is governed by Anthropic’s terms. For production or multi‑user workloads, API keys are usually the safer choice.
### Is AWS Bedrock supported?
Not currently. Clawdbot doesn’t ship a Bedrock provider today. If you must use Bedrock, the common workaround is an OpenAI‑compatible proxy in front of Bedrock, then point Clawdbot at that endpoint. See [Model providers](/providers/models) and [Model providers (full list)](/concepts/model-providers).
Yes — via pi‑ai’s **Amazon Bedrock (Converse)** provider with **manual config**. You must supply AWS credentials/region on the gateway host and add a Bedrock provider entry in your models config. See [Amazon Bedrock](/bedrock) and [Model providers](/providers/models). If you prefer a managed key flow, an OpenAI‑compatible proxy in front of Bedrock is still a valid option.
### How does Codex auth work?
@@ -144,7 +263,11 @@ Pick region-pinned endpoints. OpenRouter exposes US-hosted options for MiniMax,
### Can I use Bun?
Bun is supported for faster TypeScript execution, but **WhatsApp requires Node** in this ecosystem. The wizard lets you pick the runtime; choose **Node** if you use WhatsApp.
Bun is **not recommended**. We see runtime bugs, especially with WhatsApp and Telegram.
Use **Node** for stable gateways.
If you still want to experiment with Bun, do it on a non‑production gateway
without WhatsApp/Telegram.
### Telegram: what goes in `allowFrom`?
@@ -196,6 +319,25 @@ clawdbot daemon restart
Doctor detects a gateway service entrypoint mismatch and offers to rewrite the service config to match the current install (use `--repair` in automation).
### Should I run the Gateway on my laptop or a VPS?
Short answer: **if you want 24/7 reliability, use a VPS**. If you want the
lowest friction and you’re okay with sleep/restarts, run it locally.
**Laptop (local Gateway)**
- **Pros:** no server cost, direct access to local files, live browser window.
- **Cons:** sleep/network drops = disconnects, OS updates/reboots interrupt, must stay awake.
**VPS / cloud**
- **Pros:** always‑on, stable network, no laptop sleep issues, easier to keep running.
- **Cons:** often run headless (use screenshots), remote file access only, you must SSH for updates.
**Clawdbot‑specific note:** WhatsApp/Telegram/Slack/Discord all work fine from a VPS. The only real trade‑off is **headless browser** vs a visible window. See [Browser](/tools/browser).
**Recommended default:** VPS if you had gateway disconnects before. Local is great when you’re actively using the Mac and want local file access or UI automation with a visible browser.
## Skills and automation
### How do I customize skills without keeping the repo dirty?
Use managed overrides instead of editing the repo copy. Put your changes in `~/.clawdbot/skills/<name>/SKILL.md` (or add a folder via `skills.load.extraDirs` in `~/.clawdbot/clawdbot.json`). Precedence is `<workspace>/skills` > `~/.clawdbot/skills` > bundled, so managed overrides win without touching git. Only upstream-worthy edits should live in the repo and go out as PRs.
@@ -216,6 +358,7 @@ See [Cron jobs](/automation/cron-jobs), [Multi-Agent Routing](/concepts/multi-ag
### How do I install skills on Linux?
Use **ClawdHub** (CLI) or drop skills into your workspace. The macOS Skills UI isn’t available on Linux.
Browse skills at https://clawdhub.com.
Install the ClawdHub CLI (pick one package manager):
@@ -227,9 +370,20 @@ npm i -g clawdhub
pnpm add -g clawdhub
```
```bash
bun add -g clawdhub
```
### Do you have a Notion or HeyGen integration?
Not built‑in today.
Options:
- **Custom skill / plugin:** best for reliable API access (Notion/HeyGen both have APIs).
- **Browser automation:** works without code but is slower and more fragile.
If you want to keep context per client (agency workflows), a simple pattern is:
- One Notion page per client (context + preferences + active work).
- Ask the agent to fetch that page at the start of a session.
If you want a native integration, open a feature request or build a skill
targeting those APIs.
Install skills:
@@ -240,6 +394,8 @@ clawdhub update --all
ClawdHub installs into `./skills` under your current directory (or falls back to your configured Clawdbot workspace); Clawdbot treats that as `<workspace>/skills` on the next session. For shared skills across agents, place them in `~/.clawdbot/skills/<name>/SKILL.md`. Some skills expect binaries installed via Homebrew; on Linux that means Linuxbrew (see the Homebrew Linux FAQ entry above). See [Skills](/tools/skills) and [ClawdHub](/tools/clawdhub).
## Sandboxing and memory
### Is there a dedicated sandboxing doc?
Yes. See [Sandboxing](/gateway/sandboxing). For Docker-specific setup (full gateway in Docker or sandbox images), see [Docker](/install/docker).
@@ -290,6 +446,29 @@ Legacy single‑agent path: `~/.clawdbot/agent/*` (migrated by `clawdbot doctor`
Your **workspace** (AGENTS.md, memory files, skills, etc.) is separate and configured via `agents.defaults.workspace` (default: `~/clawd`).
### Where should AGENTS.md / SOUL.md / USER.md / MEMORY.md live?
These files live in the **agent workspace**, not `~/.clawdbot`.
- **Workspace (per agent)**: `AGENTS.md`, `SOUL.md`, `IDENTITY.md`, `USER.md`,
`MEMORY.md` (or `memory.md`), `memory/YYYY-MM-DD.md`, optional `HEARTBEAT.md`.
- **State dir (`~/.clawdbot`)**: config, credentials, auth profiles, sessions, logs,
and shared skills (`~/.clawdbot/skills`).
Default workspace is `~/clawd`, configurable via:
```json5
{
agents: { defaults: { workspace: "~/clawd" } }
}
```
If the bot “forgets” after a restart, confirm the Gateway is using the same
workspace on every launch (and remember: remote mode uses the **gateway host’s**
workspace, not your local laptop).
See [Agent workspace](/concepts/agent-workspace) and [Memory](/concepts/memory).
### How do I completely uninstall Clawdbot?
See the dedicated guide: [Uninstall](/install/uninstall).
@@ -394,6 +573,11 @@ Yes. It’s a config option:
Default is `false` (headful). Headless is more likely to trigger anti‑bot checks on some sites. See [Browser](/tools/browser).
Headless uses the **same Chromium engine** and works for most automation (forms, clicks, scraping, logins). The main differences:
- No visible browser window (use screenshots if you need visuals).
- Some sites are stricter about automation in headless mode (CAPTCHAs, anti‑bot).
For example, X/Twitter often blocks headless sessions.
## Remote gateways + nodes
### How do commands propagate between Telegram, the gateway, and nodes?
@@ -427,6 +611,52 @@ Yes. `config.apply` validates + writes the full config and restarts the Gateway
This sets your workspace and restricts who can trigger the bot.
### How do I set up Tailscale on a VPS and connect from my Mac?
Minimal steps:
1) **Install + login on the VPS**
```bash
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up
```
2) **Install + login on your Mac**
- Use the Tailscale app and sign in to the same tailnet.
3) **Enable MagicDNS (recommended)**
- In the Tailscale admin console, enable MagicDNS so the VPS has a stable name.
4) **Use the tailnet hostname**
- SSH: `ssh user@your-vps.tailnet-xxxx.ts.net`
- Gateway WS: `ws://your-vps.tailnet-xxxx.ts.net:18789`
If you want the Control UI without SSH, use Tailscale Serve on the VPS:
```bash
clawdbot gateway --tailscale serve
```
This keeps the gateway bound to loopback and exposes HTTPS via Tailscale. See [Tailscale](/gateway/tailscale).
### How do I connect a Mac node to a remote Gateway (Tailscale Serve)?
Serve only exposes the **Gateway Control UI**. Nodes use the **bridge port**.
Recommended setup:
1) **Enable the bridge on the gateway host**:
```json5
{
bridge: { enabled: true, bind: "auto" }
}
```
`auto` prefers a tailnet IP when Tailscale is present.
2) **Make sure the VPS + Mac are on the same tailnet**.
3) **Use the macOS app in Remote mode** (SSH target can be the tailnet hostname).
The app will tunnel the bridge port and connect as a node.
4) **Approve the node** on the gateway:
```bash
clawdbot nodes pending
clawdbot nodes approve <requestId>
```
Docs: [Bridge protocol](/gateway/bridge-protocol), [Discovery](/gateway/discovery), [macOS remote mode](/platforms/mac/remote).
## Env vars and .env loading
### How does Clawdbot load environment variables?
@@ -472,6 +702,30 @@ Two common fixes:
This runs your login shell and imports only missing expected keys (never overrides). Env var equivalents:
`CLAWDBOT_LOAD_SHELL_ENV=1`, `CLAWDBOT_SHELL_ENV_TIMEOUT_MS=15000`.
### I set `COPILOT_GITHUB_TOKEN`, but models status shows “Shell env: off.” Why?
`clawdbot models status` reports whether **shell env import** is enabled. “Shell env: off”
does **not** mean your env vars are missing — it just means Clawdbot won’t load
your login shell automatically.
If the Gateway runs as a daemon (launchd/systemd), it won’t inherit your shell
environment. Fix by doing one of these:
1) Put the token in `~/.clawdbot/.env`:
```
COPILOT_GITHUB_TOKEN=...
```
2) Or enable shell import (`env.shellEnv.enabled: true`).
3) Or add it to your config `env` block (applies only if missing).
Then restart the gateway and recheck:
```bash
clawdbot models status
```
Copilot tokens are read from `COPILOT_GITHUB_TOKEN` (also `GH_TOKEN` / `GITHUB_TOKEN`).
See [/concepts/model-providers](/concepts/model-providers) and [/environment](/environment).
## Sessions & multiple chats
### How do I start a fresh conversation?
@@ -503,6 +757,28 @@ Notes:
- If you used profiles (`--profile` / `CLAWDBOT_PROFILE`), reset each state dir (defaults are `~/.clawdbot-<profile>`).
- Dev reset: `clawdbot gateway --dev --reset` (dev-only; wipes dev config + credentials + sessions + workspace).
### I’m getting “context too large” errors — how do I reset or compact?
Use one of these:
- **Compact** (keeps the conversation but summarizes older turns):
```
/compact
```
or `/compact <instructions>` to guide the summary.
- **Reset** (fresh session ID for the same chat key):
```
/new
/reset
```
If it keeps happening:
- Enable or tune **session pruning** (`agents.defaults.contextPruning`) to trim old tool output.
- Use a model with a larger context window.
Docs: [Compaction](/concepts/compaction), [Session pruning](/concepts/session-pruning), [Session management](/concepts/session).
### Do I need to add a “bot account” to a WhatsApp group?
No. Clawdbot runs on **your own account**, so if you’re in the group, Clawdbot can see it.
@@ -587,6 +863,59 @@ Model "provider/model" is not allowed. Use /model to list available models.
That error is returned **instead of** a normal reply. Fix: add the model to
`agents.defaults.models`, remove the allowlist, or pick a model from `/model list`.
### Why do I see “Unknown model: minimax/MiniMax-M2.1”?
This means the **provider isn’t configured** (no MiniMax provider config or auth
profile was found), so the model can’t be resolved. A fix for this detection is
in **2026.1.12** (unreleased at the time of writing).
Fix checklist:
1) Upgrade to **2026.1.12** (or run from source `main`), then restart the gateway.
2) Make sure MiniMax is configured (wizard or JSON), or that a MiniMax API key
exists in env/auth profiles so the provider can be injected.
3) Use the exact model id (case‑sensitive): `minimax/MiniMax-M2.1` or
`minimax/MiniMax-M2.1-lightning`.
4) Run:
```bash
clawdbot models list
```
and pick from the list (or `/model list` in chat).
See [MiniMax](/providers/minimax) and [Models](/concepts/models).
### Can I use MiniMax as my default and OpenAI for complex tasks?
Yes. Use **MiniMax as the default** and switch models **per session** when needed.
Fallbacks are for **errors**, not “hard tasks,” so use `/model` or a separate agent.
**Option A: switch per session**
```json5
{
env: { MINIMAX_API_KEY: "sk-...", OPENAI_API_KEY: "sk-..." },
agents: {
defaults: {
model: { primary: "minimax/MiniMax-M2.1" },
models: {
"minimax/MiniMax-M2.1": { alias: "minimax" },
"openai/gpt-5.2": { alias: "gpt" }
}
}
}
}
```
Then:
```
/model gpt
```
**Option B: separate agents**
- Agent A default: MiniMax
- Agent B default: OpenAI
- Route by agent or use `/agent` to switch
Docs: [Models](/concepts/models), [Multi-Agent Routing](/concepts/multi-agent), [MiniMax](/providers/minimax), [OpenAI](/providers/openai).
### Are opus / sonnet / gpt built‑in shortcuts?
Yes. Clawdbot ships a few default shorthands (only applied when the model exists in `agents.defaults.models`):
@@ -684,6 +1013,23 @@ It means the system attempted to use the auth profile ID `anthropic:default`, bu
- **Sanity‑check model/auth status**
- Use `clawdbot models status` to see configured models and whether providers are authenticated.
### Fix checklist for `No credentials found for profile "anthropic:claude-cli"`
This means the run is pinned to the **Claude CLI** profile, but the Gateway
can’t find that profile in its auth store.
- **Sync the Claude CLI token on the gateway host**
- Run `clawdbot models status` (it loads + syncs Claude CLI credentials).
- If it still says missing: run `claude setup-token` (or `clawdbot models auth setup-token --provider anthropic`) and retry.
- **If you want to use an API key instead**
- Put `ANTHROPIC_API_KEY` in `~/.clawdbot/.env` on the **gateway host**.
- Clear any pinned order that forces `anthropic:claude-cli`:
```bash
clawdbot models auth order clear --provider anthropic
```
- **Confirm you’re running commands on the gateway host**
- In remote mode, auth profiles live on the gateway machine, not your laptop.
### Why did it also try Google Gemini and fail?
If your model config includes Google Gemini as a fallback (or you switched to a Gemini shorthand), Clawdbot will try it during model fallback. If you haven’t configured Google credentials, you’ll see `No API key found for provider "google"`.
@@ -826,6 +1172,8 @@ Fix:
Fix:
- Start Tailscale on that host (so it has a 100.x address), or
- Switch to `gateway.bind: "loopback"` / `"lan"`.
Note: `tailnet` is legacy and is migrated to `auto` by Doctor. Prefer `gateway.bind: "auto"` when using Tailscale.
### Can I run multiple Gateways on the same host?
@@ -845,6 +1193,29 @@ Quick setup (recommended):
Profiles also suffix service names (`com.clawdbot.<profile>`, `clawdbot-gateway-<profile>.service`, `Clawdbot Gateway (<profile>)`).
### What does “invalid handshake” / code 1008 mean?
The Gateway is a **WebSocket server**, and it expects the very first message to
be a `connect` frame. If it receives anything else, it closes the connection
with **code 1008** (policy violation).
Common causes:
- You opened the **HTTP** URL in a browser (`http://...`) instead of a WS client.
- You used the wrong port or path.
- A proxy or tunnel stripped auth headers or sent a non‑Gateway request.
Quick fixes:
1) Use the WS URL: `ws://<host>:18789` (or `wss://...` if HTTPS).
2) Don’t open the WS port in a normal browser tab.
3) If auth is on, include the token/password in the `connect` frame.
If you’re using the CLI or TUI, the URL should look like:
```
clawdbot tui --url ws://<host>:18789 --token <token>
```
Protocol details: [Gateway protocol](/gateway/protocol).
## Logging and debugging
### Where are logs?
@@ -1009,6 +1380,96 @@ clawdbot providers login
3) Check GitHub issues or Discord
4) Temporary workaround: check out an older commit
### npm install fails (allow-build-scripts / missing tar or yargs). What now?
If you’re running from source, use the repo’s package manager: **pnpm** (preferred).
The repo declares `packageManager: "pnpm@…"`, and pnpm patches are tracked in `pnpm.patchedDependencies`.
Typical recovery:
```bash
git status # ensure you’re in the repo root
pnpm install
pnpm build
pnpm clawdbot doctor
clawdbot daemon restart
```
Why: pnpm is the configured package manager for this repo, and the dependency
patching workflow relies on it.
### How do I switch between git installs and npm installs?
Use the **website installer** and select the install method with a flag. It
upgrades in place and rewrites the gateway service to point at the new install.
Switch **to git install**:
```bash
curl -fsSL https://clawd.bot/install.sh | bash -s -- --install-method git --no-onboard
```
Switch **to npm global**:
```bash
curl -fsSL https://clawd.bot/install.sh | bash
```
Notes:
- The git flow only rebases if the repo is clean. Commit or stash changes first.
- After switching, run:
```bash
clawdbot doctor
clawdbot daemon restart
```
### Telegram block streaming isn’t splitting text between tool calls. Why?
Block streaming only sends **completed text blocks**. Common reasons you see a single message:
- `agents.defaults.blockStreamingDefault` is still `"off"`.
- `telegram.blockStreaming` is set to `false`.
- `telegram.streamMode` is `partial` or `block` **and draft streaming is active**
(private chat + topics). Draft streaming disables block streaming in that case.
- Your `minChars` / coalesce settings are too high, so chunks get merged.
- The model emits one large text block (no mid‑reply flush points).
Fix checklist:
1) Put block streaming settings under `agents.defaults`, not the root.
2) Set `telegram.streamMode: "off"` if you want real multi‑message block replies.
3) Use smaller chunk/coalesce thresholds while debugging.
See [Streaming](/concepts/streaming).
### Discord doesn’t reply in my server even with `requireMention: false`. Why?
`requireMention` only controls mention‑gating **after** the channel passes allowlists.
By default `discord.groupPolicy` is **allowlist**, so guild channels must be explicitly enabled.
Fix checklist:
1) Set `discord.groupPolicy: "open"` **or** add the guild/channel allowlist.
2) Use **numeric channel IDs** in `discord.guilds.<guildId>.channels`.
3) Put `requireMention: false` **under** `discord.guilds` (global or per‑channel).
Top‑level `discord.requireMention` is not a supported key.
4) Ensure the bot has **Message Content Intent** and channel permissions.
5) Run `clawdbot providers status --probe` for audit hints.
Docs: [Discord](/providers/discord), [Providers troubleshooting](/providers/troubleshooting).
### Cloud Code Assist API error: invalid tool schema (400). What now?
This is almost always a **tool schema compatibility** issue. The Cloud Code Assist
endpoint accepts a strict subset of JSON Schema. Clawdbot scrubs/normalizes tool
schemas in current `main`, but the fix is not in the last release yet (as of
January 13, 2026).
Fix checklist:
1) **Update Clawdbot**:
- If you can run from source, pull `main` and restart the gateway.
- Otherwise, wait for the next release that includes the schema scrubber.
2) Avoid unsupported keywords like `anyOf/oneOf/allOf`, `patternProperties`,
`additionalProperties`, `minLength`, `maxLength`, `format`, etc.
3) If you define custom tools, keep the top‑level schema as `type: "object"` with
`properties` and simple enums.
See [Tools](/tools) and [TypeBox schemas](/concepts/typebox).
## Answer the exact question from the screenshot/chat log
**Q: “What’s the default model for Anthropic with an API key?”**
+3 -6
View File
@@ -79,7 +79,7 @@ What you’ll choose:
- **Auth**: OpenAI Code (Codex) subscription (OAuth) or API keys. For Anthropic we recommend an API key; `claude setup-token` is also supported.
- **Providers**: WhatsApp QR login, Telegram/Discord bot tokens, etc.
- **Daemon**: background install (launchd/systemd; WSL2 uses systemd)
- **Runtime**: Node (recommended; required for WhatsApp) or Bun (faster, but incompatible with WhatsApp)
- **Runtime**: Node (recommended; required for WhatsApp/Telegram). Bun is **not recommended**.
- **Gateway token**: the wizard generates one by default (even on loopback) and stores it in `gateway.auth.token`.
Wizard doc: [Wizard](/start/wizard)
@@ -110,11 +110,8 @@ clawdbot gateway --port 18789 --verbose
Dashboard (local loopback): `http://127.0.0.1:18789/`
If a token is configured, paste it into the Control UI settings (stored as `connect.params.auth.token`).
⚠️ **WhatsApp + Bun warning:** Baileys (WhatsApp Web library) uses a WebSocket
path that is currently incompatible with Bun and can cause memory corruption on
reconnect. If you use WhatsApp, run the Gateway with **Node** until this is
resolved. Baileys: https://github.com/WhiskeySockets/Baileys · Bun issue:
https://github.com/oven-sh/bun/issues/5951
⚠️ **Bun warning (WhatsApp + Telegram):** Bun has known issues with these
providers. If you use WhatsApp or Telegram, run the Gateway with **Node**.
## 4) Pair + connect your first chat surface
+1 -1
View File
@@ -27,7 +27,7 @@ Use these hubs to discover every page, including deep dives and reference docs t
- [Docker](/install/docker)
- [Nix](/install/nix)
- [Updating / rollback](/install/updating)
- [Bun workflow](/install/bun)
- [Bun workflow (experimental)](/install/bun)
## Core concepts
+1 -1
View File
@@ -64,7 +64,7 @@ Onboarding requests TCC permissions needed for:
## 5) CLI (optional)
The app can install the global `clawdbot` CLI via npm/pnpm/bun so terminal
The app can install the global `clawdbot` CLI via npm/pnpm so terminal
workflows and launchd tasks work out of the box.
## 6) Onboarding chat (dedicated session)
+2 -2
View File
@@ -9,7 +9,7 @@ summary: "Community-built projects and integrations powered by Clawdbot"
Real projects from the community. See what people are building with Clawdbot.
<Info>
**Want to be featured?** Share your project in [#showcase on Discord](https://discord.gg/clawdbot) or [tag @clawdbot on X](https://x.com/clawdbot).
**Want to be featured?** Share your project in [#showcase on Discord](https://discord.gg/clawd) or [tag @clawdbot on X](https://x.com/clawdbot).
</Info>
## 🎥 Clawdbot in Action
@@ -366,7 +366,7 @@ Have something to share? We'd love to feature it!
<Steps>
<Step title="Share It">
Post in [#showcase on Discord](https://discord.gg/clawdbot) or [tweet @clawdbot](https://x.com/clawdbot)
Post in [#showcase on Discord](https://discord.gg/clawd) or [tweet @clawdbot](https://x.com/clawdbot)
</Step>
<Step title="Include Details">
Tell us what it does, link to the repo/demo, share a screenshot if you have one
+2 -2
View File
@@ -119,7 +119,7 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` (
- Linux (and Windows via WSL2): systemd user unit
- Wizard attempts to enable lingering via `loginctl enable-linger <user>` so the Gateway stays up after logout.
- May prompt for sudo (writes `/var/lib/systemd/linger`); it tries without sudo first.
- **Runtime selection:** Node (recommended; required for WhatsApp) or Bun (faster, but incompatible with WhatsApp).
- **Runtime selection:** Node (recommended; required for WhatsApp/Telegram). Bun is **not recommended**.
7) **Health check**
- Starts the Gateway (if needed) and runs `clawdbot health`.
@@ -127,7 +127,7 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` (
8) **Skills (recommended)**
- Reads the available skills and checks requirements.
- Lets you choose a node manager: **npm / pnpm / bun**.
- Lets you choose a node manager: **npm / pnpm** (bun not recommended).
- Installs optional dependencies (some use Homebrew on macOS).
9) **Finish**
+1 -1
View File
@@ -38,7 +38,7 @@ clawdbot agent --to +15555550123 --message "Summon reply" --deliver
- `--local`: run locally (requires provider keys in your shell)
- `--deliver`: send the reply to the chosen provider (requires `--to`)
- `--provider`: `whatsapp|telegram|discord|slack|signal|imessage` (default: `whatsapp`)
- `--thinking <off|minimal|low|medium|high>`: persist thinking level
- `--thinking <off|minimal|low|medium|high|xhigh>`: persist thinking level (GPT-5.2 + Codex models only)
- `--verbose <on|off>`: persist verbose level
- `--timeout <seconds>`: override agent timeout
- `--json`: output structured JSON
-4
View File
@@ -42,10 +42,6 @@ npm i -g clawdhub
pnpm add -g clawdhub
```
```bash
bun add -g clawdhub
```
## How it fits into Clawdbot
By default, the CLI installs skills into `./skills` under your current working directory. If a Clawdbot workspace is configured, `clawdhub` falls back to that workspace unless you override `--workdir` (or `CLAWDHUB_WORKDIR`). Clawdbot loads workspace skills from `<workspace>/skills` and will pick them up in the **next** session. If you already use `~/.clawdbot/skills` or bundled skills, workspace skills take precedence.
+71
View File
@@ -22,6 +22,77 @@ You can globally allow/deny tools via `tools.allow` / `tools.deny` in `clawdbot.
}
```
## Tool profiles (base allowlist)
`tools.profile` sets a **base tool allowlist** before `tools.allow`/`tools.deny`.
Per-agent override: `agents.list[].tools.profile`.
Profiles:
- `minimal`: `session_status` only
- `coding`: `group:fs`, `group:runtime`, `group:sessions`, `group:memory`, `image`
- `messaging`: `group:messaging`, `sessions_list`, `sessions_history`, `sessions_send`, `session_status`
- `full`: no restriction (same as unset)
Example (messaging-only by default, allow Slack + Discord tools too):
```json5
{
tools: {
profile: "messaging",
allow: ["slack", "discord"]
}
}
```
Example (coding profile, but deny exec/process everywhere):
```json5
{
tools: {
profile: "coding",
deny: ["group:runtime"]
}
}
```
Example (global coding profile, messaging-only support agent):
```json5
{
tools: { profile: "coding" },
agents: {
list: [
{
id: "support",
tools: { profile: "messaging", allow: ["slack"] }
}
]
}
}
```
## Tool groups (shorthands)
Tool policies (global, agent, sandbox) support `group:*` entries that expand to multiple tools.
Use these in `tools.allow` / `tools.deny`.
Available groups:
- `group:runtime`: `exec`, `bash`, `process`
- `group:fs`: `read`, `write`, `edit`, `apply_patch`
- `group:sessions`: `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `session_status`
- `group:memory`: `memory_search`, `memory_get`
- `group:ui`: `browser`, `canvas`
- `group:automation`: `cron`, `gateway`
- `group:messaging`: `message`
- `group:nodes`: `nodes`
- `group:clawdbot`: all built-in Clawdbot tools (excludes provider plugins)
Example (allow only file tools + browser):
```json5
{
tools: {
allow: ["group:fs", "browser"]
}
}
```
## Plugins + tools
Plugins can register **additional tools** (and CLI commands) beyond the core set.
+3 -1
View File
@@ -20,7 +20,7 @@ All skills-related configuration lives under `skills` in `~/.clawdbot/clawdbot.j
},
install: {
preferBrew: true,
nodeManager: "npm" // npm | pnpm | yarn | bun
nodeManager: "npm" // npm | pnpm | yarn | bun (Gateway runtime still Node; bun not recommended)
},
entries: {
"nano-banana-pro": {
@@ -44,6 +44,8 @@ All skills-related configuration lives under `skills` in `~/.clawdbot/clawdbot.j
- `load.extraDirs`: additional skill directories to scan (lowest precedence).
- `install.preferBrew`: prefer brew installers when available (default: true).
- `install.nodeManager`: node installer preference (`npm` | `pnpm` | `yarn` | `bun`, default: npm).
This only affects **skill installs**; the Gateway runtime should still be Node
(Bun not recommended for WhatsApp/Telegram).
- `entries.<skillKey>`: per-skill overrides.
Per-skill fields:
+6 -3
View File
@@ -45,8 +45,9 @@ surface those skills teach.
## ClawdHub (install + sync)
ClawdHub is the public skills registry for Clawdbot. Use it to discover,
install, update, and back up skills. Full guide: [ClawdHub](/tools/clawdhub).
ClawdHub is the public skills registry for Clawdbot. Browse at
https://clawdhub.com. Use it to discover, install, update, and back up skills.
Full guide: [ClawdHub](/tools/clawdhub).
Common flows:
@@ -117,6 +118,8 @@ metadata: {"clawdbot":{"emoji":"♊️","requires":{"bins":["gemini"]},"install"
Notes:
- If multiple installers are listed, the gateway picks a **single** preferred option (brew when available, otherwise node).
- Node installs honor `skills.install.nodeManager` in `clawdbot.json` (default: npm; options: npm/pnpm/yarn/bun).
This only affects **skill installs**; the Gateway runtime should still be Node
(Bun is not recommended for WhatsApp/Telegram).
- Go installs: if `go` is missing and `brew` is available, the gateway installs Go via Homebrew first and sets `GOBIN` to Homebrew’s `bin` when possible.
If no `metadata.clawdbot` is present, the skill is always eligible (unless
@@ -201,6 +204,6 @@ See [Skills config](/tools/skills-config) for the full configuration schema.
## Looking for more skills?
Browse [ClawdHub](/tools/clawdhub).
Browse https://clawdhub.com.
---
+1 -1
View File
@@ -65,7 +65,7 @@ Text + native (when enabled):
- `/activation mention|always` (groups only)
- `/send on|off|inherit` (owner-only)
- `/reset` or `/new`
- `/think <level>` (aliases: `/thinking`, `/t`)
- `/think <off|minimal|low|medium|high|xhigh>` (GPT-5.2 + Codex models only; aliases: `/thinking`, `/t`)
- `/verbose on|off` (alias: `/v`)
- `/reasoning on|off|stream` (alias: `/reason`; when on, sends a separate message prefixed `Reasoning:`; `stream` = Telegram draft only)
- `/elevated on|off` (alias: `/elev`)
+2 -1
View File
@@ -7,11 +7,12 @@ read_when:
## What it does
- Inline directive in any inbound body: `/t <level>`, `/think:<level>`, or `/thinking <level>`.
- Levels (aliases): `off | minimal | low | medium | high`
- Levels (aliases): `off | minimal | low | medium | high | xhigh` (GPT-5.2 + Codex models only)
- minimal → “think”
- low → “think hard”
- medium → “think harder”
- high → “ultrathink” (max budget)
- xhigh → “ultrathink+” (GPT-5.2 + Codex models only)
- `highest`, `max` map to `high`.
## Resolution order
+4 -1
View File
@@ -50,7 +50,7 @@ Use SSH tunneling or Tailscale to reach the Gateway WS.
- `/agent <id>` (or `/agents`)
- `/session <key>` (or `/sessions`)
- `/model <provider/model>` (or `/model list`, `/models`)
- `/think <off|minimal|low|medium|high>`
- `/think <off|minimal|low|medium|high|xhigh>` (GPT-5.2 + Codex models only)
- `/verbose <on|off>`
- `/reasoning <on|off|stream>` (stream = Telegram draft only)
- `/cost <on|off>`
@@ -67,3 +67,6 @@ Use SSH tunneling or Tailscale to reach the Gateway WS.
## Notes
- The TUI shows Gateway chat deltas (`event: chat`) and agent tool events.
- It registers as a Gateway client with `mode: "tui"` for presence and debugging.
- The TUI uses the **Gateway’s** model/auth config and environment. If a model
token works in your shell but not in TUI, put it in `~/.clawdbot/.env` or
enable `env.shellEnv.enabled`, then restart the Gateway.
+5 -5
View File
@@ -132,10 +132,10 @@
"@grammyjs/runner": "^2.0.3",
"@grammyjs/transformer-throttler": "^1.2.1",
"@homebridge/ciao": "^1.3.4",
"@mariozechner/pi-agent-core": "^0.43.0",
"@mariozechner/pi-ai": "^0.43.0",
"@mariozechner/pi-coding-agent": "^0.43.0",
"@mariozechner/pi-tui": "^0.43.0",
"@mariozechner/pi-agent-core": "^0.45.3",
"@mariozechner/pi-ai": "^0.45.3",
"@mariozechner/pi-coding-agent": "^0.45.3",
"@mariozechner/pi-tui": "^0.45.3",
"@microsoft/agents-hosting": "^1.1.1",
"@microsoft/agents-hosting-express": "^1.1.1",
"@microsoft/agents-hosting-extensions-teams": "^1.1.1",
@@ -207,7 +207,7 @@
"@sinclair/typebox": "0.34.47"
},
"patchedDependencies": {
"@mariozechner/pi-ai@0.43.0": "patches/@mariozechner__pi-ai@0.43.0.patch"
"@mariozechner/pi-ai@0.45.3": "patches/@mariozechner__pi-ai@0.45.3.patch"
}
},
"vitest": {
@@ -1,8 +1,8 @@
diff --git a/dist/providers/google-gemini-cli.js b/dist/providers/google-gemini-cli.js
index 12540bb1069087a0d0a2967f792008627b9f79d9..f30b525620e6d8e45146b439ec3733e4053c9d2a 100644
index cc9e0cb..2b18ec4 100644
--- a/dist/providers/google-gemini-cli.js
+++ b/dist/providers/google-gemini-cli.js
@@ -248,6 +248,11 @@ export const streamGoogleGeminiCli = (model, context, options) => {
@@ -329,6 +329,11 @@ export const streamGoogleGeminiCli = (model, context, options) => {
break; // Success, exit retry loop
}
const errorText = await response.text();
@@ -14,41 +14,8 @@ index 12540bb1069087a0d0a2967f792008627b9f79d9..f30b525620e6d8e45146b439ec3733e4
// Check if retryable
if (attempt < MAX_RETRIES && isRetryableError(response.status, errorText)) {
// Use server-provided delay or exponential backoff
diff --git a/dist/providers/google-shared.js b/dist/providers/google-shared.js
index ae4710b0f134ac4a48f5b7053f454d1068bee71f..b1b5bd94586f68461ccc44e4a9cdf3acb4e0d084 100644
--- a/dist/providers/google-shared.js
+++ b/dist/providers/google-shared.js
@@ -42,6 +42,8 @@ export function retainThoughtSignature(existing, incoming) {
export function convertMessages(model, context) {
const contents = [];
const transformedMessages = transformMessages(context.messages, model);
+ const shouldStripFunctionId = typeof model.provider === "string" &&
+ model.provider.startsWith("google");
for (const msg of transformedMessages) {
if (msg.role === "user") {
if (typeof msg.content === "string") {
@@ -113,6 +115,9 @@ export function convertMessages(model, context) {
args: block.arguments,
},
};
+ if (shouldStripFunctionId && part?.functionCall?.id) {
+ delete part.functionCall.id; // Google Gemini/Vertex do not support 'id' in functionCall
+ }
if (block.thoughtSignature) {
part.thoughtSignature = block.thoughtSignature;
}
@@ -155,6 +160,9 @@ export function convertMessages(model, context) {
...(hasImages && supportsMultimodalFunctionResponse && { parts: imageParts }),
},
};
+ if (shouldStripFunctionId && functionResponsePart.functionResponse?.id) {
+ delete functionResponsePart.functionResponse.id; // Google Gemini/Vertex do not support 'id' in functionResponse
+ }
// 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.
const lastContent = contents[contents.length - 1];
diff --git a/dist/providers/openai-codex-responses.js b/dist/providers/openai-codex-responses.js
index ad0a2aabbe10382cee4e463b68a02864dd235e57..8c001acfd0b4e0743181c246f1bedcf8cd2ffb02 100644
index 7488c79..4c34587 100644
--- a/dist/providers/openai-codex-responses.js
+++ b/dist/providers/openai-codex-responses.js
@@ -517,7 +517,7 @@ function convertTools(tools) {
@@ -61,10 +28,10 @@ index ad0a2aabbe10382cee4e463b68a02864dd235e57..8c001acfd0b4e0743181c246f1bedcf8
}
function mapStopReason(status) {
diff --git a/dist/providers/openai-responses.js b/dist/providers/openai-responses.js
index f07085c64390b211340d6a826b28ea9c2e77302f..7f758532246cc7b062df48e9cec4e6c904b76a99 100644
index c4714f4..4d1e6b0 100644
--- a/dist/providers/openai-responses.js
+++ b/dist/providers/openai-responses.js
@@ -396,10 +396,16 @@ function convertMessages(model, context) {
@@ -400,10 +400,16 @@ function convertMessages(model, context) {
}
else if (msg.role === "assistant") {
const output = [];
@@ -81,7 +48,7 @@ index f07085c64390b211340d6a826b28ea9c2e77302f..7f758532246cc7b062df48e9cec4e6c9
const reasoningItem = JSON.parse(block.thinkingSignature);
output.push(reasoningItem);
}
@@ -434,6 +440,16 @@ function convertMessages(model, context) {
@@ -438,6 +444,16 @@ function convertMessages(model, context) {
});
}
}
+1258 -315
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -10,4 +10,4 @@ onlyBuiltDependencies:
- sharp
patchedDependencies:
'@mariozechner/pi-ai@0.43.0': patches/@mariozechner__pi-ai@0.43.0.patch
'@mariozechner/pi-ai@0.45.3': patches/@mariozechner__pi-ai@0.45.3.patch
+2
View File
@@ -4,6 +4,8 @@ RUN corepack enable
WORKDIR /app
ENV NODE_OPTIONS="--disable-warning=ExperimentalWarning"
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.json vitest.config.ts ./
COPY patches ./patches
COPY src ./src
@@ -20,6 +20,7 @@ docker run --rm -t \
--entrypoint bash \
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
-e HOME=/home/node \
-e NODE_OPTIONS=--disable-warning=ExperimentalWarning \
-e CLAWDBOT_LIVE_TEST=1 \
-e CLAWDBOT_LIVE_GATEWAY_MODELS="${CLAWDBOT_LIVE_GATEWAY_MODELS:-all}" \
-e CLAWDBOT_LIVE_GATEWAY_PROVIDERS="${CLAWDBOT_LIVE_GATEWAY_PROVIDERS:-}" \
+1
View File
@@ -20,6 +20,7 @@ docker run --rm -t \
--entrypoint bash \
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
-e HOME=/home/node \
-e NODE_OPTIONS=--disable-warning=ExperimentalWarning \
-e CLAWDBOT_LIVE_TEST=1 \
-e CLAWDBOT_LIVE_MODELS="${CLAWDBOT_LIVE_MODELS:-all}" \
-e CLAWDBOT_LIVE_PROVIDERS="${CLAWDBOT_LIVE_PROVIDERS:-}" \
+67 -1
View File
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveAgentConfig } from "./agent-scope.js";
import {
resolveAgentConfig,
resolveAgentModelFallbacksOverride,
resolveAgentModelPrimary,
} from "./agent-scope.js";
describe("resolveAgentConfig", () => {
it("should return undefined when no agents config exists", () => {
@@ -47,6 +51,68 @@ describe("resolveAgentConfig", () => {
});
});
it("supports per-agent model primary+fallbacks", () => {
const cfg: ClawdbotConfig = {
agents: {
defaults: {
model: {
primary: "anthropic/claude-sonnet-4",
fallbacks: ["openai/gpt-4.1"],
},
},
list: [
{
id: "linus",
model: {
primary: "anthropic/claude-opus-4",
fallbacks: ["openai/gpt-5.2"],
},
},
],
},
};
expect(resolveAgentModelPrimary(cfg, "linus")).toBe(
"anthropic/claude-opus-4",
);
expect(resolveAgentModelFallbacksOverride(cfg, "linus")).toEqual([
"openai/gpt-5.2",
]);
// If fallbacks isn't present, we don't override the global fallbacks.
const cfgNoOverride: ClawdbotConfig = {
agents: {
list: [
{
id: "linus",
model: {
primary: "anthropic/claude-opus-4",
},
},
],
},
};
expect(resolveAgentModelFallbacksOverride(cfgNoOverride, "linus")).toBe(
undefined,
);
// Explicit empty list disables global fallbacks for that agent.
const cfgDisable: ClawdbotConfig = {
agents: {
list: [
{
id: "linus",
model: {
primary: "anthropic/claude-opus-4",
fallbacks: [],
},
},
],
},
};
expect(resolveAgentModelFallbacksOverride(cfgDisable, "linus")).toEqual([]);
});
it("should return agent-specific sandbox config", () => {
const cfg: ClawdbotConfig = {
agents: {
+28 -11
View File
@@ -21,7 +21,7 @@ type ResolvedAgentConfig = {
name?: string;
workspace?: string;
agentDir?: string;
model?: string;
model?: AgentEntry["model"];
memorySearch?: AgentEntry["memorySearch"];
humanDelay?: AgentEntry["humanDelay"];
identity?: AgentEntry["identity"];
@@ -95,7 +95,11 @@ export function resolveAgentConfig(
workspace:
typeof entry.workspace === "string" ? entry.workspace : undefined,
agentDir: typeof entry.agentDir === "string" ? entry.agentDir : undefined,
model: typeof entry.model === "string" ? entry.model : undefined,
model:
typeof entry.model === "string" ||
(entry.model && typeof entry.model === "object")
? entry.model
: undefined,
memorySearch: entry.memorySearch,
humanDelay: entry.humanDelay,
identity: entry.identity,
@@ -109,6 +113,28 @@ export function resolveAgentConfig(
};
}
export function resolveAgentModelPrimary(
cfg: ClawdbotConfig,
agentId: string,
): string | undefined {
const raw = resolveAgentConfig(cfg, agentId)?.model;
if (!raw) return undefined;
if (typeof raw === "string") return raw.trim() || undefined;
const primary = raw.primary?.trim();
return primary || undefined;
}
export function resolveAgentModelFallbacksOverride(
cfg: ClawdbotConfig,
agentId: string,
): string[] | undefined {
const raw = resolveAgentConfig(cfg, agentId)?.model;
if (!raw || typeof raw === "string") return undefined;
// Important: treat an explicitly provided empty array as an override to disable global fallbacks.
if (!Object.hasOwn(raw, "fallbacks")) return undefined;
return Array.isArray(raw.fallbacks) ? raw.fallbacks : undefined;
}
export function resolveAgentWorkspaceDir(cfg: ClawdbotConfig, agentId: string) {
const id = normalizeAgentId(agentId);
const configured = resolveAgentConfig(cfg, id)?.workspace?.trim();
@@ -129,12 +155,3 @@ export function resolveAgentDir(cfg: ClawdbotConfig, agentId: string) {
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");
}
+128
View File
@@ -59,6 +59,134 @@ describe("resolveAuthProfileOrder", () => {
expect(order).toContain("anthropic:default");
});
it("drops explicit order entries that are missing from the store", () => {
const order = resolveAuthProfileOrder({
cfg: {
auth: {
order: {
minimax: ["minimax:default", "minimax:prod"],
},
},
},
store: {
version: 1,
profiles: {
"minimax:prod": {
type: "api_key",
provider: "minimax",
key: "sk-prod",
},
},
},
provider: "minimax",
});
expect(order).toEqual(["minimax:prod"]);
});
it("drops explicit order entries that belong to another provider", () => {
const order = resolveAuthProfileOrder({
cfg: {
auth: {
order: {
minimax: ["openai:default", "minimax:prod"],
},
},
},
store: {
version: 1,
profiles: {
"openai:default": {
type: "api_key",
provider: "openai",
key: "sk-openai",
},
"minimax:prod": {
type: "api_key",
provider: "minimax",
key: "sk-mini",
},
},
},
provider: "minimax",
});
expect(order).toEqual(["minimax:prod"]);
});
it("drops token profiles with empty credentials", () => {
const order = resolveAuthProfileOrder({
cfg: {
auth: {
order: {
minimax: ["minimax:default"],
},
},
},
store: {
version: 1,
profiles: {
"minimax:default": {
type: "token",
provider: "minimax",
token: " ",
},
},
},
provider: "minimax",
});
expect(order).toEqual([]);
});
it("drops token profiles that are already expired", () => {
const order = resolveAuthProfileOrder({
cfg: {
auth: {
order: {
minimax: ["minimax:default"],
},
},
},
store: {
version: 1,
profiles: {
"minimax:default": {
type: "token",
provider: "minimax",
token: "sk-minimax",
expires: Date.now() - 1000,
},
},
},
provider: "minimax",
});
expect(order).toEqual([]);
});
it("keeps oauth profiles that can refresh", () => {
const order = resolveAuthProfileOrder({
cfg: {
auth: {
order: {
anthropic: ["anthropic:oauth"],
},
},
},
store: {
version: 1,
profiles: {
"anthropic:oauth": {
type: "oauth",
provider: "anthropic",
access: "",
refresh: "refresh-token",
expires: Date.now() - 1000,
},
},
},
provider: "anthropic",
});
expect(order).toEqual(["anthropic:oauth"]);
});
it("does not prioritize lastGood over round-robin ordering", () => {
const order = resolveAuthProfileOrder({
cfg,
+31 -2
View File
@@ -1042,6 +1042,7 @@ export function resolveAuthProfileOrder(params: {
}): string[] {
const { cfg, store, provider, preferredProfile } = params;
const providerKey = normalizeProviderId(provider);
const now = Date.now();
const storedOrder = (() => {
const order = store.order;
if (!order) return undefined;
@@ -1076,7 +1077,36 @@ export function resolveAuthProfileOrder(params: {
const filtered = baseOrder.filter((profileId) => {
const cred = store.profiles[profileId];
return cred ? normalizeProviderId(cred.provider) === providerKey : true;
if (!cred) return false;
if (normalizeProviderId(cred.provider) !== providerKey) return false;
const profileConfig = cfg?.auth?.profiles?.[profileId];
if (profileConfig) {
if (normalizeProviderId(profileConfig.provider) !== providerKey) {
return false;
}
if (profileConfig.mode !== cred.type) {
const oauthCompatible =
profileConfig.mode === "oauth" && cred.type === "token";
if (!oauthCompatible) return false;
}
}
if (cred.type === "api_key") return Boolean(cred.key?.trim());
if (cred.type === "token") {
if (!cred.token?.trim()) return false;
if (
typeof cred.expires === "number" &&
Number.isFinite(cred.expires) &&
cred.expires > 0 &&
now >= cred.expires
) {
return false;
}
return true;
}
if (cred.type === "oauth") {
return Boolean(cred.access?.trim() || cred.refresh?.trim());
}
return false;
});
const deduped: string[] = [];
for (const entry of filtered) {
@@ -1089,7 +1119,6 @@ export function resolveAuthProfileOrder(params: {
if (explicitOrder && explicitOrder.length > 0) {
// ...but still respect cooldown tracking to avoid repeatedly selecting a
// known-bad/rate-limited key as the first candidate.
const now = Date.now();
const available: string[] = [];
const inCooldown: Array<{ profileId: string; cooldownUntil: number }> = [];
-14
View File
@@ -40,20 +40,6 @@ const DEFAULT_PATH =
process.env.PATH ??
"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
// NOTE: Using Type.Unsafe with enum instead of Type.Union([Type.Literal(...)])
// because Claude API on Vertex AI rejects nested anyOf schemas as invalid JSON Schema.
// Type.Union of literals compiles to { anyOf: [{enum:["a"]}, {enum:["b"]}, ...] }
// which is valid but not accepted. A flat enum { type: "string", enum: [...] } works.
const _stringEnum = <T extends readonly string[]>(
values: T,
options?: { description?: string },
) =>
Type.Unsafe<T[number]>({
type: "string",
enum: values as unknown as string[],
...options,
});
export type ExecToolDefaults = {
backgroundMs?: number;
timeoutSec?: number;
+100
View File
@@ -124,6 +124,106 @@ describe("runWithModelFallback", () => {
expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5");
});
it("does not append configured primary when fallbacksOverride is set", async () => {
const cfg = makeCfg({
agents: {
defaults: {
model: {
primary: "openai/gpt-4.1-mini",
},
},
},
});
const run = vi
.fn()
.mockImplementation(() =>
Promise.reject(Object.assign(new Error("nope"), { status: 401 })),
);
await expect(
runWithModelFallback({
cfg,
provider: "anthropic",
model: "claude-opus-4-5",
fallbacksOverride: ["anthropic/claude-haiku-3-5"],
run,
}),
).rejects.toThrow("All models failed");
expect(run.mock.calls).toEqual([
["anthropic", "claude-opus-4-5"],
["anthropic", "claude-haiku-3-5"],
]);
});
it("uses fallbacksOverride instead of agents.defaults.model.fallbacks", async () => {
const cfg = {
agents: {
defaults: {
model: {
fallbacks: ["openai/gpt-5.2"],
},
},
},
} as ClawdbotConfig;
const calls: Array<{ provider: string; model: string }> = [];
const res = await runWithModelFallback({
cfg,
provider: "anthropic",
model: "claude-opus-4-5",
fallbacksOverride: ["openai/gpt-4.1"],
run: async (provider, model) => {
calls.push({ provider, model });
if (provider === "anthropic") {
throw Object.assign(new Error("nope"), { status: 401 });
}
if (provider === "openai" && model === "gpt-4.1") {
return "ok";
}
throw new Error(`unexpected candidate: ${provider}/${model}`);
},
});
expect(res.result).toBe("ok");
expect(calls).toEqual([
{ provider: "anthropic", model: "claude-opus-4-5" },
{ provider: "openai", model: "gpt-4.1" },
]);
});
it("treats an empty fallbacksOverride as disabling global fallbacks", async () => {
const cfg = {
agents: {
defaults: {
model: {
fallbacks: ["openai/gpt-5.2"],
},
},
},
} as ClawdbotConfig;
const calls: Array<{ provider: string; model: string }> = [];
await expect(
runWithModelFallback({
cfg,
provider: "anthropic",
model: "claude-opus-4-5",
fallbacksOverride: [],
run: async (provider, model) => {
calls.push({ provider, model });
throw new Error("primary failed");
},
}),
).rejects.toThrow("primary failed");
expect(calls).toEqual([
{ provider: "anthropic", model: "claude-opus-4-5" },
]);
});
it("falls back on missing API key errors", async () => {
const cfg = makeCfg();
const run = vi
+16 -2
View File
@@ -126,6 +126,8 @@ function resolveFallbackCandidates(params: {
cfg: ClawdbotConfig | undefined;
provider: string;
model: string;
/** Optional explicit fallbacks list; when provided (even empty), replaces agents.defaults.model.fallbacks. */
fallbacksOverride?: string[];
}): ModelCandidate[] {
const provider = params.provider.trim() || DEFAULT_PROVIDER;
const model = params.model.trim() || DEFAULT_MODEL;
@@ -159,6 +161,7 @@ function resolveFallbackCandidates(params: {
addCandidate({ provider, model }, false);
const modelFallbacks = (() => {
if (params.fallbacksOverride !== undefined) return params.fallbacksOverride;
const model = params.cfg?.agents?.defaults?.model as
| { fallbacks?: string[] }
| string
@@ -177,7 +180,11 @@ function resolveFallbackCandidates(params: {
addCandidate(resolved.ref, true);
}
if (primary?.provider && primary.model) {
if (
params.fallbacksOverride === undefined &&
primary?.provider &&
primary.model
) {
addCandidate({ provider: primary.provider, model: primary.model }, false);
}
@@ -188,6 +195,8 @@ export async function runWithModelFallback<T>(params: {
cfg: ClawdbotConfig | undefined;
provider: string;
model: string;
/** Optional explicit fallbacks list; when provided (even empty), replaces agents.defaults.model.fallbacks. */
fallbacksOverride?: string[];
run: (provider: string, model: string) => Promise<T>;
onError?: (attempt: {
provider: string;
@@ -202,7 +211,12 @@ export async function runWithModelFallback<T>(params: {
model: string;
attempts: FallbackAttempt[];
}> {
const candidates = resolveFallbackCandidates(params);
const candidates = resolveFallbackCandidates({
cfg: params.cfg,
provider: params.provider,
model: params.model,
fallbacksOverride: params.fallbacksOverride,
});
const attempts: FallbackAttempt[] = [];
let lastError: unknown;
+7 -1
View File
@@ -7,7 +7,13 @@ export type ModelRef = {
model: string;
};
export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high";
export type ThinkLevel =
| "off"
| "minimal"
| "low"
| "medium"
| "high"
| "xhigh";
export type ModelAliasIndex = {
byAlias: Map<string, { alias: string; ref: ModelRef }>;
+55 -20
View File
@@ -253,19 +253,17 @@ describeLive("live models (profile keys)", () => {
parameters: Type.Object({}, { additionalProperties: false }),
};
const first = await completeSimpleWithTimeout(
let firstUserContent =
"Call the tool `noop` with {}. Do not write any other text.";
let firstUser = {
role: "user" as const,
content: firstUserContent,
timestamp: Date.now(),
};
let first = await completeSimpleWithTimeout(
model,
{
messages: [
{
role: "user",
content:
"Call the tool `noop` with {}. Do not write any other text.",
timestamp: Date.now(),
},
],
tools: [noopTool],
},
{ messages: [firstUser], tools: [noopTool] },
{
apiKey,
reasoning: resolveTestReasoning(model),
@@ -274,8 +272,49 @@ describeLive("live models (profile keys)", () => {
perModelTimeoutMs,
);
const toolCall = first.content.find((b) => b.type === "toolCall");
let toolCall = first.content.find((b) => b.type === "toolCall");
let firstText = first.content
.filter((b) => b.type === "text")
.map((b) => b.text.trim())
.join(" ")
.trim();
// Occasional flake: model answers in text instead of tool call (or adds text).
// Retry a couple times with a stronger instruction so we still exercise the tool-only replay path.
for (
let i = 0;
i < 2 && (!toolCall || firstText.length > 0);
i += 1
) {
firstUserContent =
"Call the tool `noop` with {}. IMPORTANT: respond ONLY with the tool call; no other text.";
firstUser = {
role: "user" as const,
content: firstUserContent,
timestamp: Date.now(),
};
first = await completeSimpleWithTimeout(
model,
{ messages: [firstUser], tools: [noopTool] },
{
apiKey,
reasoning: resolveTestReasoning(model),
maxTokens: 128,
},
perModelTimeoutMs,
);
toolCall = first.content.find((b) => b.type === "toolCall");
firstText = first.content
.filter((b) => b.type === "text")
.map((b) => b.text.trim())
.join(" ")
.trim();
}
expect(toolCall).toBeTruthy();
expect(firstText.length).toBe(0);
if (!toolCall || toolCall.type !== "toolCall") {
throw new Error("expected tool call");
}
@@ -284,12 +323,7 @@ describeLive("live models (profile keys)", () => {
model,
{
messages: [
{
role: "user",
content:
"Call the tool `noop` with {}. Do not write any other text.",
timestamp: Date.now(),
},
firstUser,
first,
{
role: "toolResult",
@@ -309,7 +343,8 @@ describeLive("live models (profile keys)", () => {
{
apiKey,
reasoning: resolveTestReasoning(model),
maxTokens: 64,
// Headroom: reasoning summary can consume most of the output budget.
maxTokens: 256,
},
perModelTimeoutMs,
);
+14
View File
@@ -209,6 +209,11 @@ describe("classifyFailoverReason", () => {
expect(classifyFailoverReason("resource has been exhausted")).toBe(
"rate_limit",
);
expect(
classifyFailoverReason(
'{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}',
),
).toBe("rate_limit");
expect(classifyFailoverReason("invalid request format")).toBe("format");
expect(classifyFailoverReason("credit balance too low")).toBe("billing");
expect(classifyFailoverReason("deadline exceeded")).toBe("timeout");
@@ -265,6 +270,15 @@ describe("formatAssistantErrorText", () => {
"Message ordering conflict",
);
});
it("returns a friendly message for Anthropic overload errors", () => {
const msg = makeAssistantError(
'{"type":"error","error":{"details":null,"type":"overloaded_error","message":"Overloaded"},"request_id":"req_123"}',
);
expect(formatAssistantErrorText(msg)).toBe(
"The AI service is temporarily overloaded. Please try again in a moment.",
);
});
});
describe("sanitizeToolCallId", () => {
+16 -2
View File
@@ -370,8 +370,8 @@ export function formatAssistantErrorText(
// Check for context overflow (413) errors
if (isContextOverflowError(raw)) {
return (
"Context overflow: the conversation history is too large. " +
"Use /new or /reset to start a fresh session."
"Context overflow: prompt too large for the model. " +
"Try again with less input or a larger-context model."
);
}
@@ -392,6 +392,11 @@ export function formatAssistantErrorText(
return `LLM request rejected: ${invalidRequest[1]}`;
}
// Check for overloaded errors (Anthropic API capacity)
if (isOverloadedErrorMessage(raw)) {
return "The AI service is temporarily overloaded. Please try again in a moment.";
}
// Keep it short for WhatsApp.
return raw.length > 600 ? `${raw.slice(0, 600)}…` : raw;
}
@@ -414,6 +419,10 @@ const ERROR_PATTERNS = {
"resource_exhausted",
"usage limit",
],
overloaded: [
/overloaded_error|"type"\s*:\s*"overloaded_error"/i,
"overloaded",
],
timeout: [
"timeout",
"timed out",
@@ -496,6 +505,10 @@ export function isAuthErrorMessage(raw: string): boolean {
return matchesErrorPatterns(raw, ERROR_PATTERNS.auth);
}
export function isOverloadedErrorMessage(raw: string): boolean {
return matchesErrorPatterns(raw, ERROR_PATTERNS.overloaded);
}
export function isCloudCodeAssistFormatError(raw: string): boolean {
return matchesErrorPatterns(raw, ERROR_PATTERNS.format);
}
@@ -517,6 +530,7 @@ export type FailoverReason =
export function classifyFailoverReason(raw: string): FailoverReason | null {
if (isRateLimitErrorMessage(raw)) return "rate_limit";
if (isOverloadedErrorMessage(raw)) return "rate_limit"; // Treat overloaded as rate limit for failover
if (isCloudCodeAssistFormatError(raw)) return "format";
if (isBillingErrorMessage(raw)) return "billing";
if (isTimeoutErrorMessage(raw)) return "timeout";
+11 -3
View File
@@ -362,6 +362,10 @@ export type EmbeddedPiRunMeta = {
durationMs: number;
agentMeta?: EmbeddedPiAgentMeta;
aborted?: boolean;
error?: {
kind: "context_overflow" | "compaction_failure";
message: string;
};
};
function buildModelAliasLines(cfg?: ClawdbotConfig) {
@@ -1046,7 +1050,7 @@ export function resolveEmbeddedSessionLane(key: string) {
}
function mapThinkingLevel(level?: ThinkLevel): ThinkingLevel {
// pi-agent-core supports "xhigh" too; Clawdbot doesn't surface it for now.
// pi-agent-core supports "xhigh"; Clawdbot enables it for specific models.
if (!level) return "off";
return level;
}
@@ -1976,12 +1980,15 @@ export async function runEmbeddedPiAgent(params: {
if (promptError && !aborted) {
const errorText = describeUnknownError(promptError);
if (isContextOverflowError(errorText)) {
const kind = isCompactionFailureError(errorText)
? "compaction_failure"
: "context_overflow";
return {
payloads: [
{
text:
"Context overflow: the conversation history is too large for the model. " +
"Use /new or /reset to start a fresh session, or try a model with a larger context window.",
"Context overflow: prompt too large for the model. " +
"Try again with less input or a larger-context model.",
isError: true,
},
],
@@ -1992,6 +1999,7 @@ export async function runEmbeddedPiAgent(params: {
provider,
model: model.id,
},
error: { kind, message: errorText },
},
};
}
+142 -1
View File
@@ -5,6 +5,7 @@ import type { AgentTool } from "@mariozechner/pi-agent-core";
import sharp from "sharp";
import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import { createClawdbotTools } from "./clawdbot-tools.js";
import { __testing, createClawdbotCodingTools } from "./pi-tools.js";
import { createBrowserTool } from "./tools/browser-tool.js";
@@ -16,7 +17,7 @@ describe("createClawdbotCodingTools", () => {
expect(schema.anyOf).toBeUndefined();
});
it("merges properties for union tool schemas", () => {
it("keeps browser tool schema properties after normalization", () => {
const tools = createClawdbotCodingTools();
const browser = tools.find((tool) => tool.name === "browser");
expect(browser).toBeDefined();
@@ -266,6 +267,95 @@ describe("createClawdbotCodingTools", () => {
expect(offenders).toEqual([]);
});
it("avoids anyOf/oneOf/allOf in tool schemas", () => {
const tools = createClawdbotCodingTools();
const offenders: Array<{
name: string;
keyword: string;
path: string;
}> = [];
const keywords = new Set(["anyOf", "oneOf", "allOf"]);
const walk = (value: unknown, path: string, name: string): void => {
if (!value) return;
if (Array.isArray(value)) {
for (const [index, entry] of value.entries()) {
walk(entry, `${path}[${index}]`, name);
}
return;
}
if (typeof value !== "object") return;
const record = value as Record<string, unknown>;
for (const [key, entry] of Object.entries(record)) {
const nextPath = path ? `${path}.${key}` : key;
if (keywords.has(key)) {
offenders.push({ name, keyword: key, path: nextPath });
}
walk(entry, nextPath, name);
}
};
for (const tool of tools) {
walk(tool.parameters, "", tool.name);
}
expect(offenders).toEqual([]);
});
it("keeps raw core tool schemas union-free", () => {
const tools = createClawdbotTools();
const coreTools = new Set([
"browser",
"canvas",
"nodes",
"cron",
"message",
"gateway",
"agents_list",
"sessions_list",
"sessions_history",
"sessions_send",
"sessions_spawn",
"session_status",
"memory_search",
"memory_get",
"image",
]);
const offenders: Array<{
name: string;
keyword: string;
path: string;
}> = [];
const keywords = new Set(["anyOf", "oneOf", "allOf"]);
const walk = (value: unknown, path: string, name: string): void => {
if (!value) return;
if (Array.isArray(value)) {
for (const [index, entry] of value.entries()) {
walk(entry, `${path}[${index}]`, name);
}
return;
}
if (typeof value !== "object") return;
const record = value as Record<string, unknown>;
for (const [key, entry] of Object.entries(record)) {
const nextPath = path ? `${path}.${key}` : key;
if (keywords.has(key)) {
offenders.push({ name, keyword: key, path: nextPath });
}
walk(entry, nextPath, name);
}
};
for (const tool of tools) {
if (!coreTools.has(tool.name)) continue;
walk(tool.parameters, "", tool.name);
}
expect(offenders).toEqual([]);
});
it("does not expose provider-specific message tools", () => {
const tools = createClawdbotCodingTools({ messageProvider: "discord" });
const names = new Set(tools.map((tool) => tool.name));
@@ -517,6 +607,57 @@ describe("createClawdbotCodingTools", () => {
expect(tools.some((tool) => tool.name === "browser")).toBe(false);
});
it("applies tool profiles before allow/deny policies", () => {
const tools = createClawdbotCodingTools({
config: { tools: { profile: "messaging" } },
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("message")).toBe(true);
expect(names.has("sessions_send")).toBe(true);
expect(names.has("sessions_spawn")).toBe(false);
expect(names.has("exec")).toBe(false);
expect(names.has("browser")).toBe(false);
});
it("expands group shorthands in global tool policy", () => {
const tools = createClawdbotCodingTools({
config: { tools: { allow: ["group:fs"] } },
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("read")).toBe(true);
expect(names.has("write")).toBe(true);
expect(names.has("edit")).toBe(true);
expect(names.has("exec")).toBe(false);
expect(names.has("browser")).toBe(false);
});
it("expands group shorthands in global tool deny policy", () => {
const tools = createClawdbotCodingTools({
config: { tools: { deny: ["group:fs"] } },
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("read")).toBe(false);
expect(names.has("write")).toBe(false);
expect(names.has("edit")).toBe(false);
expect(names.has("exec")).toBe(true);
});
it("lets agent profiles override global profiles", () => {
const tools = createClawdbotCodingTools({
sessionKey: "agent:work:main",
config: {
tools: { profile: "coding" },
agents: {
list: [{ id: "work", tools: { profile: "messaging" } }],
},
},
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("message")).toBe(true);
expect(names.has("exec")).toBe(false);
expect(names.has("read")).toBe(false);
});
it("removes unsupported JSON Schema keywords for Cloud Code Assist API compatibility", () => {
const tools = createClawdbotCodingTools();
+26 -23
View File
@@ -28,6 +28,11 @@ import type { SandboxContext, SandboxToolPolicy } from "./sandbox.js";
import { assertSandboxPath } from "./sandbox-paths.js";
import { cleanSchemaForGemini } from "./schema/clean-for-gemini.js";
import { sanitizeToolResultImages } from "./tool-images.js";
import {
expandToolGroups,
normalizeToolName,
resolveToolProfilePolicy,
} from "./tool-policy.js";
// NOTE(steipete): Upstream read now does file-magic MIME detection; we keep the wrapper
// to normalize payloads and sanitize oversized images before they hit providers.
@@ -291,21 +296,6 @@ function cleanToolSchemaForGemini(schema: Record<string, unknown>): unknown {
return cleanSchemaForGemini(schema);
}
const TOOL_NAME_ALIASES: Record<string, string> = {
bash: "exec",
"apply-patch": "apply_patch",
};
function normalizeToolName(name: string) {
const normalized = name.trim().toLowerCase();
return TOOL_NAME_ALIASES[normalized] ?? normalized;
}
function normalizeToolNames(list?: string[]) {
if (!list) return [];
return list.map(normalizeToolName).filter(Boolean);
}
function isOpenAIProvider(provider?: string) {
const normalized = provider?.trim().toLowerCase();
return normalized === "openai" || normalized === "openai-codex";
@@ -357,8 +347,8 @@ function isToolAllowedByPolicyName(
policy?: SandboxToolPolicy,
): boolean {
if (!policy) return true;
const deny = new Set(normalizeToolNames(policy.deny));
const allowRaw = normalizeToolNames(policy.allow);
const deny = new Set(expandToolGroups(policy.deny));
const allowRaw = expandToolGroups(policy.allow);
const allow = allowRaw.length > 0 ? new Set(allowRaw) : null;
const normalized = normalizeToolName(name);
if (deny.has(normalized)) return false;
@@ -391,11 +381,15 @@ function resolveEffectiveToolPolicy(params: {
: undefined;
const agentTools = agentConfig?.tools;
const hasAgentToolPolicy =
Array.isArray(agentTools?.allow) || Array.isArray(agentTools?.deny);
Array.isArray(agentTools?.allow) ||
Array.isArray(agentTools?.deny) ||
typeof agentTools?.profile === "string";
const globalTools = params.config?.tools;
const profile = agentTools?.profile ?? globalTools?.profile;
return {
agentId,
policy: hasAgentToolPolicy ? agentTools : globalTools,
profile,
};
}
@@ -703,10 +697,15 @@ export function createClawdbotCodingTools(options?: {
}): AnyAgentTool[] {
const execToolName = "exec";
const sandbox = options?.sandbox?.enabled ? options.sandbox : undefined;
const { agentId, policy: effectiveToolsPolicy } = resolveEffectiveToolPolicy({
const {
agentId,
policy: effectiveToolsPolicy,
profile,
} = resolveEffectiveToolPolicy({
config: options?.config,
sessionKey: options?.sessionKey,
});
const profilePolicy = resolveToolProfilePolicy(profile);
const scopeKey =
options?.exec?.scopeKey ?? (agentId ? `agent:${agentId}` : undefined);
const subagentPolicy =
@@ -714,6 +713,7 @@ export function createClawdbotCodingTools(options?: {
? resolveSubagentToolPolicy(options.config)
: undefined;
const allowBackground = isToolAllowedByPolicies("process", [
profilePolicy,
effectiveToolsPolicy,
sandbox?.tools,
subagentPolicy,
@@ -829,12 +829,15 @@ export function createClawdbotCodingTools(options?: {
hasRepliedRef: options?.hasRepliedRef,
}),
];
const toolsFiltered = effectiveToolsPolicy
? filterToolsByPolicy(tools, effectiveToolsPolicy)
const toolsFiltered = profilePolicy
? filterToolsByPolicy(tools, profilePolicy)
: tools;
const sandboxed = sandbox
? filterToolsByPolicy(toolsFiltered, sandbox.tools)
const policyFiltered = effectiveToolsPolicy
? filterToolsByPolicy(toolsFiltered, effectiveToolsPolicy)
: toolsFiltered;
const sandboxed = sandbox
? filterToolsByPolicy(policyFiltered, sandbox.tools)
: policyFiltered;
const subagentFiltered = subagentPolicy
? filterToolsByPolicy(sandboxed, subagentPolicy)
: sandboxed;
+5 -52
View File
@@ -33,6 +33,7 @@ import {
resolveSessionAgentId,
} from "./agent-scope.js";
import { syncSkillsToWorkspace } from "./skills.js";
import { expandToolGroups } from "./tool-policy.js";
import {
DEFAULT_AGENT_WORKSPACE_DIR,
DEFAULT_AGENTS_FILENAME,
@@ -239,58 +240,10 @@ const BROWSER_BRIDGES = new Map<
{ bridge: BrowserBridge; containerName: string }
>();
function normalizeToolList(values?: string[]) {
if (!values) return [];
return values
.map((value) => value.trim())
.filter(Boolean)
.map((value) => value.toLowerCase());
}
const TOOL_GROUPS: Record<string, string[]> = {
// NOTE: Keep canonical (lowercase) tool names here.
"group:memory": ["memory_search", "memory_get"],
// Basic workspace/file tools
"group:fs": ["read", "write", "edit", "apply_patch"],
// Session management tools
"group:sessions": [
"sessions_list",
"sessions_history",
"sessions_send",
"sessions_spawn",
"session_status",
],
// Host/runtime execution tools
"group:runtime": ["exec", "bash", "process"],
};
function expandToolGroupEntry(entry: string): string[] {
const raw = entry.trim();
if (!raw) return [];
const lower = raw.toLowerCase();
const group = TOOL_GROUPS[lower];
if (group) return group;
return [raw];
}
function expandToolGroups(values?: string[]): string[] {
if (!values) return [];
const out: string[] = [];
for (const value of values) {
for (const expanded of expandToolGroupEntry(value)) {
const trimmed = expanded.trim();
if (!trimmed) continue;
out.push(trimmed);
}
}
return out;
}
function isToolAllowed(policy: SandboxToolPolicy, name: string) {
const deny = new Set(normalizeToolList(expandToolGroups(policy.deny)));
const deny = new Set(expandToolGroups(policy.deny));
if (deny.has(name.toLowerCase())) return false;
const allow = normalizeToolList(expandToolGroups(policy.allow));
const allow = expandToolGroups(policy.allow);
if (allow.length === 0) return true;
return allow.includes(name.toLowerCase());
}
@@ -687,8 +640,8 @@ export function formatSandboxToolPolicyBlockedMessage(params: {
});
if (!runtime.sandboxed) return undefined;
const deny = new Set(normalizeToolList(runtime.toolPolicy.deny));
const allow = normalizeToolList(runtime.toolPolicy.allow);
const deny = new Set(expandToolGroups(runtime.toolPolicy.deny));
const allow = expandToolGroups(runtime.toolPolicy.allow);
const allowSet = allow.length > 0 ? new Set(allow) : null;
const blockedByDeny = deny.has(tool);
const blockedByAllow = allowSet ? !allowSet.has(tool) : false;
+27
View File
@@ -0,0 +1,27 @@
import { Type } from "@sinclair/typebox";
type StringEnumOptions<T extends readonly string[]> = {
description?: string;
title?: string;
default?: T[number];
};
// NOTE: Avoid Type.Union([Type.Literal(...)]) which compiles to anyOf.
// Some providers reject anyOf in tool schemas; a flat string enum is safer.
export function stringEnum<T extends readonly string[]>(
values: T,
options: StringEnumOptions<T> = {},
) {
return Type.Unsafe<T[number]>({
type: "string",
enum: [...values],
...options,
});
}
export function optionalStringEnum<T extends readonly string[]>(
values: T,
options: StringEnumOptions<T> = {},
) {
return Type.Optional(stringEnum(values, options));
}
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import {
expandToolGroups,
resolveToolProfilePolicy,
TOOL_GROUPS,
} from "./tool-policy.js";
describe("tool-policy", () => {
it("expands groups and normalizes aliases", () => {
const expanded = expandToolGroups([
"group:runtime",
"BASH",
"apply-patch",
"group:fs",
]);
const set = new Set(expanded);
expect(set.has("exec")).toBe(true);
expect(set.has("bash")).toBe(true);
expect(set.has("process")).toBe(true);
expect(set.has("apply_patch")).toBe(true);
expect(set.has("read")).toBe(true);
expect(set.has("write")).toBe(true);
expect(set.has("edit")).toBe(true);
});
it("resolves known profiles and ignores unknown ones", () => {
const coding = resolveToolProfilePolicy("coding");
expect(coding?.allow).toContain("group:fs");
expect(resolveToolProfilePolicy("nope")).toBeUndefined();
});
it("includes core tool groups in group:clawdbot", () => {
const group = TOOL_GROUPS["group:clawdbot"];
expect(group).toContain("browser");
expect(group).toContain("message");
expect(group).toContain("session_status");
});
});
+116
View File
@@ -0,0 +1,116 @@
export type ToolProfileId = "minimal" | "coding" | "messaging" | "full";
type ToolProfilePolicy = {
allow?: string[];
deny?: string[];
};
const TOOL_NAME_ALIASES: Record<string, string> = {
bash: "exec",
"apply-patch": "apply_patch",
};
export const TOOL_GROUPS: Record<string, string[]> = {
// NOTE: Keep canonical (lowercase) tool names here.
"group:memory": ["memory_search", "memory_get"],
// Basic workspace/file tools
"group:fs": ["read", "write", "edit", "apply_patch"],
// Host/runtime execution tools
"group:runtime": ["exec", "bash", "process"],
// Session management tools
"group:sessions": [
"sessions_list",
"sessions_history",
"sessions_send",
"sessions_spawn",
"session_status",
],
// UI helpers
"group:ui": ["browser", "canvas"],
// Automation + infra
"group:automation": ["cron", "gateway"],
// Messaging surface
"group:messaging": ["message"],
// Nodes + device tools
"group:nodes": ["nodes"],
// All Clawdbot native tools (excludes provider plugins).
"group:clawdbot": [
"browser",
"canvas",
"nodes",
"cron",
"message",
"gateway",
"agents_list",
"sessions_list",
"sessions_history",
"sessions_send",
"sessions_spawn",
"session_status",
"memory_search",
"memory_get",
"image",
],
};
const TOOL_PROFILES: Record<ToolProfileId, ToolProfilePolicy> = {
minimal: {
allow: ["session_status"],
},
coding: {
allow: [
"group:fs",
"group:runtime",
"group:sessions",
"group:memory",
"image",
],
},
messaging: {
allow: [
"group:messaging",
"sessions_list",
"sessions_history",
"sessions_send",
"session_status",
],
},
full: {},
};
export function normalizeToolName(name: string) {
const normalized = name.trim().toLowerCase();
return TOOL_NAME_ALIASES[normalized] ?? normalized;
}
export function normalizeToolList(list?: string[]) {
if (!list) return [];
return list.map(normalizeToolName).filter(Boolean);
}
export function expandToolGroups(list?: string[]) {
const normalized = normalizeToolList(list);
const expanded: string[] = [];
for (const value of normalized) {
const group = TOOL_GROUPS[value];
if (group) {
expanded.push(...group);
continue;
}
expanded.push(value);
}
return Array.from(new Set(expanded));
}
export function resolveToolProfilePolicy(
profile?: string,
): ToolProfilePolicy | undefined {
if (!profile) return undefined;
const resolved = TOOL_PROFILES[profile as ToolProfileId];
if (!resolved) return undefined;
if (!resolved.allow && !resolved.deny) return undefined;
return {
allow: resolved.allow ? [...resolved.allow] : undefined,
deny: resolved.deny ? [...resolved.deny] : undefined,
};
}
+29 -31
View File
@@ -22,6 +22,7 @@ import {
import { resolveBrowserConfig } from "../../browser/config.js";
import { DEFAULT_AI_SNAPSHOT_MAX_CHARS } from "../../browser/constants.js";
import { loadConfig } from "../../config/config.js";
import { optionalStringEnum, stringEnum } from "../schema/typebox.js";
import {
type AnyAgentTool,
imageResultFromFile,
@@ -43,16 +44,35 @@ const BROWSER_ACT_KINDS = [
"close",
] as const;
type BrowserActKind = (typeof BROWSER_ACT_KINDS)[number];
const BROWSER_TOOL_ACTIONS = [
"status",
"start",
"stop",
"tabs",
"open",
"focus",
"close",
"snapshot",
"screenshot",
"navigate",
"console",
"pdf",
"upload",
"dialog",
"act",
] as const;
const BROWSER_TARGETS = ["sandbox", "host", "custom"] as const;
const BROWSER_SNAPSHOT_FORMATS = ["aria", "ai"] as const;
const BROWSER_IMAGE_TYPES = ["png", "jpeg"] as const;
// NOTE: Using a flattened object schema instead of Type.Union([Type.Object(...), ...])
// because Claude API on Vertex AI rejects nested anyOf schemas as invalid JSON Schema.
// The discriminator (kind) determines which properties are relevant; runtime validates.
const BrowserActSchema = Type.Object({
kind: Type.Unsafe<BrowserActKind>({
type: "string",
enum: [...BROWSER_ACT_KINDS],
}),
kind: stringEnum(BROWSER_ACT_KINDS),
// Common fields
targetId: Type.Optional(Type.String()),
ref: Type.Optional(Type.String()),
@@ -89,37 +109,15 @@ const BrowserActSchema = Type.Object({
// A root-level `Type.Union([...])` compiles to `{ anyOf: [...] }` (no `type`),
// which OpenAI rejects ("Invalid schema ... type: None"). Keep this schema an object.
const BrowserToolSchema = Type.Object({
action: Type.Union([
Type.Literal("status"),
Type.Literal("start"),
Type.Literal("stop"),
Type.Literal("tabs"),
Type.Literal("open"),
Type.Literal("focus"),
Type.Literal("close"),
Type.Literal("snapshot"),
Type.Literal("screenshot"),
Type.Literal("navigate"),
Type.Literal("console"),
Type.Literal("pdf"),
Type.Literal("upload"),
Type.Literal("dialog"),
Type.Literal("act"),
]),
target: Type.Optional(
Type.Union([
Type.Literal("sandbox"),
Type.Literal("host"),
Type.Literal("custom"),
]),
),
action: stringEnum(BROWSER_TOOL_ACTIONS),
target: optionalStringEnum(BROWSER_TARGETS),
profile: Type.Optional(Type.String()),
controlUrl: Type.Optional(Type.String()),
targetUrl: Type.Optional(Type.String()),
targetId: Type.Optional(Type.String()),
limit: Type.Optional(Type.Number()),
maxChars: Type.Optional(Type.Number()),
format: Type.Optional(Type.Union([Type.Literal("aria"), Type.Literal("ai")])),
format: optionalStringEnum(BROWSER_SNAPSHOT_FORMATS),
interactive: Type.Optional(Type.Boolean()),
compact: Type.Optional(Type.Boolean()),
depth: Type.Optional(Type.Number()),
@@ -128,7 +126,7 @@ const BrowserToolSchema = Type.Object({
fullPage: Type.Optional(Type.Boolean()),
ref: Type.Optional(Type.String()),
element: Type.Optional(Type.String()),
type: Type.Optional(Type.Union([Type.Literal("png"), Type.Literal("jpeg")])),
type: optionalStringEnum(BROWSER_IMAGE_TYPES),
level: Type.Optional(Type.String()),
paths: Type.Optional(Type.Array(Type.String())),
inputRef: Type.Optional(Type.String()),
+39 -70
View File
@@ -8,6 +8,7 @@ import {
parseCanvasSnapshotPayload,
} from "../../cli/nodes-canvas.js";
import { imageMimeFromFormat } from "../../media/mime.js";
import { optionalStringEnum, stringEnum } from "../schema/typebox.js";
import {
type AnyAgentTool,
imageResult,
@@ -17,76 +18,44 @@ import {
import { callGatewayTool, type GatewayCallOptions } from "./gateway.js";
import { resolveNodeId } from "./nodes-utils.js";
const CanvasToolSchema = Type.Union([
Type.Object({
action: Type.Literal("present"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.Optional(Type.String()),
target: Type.Optional(Type.String()),
x: Type.Optional(Type.Number()),
y: Type.Optional(Type.Number()),
width: Type.Optional(Type.Number()),
height: Type.Optional(Type.Number()),
}),
Type.Object({
action: Type.Literal("hide"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("navigate"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.Optional(Type.String()),
url: Type.String(),
}),
Type.Object({
action: Type.Literal("eval"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.Optional(Type.String()),
javaScript: Type.String(),
}),
Type.Object({
action: Type.Literal("snapshot"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.Optional(Type.String()),
format: Type.Optional(
Type.Union([
Type.Literal("png"),
Type.Literal("jpg"),
Type.Literal("jpeg"),
]),
),
maxWidth: Type.Optional(Type.Number()),
quality: Type.Optional(Type.Number()),
delayMs: Type.Optional(Type.Number()),
}),
Type.Object({
action: Type.Literal("a2ui_push"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.Optional(Type.String()),
jsonl: Type.Optional(Type.String()),
jsonlPath: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("a2ui_reset"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.Optional(Type.String()),
}),
]);
const CANVAS_ACTIONS = [
"present",
"hide",
"navigate",
"eval",
"snapshot",
"a2ui_push",
"a2ui_reset",
] as const;
const CANVAS_SNAPSHOT_FORMATS = ["png", "jpg", "jpeg"] as const;
// Flattened schema: runtime validates per-action requirements.
const CanvasToolSchema = Type.Object({
action: stringEnum(CANVAS_ACTIONS),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.Optional(Type.String()),
// present
target: Type.Optional(Type.String()),
x: Type.Optional(Type.Number()),
y: Type.Optional(Type.Number()),
width: Type.Optional(Type.Number()),
height: Type.Optional(Type.Number()),
// navigate
url: Type.Optional(Type.String()),
// eval
javaScript: Type.Optional(Type.String()),
// snapshot
format: optionalStringEnum(CANVAS_SNAPSHOT_FORMATS),
maxWidth: Type.Optional(Type.Number()),
quality: Type.Optional(Type.Number()),
delayMs: Type.Optional(Type.Number()),
// a2ui_push
jsonl: Type.Optional(Type.String()),
jsonlPath: Type.Optional(Type.String()),
});
export function createCanvasTool(): AnyAgentTool {
return {
+31 -78
View File
@@ -3,89 +3,42 @@ import {
normalizeCronJobCreate,
normalizeCronJobPatch,
} from "../../cron/normalize.js";
import { optionalStringEnum, stringEnum } from "../schema/typebox.js";
import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js";
import { callGatewayTool, type GatewayCallOptions } from "./gateway.js";
// 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.
// instead of CronAddParamsSchema/CronJobPatchSchema because the gateway schemas
// contain nested unions. Tool schemas need to stay provider-friendly, so we
// accept "any object" here and validate at runtime.
const CronToolSchema = Type.Union([
Type.Object({
action: Type.Literal("status"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
}),
Type.Object({
action: Type.Literal("list"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
includeDisabled: Type.Optional(Type.Boolean()),
}),
Type.Object({
action: Type.Literal("add"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
job: Type.Object({}, { additionalProperties: true }),
}),
Type.Object({
action: Type.Literal("update"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
jobId: Type.Optional(Type.String()),
id: Type.Optional(Type.String()),
patch: Type.Object({}, { additionalProperties: true }),
}),
Type.Object({
action: Type.Literal("remove"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
jobId: Type.Optional(Type.String()),
id: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("run"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
jobId: Type.Optional(Type.String()),
id: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("runs"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
jobId: Type.Optional(Type.String()),
id: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("wake"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
text: Type.String(),
mode: Type.Optional(
Type.Union([Type.Literal("now"), Type.Literal("next-heartbeat")]),
),
}),
]);
const CRON_ACTIONS = [
"status",
"list",
"add",
"update",
"remove",
"run",
"runs",
"wake",
] as const;
const CRON_WAKE_MODES = ["now", "next-heartbeat"] as const;
// Flattened schema: runtime validates per-action requirements.
const CronToolSchema = Type.Object({
action: stringEnum(CRON_ACTIONS),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
includeDisabled: Type.Optional(Type.Boolean()),
job: Type.Optional(Type.Object({}, { additionalProperties: true })),
jobId: Type.Optional(Type.String()),
id: Type.Optional(Type.String()),
patch: Type.Optional(Type.Object({}, { additionalProperties: true })),
text: Type.Optional(Type.String()),
mode: optionalStringEnum(CRON_WAKE_MODES),
});
export function createCronTool(): AnyAgentTool {
return {
@@ -31,6 +31,7 @@ import {
function readParentIdParam(
params: Record<string, unknown>,
): string | null | undefined {
if (params.clearParent === true) return null;
if (params.parentId === null) return null;
return readStringParam(params, "parentId");
}
+38
View File
@@ -218,6 +218,26 @@ describe("handleDiscordGuildAction - channel management", () => {
});
});
it("clears the channel parent when clearParent is true", async () => {
await handleDiscordGuildAction(
"channelEdit",
{
channelId: "C1",
clearParent: true,
},
channelsEnabled,
);
expect(editChannelDiscord).toHaveBeenCalledWith({
channelId: "C1",
name: undefined,
topic: undefined,
position: undefined,
parentId: null,
nsfw: undefined,
rateLimitPerUser: undefined,
});
});
it("deletes a channel", async () => {
await handleDiscordGuildAction(
"channelDelete",
@@ -264,6 +284,24 @@ describe("handleDiscordGuildAction - channel management", () => {
});
});
it("clears the channel parent on move when clearParent is true", async () => {
await handleDiscordGuildAction(
"channelMove",
{
guildId: "G1",
channelId: "C1",
clearParent: true,
},
channelsEnabled,
);
expect(moveChannelDiscord).toHaveBeenCalledWith({
guildId: "G1",
channelId: "C1",
parentId: null,
position: undefined,
});
});
it("creates a category with type=4", async () => {
await handleDiscordGuildAction(
"categoryCreate",
+2 -6
View File
@@ -7,6 +7,7 @@ import {
type RestartSentinelPayload,
writeRestartSentinel,
} from "../../infra/restart-sentinel.js";
import { stringEnum } from "../schema/typebox.js";
import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js";
import { callGatewayTool } from "./gateway.js";
@@ -18,16 +19,11 @@ const GATEWAY_ACTIONS = [
"update.run",
] as const;
type GatewayAction = (typeof GATEWAY_ACTIONS)[number];
// NOTE: Using a flattened object schema instead of Type.Union([Type.Object(...), ...])
// because Claude API on Vertex AI rejects nested anyOf schemas as invalid JSON Schema.
// The discriminator (action) determines which properties are relevant; runtime validates.
const GatewayToolSchema = Type.Object({
action: Type.Unsafe<GatewayAction>({
type: "string",
enum: [...GATEWAY_ACTIONS],
}),
action: stringEnum(GATEWAY_ACTIONS),
// restart
delayMs: Type.Optional(Type.Number()),
reason: Type.Optional(Type.String()),
+11 -26
View File
@@ -16,6 +16,7 @@ import {
type ProviderMessageActionName,
} from "../../providers/plugins/types.js";
import { normalizeAccountId } from "../../routing/session-key.js";
import { stringEnum } from "../schema/typebox.js";
import type { AnyAgentTool } from "./common.js";
import { jsonResult, readNumberParam, readStringParam } from "./common.js";
@@ -90,12 +91,17 @@ const MessageToolCommonSchema = {
timeoutMs: Type.Optional(Type.Number()),
name: Type.Optional(Type.String()),
type: Type.Optional(Type.Number()),
parentId: Type.Optional(Type.Union([Type.String(), Type.Null()])),
parentId: Type.Optional(Type.String()),
topic: Type.Optional(Type.String()),
position: Type.Optional(Type.Number()),
nsfw: Type.Optional(Type.Boolean()),
rateLimitPerUser: Type.Optional(Type.Number()),
categoryId: Type.Optional(Type.String()),
clearParent: Type.Optional(
Type.Boolean({
description: "Clear the parent/category when supported by the provider.",
}),
),
};
function buildMessageToolSchemaFromActions(
@@ -105,31 +111,10 @@ function buildMessageToolSchemaFromActions(
const props: Record<string, unknown> = { ...MessageToolCommonSchema };
if (!options.includeButtons) delete props.buttons;
const schemas: Array<ReturnType<typeof Type.Object>> = [];
if (actions.includes("send")) {
schemas.push(
Type.Object({
action: Type.Literal("send"),
to: Type.String(),
message: Type.String(),
...props,
}),
);
}
const nonSendActions = actions.filter((action) => action !== "send");
if (nonSendActions.length > 0) {
schemas.push(
Type.Object({
action: Type.Union(
nonSendActions.map((action) => Type.Literal(action)),
),
...props,
}),
);
}
return schemas.length === 1 ? schemas[0] : Type.Union(schemas);
return Type.Object({
action: stringEnum(actions),
...props,
});
}
const MessageToolSchema = buildMessageToolSchemaFromActions(AllMessageActions, {
+61 -139
View File
@@ -18,151 +18,73 @@ import {
} from "../../cli/nodes-screen.js";
import { parseDurationMs } from "../../cli/parse-duration.js";
import { imageMimeFromFormat } from "../../media/mime.js";
import { optionalStringEnum, stringEnum } from "../schema/typebox.js";
import { sanitizeToolResultImages } from "../tool-images.js";
import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js";
import { callGatewayTool, type GatewayCallOptions } from "./gateway.js";
import { resolveNodeId } from "./nodes-utils.js";
const NodesToolSchema = Type.Union([
Type.Object({
action: Type.Literal("status"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
const NODES_TOOL_ACTIONS = [
"status",
"describe",
"pending",
"approve",
"reject",
"notify",
"camera_snap",
"camera_list",
"camera_clip",
"screen_record",
"location_get",
"run",
] as const;
const NOTIFY_PRIORITIES = ["passive", "active", "timeSensitive"] as const;
const NOTIFY_DELIVERIES = ["system", "overlay", "auto"] as const;
const CAMERA_FACING = ["front", "back", "both"] as const;
const LOCATION_ACCURACY = ["coarse", "balanced", "precise"] as const;
// Flattened schema: runtime validates per-action requirements.
const NodesToolSchema = Type.Object({
action: stringEnum(NODES_TOOL_ACTIONS),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.Optional(Type.String()),
requestId: Type.Optional(Type.String()),
// notify
title: Type.Optional(Type.String()),
body: Type.Optional(Type.String()),
sound: Type.Optional(Type.String()),
priority: optionalStringEnum(NOTIFY_PRIORITIES),
delivery: optionalStringEnum(NOTIFY_DELIVERIES),
// camera_snap / camera_clip
facing: optionalStringEnum(CAMERA_FACING, {
description: "camera_snap: front/back/both; camera_clip: front/back only.",
}),
Type.Object({
action: Type.Literal("describe"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.String(),
}),
Type.Object({
action: Type.Literal("pending"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
}),
Type.Object({
action: Type.Literal("approve"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
requestId: Type.String(),
}),
Type.Object({
action: Type.Literal("reject"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
requestId: Type.String(),
}),
Type.Object({
action: Type.Literal("notify"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.String(),
title: Type.Optional(Type.String()),
body: Type.Optional(Type.String()),
sound: Type.Optional(Type.String()),
priority: Type.Optional(
Type.Union([
Type.Literal("passive"),
Type.Literal("active"),
Type.Literal("timeSensitive"),
]),
),
delivery: Type.Optional(
Type.Union([
Type.Literal("system"),
Type.Literal("overlay"),
Type.Literal("auto"),
]),
),
}),
Type.Object({
action: Type.Literal("camera_snap"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.String(),
facing: Type.Optional(
Type.Union([
Type.Literal("front"),
Type.Literal("back"),
Type.Literal("both"),
]),
),
maxWidth: Type.Optional(Type.Number()),
quality: Type.Optional(Type.Number()),
delayMs: Type.Optional(Type.Number()),
deviceId: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("camera_list"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.String(),
}),
Type.Object({
action: Type.Literal("camera_clip"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.String(),
facing: Type.Optional(
Type.Union([Type.Literal("front"), Type.Literal("back")]),
),
duration: Type.Optional(Type.String()),
durationMs: Type.Optional(Type.Number()),
includeAudio: Type.Optional(Type.Boolean()),
deviceId: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("screen_record"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.String(),
duration: Type.Optional(Type.String()),
durationMs: Type.Optional(Type.Number()),
fps: Type.Optional(Type.Number()),
screenIndex: Type.Optional(Type.Number()),
includeAudio: Type.Optional(Type.Boolean()),
outPath: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("location_get"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.String(),
maxAgeMs: Type.Optional(Type.Number()),
locationTimeoutMs: Type.Optional(Type.Number()),
desiredAccuracy: Type.Optional(
Type.Union([
Type.Literal("coarse"),
Type.Literal("balanced"),
Type.Literal("precise"),
]),
),
}),
Type.Object({
action: Type.Literal("run"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.String(),
command: Type.Array(Type.String()),
cwd: Type.Optional(Type.String()),
env: Type.Optional(Type.Array(Type.String())),
commandTimeoutMs: Type.Optional(Type.Number()),
invokeTimeoutMs: Type.Optional(Type.Number()),
needsScreenRecording: Type.Optional(Type.Boolean()),
}),
]);
maxWidth: Type.Optional(Type.Number()),
quality: Type.Optional(Type.Number()),
delayMs: Type.Optional(Type.Number()),
deviceId: Type.Optional(Type.String()),
duration: Type.Optional(Type.String()),
durationMs: Type.Optional(Type.Number()),
includeAudio: Type.Optional(Type.Boolean()),
// screen_record
fps: Type.Optional(Type.Number()),
screenIndex: Type.Optional(Type.Number()),
outPath: Type.Optional(Type.String()),
// location_get
maxAgeMs: Type.Optional(Type.Number()),
locationTimeoutMs: Type.Optional(Type.Number()),
desiredAccuracy: optionalStringEnum(LOCATION_ACCURACY),
// run
command: Type.Optional(Type.Array(Type.String())),
cwd: Type.Optional(Type.String()),
env: Type.Optional(Type.Array(Type.String())),
commandTimeoutMs: Type.Optional(Type.Number()),
invokeTimeoutMs: Type.Optional(Type.Number()),
needsScreenRecording: Type.Optional(Type.Boolean()),
});
export function createNodesTool(): AnyAgentTool {
return {
+2 -3
View File
@@ -12,6 +12,7 @@ import {
import type { GatewayMessageProvider } from "../../utils/message-provider.js";
import { resolveAgentConfig } from "../agent-scope.js";
import { AGENT_LANE_SUBAGENT } from "../lanes.js";
import { optionalStringEnum } from "../schema/typebox.js";
import { buildSubagentSystemPrompt } from "../subagent-announce.js";
import { registerSubagentRun } from "../subagent-registry.js";
import type { AnyAgentTool } from "./common.js";
@@ -30,9 +31,7 @@ const SessionsSpawnToolSchema = Type.Object({
runTimeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })),
// Back-compat alias. Prefer runTimeoutSeconds.
timeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })),
cleanup: Type.Optional(
Type.Union([Type.Literal("delete"), Type.Literal("keep")]),
),
cleanup: optionalStringEnum(["delete", "keep"] as const),
});
function normalizeModelSelection(value: unknown): string | undefined {
+91
View File
@@ -69,6 +69,97 @@ describe("directive behavior", () => {
vi.restoreAllMocks();
});
it("accepts /thinking xhigh for codex models", async () => {
await withTempHome(async (home) => {
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
{
Body: "/thinking xhigh",
From: "+1004",
To: "+2000",
},
{},
{
agents: {
defaults: {
model: "openai-codex/gpt-5.2-codex",
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
session: { store: storePath },
},
);
const texts = (Array.isArray(res) ? res : [res])
.map((entry) => entry?.text)
.filter(Boolean);
expect(texts).toContain("Thinking level set to xhigh.");
});
});
it("accepts /thinking xhigh for openai gpt-5.2", async () => {
await withTempHome(async (home) => {
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
{
Body: "/thinking xhigh",
From: "+1004",
To: "+2000",
},
{},
{
agents: {
defaults: {
model: "openai/gpt-5.2",
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
session: { store: storePath },
},
);
const texts = (Array.isArray(res) ? res : [res])
.map((entry) => entry?.text)
.filter(Boolean);
expect(texts).toContain("Thinking level set to xhigh.");
});
});
it("rejects /thinking xhigh for non-codex models", async () => {
await withTempHome(async (home) => {
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
{
Body: "/thinking xhigh",
From: "+1004",
To: "+2000",
},
{},
{
agents: {
defaults: {
model: "openai/gpt-4.1-mini",
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
session: { store: storePath },
},
);
const texts = (Array.isArray(res) ? res : [res])
.map((entry) => entry?.text)
.filter(Boolean);
expect(texts).toContain(
'Thinking level "xhigh" is only supported for openai/gpt-5.2, openai-codex/gpt-5.2-codex or openai-codex/gpt-5.1-codex.',
);
});
});
it("keeps reserved command aliases from matching after trimming", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
+1 -1
View File
@@ -1456,7 +1456,7 @@ describe("trigger handling", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe(
"⚠️ Context overflow - conversation too long. Starting fresh might help!",
"⚠️ Context overflow — prompt too large for this model. Try a shorter message or a larger-context model.",
);
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
});
+37 -2
View File
@@ -29,7 +29,10 @@ import {
type ClawdbotConfig,
loadConfig,
} from "../config/config.js";
import { resolveSessionFilePath } from "../config/sessions.js";
import {
resolveSessionFilePath,
saveSessionStore,
} from "../config/sessions.js";
import { logVerbose } from "../globals.js";
import { clearCommandLane, getQueueSize } from "../process/command-queue.js";
import { getProviderDock } from "../providers/dock.js";
@@ -94,8 +97,10 @@ import {
import type { MsgContext, TemplateContext } from "./templating.js";
import {
type ElevatedLevel,
formatXHighModelHint,
normalizeThinkLevel,
type ReasoningLevel,
supportsXHighThinking,
type ThinkLevel,
type VerboseLevel,
} from "./thinking.js";
@@ -1197,7 +1202,10 @@ export async function getReplyFromConfig(
if (!resolvedThinkLevel && prefixedCommandBody) {
const parts = prefixedCommandBody.split(/\s+/);
const maybeLevel = normalizeThinkLevel(parts[0]);
if (maybeLevel) {
if (
maybeLevel &&
(maybeLevel !== "xhigh" || supportsXHighThinking(provider, model))
) {
resolvedThinkLevel = maybeLevel;
prefixedCommandBody = parts.slice(1).join(" ").trim();
}
@@ -1205,6 +1213,33 @@ export async function getReplyFromConfig(
if (!resolvedThinkLevel) {
resolvedThinkLevel = await modelState.resolveDefaultThinkingLevel();
}
if (
resolvedThinkLevel === "xhigh" &&
!supportsXHighThinking(provider, model)
) {
const explicitThink =
directives.hasThinkDirective && directives.thinkLevel !== undefined;
if (explicitThink) {
typing.cleanup();
return {
text: `Thinking level "xhigh" is only supported for ${formatXHighModelHint()}. Use /think high or switch to one of those models.`,
};
}
resolvedThinkLevel = "high";
if (
sessionEntry &&
sessionStore &&
sessionKey &&
sessionEntry.thinkingLevel === "xhigh"
) {
sessionEntry.thinkingLevel = "high";
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
await saveSessionStore(storePath, sessionStore);
}
}
}
const sessionIdFinal = sessionId ?? crypto.randomUUID();
const sessionFile = resolveSessionFilePath(sessionIdFinal, sessionEntry);
const queueBodyBase = transcribedText
@@ -525,6 +525,65 @@ describe("runReplyAgent typing (heartbeat)", () => {
}
});
it("retries after context overflow payload by resetting the session", async () => {
const prevStateDir = process.env.CLAWDBOT_STATE_DIR;
const stateDir = await fs.mkdtemp(
path.join(tmpdir(), "clawdbot-session-overflow-reset-"),
);
process.env.CLAWDBOT_STATE_DIR = stateDir;
try {
const sessionId = "session";
const storePath = path.join(stateDir, "sessions", "sessions.json");
const sessionEntry = { sessionId, updatedAt: Date.now() };
const sessionStore = { main: sessionEntry };
await fs.mkdir(path.dirname(storePath), { recursive: true });
await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8");
runEmbeddedPiAgentMock
.mockImplementationOnce(async () => ({
payloads: [
{ text: "Context overflow: prompt too large", isError: true },
],
meta: {
durationMs: 1,
error: {
kind: "context_overflow",
message:
'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}',
},
},
}))
.mockImplementationOnce(async () => ({
payloads: [{ text: "ok" }],
meta: { durationMs: 1 },
}));
const callsBefore = runEmbeddedPiAgentMock.mock.calls.length;
const { run } = createMinimalRun({
sessionEntry,
sessionStore,
sessionKey: "main",
storePath,
});
const res = await run();
expect(runEmbeddedPiAgentMock.mock.calls.length - callsBefore).toBe(2);
const payload = Array.isArray(res) ? res[0] : res;
expect(payload).toMatchObject({ text: "ok" });
expect(sessionStore.main.sessionId).not.toBe(sessionId);
const persisted = JSON.parse(await fs.readFile(storePath, "utf-8"));
expect(persisted.main.sessionId).toBe(sessionStore.main.sessionId);
} finally {
if (prevStateDir) {
process.env.CLAWDBOT_STATE_DIR = prevStateDir;
} else {
delete process.env.CLAWDBOT_STATE_DIR;
}
}
});
it("still replies even if session reset fails to persist", async () => {
const prevStateDir = process.env.CLAWDBOT_STATE_DIR;
const stateDir = await fs.mkdtemp(
+24 -1
View File
@@ -1,5 +1,6 @@
import crypto from "node:crypto";
import fs from "node:fs";
import { resolveAgentModelFallbacksOverride } from "../../agents/agent-scope.js";
import { runCliAgent } from "../../agents/cli-runner.js";
import { getCliSessionId, setCliSessionId } from "../../agents/cli-session.js";
import { lookupContextTokens } from "../../agents/context.js";
@@ -394,6 +395,10 @@ export async function runReplyAgent(params: {
cfg: followupRun.run.config,
provider: followupRun.run.provider,
model: followupRun.run.model,
fallbacksOverride: resolveAgentModelFallbacksOverride(
followupRun.run.config,
resolveAgentIdFromSessionKey(followupRun.run.sessionKey),
),
run: (provider, model) =>
runEmbeddedPiAgent({
sessionId: followupRun.run.sessionId,
@@ -586,6 +591,10 @@ export async function runReplyAgent(params: {
cfg: followupRun.run.config,
provider: followupRun.run.provider,
model: followupRun.run.model,
fallbacksOverride: resolveAgentModelFallbacksOverride(
followupRun.run.config,
resolveAgentIdFromSessionKey(followupRun.run.sessionKey),
),
run: (provider, model) => {
if (isCliProvider(provider, followupRun.run.config)) {
const startedAt = Date.now();
@@ -825,6 +834,20 @@ export async function runReplyAgent(params: {
runResult = fallbackResult.result;
fallbackProvider = fallbackResult.provider;
fallbackModel = fallbackResult.model;
// Some embedded runs surface context overflow as an error payload instead of throwing.
// Treat those as a session-level failure and auto-recover by starting a fresh session.
const embeddedError = runResult.meta?.error;
if (
embeddedError &&
isContextOverflowError(embeddedError.message) &&
!didResetAfterCompactionFailure &&
(await resetSessionAfterCompactionFailure(embeddedError.message))
) {
didResetAfterCompactionFailure = true;
continue;
}
break;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
@@ -885,7 +908,7 @@ export async function runReplyAgent(params: {
defaultRuntime.error(`Embedded agent failed before reply: ${message}`);
return finalizeWithFollowup({
text: isContextOverflow
? "⚠️ Context overflow - conversation too long. Starting fresh might help!"
? "⚠️ Context overflow — prompt too large for this model. Try a shorter message or a larger-context model."
: `⚠️ Agent failed before reply: ${message}. Check gateway logs for details.`,
});
}
+158 -122
View File
@@ -1,6 +1,6 @@
import {
resolveAgentConfig,
resolveAgentDir,
resolveAgentModelPrimary,
resolveDefaultAgentId,
resolveSessionAgentId,
} from "../../agents/agent-scope.js";
@@ -37,6 +37,11 @@ import { applyVerboseOverride } from "../../sessions/level-overrides.js";
import { shortenHomePath } from "../../utils.js";
import { extractModelDirective } from "../model.js";
import type { MsgContext } from "../templating.js";
import {
formatThinkingLevels,
formatXHighModelHint,
supportsXHighThinking,
} from "../thinking.js";
import type { ReplyPayload } from "../types.js";
import {
type ElevatedLevel,
@@ -778,6 +783,7 @@ export async function handleDirectiveOnly(params: {
allowedModelCatalog,
resetModelOverride,
provider,
model,
initialModelLabel,
formatModelSwitchEvent,
currentThinkLevel,
@@ -943,6 +949,117 @@ export async function handleDirectiveOnly(params: {
}
}
let modelSelection: ModelDirectiveSelection | undefined;
let profileOverride: string | undefined;
if (directives.hasModelDirective && directives.rawModelDirective) {
const raw = directives.rawModelDirective.trim();
if (/^[0-9]+$/.test(raw)) {
const resolvedDefault = resolveConfiguredModelRef({
cfg: params.cfg,
defaultProvider,
defaultModel,
});
const pickerCatalog: ModelPickerCatalogEntry[] = (() => {
const keys = new Set<string>();
const out: ModelPickerCatalogEntry[] = [];
const push = (entry: ModelPickerCatalogEntry) => {
const provider = normalizeProviderId(entry.provider);
const id = String(entry.id ?? "").trim();
if (!provider || !id) return;
const key = modelKey(provider, id);
if (keys.has(key)) return;
keys.add(key);
out.push({ provider, id, name: entry.name });
};
for (const entry of allowedModelCatalog) push(entry);
for (const rawKey of Object.keys(
params.cfg.agents?.defaults?.models ?? {},
)) {
const resolved = resolveModelRefFromString({
raw: String(rawKey),
defaultProvider,
aliasIndex,
});
if (!resolved) continue;
push({
provider: resolved.ref.provider,
id: resolved.ref.model,
name: resolved.ref.model,
});
}
if (resolvedDefault.model) {
push({
provider: resolvedDefault.provider,
id: resolvedDefault.model,
name: resolvedDefault.model,
});
}
return out;
})();
const items = buildModelPickerItems(pickerCatalog);
const index = Number.parseInt(raw, 10) - 1;
const item = Number.isFinite(index) ? items[index] : undefined;
if (!item) {
return {
text: `Invalid model selection "${raw}". Use /model to list.`,
};
}
const picked = pickProviderForModel({
item,
preferredProvider: params.provider,
});
if (!picked) {
return {
text: `Invalid model selection "${raw}". Use /model to list.`,
};
}
const key = `${picked.provider}/${picked.model}`;
const aliases = aliasIndex.byKey.get(key);
const alias = aliases && aliases.length > 0 ? aliases[0] : undefined;
modelSelection = {
provider: picked.provider,
model: picked.model,
isDefault:
picked.provider === defaultProvider && picked.model === defaultModel,
...(alias ? { alias } : {}),
};
} else {
const resolved = resolveModelDirectiveSelection({
raw,
defaultProvider,
defaultModel,
aliasIndex,
allowedModelKeys,
});
if (resolved.error) {
return { text: resolved.error };
}
modelSelection = resolved.selection;
}
if (modelSelection && directives.rawModelProfile) {
const profileResolved = resolveProfileOverride({
rawProfile: directives.rawModelProfile,
provider: modelSelection.provider,
cfg: params.cfg,
agentDir,
});
if (profileResolved.error) {
return { text: profileResolved.error };
}
profileOverride = profileResolved.profileId;
}
}
if (directives.rawModelProfile && !modelSelection) {
return { text: "Auth profile override requires a model selection." };
}
const resolvedProvider = modelSelection?.provider ?? provider;
const resolvedModel = modelSelection?.model ?? model;
if (directives.hasThinkDirective && !directives.thinkLevel) {
// If no argument was provided, show the current level
if (!directives.rawThinkLevel) {
@@ -950,12 +1067,12 @@ export async function handleDirectiveOnly(params: {
return {
text: withOptions(
`Current thinking level: ${level}.`,
"off, minimal, low, medium, high",
formatThinkingLevels(resolvedProvider, resolvedModel),
),
};
}
return {
text: `Unrecognized thinking level "${directives.rawThinkLevel}". Valid levels: off, minimal, low, medium, high.`,
text: `Unrecognized thinking level "${directives.rawThinkLevel}". Valid levels: ${formatThinkingLevels(resolvedProvider, resolvedModel)}.`,
};
}
if (directives.hasVerboseDirective && !directives.verboseLevel) {
@@ -1098,126 +1215,25 @@ export async function handleDirectiveOnly(params: {
return { text: errors.join(" ") };
}
let modelSelection: ModelDirectiveSelection | undefined;
let profileOverride: string | undefined;
if (directives.hasModelDirective && directives.rawModelDirective) {
const raw = directives.rawModelDirective.trim();
if (/^[0-9]+$/.test(raw)) {
const resolvedDefault = resolveConfiguredModelRef({
cfg: params.cfg,
defaultProvider,
defaultModel,
});
const pickerCatalog: ModelPickerCatalogEntry[] = (() => {
const keys = new Set<string>();
const out: ModelPickerCatalogEntry[] = [];
const push = (entry: ModelPickerCatalogEntry) => {
const provider = normalizeProviderId(entry.provider);
const id = String(entry.id ?? "").trim();
if (!provider || !id) return;
const key = modelKey(provider, id);
if (keys.has(key)) return;
keys.add(key);
out.push({ provider, id, name: entry.name });
};
for (const entry of allowedModelCatalog) push(entry);
for (const rawKey of Object.keys(
params.cfg.agents?.defaults?.models ?? {},
)) {
const resolved = resolveModelRefFromString({
raw: String(rawKey),
defaultProvider,
aliasIndex,
});
if (!resolved) continue;
push({
provider: resolved.ref.provider,
id: resolved.ref.model,
name: resolved.ref.model,
});
}
if (resolvedDefault.model) {
push({
provider: resolvedDefault.provider,
id: resolvedDefault.model,
name: resolvedDefault.model,
});
}
return out;
})();
const items = buildModelPickerItems(pickerCatalog);
const index = Number.parseInt(raw, 10) - 1;
const item = Number.isFinite(index) ? items[index] : undefined;
if (!item) {
return {
text: `Invalid model selection "${raw}". Use /model to list.`,
};
}
const picked = pickProviderForModel({
item,
preferredProvider: params.provider,
});
if (!picked) {
return {
text: `Invalid model selection "${raw}". Use /model to list.`,
};
}
const key = `${picked.provider}/${picked.model}`;
const aliases = aliasIndex.byKey.get(key);
const alias = aliases && aliases.length > 0 ? aliases[0] : undefined;
modelSelection = {
provider: picked.provider,
model: picked.model,
isDefault:
picked.provider === defaultProvider && picked.model === defaultModel,
...(alias ? { alias } : {}),
};
} else {
const resolved = resolveModelDirectiveSelection({
raw,
defaultProvider,
defaultModel,
aliasIndex,
allowedModelKeys,
});
if (resolved.error) {
return { text: resolved.error };
}
modelSelection = resolved.selection;
}
if (modelSelection) {
if (directives.rawModelProfile) {
const profileResolved = resolveProfileOverride({
rawProfile: directives.rawModelProfile,
provider: modelSelection.provider,
cfg: params.cfg,
agentDir,
});
if (profileResolved.error) {
return { text: profileResolved.error };
}
profileOverride = profileResolved.profileId;
}
const nextLabel = `${modelSelection.provider}/${modelSelection.model}`;
if (nextLabel !== initialModelLabel) {
enqueueSystemEvent(
formatModelSwitchEvent(nextLabel, modelSelection.alias),
{
sessionKey,
contextKey: `model:${nextLabel}`,
},
);
}
}
}
if (directives.rawModelProfile && !modelSelection) {
return { text: "Auth profile override requires a model selection." };
if (
directives.hasThinkDirective &&
directives.thinkLevel === "xhigh" &&
!supportsXHighThinking(resolvedProvider, resolvedModel)
) {
return {
text: `Thinking level "xhigh" is only supported for ${formatXHighModelHint()}.`,
};
}
const nextThinkLevel = directives.hasThinkDirective
? directives.thinkLevel
: ((sessionEntry?.thinkingLevel as ThinkLevel | undefined) ??
currentThinkLevel);
const shouldDowngradeXHigh =
!directives.hasThinkDirective &&
nextThinkLevel === "xhigh" &&
!supportsXHighThinking(resolvedProvider, resolvedModel);
if (sessionEntry && sessionStore && sessionKey) {
const prevElevatedLevel =
currentElevatedLevel ??
@@ -1239,6 +1255,9 @@ export async function handleDirectiveOnly(params: {
if (directives.thinkLevel === "off") delete sessionEntry.thinkingLevel;
else sessionEntry.thinkingLevel = directives.thinkLevel;
}
if (shouldDowngradeXHigh) {
sessionEntry.thinkingLevel = "high";
}
if (directives.hasVerboseDirective && directives.verboseLevel) {
applyVerboseOverride(sessionEntry, directives.verboseLevel);
}
@@ -1295,6 +1314,18 @@ export async function handleDirectiveOnly(params: {
if (storePath) {
await saveSessionStore(storePath, sessionStore);
}
if (modelSelection) {
const nextLabel = `${modelSelection.provider}/${modelSelection.model}`;
if (nextLabel !== initialModelLabel) {
enqueueSystemEvent(
formatModelSwitchEvent(nextLabel, modelSelection.alias),
{
sessionKey,
contextKey: `model:${nextLabel}`,
},
);
}
}
if (elevatedChanged) {
const nextElevated = (sessionEntry.elevatedLevel ??
"off") as ElevatedLevel;
@@ -1345,6 +1376,11 @@ export async function handleDirectiveOnly(params: {
);
if (shouldHintDirectRuntime) parts.push(formatElevatedRuntimeHint());
}
if (shouldDowngradeXHigh) {
parts.push(
`Thinking level set to high (xhigh not supported for ${resolvedProvider}/${resolvedModel}).`,
);
}
if (modelSelection) {
const label = `${modelSelection.provider}/${modelSelection.model}`;
const labelWithAlias = modelSelection.alias
@@ -1593,7 +1629,7 @@ export function resolveDefaultModel(params: {
aliasIndex: ModelAliasIndex;
} {
const agentModelOverride = params.agentId
? resolveAgentConfig(params.cfg, params.agentId)?.model?.trim()
? resolveAgentModelPrimary(params.cfg, params.agentId)
: undefined;
const cfg =
agentModelOverride && agentModelOverride.length > 0
+6
View File
@@ -1,10 +1,12 @@
import crypto from "node:crypto";
import { resolveAgentModelFallbacksOverride } from "../../agents/agent-scope.js";
import { lookupContextTokens } from "../../agents/context.js";
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
import { runWithModelFallback } from "../../agents/model-fallback.js";
import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js";
import { hasNonzeroUsage } from "../../agents/usage.js";
import {
resolveAgentIdFromSessionKey,
type SessionEntry,
updateSessionStoreEntry,
} from "../../config/sessions.js";
@@ -136,6 +138,10 @@ export function createFollowupRunner(params: {
cfg: queued.run.config,
provider: queued.run.provider,
model: queued.run.model,
fallbacksOverride: resolveAgentModelFallbacksOverride(
queued.run.config,
resolveAgentIdFromSessionKey(queued.run.sessionKey),
),
run: (provider, model) =>
runEmbeddedPiAgent({
sessionId: queued.run.sessionId,
+25 -1
View File
@@ -1,10 +1,34 @@
import { describe, expect, it } from "vitest";
import { normalizeReasoningLevel, normalizeThinkLevel } from "./thinking.js";
import {
listThinkingLevels,
normalizeReasoningLevel,
normalizeThinkLevel,
} from "./thinking.js";
describe("normalizeThinkLevel", () => {
it("accepts mid as medium", () => {
expect(normalizeThinkLevel("mid")).toBe("medium");
});
it("accepts xhigh", () => {
expect(normalizeThinkLevel("xhigh")).toBe("xhigh");
});
});
describe("listThinkingLevels", () => {
it("includes xhigh for codex models", () => {
expect(listThinkingLevels(undefined, "gpt-5.2-codex")).toContain("xhigh");
});
it("includes xhigh for openai gpt-5.2", () => {
expect(listThinkingLevels("openai", "gpt-5.2")).toContain("xhigh");
});
it("excludes xhigh for non-codex models", () => {
expect(listThinkingLevels(undefined, "gpt-4.1-mini")).not.toContain(
"xhigh",
);
});
});
describe("normalizeReasoningLevel", () => {
+61 -1
View File
@@ -1,9 +1,30 @@
export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high";
export type ThinkLevel =
| "off"
| "minimal"
| "low"
| "medium"
| "high"
| "xhigh";
export type VerboseLevel = "off" | "on";
export type ElevatedLevel = "off" | "on";
export type ReasoningLevel = "off" | "on" | "stream";
export type UsageDisplayLevel = "off" | "on";
export const XHIGH_MODEL_REFS = [
"openai/gpt-5.2",
"openai-codex/gpt-5.2-codex",
"openai-codex/gpt-5.1-codex",
] as const;
const XHIGH_MODEL_SET = new Set(
XHIGH_MODEL_REFS.map((entry) => entry.toLowerCase()),
);
const XHIGH_MODEL_IDS = new Set(
XHIGH_MODEL_REFS.map((entry) => entry.split("/")[1]?.toLowerCase()).filter(
(entry): entry is string => Boolean(entry),
),
);
// Normalize user-provided thinking level strings to the canonical enum.
export function normalizeThinkLevel(
raw?: string | null,
@@ -32,10 +53,49 @@ export function normalizeThinkLevel(
].includes(key)
)
return "high";
if (["xhigh", "x-high", "x_high"].includes(key)) return "xhigh";
if (["think"].includes(key)) return "minimal";
return undefined;
}
export function supportsXHighThinking(
provider?: string | null,
model?: string | null,
): boolean {
const modelKey = model?.trim().toLowerCase();
if (!modelKey) return false;
const providerKey = provider?.trim().toLowerCase();
if (providerKey) {
return XHIGH_MODEL_SET.has(`${providerKey}/${modelKey}`);
}
return XHIGH_MODEL_IDS.has(modelKey);
}
export function listThinkingLevels(
provider?: string | null,
model?: string | null,
): ThinkLevel[] {
const levels: ThinkLevel[] = ["off", "minimal", "low", "medium", "high"];
if (supportsXHighThinking(provider, model)) levels.push("xhigh");
return levels;
}
export function formatThinkingLevels(
provider?: string | null,
model?: string | null,
separator = ", ",
): string {
return listThinkingLevels(provider, model).join(separator);
}
export function formatXHighModelHint(): string {
const refs = [...XHIGH_MODEL_REFS] as string[];
if (refs.length === 0) return "unknown model";
if (refs.length === 1) return refs[0];
if (refs.length === 2) return `${refs[0]} or ${refs[1]}`;
return `${refs.slice(0, -1).join(", ")} or ${refs[refs.length - 1]}`;
}
// Normalize verbose flags used to toggle agent verbosity.
export function normalizeVerboseLevel(
raw?: string | null,
+63 -3
View File
@@ -1,6 +1,8 @@
import crypto from "node:crypto";
import {
resolveAgentDir,
resolveAgentModelFallbacksOverride,
resolveAgentModelPrimary,
resolveAgentWorkspaceDir,
} from "../agents/agent-scope.js";
import { ensureAuthProfileStore } from "../agents/auth-profiles.js";
@@ -28,8 +30,11 @@ import { hasNonzeroUsage } from "../agents/usage.js";
import { ensureAgentWorkspace } from "../agents/workspace.js";
import type { MsgContext } from "../auto-reply/templating.js";
import {
formatThinkingLevels,
formatXHighModelHint,
normalizeThinkLevel,
normalizeVerboseLevel,
supportsXHighThinking,
type ThinkLevel,
type VerboseLevel,
} from "../auto-reply/thinking.js";
@@ -214,17 +219,26 @@ export async function agentCommand(
ensureBootstrapFiles: !agentCfg?.skipBootstrap,
});
const workspaceDir = workspace.dir;
const configuredModel = resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
const thinkingLevelsHint = formatThinkingLevels(
configuredModel.provider,
configuredModel.model,
);
const thinkOverride = normalizeThinkLevel(opts.thinking);
const thinkOnce = normalizeThinkLevel(opts.thinkingOnce);
if (opts.thinking && !thinkOverride) {
throw new Error(
"Invalid thinking level. Use one of: off, minimal, low, medium, high.",
`Invalid thinking level. Use one of: ${thinkingLevelsHint}.`,
);
}
if (opts.thinkingOnce && !thinkOnce) {
throw new Error(
"Invalid one-shot thinking level. Use one of: off, minimal, low, medium, high.",
`Invalid one-shot thinking level. Use one of: ${thinkingLevelsHint}.`,
);
}
@@ -333,9 +347,28 @@ export async function agentCommand(
await saveSessionStore(storePath, sessionStore);
}
const agentModelPrimary = resolveAgentModelPrimary(cfg, sessionAgentId);
const cfgForModelSelection = agentModelPrimary
? {
...cfg,
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
model: {
...(typeof cfg.agents?.defaults?.model === "object"
? cfg.agents.defaults.model
: undefined),
primary: agentModelPrimary,
},
},
},
}
: cfg;
const { provider: defaultProvider, model: defaultModel } =
resolveConfiguredModelRef({
cfg,
cfg: cfgForModelSelection,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
@@ -423,6 +456,29 @@ export async function agentCommand(
catalog: catalogForThinking,
});
}
if (
resolvedThinkLevel === "xhigh" &&
!supportsXHighThinking(provider, model)
) {
const explicitThink = Boolean(thinkOnce || thinkOverride);
if (explicitThink) {
throw new Error(
`Thinking level "xhigh" is only supported for ${formatXHighModelHint()}.`,
);
}
resolvedThinkLevel = "high";
if (
sessionEntry &&
sessionStore &&
sessionKey &&
sessionEntry.thinkingLevel === "xhigh"
) {
sessionEntry.thinkingLevel = "high";
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
await saveSessionStore(storePath, sessionStore);
}
}
const sessionFile = resolveSessionFilePath(sessionId, sessionEntry, {
agentId: sessionAgentId,
});
@@ -442,6 +498,10 @@ export async function agentCommand(
cfg,
provider,
model,
fallbacksOverride: resolveAgentModelFallbacksOverride(
cfg,
sessionAgentId,
),
run: (providerOverride, modelOverride) => {
if (isCliProvider(providerOverride, cfg)) {
const cliSessionId = getCliSessionId(sessionEntry, providerOverride);
+9 -1
View File
@@ -142,7 +142,15 @@ function resolveAgentModel(cfg: ClawdbotConfig, agentId: string) {
const entry = listAgentEntries(cfg).find(
(agent) => normalizeAgentId(agent.id) === normalizeAgentId(agentId),
);
if (entry?.model?.trim()) return entry.model.trim();
if (entry?.model) {
if (typeof entry.model === "string" && entry.model.trim()) {
return entry.model.trim();
}
if (typeof entry.model === "object") {
const primary = entry.model.primary?.trim();
if (primary) return primary;
}
}
const raw = cfg.agents?.defaults?.model;
if (typeof raw === "string") return raw;
return raw?.primary?.trim() || undefined;
+11 -28
View File
@@ -18,6 +18,13 @@ vi.mock("../providers/github-copilot-auth.js", () => ({
const noopAsync = async () => {};
const noop = () => {};
const authProfilePathFor = (agentDir: string) =>
path.join(agentDir, "auth-profiles.json");
const requireAgentDir = () => {
const agentDir = process.env.CLAWDBOT_AGENT_DIR;
if (!agentDir) throw new Error("CLAWDBOT_AGENT_DIR not set");
return agentDir;
};
describe("applyAuthChoice", () => {
const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
@@ -111,13 +118,7 @@ describe("applyAuthChoice", () => {
mode: "api_key",
});
const authProfilePath = path.join(
tempStateDir,
"agents",
"main",
"agent",
"auth-profiles.json",
);
const authProfilePath = authProfilePathFor(requireAgentDir());
const raw = await fs.readFile(authProfilePath, "utf8");
const parsed = JSON.parse(raw) as {
profiles?: Record<string, { key?: string }>;
@@ -170,13 +171,7 @@ describe("applyAuthChoice", () => {
mode: "api_key",
});
const authProfilePath = path.join(
tempStateDir,
"agents",
"main",
"agent",
"auth-profiles.json",
);
const authProfilePath = authProfilePathFor(requireAgentDir());
const raw = await fs.readFile(authProfilePath, "utf8");
const parsed = JSON.parse(raw) as {
profiles?: Record<string, { key?: string }>;
@@ -337,13 +332,7 @@ describe("applyAuthChoice", () => {
"openrouter/auto",
);
const authProfilePath = path.join(
tempStateDir,
"agents",
"main",
"agent",
"auth-profiles.json",
);
const authProfilePath = authProfilePathFor(requireAgentDir());
const raw = await fs.readFile(authProfilePath, "utf8");
const parsed = JSON.parse(raw) as {
profiles?: Record<string, { key?: string }>;
@@ -426,13 +415,7 @@ describe("applyAuthChoice", () => {
mode: "oauth",
});
const authProfilePath = path.join(
tempStateDir,
"agents",
"main",
"agent",
"auth-profiles.json",
);
const authProfilePath = authProfilePathFor(requireAgentDir());
const raw = await fs.readFile(authProfilePath, "utf8");
const parsed = JSON.parse(raw) as {
profiles?: Record<
+2 -2
View File
@@ -1,5 +1,5 @@
import { loginOpenAICodex, type OAuthCredentials } from "@mariozechner/pi-ai";
import { resolveAgentConfig } from "../agents/agent-scope.js";
import { resolveAgentModelPrimary } from "../agents/agent-scope.js";
import {
CLAUDE_CLI_PROFILE_ID,
CODEX_CLI_PROFILE_ID,
@@ -152,7 +152,7 @@ export async function warnIfModelConfigLooksOff(
options?: { agentId?: string; agentDir?: string },
) {
const agentModelOverride = options?.agentId
? resolveAgentConfig(config, options.agentId)?.model?.trim()
? resolveAgentModelPrimary(config, options.agentId)
: undefined;
const configWithModel =
agentModelOverride && agentModelOverride.length > 0
+37
View File
@@ -0,0 +1,37 @@
import fs from "node:fs";
import path from "node:path";
import { note } from "../terminal/note.js";
export function noteSourceInstallIssues(root: string | null) {
if (!root) return;
const workspaceMarker = path.join(root, "pnpm-workspace.yaml");
if (!fs.existsSync(workspaceMarker)) return;
const warnings: string[] = [];
const nodeModules = path.join(root, "node_modules");
const pnpmStore = path.join(nodeModules, ".pnpm");
const tsxBin = path.join(nodeModules, ".bin", "tsx");
const srcEntry = path.join(root, "src", "entry.ts");
if (fs.existsSync(nodeModules) && !fs.existsSync(pnpmStore)) {
warnings.push(
"- node_modules was not installed by pnpm (missing node_modules/.pnpm). Run: pnpm install",
);
}
if (fs.existsSync(path.join(root, "package-lock.json"))) {
warnings.push(
"- package-lock.json present in a pnpm workspace. If you ran npm install, remove it and reinstall with pnpm.",
);
}
if (fs.existsSync(srcEntry) && !fs.existsSync(tsxBin)) {
warnings.push("- tsx binary is missing for source runs. Run: pnpm install");
}
if (warnings.length > 0) {
note(warnings.join("\n"), "Install");
}
}
+41 -2
View File
@@ -23,10 +23,49 @@ export async function maybeRepairUiProtocolFreshness(
try {
const [schemaStats, uiStats] = await Promise.all([
fs.stat(schemaPath),
fs.stat(uiIndexPath),
fs.stat(schemaPath).catch(() => null),
fs.stat(uiIndexPath).catch(() => null),
]);
if (schemaStats && !uiStats) {
note(
["- Control UI assets are missing.", "- Run: pnpm ui:build"].join("\n"),
"UI",
);
const shouldRepair = await prompter.confirmRepair({
message: "Build Control UI assets now?",
initialValue: true,
});
if (shouldRepair) {
note("Building Control UI assets... (this may take a moment)", "UI");
const uiScriptPath = path.join(root, "scripts/ui.js");
const buildResult = await runCommandWithTimeout(
[process.execPath, uiScriptPath, "build"],
{
cwd: root,
timeoutMs: 120_000,
env: { ...process.env, FORCE_COLOR: "1" },
},
);
if (buildResult.code === 0) {
note("UI build complete.", "UI");
} else {
const details = [
`UI build failed (exit ${buildResult.code ?? "unknown"}).`,
buildResult.stderr.trim() ? buildResult.stderr.trim() : null,
]
.filter(Boolean)
.join("\n");
note(details, "UI");
}
}
return;
}
if (!schemaStats || !uiStats) return;
if (schemaStats.mtime > uiStats.mtime) {
const uiMtimeIso = uiStats.mtime.toISOString();
// Find changes since the UI build
+8 -5
View File
@@ -62,6 +62,7 @@ import {
maybeRepairGatewayServiceConfig,
maybeScanExtraGatewayServices,
} from "./doctor-gateway-services.js";
import { noteSourceInstallIssues } from "./doctor-install.js";
import {
maybeMigrateLegacyConfigFile,
normalizeLegacyConfigValues,
@@ -187,6 +188,12 @@ export async function doctorCommand(
printWizardHeader(runtime);
intro("Clawdbot doctor");
const root = await resolveClawdbotPackageRoot({
moduleUrl: import.meta.url,
argv1: process.argv[1],
cwd: process.cwd(),
});
const updateInProgress = process.env.CLAWDBOT_UPDATE_IN_PROGRESS === "1";
const canOfferUpdate =
!updateInProgress &&
@@ -195,11 +202,6 @@ export async function doctorCommand(
options.repair !== true &&
Boolean(process.stdin.isTTY);
if (canOfferUpdate) {
const root = await resolveClawdbotPackageRoot({
moduleUrl: import.meta.url,
argv1: process.argv[1],
cwd: process.cwd(),
});
if (root) {
const git = await detectClawdbotGitCheckout(root);
if (git === "git") {
@@ -250,6 +252,7 @@ export async function doctorCommand(
}
await maybeRepairUiProtocolFreshness(runtime, prompter);
noteSourceInstallIssues(root);
await maybeMigrateLegacyConfigFile(runtime);
+85 -11
View File
@@ -18,9 +18,18 @@ import {
OPENROUTER_DEFAULT_MODEL_REF,
SYNTHETIC_DEFAULT_MODEL_ID,
SYNTHETIC_DEFAULT_MODEL_REF,
setMinimaxApiKey,
writeOAuthCredentials,
} from "./onboard-auth.js";
const authProfilePathFor = (agentDir: string) =>
path.join(agentDir, "auth-profiles.json");
const requireAgentDir = () => {
const agentDir = process.env.CLAWDBOT_AGENT_DIR;
if (!agentDir) throw new Error("CLAWDBOT_AGENT_DIR not set");
return agentDir;
};
describe("writeOAuthCredentials", () => {
const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR;
@@ -50,10 +59,9 @@ describe("writeOAuthCredentials", () => {
delete process.env.CLAWDBOT_OAUTH_DIR;
});
it("writes auth-profiles.json under CLAWDBOT_STATE_DIR/agents/main/agent", async () => {
it("writes auth-profiles.json under CLAWDBOT_AGENT_DIR when set", async () => {
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-oauth-"));
process.env.CLAWDBOT_STATE_DIR = tempStateDir;
// Even if legacy env vars are set, onboarding should write to the multi-agent path.
process.env.CLAWDBOT_AGENT_DIR = path.join(tempStateDir, "agent");
process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR;
@@ -65,14 +73,7 @@ describe("writeOAuthCredentials", () => {
await writeOAuthCredentials("openai-codex", creds);
// Now writes to the multi-agent path: agents/main/agent
const authProfilePath = path.join(
tempStateDir,
"agents",
"main",
"agent",
"auth-profiles.json",
);
const authProfilePath = authProfilePathFor(requireAgentDir());
const raw = await fs.readFile(authProfilePath, "utf8");
const parsed = JSON.parse(raw) as {
profiles?: Record<string, OAuthCredentials & { type?: string }>;
@@ -85,7 +86,80 @@ describe("writeOAuthCredentials", () => {
await expect(
fs.readFile(
path.join(tempStateDir, "agent", "auth-profiles.json"),
path.join(
tempStateDir,
"agents",
"main",
"agent",
"auth-profiles.json",
),
"utf8",
),
).rejects.toThrow();
});
});
describe("setMinimaxApiKey", () => {
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("writes to CLAWDBOT_AGENT_DIR when set", async () => {
tempStateDir = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-minimax-"),
);
process.env.CLAWDBOT_STATE_DIR = tempStateDir;
process.env.CLAWDBOT_AGENT_DIR = path.join(tempStateDir, "custom-agent");
process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR;
await setMinimaxApiKey("sk-minimax-test");
const customAuthPath = authProfilePathFor(requireAgentDir());
const raw = await fs.readFile(customAuthPath, "utf8");
const parsed = JSON.parse(raw) as {
profiles?: Record<
string,
{ type?: string; provider?: string; key?: string }
>;
};
expect(parsed.profiles?.["minimax:default"]).toMatchObject({
type: "api_key",
provider: "minimax",
key: "sk-minimax-test",
});
await expect(
fs.readFile(
path.join(
tempStateDir,
"agents",
"main",
"agent",
"auth-profiles.json",
),
"utf8",
),
).rejects.toThrow();
+20 -17
View File
@@ -1,5 +1,5 @@
import type { OAuthCredentials } from "@mariozechner/pi-ai";
import { resolveDefaultAgentDir } from "../agents/agent-scope.js";
import { resolveClawdbotAgentDir } from "../agents/agent-paths.js";
import { upsertAuthProfile } from "../agents/auth-profiles.js";
import { OPENCODE_ZEN_DEFAULT_MODEL_REF } from "../agents/opencode-zen-models.js";
import {
@@ -24,6 +24,9 @@ const MOONSHOT_DEFAULT_CONTEXT_WINDOW = 256000;
const MOONSHOT_DEFAULT_MAX_TOKENS = 8192;
export const MOONSHOT_DEFAULT_MODEL_REF = `moonshot/${MOONSHOT_DEFAULT_MODEL_ID}`;
export { SYNTHETIC_DEFAULT_MODEL_ID, SYNTHETIC_DEFAULT_MODEL_REF };
const resolveAuthAgentDir = (agentDir?: string) =>
agentDir ?? resolveClawdbotAgentDir();
// Pricing: MiniMax doesn't publish public rates. Override in models.json for accurate costs.
const MINIMAX_API_COST = {
input: 15,
@@ -107,7 +110,7 @@ export async function writeOAuthCredentials(
creds: OAuthCredentials,
agentDir?: string,
): Promise<void> {
// Write to the multi-agent path so gateway finds credentials on startup
// Write to resolved agent dir so gateway finds credentials on startup.
upsertAuthProfile({
profileId: `${provider}:${creds.email ?? "default"}`,
credential: {
@@ -115,12 +118,12 @@ export async function writeOAuthCredentials(
provider,
...creds,
},
agentDir: agentDir ?? resolveDefaultAgentDir(),
agentDir: resolveAuthAgentDir(agentDir),
});
}
export async function setAnthropicApiKey(key: string, agentDir?: string) {
// Write to the multi-agent path so gateway finds credentials on startup
// Write to resolved agent dir so gateway finds credentials on startup.
upsertAuthProfile({
profileId: "anthropic:default",
credential: {
@@ -128,12 +131,12 @@ export async function setAnthropicApiKey(key: string, agentDir?: string) {
provider: "anthropic",
key,
},
agentDir: agentDir ?? resolveDefaultAgentDir(),
agentDir: resolveAuthAgentDir(agentDir),
});
}
export async function setGeminiApiKey(key: string, agentDir?: string) {
// Write to the multi-agent path so gateway finds credentials on startup
// Write to resolved agent dir so gateway finds credentials on startup.
upsertAuthProfile({
profileId: "google:default",
credential: {
@@ -141,12 +144,12 @@ export async function setGeminiApiKey(key: string, agentDir?: string) {
provider: "google",
key,
},
agentDir: agentDir ?? resolveDefaultAgentDir(),
agentDir: resolveAuthAgentDir(agentDir),
});
}
export async function setMinimaxApiKey(key: string, agentDir?: string) {
// Write to the multi-agent path so gateway finds credentials on startup
// Write to resolved agent dir so gateway finds credentials on startup.
upsertAuthProfile({
profileId: "minimax:default",
credential: {
@@ -154,12 +157,12 @@ export async function setMinimaxApiKey(key: string, agentDir?: string) {
provider: "minimax",
key,
},
agentDir: agentDir ?? resolveDefaultAgentDir(),
agentDir: resolveAuthAgentDir(agentDir),
});
}
export async function setMoonshotApiKey(key: string, agentDir?: string) {
// Write to the multi-agent path so gateway finds credentials on startup
// Write to resolved agent dir so gateway finds credentials on startup.
upsertAuthProfile({
profileId: "moonshot:default",
credential: {
@@ -167,12 +170,12 @@ export async function setMoonshotApiKey(key: string, agentDir?: string) {
provider: "moonshot",
key,
},
agentDir: agentDir ?? resolveDefaultAgentDir(),
agentDir: resolveAuthAgentDir(agentDir),
});
}
export async function setSyntheticApiKey(key: string, agentDir?: string) {
// Write to the multi-agent path so gateway finds credentials on startup
// Write to resolved agent dir so gateway finds credentials on startup.
upsertAuthProfile({
profileId: "synthetic:default",
credential: {
@@ -180,7 +183,7 @@ export async function setSyntheticApiKey(key: string, agentDir?: string) {
provider: "synthetic",
key,
},
agentDir: agentDir ?? resolveDefaultAgentDir(),
agentDir: resolveAuthAgentDir(agentDir),
});
}
@@ -188,7 +191,7 @@ export const ZAI_DEFAULT_MODEL_REF = "zai/glm-4.7";
export const OPENROUTER_DEFAULT_MODEL_REF = "openrouter/auto";
export async function setZaiApiKey(key: string, agentDir?: string) {
// Write to the multi-agent path so gateway finds credentials on startup
// Write to resolved agent dir so gateway finds credentials on startup.
upsertAuthProfile({
profileId: "zai:default",
credential: {
@@ -196,7 +199,7 @@ export async function setZaiApiKey(key: string, agentDir?: string) {
provider: "zai",
key,
},
agentDir: agentDir ?? resolveDefaultAgentDir(),
agentDir: resolveAuthAgentDir(agentDir),
});
}
@@ -208,7 +211,7 @@ export async function setOpenrouterApiKey(key: string, agentDir?: string) {
provider: "openrouter",
key,
},
agentDir: agentDir ?? resolveDefaultAgentDir(),
agentDir: resolveAuthAgentDir(agentDir),
});
}
@@ -714,7 +717,7 @@ export async function setOpencodeZenApiKey(key: string, agentDir?: string) {
provider: "opencode",
key,
},
agentDir: agentDir ?? resolveDefaultAgentDir(),
agentDir: resolveAuthAgentDir(agentDir),
});
}
+2
View File
@@ -107,6 +107,8 @@ const FIELD_LABELS: Record<string, string> = {
"tools.audio.transcription.args": "Audio Transcription Args",
"tools.audio.transcription.timeoutSeconds":
"Audio Transcription Timeout (sec)",
"tools.profile": "Tool Profile",
"agents.list[].tools.profile": "Agent Tool Profile",
"tools.exec.applyPatch.enabled": "Enable apply_patch",
"tools.exec.applyPatch.allowModels": "apply_patch Model Allowlist",
"gateway.controlUi.basePath": "Control UI Base Path",
+17 -2
View File
@@ -988,7 +988,11 @@ export type QueueConfig = {
drop?: QueueDropPolicy;
};
export type ToolProfileId = "minimal" | "coding" | "messaging" | "full";
export type AgentToolsConfig = {
/** Base tool profile applied before allow/deny lists. */
profile?: ToolProfileId;
allow?: string[];
deny?: string[];
/** Per-agent elevated exec gate (can only further restrict global tools.elevated). */
@@ -1053,6 +1057,8 @@ export type MemorySearchConfig = {
};
export type ToolsConfig = {
/** Base tool profile applied before allow/deny lists. */
profile?: ToolProfileId;
allow?: string[];
deny?: string[];
audio?: {
@@ -1121,13 +1127,22 @@ export type ToolsConfig = {
};
};
export type AgentModelConfig =
| string
| {
/** Primary model (provider/model). */
primary?: string;
/** Per-agent model fallbacks (provider/model). */
fallbacks?: string[];
};
export type AgentConfig = {
id: string;
default?: boolean;
name?: string;
workspace?: string;
agentDir?: string;
model?: string;
model?: AgentModelConfig;
memorySearch?: MemorySearchConfig;
/** Human-like delay between block replies for this agent. */
humanDelay?: HumanDelayConfig;
@@ -1618,7 +1633,7 @@ export type AgentDefaultsConfig = {
/** Vector memory search configuration (per-agent overrides supported). */
memorySearch?: MemorySearchConfig;
/** Default thinking level when no /think directive is present. */
thinkingDefault?: "off" | "minimal" | "low" | "medium" | "high";
thinkingDefault?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
/** Default verbose level when no /verbose directive is present. */
verboseDefault?: "off" | "on";
/** Default elevated level when no /elevated directive is present. */
+20 -2
View File
@@ -839,6 +839,15 @@ const ToolPolicySchema = z
})
.optional();
const ToolProfileSchema = z
.union([
z.literal("minimal"),
z.literal("coding"),
z.literal("messaging"),
z.literal("full"),
])
.optional();
// Provider docking: allowlists keyed by provider id (no schema updates when adding providers).
const ElevatedAllowFromSchema = z
.record(z.string(), z.array(z.union([z.string(), z.number()])))
@@ -868,6 +877,7 @@ const AgentSandboxSchema = z
const AgentToolsSchema = z
.object({
profile: ToolProfileSchema,
allow: z.array(z.string()).optional(),
deny: z.array(z.string()).optional(),
elevated: z
@@ -932,14 +942,20 @@ const MemorySearchSchema = z
.optional(),
})
.optional();
const AgentModelSchema = z.union([
z.string(),
z.object({
primary: z.string().optional(),
fallbacks: z.array(z.string()).optional(),
}),
]);
const AgentEntrySchema = z.object({
id: z.string(),
default: z.boolean().optional(),
name: z.string().optional(),
workspace: z.string().optional(),
agentDir: z.string().optional(),
model: z.string().optional(),
model: AgentModelSchema.optional(),
memorySearch: MemorySearchSchema,
humanDelay: HumanDelaySchema.optional(),
identity: IdentitySchema,
@@ -964,6 +980,7 @@ const AgentEntrySchema = z.object({
const ToolsSchema = z
.object({
profile: ToolProfileSchema,
allow: z.array(z.string()).optional(),
deny: z.array(z.string()).optional(),
audio: z
@@ -1233,6 +1250,7 @@ const AgentDefaultsSchema = z
z.literal("low"),
z.literal("medium"),
z.literal("high"),
z.literal("xhigh"),
])
.optional(),
verboseDefault: z.union([z.literal("off"), z.literal("on")]).optional(),
+15 -1
View File
@@ -1,6 +1,7 @@
import crypto from "node:crypto";
import {
resolveAgentConfig,
resolveAgentModelFallbacksOverride,
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
} from "../agents/agent-scope.js";
@@ -31,7 +32,11 @@ import {
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
stripHeartbeatToken,
} from "../auto-reply/heartbeat.js";
import { normalizeThinkLevel } from "../auto-reply/thinking.js";
import {
formatXHighModelHint,
normalizeThinkLevel,
supportsXHighThinking,
} from "../auto-reply/thinking.js";
import type { CliDeps } from "../cli/deps.js";
import type { ClawdbotConfig } from "../config/config.js";
import {
@@ -366,6 +371,11 @@ export async function runCronIsolatedAgentTurn(params: {
catalog: await loadCatalog(),
});
}
if (thinkLevel === "xhigh" && !supportsXHighThinking(provider, model)) {
throw new Error(
`Thinking level "xhigh" is only supported for ${formatXHighModelHint()}.`,
);
}
const timeoutMs = resolveAgentTimeoutMs({
cfg: cfgWithAgentDefaults,
@@ -449,6 +459,10 @@ export async function runCronIsolatedAgentTurn(params: {
cfg: cfgWithAgentDefaults,
provider,
model,
fallbacksOverride: resolveAgentModelFallbacksOverride(
params.cfg,
agentId,
),
run: (providerOverride, modelOverride) => {
if (isCliProvider(providerOverride, cfgWithAgentDefaults)) {
const cliSessionId = getCliSessionId(
+28
View File
@@ -1,4 +1,5 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import process from "node:process";
import { applyCliProfileEnv, parseCliProfileArgs } from "./cli/profile.js";
@@ -8,6 +9,33 @@ if (process.argv.includes("--no-color")) {
process.env.FORCE_COLOR = "0";
}
const EXPERIMENTAL_WARNING_FLAG = "--disable-warning=ExperimentalWarning";
function hasExperimentalWarningSuppressed(nodeOptions: string): boolean {
if (!nodeOptions) return false;
return (
nodeOptions.includes(EXPERIMENTAL_WARNING_FLAG) ||
nodeOptions.includes("--no-warnings")
);
}
function ensureExperimentalWarningSuppressed(): void {
if (process.env.CLAWDBOT_NODE_OPTIONS_READY === "1") return;
const nodeOptions = process.env.NODE_OPTIONS ?? "";
if (hasExperimentalWarningSuppressed(nodeOptions)) return;
process.env.CLAWDBOT_NODE_OPTIONS_READY = "1";
process.env.NODE_OPTIONS =
`${nodeOptions} ${EXPERIMENTAL_WARNING_FLAG}`.trim();
const result = spawnSync(process.execPath, process.argv.slice(1), {
stdio: "inherit",
env: process.env,
});
if (result.signal) process.exit(1);
process.exit(result.status ?? 1);
}
ensureExperimentalWarningSuppressed();
const parsed = parseCliProfileArgs(process.argv);
if (!parsed.ok) {
// Keep it simple; Commander will handle rich help/errors after we strip flags.
+31 -1
View File
@@ -8,10 +8,13 @@ import {
} from "../agents/model-selection.js";
import { normalizeGroupActivation } from "../auto-reply/group-activation.js";
import {
formatThinkingLevels,
formatXHighModelHint,
normalizeElevatedLevel,
normalizeReasoningLevel,
normalizeThinkLevel,
normalizeUsageDisplay,
supportsXHighThinking,
} from "../auto-reply/thinking.js";
import type { ClawdbotConfig } from "../config/config.js";
import type { SessionEntry } from "../config/sessions.js";
@@ -95,8 +98,17 @@ export async function applySessionsPatchToStore(params: {
} else if (raw !== undefined) {
const normalized = normalizeThinkLevel(String(raw));
if (!normalized) {
const resolvedDefault = resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
const hintProvider =
existing?.providerOverride?.trim() || resolvedDefault.provider;
const hintModel =
existing?.modelOverride?.trim() || resolvedDefault.model;
return invalid(
"invalid thinkingLevel (use off|minimal|low|medium|high)",
`invalid thinkingLevel (use ${formatThinkingLevels(hintProvider, hintModel, "|")})`,
);
}
if (normalized === "off") delete next.thinkingLevel;
@@ -196,6 +208,24 @@ export async function applySessionsPatchToStore(params: {
}
}
if (next.thinkingLevel === "xhigh") {
const resolvedDefault = resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
const effectiveProvider = next.providerOverride ?? resolvedDefault.provider;
const effectiveModel = next.modelOverride ?? resolvedDefault.model;
if (!supportsXHighThinking(effectiveProvider, effectiveModel)) {
if ("thinkingLevel" in patch) {
return invalid(
`thinkingLevel "xhigh" is only supported for ${formatXHighModelHint()}`,
);
}
next.thinkingLevel = "high";
}
}
if ("sendPolicy" in patch) {
const raw = patch.sendPolicy;
if (raw === null) {
+4 -4
View File
@@ -171,7 +171,7 @@ describe("google-shared convertMessages", () => {
{
type: "thinking",
thinking: "hidden",
thinkingSignature: "sig",
thinkingSignature: "c2ln",
},
],
api: "google-generative-ai",
@@ -202,7 +202,7 @@ describe("google-shared convertMessages", () => {
expect(contents[0].role).toBe("model");
expect(contents[0].parts?.[0]).toMatchObject({
thought: true,
thoughtSignature: "sig",
thoughtSignature: "c2ln",
});
});
@@ -216,7 +216,7 @@ describe("google-shared convertMessages", () => {
{
type: "thinking",
thinking: "structured",
thinkingSignature: "sig",
thinkingSignature: "c2ln",
},
],
api: "google-generative-ai",
@@ -247,7 +247,7 @@ describe("google-shared convertMessages", () => {
expect(parts).toHaveLength(1);
expect(parts[0]).toMatchObject({
thought: true,
thoughtSignature: "sig",
thoughtSignature: "c2ln",
});
});
+11 -6
View File
@@ -13,6 +13,14 @@ import type {
const providerId = "discord";
function readParentIdParam(
params: Record<string, unknown>,
): string | null | undefined {
if (params.clearParent === true) return null;
if (params.parentId === null) return null;
return readStringParam(params, "parentId");
}
export const discordMessageActions: ProviderMessageActionAdapter = {
listActions: ({ cfg }) => {
const accounts = listEnabledDiscordAccounts(cfg).filter(
@@ -462,8 +470,7 @@ export const discordMessageActions: ProviderMessageActionAdapter = {
const guildId = readStringParam(params, "guildId", { required: true });
const name = readStringParam(params, "name", { required: true });
const type = readNumberParam(params, "type", { integer: true });
const parentId =
params.parentId === null ? null : readStringParam(params, "parentId");
const parentId = readParentIdParam(params);
const topic = readStringParam(params, "topic");
const position = readNumberParam(params, "position", { integer: true });
const nsfw = typeof params.nsfw === "boolean" ? params.nsfw : undefined;
@@ -489,8 +496,7 @@ export const discordMessageActions: ProviderMessageActionAdapter = {
const name = readStringParam(params, "name");
const topic = readStringParam(params, "topic");
const position = readNumberParam(params, "position", { integer: true });
const parentId =
params.parentId === null ? null : readStringParam(params, "parentId");
const parentId = readParentIdParam(params);
const nsfw = typeof params.nsfw === "boolean" ? params.nsfw : undefined;
const rateLimitPerUser = readNumberParam(params, "rateLimitPerUser", {
integer: true,
@@ -525,8 +531,7 @@ export const discordMessageActions: ProviderMessageActionAdapter = {
const channelId = readStringParam(params, "channelId", {
required: true,
});
const parentId =
params.parentId === null ? null : readStringParam(params, "parentId");
const parentId = readParentIdParam(params);
const position = readNumberParam(params, "position", { integer: true });
return await handleDiscordAction(
{
+23 -7
View File
@@ -1,6 +1,9 @@
import type { SlashCommand } from "@mariozechner/pi-tui";
import {
formatThinkingLevels,
listThinkingLevels,
} from "../auto-reply/thinking.js";
const THINK_LEVELS = ["off", "minimal", "low", "medium", "high"];
const VERBOSE_LEVELS = ["on", "off"];
const REASONING_LEVELS = ["on", "off"];
const ELEVATED_LEVELS = ["on", "off"];
@@ -12,6 +15,11 @@ export type ParsedCommand = {
args: string;
};
export type SlashCommandOptions = {
provider?: string;
model?: string;
};
const COMMAND_ALIASES: Record<string, string> = {
elev: "elevated",
};
@@ -27,7 +35,10 @@ export function parseCommand(input: string): ParsedCommand {
};
}
export function getSlashCommands(): SlashCommand[] {
export function getSlashCommands(
options: SlashCommandOptions = {},
): SlashCommand[] {
const thinkLevels = listThinkingLevels(options.provider, options.model);
return [
{ name: "help", description: "Show slash command help" },
{ name: "status", description: "Show gateway status summary" },
@@ -44,9 +55,9 @@ export function getSlashCommands(): SlashCommand[] {
name: "think",
description: "Set thinking level",
getArgumentCompletions: (prefix) =>
THINK_LEVELS.filter((v) => v.startsWith(prefix.toLowerCase())).map(
(value) => ({ value, label: value }),
),
thinkLevels
.filter((v) => v.startsWith(prefix.toLowerCase()))
.map((value) => ({ value, label: value })),
},
{
name: "verbose",
@@ -105,7 +116,12 @@ export function getSlashCommands(): SlashCommand[] {
];
}
export function helpText(): string {
export function helpText(options: SlashCommandOptions = {}): string {
const thinkLevels = formatThinkingLevels(
options.provider,
options.model,
"|",
);
return [
"Slash commands:",
"/help",
@@ -113,7 +129,7 @@ export function helpText(): string {
"/agent <id> (or /agents)",
"/session <key> (or /sessions)",
"/model <provider/model> (or /models)",
"/think <off|minimal|low|medium|high>",
`/think <${thinkLevels}>`,
"/verbose <on|off>",
"/reasoning <on|off>",
"/cost <on|off>",
Loaded 100 of 104 files, more files were not shown because too many files have changed in this diff. Show more