Compare commits
283
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0a79c19f9 | ||
|
|
cb0f6cefa4 | ||
|
|
25ef01b74a | ||
|
|
6db0201fcd | ||
|
|
40e508823f | ||
|
|
3368284b2a | ||
|
|
ece01d89fe | ||
|
|
9c0c4f50ec | ||
|
|
6729637f61 | ||
|
|
18d22aa426 | ||
|
|
a3641526ab | ||
|
|
f50e06a1b6 | ||
|
|
2ae3b45ac1 | ||
|
|
780a43711f | ||
|
|
d682b604de | ||
|
|
fa75d84b75 | ||
|
|
bb2df13be0 | ||
|
|
5918def440 | ||
|
|
1fdd3592d3 | ||
|
|
a96d299971 | ||
|
|
42ff634a9d | ||
|
|
b45d7c3256 | ||
|
|
77ca508274 | ||
|
|
61b7398cb7 | ||
|
|
0321d5ed74 | ||
|
|
78627ce7c2 | ||
|
|
c851bdd47a | ||
|
|
9ec0016258 | ||
|
|
01776e0569 | ||
|
|
d8f14078f0 | ||
|
|
38244b8e94 | ||
|
|
9308762d0b | ||
|
|
8eb1c76337 | ||
|
|
f94ad21f1e | ||
|
|
8d640ccc68 | ||
|
|
f566e6451f | ||
|
|
75a7855223 | ||
|
|
8f105288d2 | ||
|
|
696a6ec4d4 | ||
|
|
3271ff1d6e | ||
|
|
0efcfc0864 | ||
|
|
0aba911912 | ||
|
|
4efb5cc18e | ||
|
|
57db3f22a1 | ||
|
|
9b44c80b30 | ||
|
|
7c7f4d0eb7 | ||
|
|
ccc24e2c26 | ||
|
|
62bdbe1821 | ||
|
|
58d1d11762 | ||
|
|
8a9096cd52 | ||
|
|
b70298fbca | ||
|
|
7616b02bb1 | ||
|
|
d4c205f8e1 | ||
|
|
0ba60ff69c | ||
|
|
755a7e1b20 | ||
|
|
3061d8e057 | ||
|
|
ea5597b483 | ||
|
|
b41e75a15d | ||
|
|
bfdbaa5ab6 | ||
|
|
93ae3b8405 | ||
|
|
f249a82383 | ||
|
|
ea9486ae2d | ||
|
|
da95b58a2a | ||
|
|
e15d5d0533 | ||
|
|
1cf45f8439 | ||
|
|
2a9ef806a0 | ||
|
|
32115a8b98 | ||
|
|
7ce902b096 | ||
|
|
c4e8b60d2c | ||
|
|
c9fdd68232 | ||
|
|
50260fd385 | ||
|
|
0822f5610a | ||
|
|
2c2ca7f03b | ||
|
|
c4014c0092 | ||
|
|
c08441c42c | ||
|
|
980f274fc9 | ||
|
|
9f1f65f0e3 | ||
|
|
cddd836909 | ||
|
|
bab7eeaf91 | ||
|
|
d45915d39f | ||
|
|
fcc814accd | ||
|
|
78a3d965e0 | ||
|
|
66ad8a9289 | ||
|
|
df6634727e | ||
|
|
8b5cd97ceb | ||
|
|
25297ce3f5 | ||
|
|
3800fea962 | ||
|
|
ecb91bbb1a | ||
|
|
ab993904d7 | ||
|
|
2467a103b2 | ||
|
|
68569afb4b | ||
|
|
67d0ab3030 | ||
|
|
4024bca55d | ||
|
|
b928b96af5 | ||
|
|
cb871a3fc1 | ||
|
|
da0a062fa7 | ||
|
|
ba316a10cc | ||
|
|
4086408b10 | ||
|
|
c1f82d9ec1 | ||
|
|
eb6cace60f | ||
|
|
00f4331343 | ||
|
|
cb469ecf45 | ||
|
|
46a694bbc7 | ||
|
|
56c406b19e | ||
|
|
79a6506593 | ||
|
|
99a6fcf3f7 | ||
|
|
abe9440096 | ||
|
|
542c8020ec | ||
|
|
8edf2146ae | ||
|
|
3c8d0083cb | ||
|
|
28248f9602 | ||
|
|
d225c4a7d1 | ||
|
|
e05a8477b9 | ||
|
|
357a6e340a | ||
|
|
a87d37f26d | ||
|
|
958a4fd414 | ||
|
|
a27efd57bd | ||
|
|
d57db17300 | ||
|
|
4f1c6e76fd | ||
|
|
2111d0c653 | ||
|
|
642e6acf49 | ||
|
|
88716d8d2a | ||
|
|
c2e37c78ff | ||
|
|
5a2688c7b5 | ||
|
|
ba1d80bd00 | ||
|
|
4dfcd56893 | ||
|
|
e0ddc488d0 | ||
|
|
c012019a8a | ||
|
|
7896b30489 | ||
|
|
ffc465394e | ||
|
|
0edbdb1948 | ||
|
|
5dc187f00c | ||
|
|
231d2d5fdf | ||
|
|
20ba8d4891 | ||
|
|
b32f6a0e00 | ||
|
|
e5c77315ce | ||
|
|
dd8f7552ad | ||
|
|
95ed49ce9a | ||
|
|
92760de472 | ||
|
|
24190d09da | ||
|
|
07bdb8af7e | ||
|
|
6a48688c09 | ||
|
|
c03a745f61 | ||
|
|
48fdf3775d | ||
|
|
e5708d443a | ||
|
|
e2ea20f862 | ||
|
|
1eb924739b | ||
|
|
350f956f2c | ||
|
|
1f3ae2346e | ||
|
|
4f3bedfdb7 | ||
|
|
6f75feaeb8 | ||
|
|
e949cc383f | ||
|
|
a4bd960880 | ||
|
|
3636a2bf51 | ||
|
|
103003d9ff | ||
|
|
ce23c70855 | ||
|
|
c5fa757ef6 | ||
|
|
bb9a9633a8 | ||
|
|
ca98f87b2f | ||
|
|
df64771ecf | ||
|
|
cbe11e3de0 | ||
|
|
daa753112c | ||
|
|
2e654e8d63 | ||
|
|
cf92099d40 | ||
|
|
a55c880191 | ||
|
|
a8680f9a09 | ||
|
|
2e08a868a7 | ||
|
|
2785009c6f | ||
|
|
73d9469bf8 | ||
|
|
72100ba3ab | ||
|
|
ec5099db89 | ||
|
|
209380edf8 | ||
|
|
cf8251bb81 | ||
|
|
6ed2d69ff9 | ||
|
|
e7e544174f | ||
|
|
9b0d9db3a3 | ||
|
|
fd768334a9 | ||
|
|
9f90d0721a | ||
|
|
1920138122 | ||
|
|
27d940f5b6 | ||
|
|
7ba72aeb6c | ||
|
|
f19d37c7bb | ||
|
|
9d5bf38416 | ||
|
|
e0c1f2fdc0 | ||
|
|
08fdac0561 | ||
|
|
d3eeddfc2f | ||
|
|
0a24bc0427 | ||
|
|
9da97d1a41 | ||
|
|
29b7b2068a | ||
|
|
f4ab057807 | ||
|
|
cb35db0c7e | ||
|
|
f13db1c836 | ||
|
|
59063a7c15 | ||
|
|
5bc4971432 | ||
|
|
59c8d2d17f | ||
|
|
21405b0dfc | ||
|
|
256304037e | ||
|
|
d8ae905d54 | ||
|
|
bbc34215a2 | ||
|
|
583fc4fb11 | ||
|
|
0b2b8c7c52 | ||
|
|
5d83be76c9 | ||
|
|
877bc61b53 | ||
|
|
fcaeee7073 | ||
|
|
6857f16609 | ||
|
|
2faf7cea93 | ||
|
|
26d5cca97c | ||
|
|
99fea64823 | ||
|
|
42c17adb5e | ||
|
|
3467b0ba07 | ||
|
|
490cb834e5 | ||
|
|
cd12ad8aab | ||
|
|
eceb41f6f7 | ||
|
|
6f496b7739 | ||
|
|
e961e02f71 | ||
|
|
36a02b3e67 | ||
|
|
b73042500e | ||
|
|
6406ed869a | ||
|
|
f839d949b2 | ||
|
|
d4f7dc067e | ||
|
|
3dbfe65eea | ||
|
|
ddd4b55cf6 | ||
|
|
298c6eea1f | ||
|
|
74806aa5e3 | ||
|
|
55aeb8a0d3 | ||
|
|
86ea00dc21 | ||
|
|
a0a7e74a62 | ||
|
|
bb7397c636 | ||
|
|
7dc44b04c1 | ||
|
|
45232137a2 | ||
|
|
b1c3e38df0 | ||
|
|
0be62c3542 | ||
|
|
523f91758d | ||
|
|
5baba5f84e | ||
|
|
ffbcd83d1e | ||
|
|
1baf9f6a83 | ||
|
|
77b20377cc | ||
|
|
3ffb9a3b5e | ||
|
|
29807119d5 | ||
|
|
b88ea39b83 | ||
|
|
0a2dcd844b | ||
|
|
2ed95634fe | ||
|
|
9526f9861a | ||
|
|
7b93356fb7 | ||
|
|
17ff25bd20 | ||
|
|
d24de1ec3b | ||
|
|
44e1f271c8 | ||
|
|
8ff09f8337 | ||
|
|
e91aa0657e | ||
|
|
14801b46fc | ||
|
|
99a7548c07 | ||
|
|
35bbc2ba87 | ||
|
|
cf78d28d74 | ||
|
|
eeca541dde | ||
|
|
4bba49770d | ||
|
|
f5d5661adf | ||
|
|
f83fb70360 | ||
|
|
355c13564c | ||
|
|
f1dd59bf82 | ||
|
|
fd1e959c2d | ||
|
|
1b2c1545a0 | ||
|
|
05ac67c520 | ||
|
|
f5ee2b3a4f | ||
|
|
8afdf75e2b | ||
|
|
6aed3c0fd3 | ||
|
|
f0fbd8b012 | ||
|
|
5a3eb5ad62 | ||
|
|
79beb20ba2 | ||
|
|
3da1afed68 | ||
|
|
717a259056 | ||
|
|
adaa30c73a | ||
|
|
ff292e67ce | ||
|
|
31752aa944 | ||
|
|
bf11a42c37 | ||
|
|
8049f33435 | ||
|
|
115591c5b6 | ||
|
|
a3938d62f6 | ||
|
|
3c7a8579ad | ||
|
|
f5a9421b10 | ||
|
|
562d0e3b5f | ||
|
|
bf7e813573 | ||
|
|
c69abe08eb | ||
|
|
5a29ec78ca |
No files matched your search
@@ -1,4 +1,6 @@
|
||||
# Repository Guidelines
|
||||
- Repo: https://github.com/clawdbot/clawdbot
|
||||
- GitHub issues: use literal multiline strings or $'...' for newlines; avoid "\\n" escapes in `gh issue create/edit`.
|
||||
|
||||
## Project Structure & Module Organization
|
||||
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
|
||||
@@ -11,6 +13,7 @@
|
||||
- Internal doc links in `docs/**/*.md`: root-relative, no `.md`/`.mdx` (example: `[Config](/configuration)`).
|
||||
- Section cross-references: use anchors on root-relative paths (example: `[Hooks](/configuration#hooks)`).
|
||||
- When Peter asks for links, reply with full `https://docs.clawd.bot/...` URLs (not root-relative).
|
||||
- When you touch docs, end the reply with the `https://docs.clawd.bot/...` URLs you referenced.
|
||||
- README (GitHub): keep absolute docs URLs (`https://docs.clawd.bot/...`) so links work on GitHub.
|
||||
- Docs content must be generic: no personal device names/hostnames/paths; use placeholders like `user@gateway-host` and “gateway host”.
|
||||
|
||||
@@ -97,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.
|
||||
|
||||
+120
-28
@@ -1,15 +1,128 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.1.11-2
|
||||
## 2026.1.13-2
|
||||
|
||||
### Changes
|
||||
- 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.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)
|
||||
- Agents: support per-agent model fallbacks via `agents.list[].model`. (#583 — thanks @mitschabaude-bot)
|
||||
- Tools: add tool profiles plus group shorthands for tool policy allow/deny (global, per-agent, sandbox).
|
||||
- Thinking: allow xhigh for GPT-5.2 + Codex models and downgrade on unsupported switches. (#444 — thanks @grp06)
|
||||
|
||||
### Fixes
|
||||
- Installer: ensure the CLI entrypoint is executable after npm installs.
|
||||
- Packaging: include `dist/plugins/` in the npm package to avoid missing module errors.
|
||||
- Gateway: honor `CLAWDBOT_LAUNCHD_LABEL` / `CLAWDBOT_SYSTEMD_UNIT` overrides when checking or restarting the daemon.
|
||||
- Tools: harden tool input schemas for strict providers (no unions; flattened actions; enum helpers + tests).
|
||||
- Discord: allow clearing channel parents via message tool without nullable schema inputs.
|
||||
|
||||
## 2026.1.11-1
|
||||
## 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
|
||||
- Installer: include `patches/` in the npm package so postinstall patching works for npm/bun installs.
|
||||
- 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)
|
||||
|
||||
### Highlights
|
||||
- Memory: add vector search for agent memories (Markdown-only scope) with SQLite index, chunking, lazy sync + file watch, and per-agent enablement/fallback.
|
||||
|
||||
### 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)
|
||||
|
||||
### 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)
|
||||
|
||||
## 2026.1.11
|
||||
|
||||
@@ -20,9 +133,6 @@
|
||||
- Agents: automatic pre-compaction memory flush turn to store durable memories before compaction.
|
||||
|
||||
### Changes
|
||||
- Deps: update pi-agent-core/pi-ai/pi-coding-agent/pi-tui and refresh the pi-ai patch.
|
||||
- Dev: bump @types/node.
|
||||
- macOS: add wizard debug CLI and share wizard parsing helpers.
|
||||
- CLI/Onboarding: simplify MiniMax auth choice to a single M2.1 option.
|
||||
- CLI: configure section selection now loops until Continue.
|
||||
- Docs: explain MiniMax vs MiniMax Lightning (speed vs cost) and restore LM Studio example.
|
||||
@@ -30,20 +140,16 @@
|
||||
- Onboarding/CLI: group model/auth choice by provider and label Z.AI as GLM 4.7.
|
||||
- Onboarding/Docs: add Moonshot AI (Kimi K2) auth choice + config example.
|
||||
- CLI/Onboarding: prompt to reuse detected API keys for Moonshot/MiniMax/Z.AI/Gemini/Anthropic/OpenCode.
|
||||
- CLI/Onboarding: move MiniMax to the top of the provider list.
|
||||
- CLI/Onboarding: add MiniMax M2.1 Lightning auth choice.
|
||||
- CLI/Onboarding: show key previews when reusing detected API keys.
|
||||
- Auto-reply: add compact `/model` picker (models + available providers) and show provider endpoints in `/model status`.
|
||||
- Control UI: add Config tab model presets (MiniMax M2.1, GLM 4.7, Kimi) for one-click setup.
|
||||
- Plugins: add extension loader (tools/RPC/CLI/services), discovery paths, and config schema + Control UI labels (uiHints).
|
||||
- Plugins: add `clawdbot plugins install` (path/tgz/npm), plus `list|info|enable|disable|doctor` UX.
|
||||
- Plugins: voice-call plugin now real (Twilio/log), adds start/status RPC/CLI/tool + tests.
|
||||
- Docs: add plugins doc + cross-links from tools/skills/gateway config.
|
||||
- Docs: clarify memory flush behavior + writable workspace requirement in Memory/Session/FAQ.
|
||||
- Docs: add beginner-friendly plugin quick start + expand Voice Call plugin docs.
|
||||
- Tests: add Docker plugin loader + tgz-install smoke test.
|
||||
- Tests: extend Docker plugin E2E to cover installing from local folders (`plugins.load.paths`) and `file:` npm specs.
|
||||
- Tests: add coverage for pre-compaction memory flush settings (including read-only/CLI skips).
|
||||
- Tests: add coverage for pre-compaction memory flush settings.
|
||||
- Tests: modernize live model smoke selection for current releases and enforce tools/images/thinking-high coverage. (#769) — thanks @steipete.
|
||||
- Agents/Tools: add `apply_patch` tool for multi-file edits (experimental; gated by tools.exec.applyPatch; OpenAI-only).
|
||||
- Agents/Tools: rename the bash tool to exec (config alias maintained). (#748) — thanks @myfunc.
|
||||
@@ -70,17 +176,9 @@
|
||||
- Installer UX: add `--install-method git|npm` and auto-detect source checkouts (prompt to update git checkout vs migrate to npm).
|
||||
|
||||
### Fixes
|
||||
- Control UI: flatten nav into a single horizontal scroll row on tablet/mobile (and always show collapsed group items). (#771) — thanks @carlulsoe.
|
||||
- macOS: start + await local gateway before onboarding wizard begins.
|
||||
- macOS: cancel onboarding wizard on close, recover if the gateway drops the session, and time out stalled gateway connects.
|
||||
- macOS: wizard debug CLI now surfaces error status instead of exiting as complete.
|
||||
- Models/Onboarding: configure MiniMax (minimax.io) via Anthropic-compatible `/anthropic` endpoint by default (keep `minimax-api` as a legacy alias).
|
||||
- Agents/Browser: cap Playwright AI snapshots for tool calls (maxChars); CLI snapshots remain full. (#763) — thanks @thesash.
|
||||
- Models: normalize Gemini 3 Pro/Flash IDs to preview names for live model lookups. (#769) — thanks @steipete.
|
||||
- CLI: fix guardCancel typing for configure prompts. (#769) — thanks @steipete.
|
||||
- Providers: default groupPolicy to allowlist across providers and warn in doctor when groups are open.
|
||||
- MS Teams: add groupPolicy/groupAllowFrom gating for group chats and warn when groups are open.
|
||||
- Providers: strip tool call/result ids from Gemini CLI payloads to avoid API 400s. (#756)
|
||||
- Gateway/WebChat: include handshake validation details in the WebSocket close reason for easier debugging; preserve close codes.
|
||||
- Gateway/Auth: send invalid connect responses before closing the handshake; stabilize invalid-connect auth test.
|
||||
- Gateway: tighten gateway listener detection.
|
||||
@@ -92,23 +190,17 @@
|
||||
- Config: expand `~` in `CLAWDBOT_CONFIG_PATH` and common path-like config fields (including `plugins.load.paths`); guard invalid `$include` paths. (#731) — thanks @pasogott.
|
||||
- Agents: stop pre-creating session transcripts so first user messages persist in JSONL history.
|
||||
- Agents: skip pre-compaction memory flush when the session workspace is read-only.
|
||||
- Auto-reply: allow inline `/status` for allowlisted senders (stripped before the model); unauthorized senders see it as plain text.
|
||||
- Auto-reply: include config-only allowlisted models in `/model` even when the catalog is partial.
|
||||
- Auto-reply: allow fuzzy `/model` matches (e.g. `/model kimi` or `/model moonshot/kimi`) when unambiguous.
|
||||
- Auto-reply: ignore inline `/status` directives unless the message is directive-only.
|
||||
- CLI/Configure: enter the selected section immediately, then return to the section picker.
|
||||
- CLI/Configure: apply the chosen auth model as default (skip the extra picker) and refresh the model catalog for new providers.
|
||||
- Auto-reply: align `/think` default display with model reasoning defaults. (#751) — thanks @gabriel-trigo.
|
||||
- Auto-reply: flush block reply buffers on tool boundaries. (#750) — thanks @sebslight.
|
||||
- Auto-reply: allow sender fallback for command authorization when `SenderId` is empty (WhatsApp self-chat). (#755) — thanks @juanpablodlc.
|
||||
- Auto-reply: treat whitespace-only sender ids as missing for command authorization (WhatsApp self-chat). (#766) — thanks @steipete.
|
||||
- Heartbeat: refresh prompt text for updated defaults.
|
||||
- Agents/Tools: use PowerShell on Windows to capture system utility output. (#748) — thanks @myfunc.
|
||||
- Agents/Tools: normalize Claude Code-style read/write/edit params (file_path/old_string/new_string) and keep sandbox guards in place. (#768) — thanks @hsrvc.
|
||||
- Docker: tolerate unset optional env vars in docker-setup.sh under strict mode. (#725) — thanks @petradonka.
|
||||
- CLI/Update: preserve base environment when passing overrides to update subprocesses. (#713) — thanks @danielz1z.
|
||||
- Agents: treat message tool errors as failures so fallback replies still send; require `to` + `message` for `action=send`. (#717) — thanks @theglove44.
|
||||
- Agents: preserve reasoning items on tool-only turns.
|
||||
- Agents: enforce `<final>` gating for reasoning-tag providers to prevent tag/reasoning leaks. (#754) — thanks @mcinteerj.
|
||||
- Agents/Subagents: wait for completion before announcing, align wait timeout with run timeout, and make announce prompts more emphatic.
|
||||
- Agents: route subagent transcripts to the target agent sessions directory and add regression coverage. (#708) — thanks @xMikeMickelson.
|
||||
- Agents/Tools: preserve action enums when flattening tool schemas. (#708) — thanks @xMikeMickelson.
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
</p>
|
||||
|
||||
**Clawdbot** is a *personal AI assistant* you run on your own devices.
|
||||
It answers you on the providers you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, WebChat), can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
|
||||
It answers you on the providers you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, WebChat), can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
|
||||
|
||||
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
|
||||
|
||||
@@ -64,7 +64,7 @@ clawdbot gateway --port 18789 --verbose
|
||||
# Send a message
|
||||
clawdbot message send --to +1234567890 --message "Hello from Clawdbot"
|
||||
|
||||
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Slack/Discord)
|
||||
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Slack/Discord/Microsoft Teams)
|
||||
clawdbot agent --message "Ship checklist" --thinking high
|
||||
```
|
||||
|
||||
@@ -96,7 +96,7 @@ Clawdbot connects to real messaging surfaces. Treat inbound DMs as **untrusted i
|
||||
|
||||
Full security guide: [Security](https://docs.clawd.bot/gateway/security)
|
||||
|
||||
Default behavior on Telegram/WhatsApp/Signal/iMessage/Discord/Slack:
|
||||
Default behavior on Telegram/WhatsApp/Signal/iMessage/Microsoft Teams/Discord/Slack:
|
||||
- **DM pairing** (`dmPolicy="pairing"` / `discord.dm.policy="pairing"` / `slack.dm.policy="pairing"`): unknown senders receive a short pairing code and the bot does not process their message.
|
||||
- Approve with: `clawdbot pairing approve <provider> <code>` (then the sender is added to a local allowlist store).
|
||||
- Public inbound DMs require an explicit opt-in: set `dmPolicy="open"` and include `"*"` in the provider allowlist (`allowFrom` / `discord.dm.allowFrom` / `slack.dm.allowFrom`).
|
||||
@@ -106,7 +106,7 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
|
||||
## Highlights
|
||||
|
||||
- **[Local-first Gateway](https://docs.clawd.bot/gateway)** — single control plane for sessions, providers, tools, and events.
|
||||
- **[Multi-provider inbox](https://docs.clawd.bot/providers)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, WebChat, macOS, iOS/Android.
|
||||
- **[Multi-provider inbox](https://docs.clawd.bot/providers)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, WebChat, macOS, iOS/Android.
|
||||
- **[Multi-agent routing](https://docs.clawd.bot/gateway/configuration)** — route inbound providers/accounts/peers to isolated agents (workspaces + per-agent sessions).
|
||||
- **[Voice Wake](https://docs.clawd.bot/nodes/voicewake) + [Talk Mode](https://docs.clawd.bot/nodes/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs.
|
||||
- **[Live Canvas](https://docs.clawd.bot/platforms/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.clawd.bot/platforms/mac/canvas#canvas-a2ui).
|
||||
@@ -114,6 +114,10 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
|
||||
- **[Companion apps](https://docs.clawd.bot/platforms/macos)** — macOS menu bar app + iOS/Android [nodes](https://docs.clawd.bot/nodes).
|
||||
- **[Onboarding](https://docs.clawd.bot/start/wizard) + [skills](https://docs.clawd.bot/tools/skills)** — wizard-driven setup with bundled/managed/workspace skills.
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#clawdbot/clawdbot&type=date&legend=top-left)
|
||||
|
||||
## Everything we built so far
|
||||
|
||||
### Core platform
|
||||
@@ -124,7 +128,7 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
|
||||
- [Media pipeline](https://docs.clawd.bot/nodes/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.clawd.bot/nodes/audio).
|
||||
|
||||
### Providers
|
||||
- [Providers](https://docs.clawd.bot/providers): [WhatsApp](https://docs.clawd.bot/providers/whatsapp) (Baileys), [Telegram](https://docs.clawd.bot/providers/telegram) (grammY), [Slack](https://docs.clawd.bot/providers/slack) (Bolt), [Discord](https://docs.clawd.bot/providers/discord) (discord.js), [Signal](https://docs.clawd.bot/providers/signal) (signal-cli), [iMessage](https://docs.clawd.bot/providers/imessage) (imsg), [WebChat](https://docs.clawd.bot/web/webchat).
|
||||
- [Providers](https://docs.clawd.bot/providers): [WhatsApp](https://docs.clawd.bot/providers/whatsapp) (Baileys), [Telegram](https://docs.clawd.bot/providers/telegram) (grammY), [Slack](https://docs.clawd.bot/providers/slack) (Bolt), [Discord](https://docs.clawd.bot/providers/discord) (discord.js), [Signal](https://docs.clawd.bot/providers/signal) (signal-cli), [iMessage](https://docs.clawd.bot/providers/imessage) (imsg), [Microsoft Teams](https://docs.clawd.bot/providers/msteams) (Bot Framework), [WebChat](https://docs.clawd.bot/web/webchat).
|
||||
- [Group routing](https://docs.clawd.bot/concepts/group-messages): mention gating, reply tags, per-provider chunking and routing. Provider rules: [Providers](https://docs.clawd.bot/providers).
|
||||
|
||||
### Apps + nodes
|
||||
@@ -155,7 +159,7 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
|
||||
## How it works (short)
|
||||
|
||||
```
|
||||
WhatsApp / Telegram / Slack / Discord / Signal / iMessage / WebChat
|
||||
WhatsApp / Telegram / Slack / Discord / Signal / iMessage / Microsoft Teams / WebChat
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────┐
|
||||
@@ -238,12 +242,12 @@ ClawdHub is a minimal skill registry. With ClawdHub enabled, the agent can searc
|
||||
|
||||
## Chat commands
|
||||
|
||||
Send these in WhatsApp/Telegram/Slack/WebChat (group commands are owner-only):
|
||||
Send these in WhatsApp/Telegram/Slack/Microsoft Teams/WebChat (group commands are owner-only):
|
||||
|
||||
- `/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)
|
||||
@@ -356,6 +360,11 @@ Details: [Security guide](https://docs.clawd.bot/gateway/security) · [Docker +
|
||||
- macOS only; Messages must be signed in.
|
||||
- If `imessage.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
|
||||
|
||||
### [Microsoft Teams](https://docs.clawd.bot/providers/msteams)
|
||||
|
||||
- Configure a Teams app + Bot Framework, then add a `msteams` config section.
|
||||
- Allowlist who can talk via `msteams.allowFrom`; group access via `msteams.groupAllowFrom` or `msteams.groupPolicy: "open"`.
|
||||
|
||||
### [WebChat](https://docs.clawd.bot/web/webchat)
|
||||
|
||||
- Uses the Gateway WebSocket; no separate WebChat port/config.
|
||||
@@ -458,20 +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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></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/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/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/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/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/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/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/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/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/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/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/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=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></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/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/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/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/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>
|
||||
</p>
|
||||
+17
-123
@@ -2,6 +2,23 @@
|
||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<title>Clawdbot</title>
|
||||
<item>
|
||||
<title>2026.1.11-3</title>
|
||||
<pubDate>Mon, 12 Jan 2026 10:40:23 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||
<sparkle:version>5212</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.11-3</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.11-3</h2>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>CLI: avoid top-level await warnings in the entrypoint on fresh installs.</li>
|
||||
<li>CLI: show a commit hash in the banner for npm installs (package.json gitHead fallback).</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.11-3/Clawdbot-2026.1.11-3.zip" length="19860758" type="application/octet-stream" sparkle:edSignature="LbvGUSjc3jGO7aVo2UVA0nEkaJbb3O4iwRBo1TBqoapdTtxnDlS3s6N+Z4vOSLRAoAm22EoZOwbpK9085c7HAQ=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.1.11-2</title>
|
||||
<pubDate>Mon, 12 Jan 2026 10:25:53 +0000</pubDate>
|
||||
@@ -35,128 +52,5 @@
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.11-1/Clawdbot-2026.1.11-1.zip" length="19860761" type="application/octet-stream" sparkle:edSignature="CXKzzha/s6cGBeF0TMz+cV8/pfqoAL9ZyNVacYRLnnHEwA1cMbOWRftpGRhYe4HknVQYYBgNQqZK2lBxpOZgBg=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.1.11</title>
|
||||
<pubDate>Mon, 12 Jan 2026 09:37:49 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||
<sparkle:version>5205</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.11</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.11</h2>
|
||||
<h3>Highlights</h3>
|
||||
<ul>
|
||||
<li>Plugins are now first-class: loader + CLI management, plus the new Voice Call plugin.</li>
|
||||
<li>Config: modular <code>$include</code> support for split config files. (#731) — thanks @pasogott.</li>
|
||||
<li>Agents/Pi: reserve compaction headroom so pre-compaction memory writes can run before auto-compaction.</li>
|
||||
<li>Agents: automatic pre-compaction memory flush turn to store durable memories before compaction.</li>
|
||||
</ul>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Deps: update pi-agent-core/pi-ai/pi-coding-agent/pi-tui and refresh the pi-ai patch.</li>
|
||||
<li>Dev: bump @types/node.</li>
|
||||
<li>macOS: add wizard debug CLI and share wizard parsing helpers.</li>
|
||||
<li>CLI/Onboarding: simplify MiniMax auth choice to a single M2.1 option.</li>
|
||||
<li>CLI: configure section selection now loops until Continue.</li>
|
||||
<li>Docs: explain MiniMax vs MiniMax Lightning (speed vs cost) and restore LM Studio example.</li>
|
||||
<li>Docs: add Cerebras GLM 4.6/4.7 config example (OpenAI-compatible endpoint).</li>
|
||||
<li>Onboarding/CLI: group model/auth choice by provider and label Z.AI as GLM 4.7.</li>
|
||||
<li>Onboarding/Docs: add Moonshot AI (Kimi K2) auth choice + config example.</li>
|
||||
<li>CLI/Onboarding: prompt to reuse detected API keys for Moonshot/MiniMax/Z.AI/Gemini/Anthropic/OpenCode.</li>
|
||||
<li>CLI/Onboarding: move MiniMax to the top of the provider list.</li>
|
||||
<li>CLI/Onboarding: add MiniMax M2.1 Lightning auth choice.</li>
|
||||
<li>CLI/Onboarding: show key previews when reusing detected API keys.</li>
|
||||
<li>Auto-reply: add compact <code>/model</code> picker (models + available providers) and show provider endpoints in <code>/model status</code>.</li>
|
||||
<li>Control UI: add Config tab model presets (MiniMax M2.1, GLM 4.7, Kimi) for one-click setup.</li>
|
||||
<li>Plugins: add extension loader (tools/RPC/CLI/services), discovery paths, and config schema + Control UI labels (uiHints).</li>
|
||||
<li>Plugins: add <code>clawdbot plugins install</code> (path/tgz/npm), plus <code>list|info|enable|disable|doctor</code> UX.</li>
|
||||
<li>Plugins: voice-call plugin now real (Twilio/log), adds start/status RPC/CLI/tool + tests.</li>
|
||||
<li>Docs: add plugins doc + cross-links from tools/skills/gateway config.</li>
|
||||
<li>Docs: clarify memory flush behavior + writable workspace requirement in Memory/Session/FAQ.</li>
|
||||
<li>Docs: add beginner-friendly plugin quick start + expand Voice Call plugin docs.</li>
|
||||
<li>Tests: add Docker plugin loader + tgz-install smoke test.</li>
|
||||
<li>Tests: extend Docker plugin E2E to cover installing from local folders (<code>plugins.load.paths</code>) and <code>file:</code> npm specs.</li>
|
||||
<li>Tests: add coverage for pre-compaction memory flush settings (including read-only/CLI skips).</li>
|
||||
<li>Tests: modernize live model smoke selection for current releases and enforce tools/images/thinking-high coverage. (#769) — thanks @steipete.</li>
|
||||
<li>Agents/Tools: add <code>apply_patch</code> tool for multi-file edits (experimental; gated by tools.exec.applyPatch; OpenAI-only).</li>
|
||||
<li>Agents/Tools: rename the bash tool to exec (config alias maintained). (#748) — thanks @myfunc.</li>
|
||||
<li>Agents: add pre-compaction memory flush config (<code>agents.defaults.compaction.*</code>) with a soft threshold + system prompt.</li>
|
||||
<li>Config: add <code>$include</code> directive for modular config files. (#731) — thanks @pasogott.</li>
|
||||
<li>Build: set pnpm minimum release age to 2880 minutes (2 days). (#718) — thanks @dan-dr.</li>
|
||||
<li>macOS: prompt to install the global <code>clawdbot</code> CLI when missing in local mode; install via <code>clawd.bot/install-cli.sh</code> (no onboarding) and use external launchd/CLI instead of the embedded gateway runtime.</li>
|
||||
<li>Docs: add gog calendar event color IDs from <code>gog calendar colors</code>. (#715) — thanks @mjrussell.</li>
|
||||
<li>Cron/CLI: add <code>--model</code> flag to cron add/edit commands. (#711) — thanks @mjrussell.</li>
|
||||
<li>Cron/CLI: trim model overrides on cron edits and document main-session guidance. (#711) — thanks @mjrussell.</li>
|
||||
<li>Skills: bundle <code>skill-creator</code> to guide creating and packaging skills.</li>
|
||||
<li>Providers: add per-DM history limit overrides (<code>dmHistoryLimit</code>) with provider-level config. (#728) — thanks @pkrmf.</li>
|
||||
<li>Discord: expose channel/category management actions in the message tool. (#730) — thanks @NicholasSpisak.</li>
|
||||
<li>Docs: rename README “macOS app” section to “Apps”. (#733) — thanks @AbhisekBasu1.</li>
|
||||
<li>Gateway: require <code>client.id</code> in WebSocket connect params; use <code>client.instanceId</code> for presence de-dupe; update docs/tests.</li>
|
||||
<li>macOS: remove the attach-only gateway setting; local mode now always manages launchd while still attaching to an existing gateway if present.</li>
|
||||
</ul>
|
||||
<h3>Installer</h3>
|
||||
<ul>
|
||||
<li>Postinstall: replace <code>git apply</code> with builtin JS patcher (works npm/pnpm/bun; no git dependency) plus regression tests.</li>
|
||||
<li>Postinstall: skip pnpm patch fallback when the new patcher is active.</li>
|
||||
<li>Installer tests: add root+non-root docker smokes, CI workflow to fetch clawd.bot scripts and run install sh/cli with onboarding skipped.</li>
|
||||
<li>Installer UX: support <code>CLAWDBOT_NO_ONBOARD=1</code> for non-interactive installs; fix npm prefix on Linux and auto-install git.</li>
|
||||
<li>Installer UX: add <code>install.sh --help</code> with flags/env and git install hint.</li>
|
||||
<li>Installer UX: add <code>--install-method git|npm</code> and auto-detect source checkouts (prompt to update git checkout vs migrate to npm).</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Control UI: flatten nav into a single horizontal scroll row on tablet/mobile (and always show collapsed group items). (#771) — thanks @carlulsoe.</li>
|
||||
<li>macOS: start + await local gateway before onboarding wizard begins.</li>
|
||||
<li>macOS: cancel onboarding wizard on close, recover if the gateway drops the session, and time out stalled gateway connects.</li>
|
||||
<li>macOS: wizard debug CLI now surfaces error status instead of exiting as complete.</li>
|
||||
<li>Models/Onboarding: configure MiniMax (minimax.io) via Anthropic-compatible <code>/anthropic</code> endpoint by default (keep <code>minimax-api</code> as a legacy alias).</li>
|
||||
<li>Agents/Browser: cap Playwright AI snapshots for tool calls (maxChars); CLI snapshots remain full. (#763) — thanks @thesash.</li>
|
||||
<li>Models: normalize Gemini 3 Pro/Flash IDs to preview names for live model lookups. (#769) — thanks @steipete.</li>
|
||||
<li>CLI: fix guardCancel typing for configure prompts. (#769) — thanks @steipete.</li>
|
||||
<li>Providers: default groupPolicy to allowlist across providers and warn in doctor when groups are open.</li>
|
||||
<li>MS Teams: add groupPolicy/groupAllowFrom gating for group chats and warn when groups are open.</li>
|
||||
<li>Providers: strip tool call/result ids from Gemini CLI payloads to avoid API 400s. (#756)</li>
|
||||
<li>Gateway/WebChat: include handshake validation details in the WebSocket close reason for easier debugging; preserve close codes.</li>
|
||||
<li>Gateway/Auth: send invalid connect responses before closing the handshake; stabilize invalid-connect auth test.</li>
|
||||
<li>Gateway: tighten gateway listener detection.</li>
|
||||
<li>Control UI: hide onboarding chat when configured and guard the mobile chat sidebar overlay.</li>
|
||||
<li>Auth: read Codex keychain credentials and make the lookup platform-aware.</li>
|
||||
<li>macOS/Release: avoid bundling dist artifacts in relay builds and generate appcasts from zip-only sources.</li>
|
||||
<li>Doctor: surface plugin diagnostics in the report.</li>
|
||||
<li>Plugins: treat <code>plugins.load.paths</code> directory entries as package roots when they contain <code>package.json</code> + <code>clawdbot.extensions</code>; load plugin packages from config dirs; extract archives without system tar.</li>
|
||||
<li>Config: expand <code>~</code> in <code>CLAWDBOT_CONFIG_PATH</code> and common path-like config fields (including <code>plugins.load.paths</code>); guard invalid <code>$include</code> paths. (#731) — thanks @pasogott.</li>
|
||||
<li>Agents: stop pre-creating session transcripts so first user messages persist in JSONL history.</li>
|
||||
<li>Agents: skip pre-compaction memory flush when the session workspace is read-only.</li>
|
||||
<li>Auto-reply: allow inline <code>/status</code> for allowlisted senders (stripped before the model); unauthorized senders see it as plain text.</li>
|
||||
<li>Auto-reply: include config-only allowlisted models in <code>/model</code> even when the catalog is partial.</li>
|
||||
<li>Auto-reply: allow fuzzy <code>/model</code> matches (e.g. <code>/model kimi</code> or <code>/model moonshot/kimi</code>) when unambiguous.</li>
|
||||
<li>Auto-reply: ignore inline <code>/status</code> directives unless the message is directive-only.</li>
|
||||
<li>CLI/Configure: enter the selected section immediately, then return to the section picker.</li>
|
||||
<li>CLI/Configure: apply the chosen auth model as default (skip the extra picker) and refresh the model catalog for new providers.</li>
|
||||
<li>Auto-reply: align <code>/think</code> default display with model reasoning defaults. (#751) — thanks @gabriel-trigo.</li>
|
||||
<li>Auto-reply: flush block reply buffers on tool boundaries. (#750) — thanks @sebslight.</li>
|
||||
<li>Auto-reply: allow sender fallback for command authorization when <code>SenderId</code> is empty (WhatsApp self-chat). (#755) — thanks @juanpablodlc.</li>
|
||||
<li>Heartbeat: refresh prompt text for updated defaults.</li>
|
||||
<li>Agents/Tools: use PowerShell on Windows to capture system utility output. (#748) — thanks @myfunc.</li>
|
||||
<li>Agents/Tools: normalize Claude Code-style read/write/edit params (file_path/old_string/new_string) and keep sandbox guards in place. (#768) — thanks @hsrvc.</li>
|
||||
<li>Docker: tolerate unset optional env vars in docker-setup.sh under strict mode. (#725) — thanks @petradonka.</li>
|
||||
<li>CLI/Update: preserve base environment when passing overrides to update subprocesses. (#713) — thanks @danielz1z.</li>
|
||||
<li>Agents: treat message tool errors as failures so fallback replies still send; require <code>to</code> + <code>message</code> for <code>action=send</code>. (#717) — thanks @theglove44.</li>
|
||||
<li>Agents: preserve reasoning items on tool-only turns.</li>
|
||||
<li>Agents: enforce <code><final></code> gating for reasoning-tag providers to prevent tag/reasoning leaks. (#754) — thanks @mcinteerj.</li>
|
||||
<li>Agents/Subagents: wait for completion before announcing, align wait timeout with run timeout, and make announce prompts more emphatic.</li>
|
||||
<li>Agents: route subagent transcripts to the target agent sessions directory and add regression coverage. (#708) — thanks @xMikeMickelson.</li>
|
||||
<li>Agents/Tools: preserve action enums when flattening tool schemas. (#708) — thanks @xMikeMickelson.</li>
|
||||
<li>Gateway/Agents: canonicalize main session aliases for store writes and add regression coverage. (#709) — thanks @xMikeMickelson.</li>
|
||||
<li>Agents: reset sessions and retry when auto-compaction overflows instead of crashing the gateway.</li>
|
||||
<li>Providers/Telegram: normalize command mentions for consistent parsing. (#729) — thanks @obviyus.</li>
|
||||
<li>Providers: skip DM history limit handling for non-DM sessions. (#728) — thanks @pkrmf.</li>
|
||||
<li>Sandbox: fix non-main mode incorrectly sandboxing the main DM session and align <code>/status</code> runtime reporting with effective sandbox state.</li>
|
||||
<li>Sandbox/Gateway: treat <code>agent:<id>:main</code> as a main-session alias when <code>session.mainKey</code> is customized (backwards compatible).</li>
|
||||
<li>Auto-reply: fast-path allowlisted slash commands (inline <code>/help</code>/<code>/commands</code>/<code>/status</code>/<code>/whoami</code> stripped before model).</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.11/Clawdbot-2026.1.11.zip" length="19860746" type="application/octet-stream" sparkle:edSignature="zmN6RovfpvPNm7PPYKDQ2c8nUtlq4k1+S6c3fvZwEHFRwKOxjhQtaW0phriDJwcqfguU3ibAQd/cpRtP4LYdCQ=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -21,8 +21,8 @@ android {
|
||||
applicationId = "com.clawdbot.android"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 202601112
|
||||
versionName = "2026.1.11-2"
|
||||
versionCode = 202601114
|
||||
versionName = "2026.1.11-4"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.1.11-2</string>
|
||||
<string>2026.1.11-4</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>202601112</string>
|
||||
<string>202601113</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>BNDL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.1.11-2</string>
|
||||
<string>2026.1.11-4</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>202601112</string>
|
||||
<string>202601113</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -204,7 +204,7 @@ final class AppState {
|
||||
private var earBoostTask: Task<Void, Never>?
|
||||
|
||||
init(preview: Bool = false) {
|
||||
self.isPreview = preview
|
||||
self.isPreview = preview || ProcessInfo.processInfo.isRunningTests
|
||||
let onboardingSeen = UserDefaults.standard.bool(forKey: "clawdbot.onboardingSeen")
|
||||
self.isPaused = UserDefaults.standard.bool(forKey: pauseDefaultsKey)
|
||||
self.launchAtLogin = false
|
||||
|
||||
@@ -13,7 +13,9 @@ extension CronJobEditor {
|
||||
guard let job else { return }
|
||||
self.name = job.name
|
||||
self.description = job.description ?? ""
|
||||
self.agentId = job.agentId ?? ""
|
||||
self.enabled = job.enabled
|
||||
self.deleteAfterRun = job.deleteAfterRun ?? false
|
||||
self.sessionTarget = job.sessionTarget
|
||||
self.wakeMode = job.wakeMode
|
||||
|
||||
@@ -59,18 +61,60 @@ extension CronJobEditor {
|
||||
}
|
||||
|
||||
func buildPayload() throws -> [String: AnyCodable] {
|
||||
let name = self.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let name = try self.requireName()
|
||||
let description = self.trimmed(self.description)
|
||||
let agentId = self.trimmed(self.agentId)
|
||||
let schedule = try self.buildSchedule()
|
||||
let payload = try self.buildSelectedPayload()
|
||||
|
||||
try self.validateSessionTarget(payload)
|
||||
try self.validatePayloadRequiredFields(payload)
|
||||
|
||||
var root: [String: Any] = [
|
||||
"name": name,
|
||||
"enabled": self.enabled,
|
||||
"schedule": schedule,
|
||||
"sessionTarget": self.sessionTarget.rawValue,
|
||||
"wakeMode": self.wakeMode.rawValue,
|
||||
"payload": payload,
|
||||
]
|
||||
self.applyDeleteAfterRun(to: &root)
|
||||
if !description.isEmpty { root["description"] = description }
|
||||
if !agentId.isEmpty {
|
||||
root["agentId"] = agentId
|
||||
} else if self.job?.agentId != nil {
|
||||
root["agentId"] = NSNull()
|
||||
}
|
||||
|
||||
if self.sessionTarget == .isolated {
|
||||
let trimmed = self.postPrefix.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
root["isolation"] = [
|
||||
"postToMainPrefix": trimmed.isEmpty ? "Cron" : trimmed,
|
||||
]
|
||||
}
|
||||
|
||||
return root.mapValues { AnyCodable($0) }
|
||||
}
|
||||
|
||||
func trimmed(_ value: String) -> String {
|
||||
value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
func requireName() throws -> String {
|
||||
let name = self.trimmed(self.name)
|
||||
if name.isEmpty {
|
||||
throw NSError(
|
||||
domain: "Cron",
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Name is required."])
|
||||
}
|
||||
let description = self.description.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let schedule: [String: Any]
|
||||
return name
|
||||
}
|
||||
|
||||
func buildSchedule() throws -> [String: Any] {
|
||||
switch self.scheduleKind {
|
||||
case .at:
|
||||
schedule = ["kind": "at", "atMs": Int(self.atDate.timeIntervalSince1970 * 1000)]
|
||||
return ["kind": "at", "atMs": Int(self.atDate.timeIntervalSince1970 * 1000)]
|
||||
case .every:
|
||||
guard let ms = Self.parseDurationMs(self.everyText) else {
|
||||
throw NSError(
|
||||
@@ -78,34 +122,35 @@ extension CronJobEditor {
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Invalid every duration (use 10m, 1h, 1d)."])
|
||||
}
|
||||
schedule = ["kind": "every", "everyMs": ms]
|
||||
return ["kind": "every", "everyMs": ms]
|
||||
case .cron:
|
||||
let expr = self.cronExpr.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let expr = self.trimmed(self.cronExpr)
|
||||
if expr.isEmpty {
|
||||
throw NSError(
|
||||
domain: "Cron",
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Cron expression is required."])
|
||||
}
|
||||
let tz = self.cronTz.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let tz = self.trimmed(self.cronTz)
|
||||
if tz.isEmpty {
|
||||
schedule = ["kind": "cron", "expr": expr]
|
||||
} else {
|
||||
schedule = ["kind": "cron", "expr": expr, "tz": tz]
|
||||
return ["kind": "cron", "expr": expr]
|
||||
}
|
||||
return ["kind": "cron", "expr": expr, "tz": tz]
|
||||
}
|
||||
}
|
||||
|
||||
let payload: [String: Any] = {
|
||||
if self.sessionTarget == .isolated { return self.buildAgentTurnPayload() }
|
||||
switch self.payloadKind {
|
||||
case .systemEvent:
|
||||
let text = self.systemEventText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return ["kind": "systemEvent", "text": text]
|
||||
case .agentTurn:
|
||||
return self.buildAgentTurnPayload()
|
||||
}
|
||||
}()
|
||||
func buildSelectedPayload() throws -> [String: Any] {
|
||||
if self.sessionTarget == .isolated { return self.buildAgentTurnPayload() }
|
||||
switch self.payloadKind {
|
||||
case .systemEvent:
|
||||
let text = self.trimmed(self.systemEventText)
|
||||
return ["kind": "systemEvent", "text": text]
|
||||
case .agentTurn:
|
||||
return self.buildAgentTurnPayload()
|
||||
}
|
||||
}
|
||||
|
||||
func validateSessionTarget(_ payload: [String: Any]) throws {
|
||||
if self.sessionTarget == .main, payload["kind"] as? String == "agentTurn" {
|
||||
throw NSError(
|
||||
domain: "Cron",
|
||||
@@ -122,7 +167,9 @@ extension CronJobEditor {
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Isolated jobs require agentTurn payloads."])
|
||||
}
|
||||
}
|
||||
|
||||
func validatePayloadRequiredFields(_ payload: [String: Any]) throws {
|
||||
if payload["kind"] as? String == "systemEvent" {
|
||||
if (payload["text"] as? String ?? "").isEmpty {
|
||||
throw NSError(
|
||||
@@ -130,7 +177,8 @@ extension CronJobEditor {
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "System event text is required."])
|
||||
}
|
||||
} else if payload["kind"] as? String == "agentTurn" {
|
||||
}
|
||||
if payload["kind"] as? String == "agentTurn" {
|
||||
if (payload["message"] as? String ?? "").isEmpty {
|
||||
throw NSError(
|
||||
domain: "Cron",
|
||||
@@ -138,25 +186,14 @@ extension CronJobEditor {
|
||||
userInfo: [NSLocalizedDescriptionKey: "Agent message is required."])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var root: [String: Any] = [
|
||||
"name": name,
|
||||
"enabled": self.enabled,
|
||||
"schedule": schedule,
|
||||
"sessionTarget": self.sessionTarget.rawValue,
|
||||
"wakeMode": self.wakeMode.rawValue,
|
||||
"payload": payload,
|
||||
]
|
||||
if !description.isEmpty { root["description"] = description }
|
||||
|
||||
if self.sessionTarget == .isolated {
|
||||
let trimmed = self.postPrefix.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
root["isolation"] = [
|
||||
"postToMainPrefix": trimmed.isEmpty ? "Cron" : trimmed,
|
||||
]
|
||||
func applyDeleteAfterRun(to root: inout [String: Any]) {
|
||||
if self.scheduleKind == .at {
|
||||
root["deleteAfterRun"] = self.deleteAfterRun
|
||||
} else if self.job?.deleteAfterRun != nil {
|
||||
root["deleteAfterRun"] = false
|
||||
}
|
||||
|
||||
return root.mapValues { AnyCodable($0) }
|
||||
}
|
||||
|
||||
func buildAgentTurnPayload() -> [String: Any] {
|
||||
|
||||
@@ -3,6 +3,7 @@ extension CronJobEditor {
|
||||
mutating func exerciseForTesting() {
|
||||
self.name = "Test job"
|
||||
self.description = "Test description"
|
||||
self.agentId = "ops"
|
||||
self.enabled = true
|
||||
self.sessionTarget = .isolated
|
||||
self.wakeMode = .now
|
||||
|
||||
@@ -27,9 +27,11 @@ struct CronJobEditor: View {
|
||||
|
||||
@State var name: String = ""
|
||||
@State var description: String = ""
|
||||
@State var agentId: String = ""
|
||||
@State var enabled: Bool = true
|
||||
@State var sessionTarget: CronSessionTarget = .main
|
||||
@State var wakeMode: CronWakeMode = .nextHeartbeat
|
||||
@State var deleteAfterRun: Bool = false
|
||||
|
||||
enum ScheduleKind: String, CaseIterable, Identifiable { case at, every, cron; var id: String { rawValue } }
|
||||
@State var scheduleKind: ScheduleKind = .every
|
||||
@@ -77,6 +79,12 @@ struct CronJobEditor: View {
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Agent ID")
|
||||
TextField("Optional (default agent)", text: self.$agentId)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Enabled")
|
||||
Toggle("", isOn: self.$enabled)
|
||||
@@ -149,6 +157,11 @@ struct CronJobEditor: View {
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Auto-delete")
|
||||
Toggle("Delete after successful run", isOn: self.$deleteAfterRun)
|
||||
.toggleStyle(.switch)
|
||||
}
|
||||
case .every:
|
||||
GridRow {
|
||||
self.gridLabel("Every")
|
||||
|
||||
@@ -145,9 +145,11 @@ struct CronJobState: Codable, Equatable {
|
||||
|
||||
struct CronJob: Identifiable, Codable, Equatable {
|
||||
let id: String
|
||||
let agentId: String?
|
||||
var name: String
|
||||
var description: String?
|
||||
var enabled: Bool
|
||||
var deleteAfterRun: Bool?
|
||||
let createdAtMs: Int
|
||||
let updatedAtMs: Int
|
||||
let schedule: CronSchedule
|
||||
|
||||
@@ -20,6 +20,9 @@ extension CronSettings {
|
||||
HStack(spacing: 6) {
|
||||
StatusPill(text: job.sessionTarget.rawValue, tint: .secondary)
|
||||
StatusPill(text: job.wakeMode.rawValue, tint: .secondary)
|
||||
if let agentId = job.agentId, !agentId.isEmpty {
|
||||
StatusPill(text: "agent \(agentId)", tint: .secondary)
|
||||
}
|
||||
if let status = job.state.lastStatus {
|
||||
StatusPill(text: status, tint: status == "ok" ? .green : .orange)
|
||||
}
|
||||
@@ -91,9 +94,15 @@ extension CronSettings {
|
||||
func detailCard(_ job: CronJob) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
LabeledContent("Schedule") { Text(self.scheduleSummary(job.schedule)).font(.callout) }
|
||||
if case .at = job.schedule, job.deleteAfterRun == true {
|
||||
LabeledContent("Auto-delete") { Text("after success") }
|
||||
}
|
||||
if let desc = job.description, !desc.isEmpty {
|
||||
LabeledContent("Description") { Text(desc).font(.callout) }
|
||||
}
|
||||
if let agentId = job.agentId, !agentId.isEmpty {
|
||||
LabeledContent("Agent") { Text(agentId) }
|
||||
}
|
||||
LabeledContent("Session") { Text(job.sessionTarget.rawValue) }
|
||||
LabeledContent("Wake") { Text(job.wakeMode.rawValue) }
|
||||
LabeledContent("Next run") {
|
||||
|
||||
@@ -7,9 +7,11 @@ struct CronSettings_Previews: PreviewProvider {
|
||||
store.jobs = [
|
||||
CronJob(
|
||||
id: "job-1",
|
||||
agentId: "ops",
|
||||
name: "Daily summary",
|
||||
description: nil,
|
||||
enabled: true,
|
||||
deleteAfterRun: nil,
|
||||
createdAtMs: 0,
|
||||
updatedAtMs: 0,
|
||||
schedule: .every(everyMs: 86_400_000, anchorMs: nil),
|
||||
@@ -59,9 +61,11 @@ extension CronSettings {
|
||||
|
||||
let job = CronJob(
|
||||
id: "job-1",
|
||||
agentId: "ops",
|
||||
name: "Daily summary",
|
||||
description: "Summary job",
|
||||
enabled: true,
|
||||
deleteAfterRun: nil,
|
||||
createdAtMs: 1_700_000_000_000,
|
||||
updatedAtMs: 1_700_000_100_000,
|
||||
schedule: .cron(expr: "0 8 * * *", tz: "UTC"),
|
||||
|
||||
@@ -149,8 +149,7 @@ final class OnboardingWizardModel {
|
||||
onboardingWizardLogger.error("wizard step decode failed")
|
||||
}
|
||||
if res.done { self.currentStep = nil }
|
||||
if res.done || status == "done" || status == "cancelled" || status == "error"
|
||||
{
|
||||
if res.done || status == "done" || status == "cancelled" || status == "error" {
|
||||
self.sessionId = nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.1.11-2</string>
|
||||
<string>2026.1.11-4</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>202601112</string>
|
||||
<string>202601113</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>Clawdbot</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
@@ -1352,9 +1352,11 @@ public struct SkillsUpdateParams: Codable, Sendable {
|
||||
|
||||
public struct CronJob: Codable, Sendable {
|
||||
public let id: String
|
||||
public let agentid: String?
|
||||
public let name: String
|
||||
public let description: String?
|
||||
public let enabled: Bool
|
||||
public let deleteafterrun: Bool?
|
||||
public let createdatms: Int
|
||||
public let updatedatms: Int
|
||||
public let schedule: AnyCodable
|
||||
@@ -1366,9 +1368,11 @@ public struct CronJob: Codable, Sendable {
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
agentid: String?,
|
||||
name: String,
|
||||
description: String?,
|
||||
enabled: Bool,
|
||||
deleteafterrun: Bool?,
|
||||
createdatms: Int,
|
||||
updatedatms: Int,
|
||||
schedule: AnyCodable,
|
||||
@@ -1379,9 +1383,11 @@ public struct CronJob: Codable, Sendable {
|
||||
state: [String: AnyCodable]
|
||||
) {
|
||||
self.id = id
|
||||
self.agentid = agentid
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.enabled = enabled
|
||||
self.deleteafterrun = deleteafterrun
|
||||
self.createdatms = createdatms
|
||||
self.updatedatms = updatedatms
|
||||
self.schedule = schedule
|
||||
@@ -1393,9 +1399,11 @@ public struct CronJob: Codable, Sendable {
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case agentid = "agentId"
|
||||
case name
|
||||
case description
|
||||
case enabled
|
||||
case deleteafterrun = "deleteAfterRun"
|
||||
case createdatms = "createdAtMs"
|
||||
case updatedatms = "updatedAtMs"
|
||||
case schedule
|
||||
@@ -1425,8 +1433,10 @@ public struct CronStatusParams: Codable, Sendable {
|
||||
|
||||
public struct CronAddParams: Codable, Sendable {
|
||||
public let name: String
|
||||
public let agentid: AnyCodable?
|
||||
public let description: String?
|
||||
public let enabled: Bool?
|
||||
public let deleteafterrun: Bool?
|
||||
public let schedule: AnyCodable
|
||||
public let sessiontarget: AnyCodable
|
||||
public let wakemode: AnyCodable
|
||||
@@ -1435,8 +1445,10 @@ public struct CronAddParams: Codable, Sendable {
|
||||
|
||||
public init(
|
||||
name: String,
|
||||
agentid: AnyCodable?,
|
||||
description: String?,
|
||||
enabled: Bool?,
|
||||
deleteafterrun: Bool?,
|
||||
schedule: AnyCodable,
|
||||
sessiontarget: AnyCodable,
|
||||
wakemode: AnyCodable,
|
||||
@@ -1444,8 +1456,10 @@ public struct CronAddParams: Codable, Sendable {
|
||||
isolation: [String: AnyCodable]?
|
||||
) {
|
||||
self.name = name
|
||||
self.agentid = agentid
|
||||
self.description = description
|
||||
self.enabled = enabled
|
||||
self.deleteafterrun = deleteafterrun
|
||||
self.schedule = schedule
|
||||
self.sessiontarget = sessiontarget
|
||||
self.wakemode = wakemode
|
||||
@@ -1454,8 +1468,10 @@ public struct CronAddParams: Codable, Sendable {
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
case agentid = "agentId"
|
||||
case description
|
||||
case enabled
|
||||
case deleteafterrun = "deleteAfterRun"
|
||||
case schedule
|
||||
case sessiontarget = "sessionTarget"
|
||||
case wakemode = "wakeMode"
|
||||
@@ -1464,70 +1480,6 @@ public struct CronAddParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronUpdateParams: Codable, Sendable {
|
||||
public let id: String
|
||||
public let patch: [String: AnyCodable]
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
patch: [String: AnyCodable]
|
||||
) {
|
||||
self.id = id
|
||||
self.patch = patch
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case patch
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronRemoveParams: Codable, Sendable {
|
||||
public let id: String
|
||||
|
||||
public init(
|
||||
id: String
|
||||
) {
|
||||
self.id = id
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronRunParams: Codable, Sendable {
|
||||
public let id: String
|
||||
public let mode: AnyCodable?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
mode: AnyCodable?
|
||||
) {
|
||||
self.id = id
|
||||
self.mode = mode
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case mode
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronRunsParams: Codable, Sendable {
|
||||
public let id: String
|
||||
public let limit: Int?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
limit: Int?
|
||||
) {
|
||||
self.id = id
|
||||
self.limit = limit
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case limit
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronRunLogEntry: Codable, Sendable {
|
||||
public let ts: Int
|
||||
public let jobid: String
|
||||
|
||||
@@ -22,17 +22,17 @@ struct WizardCliOptions {
|
||||
case "--json":
|
||||
opts.json = true
|
||||
case "--url":
|
||||
opts.url = nextValue(args, index: &i)
|
||||
opts.url = self.nextValue(args, index: &i)
|
||||
case "--token":
|
||||
opts.token = nextValue(args, index: &i)
|
||||
opts.token = self.nextValue(args, index: &i)
|
||||
case "--password":
|
||||
opts.password = nextValue(args, index: &i)
|
||||
opts.password = self.nextValue(args, index: &i)
|
||||
case "--mode":
|
||||
if let value = nextValue(args, index: &i) {
|
||||
opts.mode = value
|
||||
}
|
||||
case "--workspace":
|
||||
opts.workspace = nextValue(args, index: &i)
|
||||
opts.workspace = self.nextValue(args, index: &i)
|
||||
default:
|
||||
break
|
||||
}
|
||||
@@ -118,7 +118,8 @@ private func printUsage() {
|
||||
clawdbot-mac-wizard
|
||||
|
||||
Usage:
|
||||
clawdbot-mac-wizard [--url <ws://host:port>] [--token <token>] [--password <password>] [--mode <local|remote>] [--workspace <path>] [--json]
|
||||
clawdbot-mac-wizard [--url <ws://host:port>] [--token <token>] [--password <password>]
|
||||
[--mode <local|remote>] [--workspace <path>] [--json]
|
||||
|
||||
Options:
|
||||
--url <url> Gateway WebSocket URL (overrides config)
|
||||
|
||||
@@ -23,8 +23,11 @@ struct CronJobEditorSmokeTests {
|
||||
@Test func cronJobEditorBuildsBodyForExistingJob() {
|
||||
let job = CronJob(
|
||||
id: "job-1",
|
||||
agentId: "ops",
|
||||
name: "Daily summary",
|
||||
description: nil,
|
||||
enabled: true,
|
||||
deleteAfterRun: nil,
|
||||
createdAtMs: 1_700_000_000_000,
|
||||
updatedAtMs: 1_700_000_000_000,
|
||||
schedule: .every(everyMs: 3_600_000, anchorMs: 1_700_000_000_000),
|
||||
@@ -65,4 +68,24 @@ struct CronJobEditorSmokeTests {
|
||||
onSave: { _ in })
|
||||
view.exerciseForTesting()
|
||||
}
|
||||
|
||||
@Test func cronJobEditorIncludesDeleteAfterRunForAtSchedule() throws {
|
||||
var view = CronJobEditor(
|
||||
job: nil,
|
||||
isSaving: .constant(false),
|
||||
error: .constant(nil),
|
||||
onCancel: {},
|
||||
onSave: { _ in })
|
||||
view.name = "One-shot"
|
||||
view.sessionTarget = .main
|
||||
view.payloadKind = .systemEvent
|
||||
view.systemEventText = "hello"
|
||||
view.scheduleKind = .at
|
||||
view.atDate = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
view.deleteAfterRun = true
|
||||
|
||||
let payload = try view.buildPayload()
|
||||
let raw = payload["deleteAfterRun"]?.value as? Bool
|
||||
#expect(raw == true)
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,27 @@ struct CronModelsTests {
|
||||
#expect(decoded == payload)
|
||||
}
|
||||
|
||||
@Test func jobEncodesAndDecodesDeleteAfterRun() throws {
|
||||
let job = CronJob(
|
||||
id: "job-1",
|
||||
agentId: nil,
|
||||
name: "One-shot",
|
||||
description: nil,
|
||||
enabled: true,
|
||||
deleteAfterRun: true,
|
||||
createdAtMs: 0,
|
||||
updatedAtMs: 0,
|
||||
schedule: .at(atMs: 1_700_000_000_000),
|
||||
sessionTarget: .main,
|
||||
wakeMode: .now,
|
||||
payload: .systemEvent(text: "ping"),
|
||||
isolation: nil,
|
||||
state: CronJobState())
|
||||
let data = try JSONEncoder().encode(job)
|
||||
let decoded = try JSONDecoder().decode(CronJob.self, from: data)
|
||||
#expect(decoded.deleteAfterRun == true)
|
||||
}
|
||||
|
||||
@Test func scheduleDecodeRejectsUnknownKind() {
|
||||
let json = """
|
||||
{"kind":"wat","atMs":1}
|
||||
@@ -60,9 +81,11 @@ struct CronModelsTests {
|
||||
@Test func displayNameTrimsWhitespaceAndFallsBack() {
|
||||
let base = CronJob(
|
||||
id: "x",
|
||||
agentId: nil,
|
||||
name: " hello ",
|
||||
description: nil,
|
||||
enabled: true,
|
||||
deleteAfterRun: nil,
|
||||
createdAtMs: 0,
|
||||
updatedAtMs: 0,
|
||||
schedule: .at(atMs: 0),
|
||||
@@ -81,9 +104,11 @@ struct CronModelsTests {
|
||||
@Test func nextRunDateAndLastRunDateDeriveFromState() {
|
||||
let job = CronJob(
|
||||
id: "x",
|
||||
agentId: nil,
|
||||
name: "t",
|
||||
description: nil,
|
||||
enabled: true,
|
||||
deleteAfterRun: nil,
|
||||
createdAtMs: 0,
|
||||
updatedAtMs: 0,
|
||||
schedule: .at(atMs: 0),
|
||||
|
||||
@@ -19,22 +19,19 @@ import Testing
|
||||
error: "gateway connect failed",
|
||||
elapsedMs: 12,
|
||||
bot: nil,
|
||||
webhook: nil
|
||||
),
|
||||
lastProbeAt: 0
|
||||
),
|
||||
webhook: nil),
|
||||
lastProbeAt: 0),
|
||||
],
|
||||
providerOrder: ["whatsapp"],
|
||||
providerLabels: ["whatsapp": "WhatsApp"],
|
||||
heartbeatSeconds: 60,
|
||||
sessions: .init(path: "/tmp/sessions.json", count: 0, recent: [])
|
||||
)
|
||||
sessions: .init(path: "/tmp/sessions.json", count: 0, recent: []))
|
||||
|
||||
let store = HealthStore.shared
|
||||
store.__setSnapshotForTest(snap, lastError: nil)
|
||||
|
||||
switch store.state {
|
||||
case .degraded(let message):
|
||||
case let .degraded(message):
|
||||
#expect(!message.isEmpty)
|
||||
default:
|
||||
Issue.record("Expected degraded state when probe fails for linked provider")
|
||||
@@ -43,4 +40,3 @@ import Testing
|
||||
#expect(store.summaryLine.contains("probe degraded"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,9 +12,11 @@ struct SettingsViewSmokeTests {
|
||||
|
||||
let job1 = CronJob(
|
||||
id: "job-1",
|
||||
agentId: "ops",
|
||||
name: " Morning Check-in ",
|
||||
description: nil,
|
||||
enabled: true,
|
||||
deleteAfterRun: nil,
|
||||
createdAtMs: 1_700_000_000_000,
|
||||
updatedAtMs: 1_700_000_100_000,
|
||||
schedule: .cron(expr: "0 8 * * *", tz: "UTC"),
|
||||
@@ -32,9 +34,11 @@ struct SettingsViewSmokeTests {
|
||||
|
||||
let job2 = CronJob(
|
||||
id: "job-2",
|
||||
agentId: nil,
|
||||
name: "",
|
||||
description: nil,
|
||||
enabled: false,
|
||||
deleteAfterRun: nil,
|
||||
createdAtMs: 1_700_000_000_000,
|
||||
updatedAtMs: 1_700_000_100_000,
|
||||
schedule: .every(everyMs: 30000, anchorMs: nil),
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 254 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
@@ -20,6 +20,24 @@ cron is the mechanism.
|
||||
- **Isolated**: run a dedicated agent turn in `cron:<jobId>`, optionally deliver output.
|
||||
- Wakeups are first-class: a job can request “wake now” vs “next heartbeat”.
|
||||
|
||||
## Beginner-friendly overview
|
||||
Think of a cron job as: **when** to run + **what** to do.
|
||||
|
||||
1) **Choose a schedule**
|
||||
- One-shot reminder → `schedule.kind = "at"` (CLI: `--at`)
|
||||
- Repeating job → `schedule.kind = "every"` or `schedule.kind = "cron"`
|
||||
- If your ISO timestamp omits a timezone, it is treated as **UTC**.
|
||||
|
||||
2) **Choose where it runs**
|
||||
- `sessionTarget: "main"` → run during the next heartbeat with main context.
|
||||
- `sessionTarget: "isolated"` → run a dedicated agent turn in `cron:<jobId>`.
|
||||
|
||||
3) **Choose the payload**
|
||||
- Main session → `payload.kind = "systemEvent"`
|
||||
- Isolated session → `payload.kind = "agentTurn"`
|
||||
|
||||
Optional: `deleteAfterRun: true` removes successful one-shot jobs from the store.
|
||||
|
||||
## Concepts
|
||||
|
||||
### Jobs
|
||||
@@ -27,13 +45,16 @@ A cron job is a stored record with:
|
||||
- a **schedule** (when it should run),
|
||||
- a **payload** (what it should do),
|
||||
- optional **delivery** (where output should be sent).
|
||||
- optional **agent binding** (`agentId`): run the job under a specific agent; if
|
||||
missing or unknown, the gateway falls back to the default agent.
|
||||
|
||||
Jobs are identified by a stable `jobId` (used by CLI/Gateway APIs).
|
||||
In agent tool calls, `jobId` is canonical; legacy `id` is accepted for compatibility.
|
||||
Jobs can optionally auto-delete after a successful one-shot run via `deleteAfterRun: true`.
|
||||
|
||||
### Schedules
|
||||
Cron supports three schedule kinds:
|
||||
- `at`: one-shot timestamp (ms since epoch).
|
||||
- `at`: one-shot timestamp (ms since epoch). Gateway accepts ISO 8601 and coerces to UTC.
|
||||
- `every`: fixed interval (ms).
|
||||
- `cron`: 5-field cron expression with optional IANA timezone.
|
||||
|
||||
@@ -84,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
|
||||
@@ -141,6 +162,17 @@ Disable cron entirely:
|
||||
|
||||
## CLI quickstart
|
||||
|
||||
One-shot reminder (UTC ISO, auto-delete after success):
|
||||
```bash
|
||||
clawdbot cron add \
|
||||
--name "Send reminder" \
|
||||
--at "2026-01-12T18:00:00Z" \
|
||||
--session main \
|
||||
--system-event "Reminder: submit expense report." \
|
||||
--wake now \
|
||||
--delete-after-run
|
||||
```
|
||||
|
||||
One-shot reminder (main session, wake immediately):
|
||||
```bash
|
||||
clawdbot cron add \
|
||||
@@ -190,6 +222,16 @@ clawdbot cron add \
|
||||
--deliver \
|
||||
--provider whatsapp \
|
||||
--to "+15551234567"
|
||||
|
||||
Agent selection (multi-agent setups):
|
||||
```bash
|
||||
# Pin a job to agent "ops" (falls back to default if that agent is missing)
|
||||
clawdbot cron add --name "Ops sweep" --cron "0 6 * * *" --session isolated --message "Check ops queue" --agent ops
|
||||
|
||||
# Switch or clear the agent on an existing job
|
||||
clawdbot cron edit <jobId> --agent ops
|
||||
clawdbot cron edit <jobId> --clear-agent
|
||||
```
|
||||
```
|
||||
|
||||
Manual run (debug):
|
||||
|
||||
@@ -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.
|
||||
+13
-1
@@ -70,6 +70,10 @@ clawdbot [--dev] [--profile <name>] <command>
|
||||
enable
|
||||
disable
|
||||
doctor
|
||||
memory
|
||||
status
|
||||
index
|
||||
search
|
||||
message
|
||||
agent
|
||||
agents
|
||||
@@ -188,6 +192,14 @@ Manage extensions and their config:
|
||||
|
||||
Most plugin changes require a gateway restart. See [/plugin](/plugin).
|
||||
|
||||
## Memory
|
||||
|
||||
Vector search over `MEMORY.md` + `memory/*.md`:
|
||||
|
||||
- `clawdbot memory status` — show index stats.
|
||||
- `clawdbot memory index` — reindex memory files.
|
||||
- `clawdbot memory search "<query>"` — semantic search over memory.
|
||||
|
||||
## Chat slash commands
|
||||
|
||||
Chat messages support `/...` commands (text and native). See [/tools/slash-commands](/tools/slash-commands).
|
||||
@@ -386,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`
|
||||
|
||||
+2
-2
@@ -46,7 +46,7 @@ Target formats (`--to`):
|
||||
- Providers: WhatsApp/Telegram/Discord/Slack/Signal/iMessage/MS Teams
|
||||
- Required: `--to`, `--message`
|
||||
- Optional: `--media`, `--reply-to`, `--thread-id`, `--gif-playback`
|
||||
- Telegram only: `--buttons-json` (requires `"inlineButtons"` in `telegram.capabilities` or `telegram.accounts.<id>.capabilities`)
|
||||
- Telegram only: `--buttons` (requires `"inlineButtons"` in `telegram.capabilities` or `telegram.accounts.<id>.capabilities`)
|
||||
- Telegram only: `--thread-id` (forum topic id)
|
||||
- Slack only: `--thread-id` (thread timestamp; `--reply-to` uses the same field)
|
||||
- WhatsApp only: `--gif-playback`
|
||||
@@ -206,5 +206,5 @@ clawdbot message react --provider slack \
|
||||
Send Telegram inline buttons:
|
||||
```
|
||||
clawdbot message send --provider telegram --to @mychat --message "Choose:" \
|
||||
--buttons-json '[ [{"text":"Yes","callback_data":"cmd:yes"}], [{"text":"No","callback_data":"cmd:no"}] ]'
|
||||
--buttons '[ [{"text":"Yes","callback_data":"cmd:yes"}], [{"text":"No","callback_data":"cmd:no"}] ]'
|
||||
```
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
summary: "Compaction modes and configuration"
|
||||
read_when:
|
||||
- You want to configure compaction summarization behavior
|
||||
- You are tuning compaction settings in clawdbot.json
|
||||
---
|
||||
# Compaction
|
||||
|
||||
Compaction summarizes older session history so the conversation stays within the model context window. The summary is stored in the session JSONL history and combined with the most recent messages.
|
||||
|
||||
## Modes
|
||||
|
||||
`agents.defaults.compaction.mode` controls how summaries are generated.
|
||||
|
||||
- `default` (default): use the built-in compaction summarizer.
|
||||
- `safeguard`: uses a chunked summarization pass to avoid context overflow for very long histories. If chunked summarization fails, Clawdbot falls back to a minimal summary plus file-operation metadata.
|
||||
|
||||
## Configuration
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
compaction: {
|
||||
mode: "safeguard",
|
||||
reserveTokensFloor: 20000,
|
||||
memoryFlush: {
|
||||
enabled: true,
|
||||
softThresholdTokens: 4000
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Related docs
|
||||
|
||||
- [Context window + compaction behavior](/concepts/compaction)
|
||||
- [Gateway configuration reference](/gateway/configuration)
|
||||
@@ -109,8 +109,10 @@ See [Memory](/concepts/memory) for the workflow and automatic memory flush.
|
||||
- Canvas UI files for node displays (for example `canvas/index.html`).
|
||||
|
||||
If any bootstrap file is missing, Clawdbot injects a "missing file" marker into
|
||||
the session and continues. `clawdbot setup` can recreate missing defaults
|
||||
without overwriting existing files.
|
||||
the session and continues. Large bootstrap files are truncated when injected;
|
||||
adjust the limit with `agents.defaults.bootstrapMaxChars` (default: 20000).
|
||||
`clawdbot setup` can recreate missing defaults without overwriting existing
|
||||
files.
|
||||
|
||||
## What is NOT in the workspace
|
||||
|
||||
|
||||
@@ -15,6 +15,9 @@ Compaction **summarizes older conversation** into a compact summary entry and ke
|
||||
|
||||
Compaction **persists** in the session’s JSONL history.
|
||||
|
||||
## Configuration
|
||||
See [Compaction config & modes](/compaction) for the `agents.defaults.compaction` settings.
|
||||
|
||||
## Auto-compaction (default on)
|
||||
When a session nears or exceeds the model’s context window, Clawdbot triggers auto-compaction and may retry the original request using the compacted context.
|
||||
|
||||
|
||||
@@ -212,7 +212,7 @@ Group inbound payloads set:
|
||||
- `WasMentioned` (mention gating result)
|
||||
- Telegram forum topics also include `MessageThreadId` and `IsForum`.
|
||||
|
||||
The agent system prompt includes a group intro on the first turn of a new group session.
|
||||
The agent system prompt includes a group intro on the first turn of a new group session. It reminds the model to respond like a human, avoid Markdown tables, and avoid typing literal `\n` sequences.
|
||||
|
||||
## iMessage specifics
|
||||
- Prefer `chat_id:<id>` when routing or allowlisting.
|
||||
|
||||
@@ -67,3 +67,110 @@ Details:
|
||||
|
||||
For the full compaction lifecycle, see
|
||||
[Session management + compaction](/reference/session-management-compaction).
|
||||
|
||||
## Vector memory search
|
||||
|
||||
Clawdbot can build a small vector index over `MEMORY.md` and `memory/*.md` so
|
||||
semantic queries can find related notes even when wording differs.
|
||||
|
||||
Defaults:
|
||||
- Enabled by default.
|
||||
- Watches memory files for changes (debounced).
|
||||
- Uses remote embeddings (OpenAI) unless configured for local.
|
||||
- Local mode uses node-llama-cpp and may require `pnpm approve-builds`.
|
||||
|
||||
Remote embeddings **require** an API key for the embedding provider. By default
|
||||
this is OpenAI (`OPENAI_API_KEY` or `models.providers.openai.apiKey`). Codex
|
||||
OAuth only covers chat/completions and does **not** satisfy embeddings for
|
||||
memory search. When using a custom OpenAI-compatible endpoint, set
|
||||
`memorySearch.remote.apiKey` (and optional `memorySearch.remote.headers`).
|
||||
|
||||
If you want to use a **custom OpenAI-compatible endpoint** (like Gemini, OpenRouter, or a proxy),
|
||||
you can use the `remote` configuration:
|
||||
|
||||
```json5
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
provider: "openai",
|
||||
model: "text-embedding-3-small",
|
||||
remote: {
|
||||
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/",
|
||||
apiKey: "YOUR_GEMINI_API_KEY",
|
||||
headers: { "X-Custom-Header": "value" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If you don't want to set an API key, use `memorySearch.provider = "local"` or set
|
||||
`memorySearch.fallback = "none"`.
|
||||
|
||||
Config example:
|
||||
|
||||
```json5
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
provider: "openai",
|
||||
model: "text-embedding-3-small",
|
||||
fallback: "openai",
|
||||
sync: { watch: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Tools:
|
||||
- `memory_search` — returns snippets with file + line ranges.
|
||||
- `memory_get` — read memory file content by path.
|
||||
|
||||
Local mode:
|
||||
- Set `agents.defaults.memorySearch.provider = "local"`.
|
||||
- Provide `agents.defaults.memorySearch.local.modelPath` (GGUF or `hf:` URI).
|
||||
- Optional: set `agents.defaults.memorySearch.fallback = "none"` to avoid remote fallback.
|
||||
|
||||
### How the memory tools work
|
||||
|
||||
- `memory_search` semantically searches Markdown chunks (~400 token target, 80-token overlap) from `MEMORY.md` + `memory/**/*.md`. It returns snippet text (capped ~700 chars), file path, line range, score, provider/model, and whether we fell back from local → remote embeddings. No full file payload is returned.
|
||||
- `memory_get` reads a specific memory Markdown file (workspace-relative), optionally from a starting line and for N lines. Paths outside `MEMORY.md` / `memory/` are rejected.
|
||||
- Both tools are enabled only when `memorySearch.enabled` resolves true for the agent.
|
||||
|
||||
### What gets indexed (and when)
|
||||
|
||||
- File type: Markdown only (`MEMORY.md`, `memory/**/*.md`).
|
||||
- Index storage: per-agent SQLite at `~/.clawdbot/state/memory/<agentId>.sqlite` (configurable via `agents.defaults.memorySearch.store.path`, supports `{agentId}` token).
|
||||
- Freshness: watcher on `MEMORY.md` + `memory/` marks the index dirty (debounce 1.5s). Sync runs on session start, on first search when dirty, and optionally on an interval. Reindex triggers when embedding model/provider or chunk sizes change.
|
||||
|
||||
### Local embedding auto-download
|
||||
|
||||
- Default local embedding model: `hf:ggml-org/embeddinggemma-300M-GGUF/embeddinggemma-300M-Q8_0.gguf` (~0.6 GB).
|
||||
- When `memorySearch.provider = "local"`, `node-llama-cpp` resolves `modelPath`; if the GGUF is missing it **auto-downloads** to the cache (or `local.modelCacheDir` if set), then loads it. Downloads resume on retry.
|
||||
- Native build requirement: run `pnpm approve-builds`, pick `node-llama-cpp`, then `pnpm rebuild node-llama-cpp`.
|
||||
- Fallback: if local setup fails and `memorySearch.fallback = "openai"`, we automatically switch to remote embeddings (`openai/text-embedding-3-small` unless overridden) and record the reason.
|
||||
|
||||
### Custom OpenAI-compatible endpoint example
|
||||
|
||||
```json5
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
provider: "openai",
|
||||
model: "text-embedding-3-small",
|
||||
remote: {
|
||||
baseUrl: "https://api.example.com/v1/",
|
||||
apiKey: "YOUR_REMOTE_API_KEY",
|
||||
headers: {
|
||||
"X-Organization": "org-id",
|
||||
"X-Project": "project-id"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `remote.*` takes precedence over `models.providers.openai.*`.
|
||||
- `remote.headers` merge with OpenAI headers; remote wins on key conflicts. Omit `remote.headers` to use the OpenAI defaults.
|
||||
@@ -117,8 +117,13 @@ Moonshot uses OpenAI-compatible endpoints, so configure it as a custom provider:
|
||||
- Provider: `moonshot`
|
||||
- Auth: `MOONSHOT_API_KEY`
|
||||
- Example model: `moonshot/kimi-k2-0905-preview`
|
||||
- CLI: `clawdbot onboard --auth-choice moonshot-api-key`
|
||||
|
||||
- Kimi K2 model IDs:
|
||||
{/* moonshot-kimi-k2-model-refs:start */}
|
||||
- `moonshot/kimi-k2-0905-preview`
|
||||
- `moonshot/kimi-k2-turbo-preview`
|
||||
- `moonshot/kimi-k2-thinking`
|
||||
- `moonshot/kimi-k2-thinking-turbo`
|
||||
{/* moonshot-kimi-k2-model-refs:end */}
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
@@ -138,6 +143,34 @@ Moonshot uses OpenAI-compatible endpoints, so configure it as a custom provider:
|
||||
}
|
||||
```
|
||||
|
||||
### Synthetic
|
||||
|
||||
Synthetic provides Anthropic-compatible models behind the `synthetic` provider:
|
||||
|
||||
- Provider: `synthetic`
|
||||
- Auth: `SYNTHETIC_API_KEY`
|
||||
- Example model: `synthetic/hf:MiniMaxAI/MiniMax-M2.1`
|
||||
- CLI: `clawdbot onboard --auth-choice synthetic-api-key`
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: { model: { primary: "synthetic/hf:MiniMaxAI/MiniMax-M2.1" } }
|
||||
},
|
||||
models: {
|
||||
mode: "merge",
|
||||
providers: {
|
||||
synthetic: {
|
||||
baseUrl: "https://api.synthetic.new/anthropic",
|
||||
apiKey: "${SYNTHETIC_API_KEY}",
|
||||
api: "anthropic-messages",
|
||||
models: [{ id: "hf:MiniMaxAI/MiniMax-M2.1", name: "MiniMax M2.1" }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### MiniMax
|
||||
|
||||
MiniMax is configured via `models.providers` because it uses custom endpoints:
|
||||
|
||||
@@ -38,7 +38,9 @@ Bootstrap files are trimmed and appended under **Project Context** so the model
|
||||
- `HEARTBEAT.md`
|
||||
- `BOOTSTRAP.md` (only on brand-new workspaces)
|
||||
|
||||
Large files are truncated with a marker. Missing files inject a short missing-file marker.
|
||||
Large files are truncated with a marker. The max per-file size is controlled by
|
||||
`agents.defaults.bootstrapMaxChars` (default: 20000). Missing files inject a
|
||||
short missing-file marker.
|
||||
|
||||
## Time handling
|
||||
|
||||
|
||||
+74
-2
@@ -45,6 +45,70 @@
|
||||
"source": "/messages/",
|
||||
"destination": "/concepts/messages"
|
||||
},
|
||||
{
|
||||
"source": "/minimax",
|
||||
"destination": "/providers/minimax"
|
||||
},
|
||||
{
|
||||
"source": "/minimax/",
|
||||
"destination": "/providers/minimax"
|
||||
},
|
||||
{
|
||||
"source": "/openai",
|
||||
"destination": "/providers/openai"
|
||||
},
|
||||
{
|
||||
"source": "/openai/",
|
||||
"destination": "/providers/openai"
|
||||
},
|
||||
{
|
||||
"source": "/anthropic",
|
||||
"destination": "/providers/anthropic"
|
||||
},
|
||||
{
|
||||
"source": "/anthropic/",
|
||||
"destination": "/providers/anthropic"
|
||||
},
|
||||
{
|
||||
"source": "/moonshot",
|
||||
"destination": "/providers/moonshot"
|
||||
},
|
||||
{
|
||||
"source": "/moonshot/",
|
||||
"destination": "/providers/moonshot"
|
||||
},
|
||||
{
|
||||
"source": "/openrouter",
|
||||
"destination": "/providers/openrouter"
|
||||
},
|
||||
{
|
||||
"source": "/openrouter/",
|
||||
"destination": "/providers/openrouter"
|
||||
},
|
||||
{
|
||||
"source": "/opencode",
|
||||
"destination": "/providers/opencode"
|
||||
},
|
||||
{
|
||||
"source": "/opencode/",
|
||||
"destination": "/providers/opencode"
|
||||
},
|
||||
{
|
||||
"source": "/glm",
|
||||
"destination": "/providers/glm"
|
||||
},
|
||||
{
|
||||
"source": "/glm/",
|
||||
"destination": "/providers/glm"
|
||||
},
|
||||
{
|
||||
"source": "/zai",
|
||||
"destination": "/providers/zai"
|
||||
},
|
||||
{
|
||||
"source": "/zai/",
|
||||
"destination": "/providers/zai"
|
||||
},
|
||||
{
|
||||
"source": "/message",
|
||||
"destination": "/cli/message"
|
||||
@@ -675,14 +739,22 @@
|
||||
{
|
||||
"group": "Providers",
|
||||
"pages": [
|
||||
"providers/whatsapp",
|
||||
"broadcast-groups",
|
||||
"providers/openai",
|
||||
"providers/anthropic",
|
||||
"providers/moonshot",
|
||||
"providers/minimax",
|
||||
"providers/openrouter",
|
||||
"providers/opencode",
|
||||
"providers/glm",
|
||||
"providers/zai",
|
||||
"providers/telegram",
|
||||
"providers/grammy",
|
||||
"providers/discord",
|
||||
"providers/slack",
|
||||
"providers/signal",
|
||||
"providers/imessage",
|
||||
"providers/whatsapp",
|
||||
"broadcast-groups",
|
||||
"providers/msteams",
|
||||
"providers/troubleshooting",
|
||||
"providers/location"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -239,7 +239,15 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number.
|
||||
target: "last",
|
||||
to: "+15555550123",
|
||||
prompt: "HEARTBEAT",
|
||||
ackMaxChars: 30
|
||||
ackMaxChars: 300
|
||||
},
|
||||
memorySearch: {
|
||||
provider: "openai",
|
||||
model: "text-embedding-004",
|
||||
remote: {
|
||||
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/",
|
||||
apiKey: "${GEMINI_API_KEY}"
|
||||
}
|
||||
},
|
||||
sandbox: {
|
||||
mode: "non-main",
|
||||
|
||||
@@ -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.).
|
||||
@@ -756,8 +759,8 @@ Controls how chat commands are enabled across connectors.
|
||||
Notes:
|
||||
- Text commands must be sent as a **standalone** message and use the leading `/` (no plain-text aliases).
|
||||
- `commands.text: false` disables parsing chat messages for commands.
|
||||
- `commands.native: true` registers native commands on supported connectors (Discord/Slack/Telegram). Platforms without native commands still rely on text commands.
|
||||
- `commands.native: false` skips native registration; Discord/Telegram clear previously registered commands on startup. Slack commands are managed in the Slack app.
|
||||
- `commands.native: "auto"` (default) turns on native commands for Discord/Telegram and leaves Slack off; unsupported providers stay text-only.
|
||||
- Set `commands.native: true|false` to force all, or override per provider with `discord.commands.native`, `telegram.commands.native`, `slack.commands.native` (bool or `"auto"`). `false` clears previously registered commands on Discord/Telegram at startup; Slack commands are managed in the Slack app.
|
||||
- `commands.config: true` enables `/config` (reads/writes `clawdbot.json`).
|
||||
- `commands.debug: true` enables `/debug` (runtime-only overrides).
|
||||
- `commands.restart: true` enables `/restart` and the gateway tool restart action.
|
||||
@@ -852,6 +855,7 @@ Multi-account support lives under `discord.accounts` (see the multi-account sect
|
||||
enabled: true,
|
||||
token: "your-bot-token",
|
||||
mediaMaxMb: 8, // clamp inbound media size
|
||||
allowBots: false, // allow bot-authored messages
|
||||
actions: { // tool action gates (false disables)
|
||||
reactions: true,
|
||||
stickers: true,
|
||||
@@ -910,6 +914,7 @@ Multi-account support lives under `discord.accounts` (see the multi-account sect
|
||||
|
||||
Clawdbot starts Discord only when a `discord` config section exists. The token is resolved from `DISCORD_BOT_TOKEN` or `discord.token` (unless `discord.enabled` is `false`). Use `user:<id>` (DM) or `channel:<id>` (guild channel) when specifying delivery targets for cron/CLI commands; bare numeric IDs are ambiguous and rejected.
|
||||
Guild slugs are lowercase with spaces replaced by `-`; channel keys use the slugged channel name (no leading `#`). Prefer guild ids as keys to avoid rename ambiguity.
|
||||
Bot-authored messages are ignored by default. Enable with `discord.allowBots` (own messages are still filtered to prevent self-reply loops).
|
||||
Reaction notification modes:
|
||||
- `off`: no reaction events.
|
||||
- `own`: reactions on the bot's own messages (default).
|
||||
@@ -1073,6 +1078,20 @@ Use this for pre-seeded deployments where your workspace files come from a repo.
|
||||
}
|
||||
```
|
||||
|
||||
### `agents.defaults.bootstrapMaxChars`
|
||||
|
||||
Max characters of each workspace bootstrap file injected into the system prompt
|
||||
before truncation. Default: `20000`.
|
||||
|
||||
When a file exceeds this limit, Clawdbot logs a warning and injects a truncated
|
||||
head/tail with a marker.
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: { defaults: { bootstrapMaxChars: 20000 } }
|
||||
}
|
||||
```
|
||||
|
||||
### `agents.defaults.userTimezone`
|
||||
|
||||
Sets the user’s timezone for **system prompt context** (not for timestamps in
|
||||
@@ -1276,6 +1295,7 @@ Example:
|
||||
},
|
||||
maxConcurrent: 3,
|
||||
subagents: {
|
||||
model: "minimax/MiniMax-M2.1",
|
||||
maxConcurrent: 1,
|
||||
archiveAfterMinutes: 60
|
||||
},
|
||||
@@ -1375,6 +1395,8 @@ See [/concepts/session-pruning](/concepts/session-pruning) for behavior details.
|
||||
|
||||
#### `agents.defaults.compaction` (reserve headroom + memory flush)
|
||||
|
||||
`agents.defaults.compaction.mode` selects the compaction summarization strategy. Defaults to `default`; set `safeguard` to enable chunked summarization for very long histories. See [/compaction](/compaction).
|
||||
|
||||
`agents.defaults.compaction.reserveTokensFloor` enforces a minimum `reserveTokens`
|
||||
value for Pi compaction (default: `20000`). Set it to `0` to disable the floor.
|
||||
|
||||
@@ -1396,6 +1418,7 @@ Example (tuned):
|
||||
agents: {
|
||||
defaults: {
|
||||
compaction: {
|
||||
mode: "safeguard",
|
||||
reserveTokensFloor: 24000,
|
||||
memoryFlush: {
|
||||
enabled: true,
|
||||
@@ -1463,7 +1486,7 @@ Z.AI models are available as `zai/<model>` (e.g. `zai/glm-4.7`) and require
|
||||
- `target`: optional delivery provider (`last`, `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`, `none`). Default: `last`.
|
||||
- `to`: optional recipient override (provider-specific id, e.g. E.164 for WhatsApp, chat id for Telegram).
|
||||
- `prompt`: optional override for the heartbeat body (default: `Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`). Overrides are sent verbatim; include a `Read HEARTBEAT.md if exists` line if you still want the file read.
|
||||
- `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery (default: 30).
|
||||
- `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery (default: 300).
|
||||
|
||||
Heartbeats run full agent turns. Shorter intervals burn more tokens; be mindful
|
||||
of `every`, keep `HEARTBEAT.md` tiny, and/or choose a cheaper `model`.
|
||||
@@ -1478,10 +1501,39 @@ Note: `applyPatch` is only under `tools.exec` (no `tools.bash` alias).
|
||||
Legacy: `tools.bash` is still accepted as an alias.
|
||||
|
||||
`agents.defaults.subagents` configures sub-agent defaults:
|
||||
- `model`: default model for spawned sub-agents (string or `{ primary, fallbacks }`). If omitted, sub-agents inherit the caller’s model unless overridden per agent or per call.
|
||||
- `maxConcurrent`: max concurrent sub-agent runs (default 1)
|
||||
- `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**.
|
||||
|
||||
@@ -1492,6 +1544,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)
|
||||
@@ -1560,6 +1623,7 @@ Defaults (if enabled):
|
||||
- auto-prune: idle > 24h OR age > 7d
|
||||
- tool policy: allow only `exec`, `process`, `read`, `write`, `edit`, `apply_patch`, `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `session_status` (deny wins)
|
||||
- configure via `tools.sandbox.tools`, override per-agent via `agents.list[].tools.sandbox.tools`
|
||||
- tool group shorthands supported in sandbox policy: `group:runtime`, `group:fs`, `group:sessions`, `group:memory` (see [Sandbox vs Tool Policy vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated#tool-groups-shorthands))
|
||||
- optional sandboxed browser (Chromium + CDP, noVNC observer)
|
||||
- hardening knobs: `network`, `user`, `pidsLimit`, `memory`, `cpus`, `ulimits`, `seccompProfile`, `apparmorProfile`
|
||||
|
||||
@@ -1601,7 +1665,8 @@ Legacy: `perSession` is still supported (`true` → `scope: "session"`,
|
||||
seccompProfile: "/path/to/seccomp.json",
|
||||
apparmorProfile: "clawdbot-sandbox",
|
||||
dns: ["1.1.1.1", "8.8.8.8"],
|
||||
extraHosts: ["internal.service:10.0.0.5"]
|
||||
extraHosts: ["internal.service:10.0.0.5"],
|
||||
binds: ["/var/run/docker.sock:/var/run/docker.sock", "/home/user/source:/source:rw"]
|
||||
},
|
||||
browser: {
|
||||
enabled: false,
|
||||
@@ -1647,6 +1712,8 @@ to `"bridge"` (or your custom network) if the agent needs outbound access.
|
||||
|
||||
Note: inbound attachments are staged into the active workspace at `media/inbound/*`. With `workspaceAccess: "rw"`, that means files are written into the agent workspace.
|
||||
|
||||
Note: `docker.binds` mounts additional host directories; global and per-agent binds are merged.
|
||||
|
||||
Build the optional browser image with:
|
||||
```bash
|
||||
scripts/sandbox-browser-setup.sh
|
||||
@@ -1811,38 +1878,35 @@ Notes:
|
||||
- Model ref: `moonshot/kimi-k2-0905-preview`.
|
||||
- Use `https://api.moonshot.cn/v1` if you need the China endpoint.
|
||||
|
||||
### Local models (LM Studio) — recommended setup
|
||||
### Synthetic (Anthropic-compatible)
|
||||
|
||||
Best current local setup (what we’re running): **MiniMax M2.1** on a powerful local machine
|
||||
via **LM Studio** using the **Responses API**.
|
||||
Use Synthetic's Anthropic-compatible endpoint:
|
||||
|
||||
```json5
|
||||
{
|
||||
env: { SYNTHETIC_API_KEY: "sk-..." },
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "lmstudio/minimax-m2.1-gs32" },
|
||||
models: {
|
||||
"anthropic/claude-opus-4-5": { alias: "Opus" },
|
||||
"lmstudio/minimax-m2.1-gs32": { alias: "Minimax" }
|
||||
}
|
||||
model: { primary: "synthetic/hf:MiniMaxAI/MiniMax-M2.1" },
|
||||
models: { "synthetic/hf:MiniMaxAI/MiniMax-M2.1": { alias: "MiniMax M2.1" } }
|
||||
}
|
||||
},
|
||||
models: {
|
||||
mode: "merge",
|
||||
providers: {
|
||||
lmstudio: {
|
||||
baseUrl: "http://127.0.0.1:1234/v1",
|
||||
apiKey: "lmstudio",
|
||||
api: "openai-responses",
|
||||
synthetic: {
|
||||
baseUrl: "https://api.synthetic.new/anthropic",
|
||||
apiKey: "${SYNTHETIC_API_KEY}",
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "minimax-m2.1-gs32",
|
||||
name: "MiniMax M2.1 GS32",
|
||||
id: "hf:MiniMaxAI/MiniMax-M2.1",
|
||||
name: "MiniMax M2.1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 196608,
|
||||
maxTokens: 8192
|
||||
contextWindow: 192000,
|
||||
maxTokens: 65536
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1852,9 +1916,13 @@ via **LM Studio** using the **Responses API**.
|
||||
```
|
||||
|
||||
Notes:
|
||||
- LM Studio must have the model loaded and the local server enabled (default URL above).
|
||||
- Responses API enables clean reasoning/output separation; WhatsApp sees only final text.
|
||||
- Adjust `contextWindow`/`maxTokens` if your LM Studio context length differs.
|
||||
- Set `SYNTHETIC_API_KEY` or use `clawdbot onboard --auth-choice synthetic-api-key`.
|
||||
- Model ref: `synthetic/hf:MiniMaxAI/MiniMax-M2.1`.
|
||||
- Base URL should omit `/v1` because the Anthropic client appends it.
|
||||
|
||||
### Local models (LM Studio) — recommended setup
|
||||
|
||||
See [/gateway/local-models](/gateway/local-models) for the current local guidance. TL;DR: run MiniMax M2.1 via LM Studio Responses API on serious hardware; keep hosted models merged for fallback.
|
||||
|
||||
### MiniMax M2.1
|
||||
|
||||
@@ -2168,7 +2236,12 @@ Auth and Tailscale:
|
||||
- `gateway.auth.token` stores the shared token for token auth (used by the CLI on the same machine).
|
||||
- When `gateway.auth.mode` is set, only that method is accepted (plus optional Tailscale headers).
|
||||
- `gateway.auth.password` can be set here, or via `CLAWDBOT_GATEWAY_PASSWORD` (recommended).
|
||||
- `gateway.auth.allowTailscale` controls whether Tailscale identity headers can satisfy auth.
|
||||
- `gateway.auth.allowTailscale` allows Tailscale Serve identity headers
|
||||
(`tailscale-user-login`) to satisfy auth when the request arrives on loopback
|
||||
with `x-forwarded-for`, `x-forwarded-proto`, and `x-forwarded-host`. When
|
||||
`true`, Serve requests do not need a token/password; set `false` to require
|
||||
explicit credentials. Defaults to `true` when `tailscale.mode = "serve"` and
|
||||
auth mode is not `password`.
|
||||
- `gateway.tailscale.mode: "serve"` uses Tailscale Serve (tailnet only, loopback bind).
|
||||
- `gateway.tailscale.mode: "funnel"` exposes the dashboard publicly; requires auth.
|
||||
- `gateway.tailscale.resetOnExit` resets Serve/Funnel config on shutdown.
|
||||
|
||||
@@ -56,6 +56,7 @@ cat ~/.clawdbot/clawdbot.json
|
||||
|
||||
## What it does (summary)
|
||||
- Optional pre-flight update for git installs (interactive only).
|
||||
- UI protocol freshness check (rebuilds Control UI when the protocol schema is newer).
|
||||
- Health check + restart prompt.
|
||||
- Skills status summary (eligible/missing/blocked).
|
||||
- Legacy config migration and normalization.
|
||||
|
||||
@@ -57,7 +57,7 @@ custom body (sent verbatim).
|
||||
- If nothing needs attention, reply with **`HEARTBEAT_OK`**.
|
||||
- During heartbeat runs, Clawdbot treats `HEARTBEAT_OK` as an ack when it appears
|
||||
at the **start or end** of the reply. The token is stripped and the reply is
|
||||
dropped if the remaining content is **≤ `ackMaxChars`** (default: 30).
|
||||
dropped if the remaining content is **≤ `ackMaxChars`** (default: 300).
|
||||
- If `HEARTBEAT_OK` appears in the **middle** of a reply, it is not treated
|
||||
specially.
|
||||
- For alerts, **do not** include `HEARTBEAT_OK`; return only the alert text.
|
||||
@@ -78,7 +78,7 @@ and logged; a message that is only `HEARTBEAT_OK` is dropped.
|
||||
target: "last", // last | whatsapp | telegram | discord | slack | signal | imessage | none
|
||||
to: "+15551234567", // optional provider-specific override
|
||||
prompt: "Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.",
|
||||
ackMaxChars: 30 // max chars allowed after HEARTBEAT_OK
|
||||
ackMaxChars: 300 // max chars allowed after HEARTBEAT_OK
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,8 @@ pnpm gateway:watch
|
||||
|
||||
## Multiple gateways (same host)
|
||||
|
||||
Usually unnecessary: one Gateway can serve multiple messaging channels and agents. Use multiple Gateways only for redundancy or strict isolation (ex: rescue bot).
|
||||
|
||||
Supported if you isolate state + config and use unique ports.
|
||||
|
||||
Service names are profile-aware:
|
||||
@@ -96,6 +98,12 @@ Checklist per instance:
|
||||
- unique `agents.defaults.workspace`
|
||||
- separate WhatsApp numbers (if using WA)
|
||||
|
||||
Daemon install per profile:
|
||||
```bash
|
||||
clawdbot --profile main daemon install
|
||||
clawdbot --profile rescue daemon install
|
||||
```
|
||||
|
||||
Example:
|
||||
```bash
|
||||
CLAWDBOT_CONFIG_PATH=~/.clawdbot/a.json CLAWDBOT_STATE_DIR=~/.clawdbot-a clawdbot gateway --port 19001
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
---
|
||||
summary: "Run Clawdbot on local LLMs (LM Studio, vLLM, LiteLLM, custom OpenAI endpoints)"
|
||||
read_when:
|
||||
- You want to serve models from your own GPU box
|
||||
- You are wiring LM Studio or an OpenAI-compatible proxy
|
||||
- You need the safest local model guidance
|
||||
---
|
||||
# Local models
|
||||
|
||||
Local is doable, but Clawdbot expects large context + strong defenses against prompt injection. Small cards truncate context and leak safety. Aim high: **≥2 maxed-out Mac Studios or equivalent GPU rig (~$30k+)**. A single **24 GB** GPU works only for lighter prompts with higher latency. Use the **largest / full-size model variant you can run**; aggressively quantized or “small” checkpoints raise prompt-injection risk (see [Security](/gateway/security)).
|
||||
|
||||
## Recommended: LM Studio + MiniMax M2.1 (Responses API, full-size)
|
||||
|
||||
Best current local stack. Load MiniMax M2.1 in LM Studio, enable the local server (default `http://127.0.0.1:1234`), and use Responses API to keep reasoning separate from final text.
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "lmstudio/minimax-m2.1-gs32" },
|
||||
models: {
|
||||
"anthropic/claude-opus-4-5": { alias: "Opus" },
|
||||
"lmstudio/minimax-m2.1-gs32": { alias: "Minimax" }
|
||||
}
|
||||
}
|
||||
},
|
||||
models: {
|
||||
mode: "merge",
|
||||
providers: {
|
||||
lmstudio: {
|
||||
baseUrl: "http://127.0.0.1:1234/v1",
|
||||
apiKey: "lmstudio",
|
||||
api: "openai-responses",
|
||||
models: [
|
||||
{
|
||||
id: "minimax-m2.1-gs32",
|
||||
name: "MiniMax M2.1 GS32",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 196608,
|
||||
maxTokens: 8192
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Setup checklist**
|
||||
- Install LM Studio: https://lmstudio.ai
|
||||
- In LM Studio, download the **largest MiniMax M2.1 build available** (avoid “small”/heavily quantized variants), start the server, confirm `http://127.0.0.1:1234/v1/models` lists it.
|
||||
- Keep the model loaded; cold-load adds startup latency.
|
||||
- Adjust `contextWindow`/`maxTokens` if your LM Studio build differs.
|
||||
- For WhatsApp, stick to Responses API so only final text is sent.
|
||||
|
||||
Keep hosted models configured even when running local; use `models.mode: "merge"` so fallbacks stay available.
|
||||
|
||||
### Hybrid config: hosted primary, local fallback
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "anthropic/claude-sonnet-4-5",
|
||||
fallbacks: ["lmstudio/minimax-m2.1-gs32", "anthropic/claude-opus-4-5"]
|
||||
},
|
||||
models: {
|
||||
"anthropic/claude-sonnet-4-5": { alias: "Sonnet" },
|
||||
"lmstudio/minimax-m2.1-gs32": { alias: "MiniMax Local" },
|
||||
"anthropic/claude-opus-4-5": { alias: "Opus" }
|
||||
}
|
||||
}
|
||||
},
|
||||
models: {
|
||||
mode: "merge",
|
||||
providers: {
|
||||
lmstudio: {
|
||||
baseUrl: "http://127.0.0.1:1234/v1",
|
||||
apiKey: "lmstudio",
|
||||
api: "openai-responses",
|
||||
models: [
|
||||
{
|
||||
id: "minimax-m2.1-gs32",
|
||||
name: "MiniMax M2.1 GS32",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 196608,
|
||||
maxTokens: 8192
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Local-first with hosted safety net
|
||||
|
||||
Swap the primary and fallback order; keep the same providers block and `models.mode: "merge"` so you can fall back to Sonnet or Opus when the local box is down.
|
||||
|
||||
### Regional hosting / data routing
|
||||
|
||||
- Hosted MiniMax/Kimi/GLM variants also exist on OpenRouter with region-pinned endpoints (e.g., US-hosted). Pick the regional variant there to keep traffic in your chosen jurisdiction while still using `models.mode: "merge"` for Anthropic/OpenAI fallbacks.
|
||||
- Local-only remains the strongest privacy path; hosted regional routing is the middle ground when you need provider features but want control over data flow.
|
||||
|
||||
## Other OpenAI-compatible local proxies
|
||||
|
||||
vLLM, LiteLLM, OAI-proxy, or custom gateways work if they expose an OpenAI-style `/v1` endpoint. Replace the provider block above with your endpoint and model ID:
|
||||
|
||||
```json5
|
||||
{
|
||||
models: {
|
||||
mode: "merge",
|
||||
providers: {
|
||||
local: {
|
||||
baseUrl: "http://127.0.0.1:8000/v1",
|
||||
apiKey: "sk-local",
|
||||
api: "openai-responses",
|
||||
models: [
|
||||
{
|
||||
id: "my-local-model",
|
||||
name: "Local Model",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 120000,
|
||||
maxTokens: 8192
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Keep `models.mode: "merge"` so hosted models stay available as fallbacks.
|
||||
|
||||
## Troubleshooting
|
||||
- Gateway can reach the proxy? `curl http://127.0.0.1:1234/v1/models`.
|
||||
- LM Studio model unloaded? Reload; cold start is a common “hanging” cause.
|
||||
- Context errors? Lower `contextWindow` or raise your server limit.
|
||||
- Safety: local models skip provider-side filters; keep agents narrow and compaction on to limit prompt injection blast radius.
|
||||
@@ -39,9 +39,18 @@ Sandboxing is controlled by `agents.defaults.sandbox.mode`:
|
||||
|
||||
See [Sandboxing](/gateway/sandboxing) for the full matrix (scope, workspace mounts, images).
|
||||
|
||||
### Bind mounts (security quick check)
|
||||
|
||||
- `docker.binds` *pierces* the sandbox filesystem: whatever you mount is visible inside the container with the mode you set (`:ro` or `:rw`).
|
||||
- Default is read-write if you omit the mode; prefer `:ro` for source/secrets.
|
||||
- `scope: "shared"` ignores per-agent binds (only global binds apply).
|
||||
- Binding `/var/run/docker.sock` effectively hands host control to the sandbox; only do this intentionally.
|
||||
- Workspace access (`workspaceAccess: "ro"`/`"rw"`) is independent of bind modes.
|
||||
|
||||
## 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.*`
|
||||
|
||||
@@ -49,6 +58,33 @@ Rules of thumb:
|
||||
- `deny` always wins.
|
||||
- If `allow` is non-empty, everything else is treated as blocked.
|
||||
|
||||
### Tool groups (shorthands)
|
||||
|
||||
Tool policies (global, agent, sandbox) support `group:*` entries that expand to multiple tools:
|
||||
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
sandbox: {
|
||||
tools: {
|
||||
allow: ["group:runtime", "group:fs", "group:sessions", "group:memory"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
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)
|
||||
|
||||
## Elevated: exec-only “run on host”
|
||||
|
||||
Elevated does **not** grant extra tools; it only affects `exec`.
|
||||
|
||||
@@ -56,6 +56,47 @@ Clawdbot mirrors eligible skills into the sandbox workspace (`.../skills`) so
|
||||
they can be read. With `"rw"`, workspace skills are readable from
|
||||
`/workspace/skills`.
|
||||
|
||||
## Custom bind mounts
|
||||
`agents.defaults.sandbox.docker.binds` mounts additional host directories into the container.
|
||||
Format: `host:container:mode` (e.g., `"/home/user/source:/source:rw"`).
|
||||
|
||||
Global and per-agent binds are **merged** (not replaced). Under `scope: "shared"`, per-agent binds are ignored.
|
||||
|
||||
Example (read-only source + docker socket):
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
sandbox: {
|
||||
docker: {
|
||||
binds: [
|
||||
"/home/user/source:/source:ro",
|
||||
"/var/run/docker.sock:/var/run/docker.sock"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
list: [
|
||||
{
|
||||
id: "build",
|
||||
sandbox: {
|
||||
docker: {
|
||||
binds: ["/mnt/cache:/cache:rw"]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Security notes:
|
||||
- Binds bypass the sandbox filesystem: they expose host paths with whatever mode you set (`:ro` or `:rw`).
|
||||
- Sensitive mounts (e.g., `docker.sock`, secrets, SSH keys) should be `:ro` unless absolutely required.
|
||||
- Combine with `workspaceAccess: "ro"` if you only need read access to the workspace; bind modes stay independent.
|
||||
- See [Sandbox vs Tool Policy vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated) for how binds interact with tool policy and elevated exec.
|
||||
|
||||
## Images + setup
|
||||
Default image: `clawdbot-sandbox:bookworm-slim`
|
||||
|
||||
|
||||
@@ -145,6 +145,20 @@ Doctor can generate one for you: `clawdbot doctor --generate-gateway-token`.
|
||||
Note: `gateway.remote.token` is **only** for remote CLI calls; it does not
|
||||
protect local WS access.
|
||||
|
||||
### 0.6) Tailscale Serve identity headers
|
||||
|
||||
When `gateway.auth.allowTailscale` is `true` (default for Serve), Clawdbot
|
||||
accepts Tailscale Serve identity headers (`tailscale-user-login`) as
|
||||
authentication. This only triggers for requests that hit loopback and include
|
||||
`x-forwarded-for`, `x-forwarded-proto`, and `x-forwarded-host` as injected by
|
||||
Tailscale.
|
||||
|
||||
**Security rule:** do not forward these headers from your own reverse proxy. If
|
||||
you terminate TLS or proxy in front of the gateway, disable
|
||||
`gateway.auth.allowTailscale` and use token/password auth instead.
|
||||
|
||||
See [Tailscale](/gateway/tailscale) and [Web overview](/web).
|
||||
|
||||
### 1) DMs: pairing by default
|
||||
|
||||
```json5
|
||||
|
||||
@@ -23,9 +23,13 @@ Set `gateway.auth.mode` to control the handshake:
|
||||
- `token` (default when `CLAWDBOT_GATEWAY_TOKEN` is set)
|
||||
- `password` (shared secret via `CLAWDBOT_GATEWAY_PASSWORD` or config)
|
||||
|
||||
When `tailscale.mode = "serve"`, the gateway trusts Tailscale identity headers by
|
||||
default unless you force `gateway.auth.mode` to `password` or set
|
||||
`gateway.auth.allowTailscale: false`.
|
||||
When `tailscale.mode = "serve"` and `gateway.auth.allowTailscale` is `true`,
|
||||
valid Serve proxy requests can authenticate via Tailscale identity headers
|
||||
(`tailscale-user-login`) without supplying a token/password. Clawdbot only
|
||||
treats a request as Serve when it arrives from loopback with Tailscale’s
|
||||
`x-forwarded-for`, `x-forwarded-proto`, and `x-forwarded-host` headers.
|
||||
To require explicit credentials, set `gateway.auth.allowTailscale: false` or
|
||||
force `gateway.auth.mode: "password"`.
|
||||
|
||||
## Config examples
|
||||
|
||||
|
||||
@@ -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,13 +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)
|
||||
|
||||
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).
|
||||
|
||||
@@ -29,17 +29,17 @@ Notes:
|
||||
# From repo root; set release IDs so Sparkle feed is enabled.
|
||||
# APP_BUILD must be numeric + monotonic for Sparkle compare.
|
||||
BUNDLE_ID=com.clawdbot.mac \
|
||||
APP_VERSION=2026.1.11-2 \
|
||||
APP_VERSION=2026.1.11-4 \
|
||||
APP_BUILD="$(git rev-list --count HEAD)" \
|
||||
BUILD_CONFIG=release \
|
||||
SIGN_IDENTITY="Developer ID Application: Peter Steinberger (Y5PE65HELJ)" \
|
||||
scripts/package-mac-app.sh
|
||||
|
||||
# Zip for distribution (includes resource forks for Sparkle delta support)
|
||||
ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.11-2.zip
|
||||
ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.11-4.zip
|
||||
|
||||
# Optional: also build a styled DMG for humans (drag to /Applications)
|
||||
scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.11-2.dmg
|
||||
scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.11-4.dmg
|
||||
|
||||
# Recommended: build + notarize/staple zip + DMG
|
||||
# First, create a keychain profile once:
|
||||
@@ -47,26 +47,26 @@ scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.11-2.dmg
|
||||
# --apple-id "<apple-id>" --team-id "<team-id>" --password "<app-specific-password>"
|
||||
NOTARIZE=1 NOTARYTOOL_PROFILE=clawdbot-notary \
|
||||
BUNDLE_ID=com.clawdbot.mac \
|
||||
APP_VERSION=2026.1.11-2 \
|
||||
APP_VERSION=2026.1.11-4 \
|
||||
APP_BUILD="$(git rev-list --count HEAD)" \
|
||||
BUILD_CONFIG=release \
|
||||
SIGN_IDENTITY="Developer ID Application: Peter Steinberger (Y5PE65HELJ)" \
|
||||
scripts/package-mac-dist.sh
|
||||
|
||||
# Optional: ship dSYM alongside the release
|
||||
ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.11-2.dSYM.zip
|
||||
ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.11-4.dSYM.zip
|
||||
```
|
||||
|
||||
## Appcast entry
|
||||
Use the release note generator so Sparkle renders formatted HTML notes:
|
||||
```bash
|
||||
SPARKLE_PRIVATE_KEY_FILE=/Users/steipete/Library/CloudStorage/Dropbox/Backup/Sparkle/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-2026.1.11-2.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml
|
||||
SPARKLE_PRIVATE_KEY_FILE=/Users/steipete/Library/CloudStorage/Dropbox/Backup/Sparkle/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-2026.1.11-4.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml
|
||||
```
|
||||
Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry.
|
||||
Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when publishing.
|
||||
|
||||
## Publish & verify
|
||||
- Upload `Clawdbot-2026.1.11-2.zip` (and `Clawdbot-2026.1.11-2.dSYM.zip`) to the GitHub release for tag `v2026.1.11-2`.
|
||||
- Upload `Clawdbot-2026.1.11-4.zip` (and `Clawdbot-2026.1.11-4.dSYM.zip`) to the GitHub release for tag `v2026.1.11-4`.
|
||||
- Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml`.
|
||||
- Sanity checks:
|
||||
- `curl -I https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml` returns 200.
|
||||
|
||||
@@ -148,6 +148,7 @@ export default {
|
||||
clawdbot plugins list
|
||||
clawdbot plugins info <id>
|
||||
clawdbot plugins install <path> # add a local file/dir to plugins.load.paths
|
||||
clawdbot plugins install ./extensions/voice-call # relative path ok
|
||||
clawdbot plugins install ./plugin.tgz # install from a local tarball
|
||||
clawdbot plugins install @clawdbot/voice-call # install from npm
|
||||
clawdbot plugins enable <id>
|
||||
|
||||
+69
-65
@@ -1,5 +1,5 @@
|
||||
---
|
||||
summary: "Voice Call plugin: Twilio-backed outbound calls (plugin install + config + CLI)"
|
||||
summary: "Voice Call plugin: outbound + inbound calls via Twilio/Telnyx (plugin install + config + CLI)"
|
||||
read_when:
|
||||
- You want to place an outbound voice call from Clawdbot
|
||||
- You are configuring or developing the voice-call plugin
|
||||
@@ -7,20 +7,19 @@ read_when:
|
||||
|
||||
# Voice Call (plugin)
|
||||
|
||||
Outbound voice calls for Clawdbot via a plugin.
|
||||
|
||||
If you’re new to plugins, start with [Plugins](/plugin): what they are, where
|
||||
they live on disk, and how install/config works.
|
||||
Voice calls for Clawdbot via a plugin. Supports outbound notifications and
|
||||
multi-turn conversations with inbound policies.
|
||||
|
||||
Current providers:
|
||||
- `twilio` (real calls)
|
||||
- `log` (dev fallback; no network)
|
||||
- `twilio` (Programmable Voice + Media Streams)
|
||||
- `telnyx` (Call Control v2)
|
||||
- `mock` (dev/no network)
|
||||
|
||||
Quick mental model:
|
||||
- Install plugin
|
||||
- Restart Gateway
|
||||
- Configure under `plugins.entries.voice-call.config`
|
||||
- Use `clawdbot voicecall …` or the `voice_call` tool
|
||||
- Use `clawdbot voicecall ...` or the `voice_call` tool
|
||||
|
||||
## Where it runs (local vs remote)
|
||||
|
||||
@@ -28,23 +27,6 @@ The Voice Call plugin runs **inside the Gateway process**.
|
||||
|
||||
If you use a remote Gateway, install/configure the plugin on the **machine running the Gateway**, then restart the Gateway to load it.
|
||||
|
||||
## Twilio setup (getting the credentials)
|
||||
|
||||
You’ll need:
|
||||
- **Account SID** (`AC...`)
|
||||
- **Auth Token** (treat like a password)
|
||||
- A Twilio **Voice-capable phone number** to use as `from` (E.164, like `+15551234567`)
|
||||
|
||||
Steps:
|
||||
1) Create/sign in to Twilio: `https://www.twilio.com/`
|
||||
2) Open the Twilio Console: `https://console.twilio.com/`
|
||||
3) Find your **Account SID** + **Auth Token** (typically on the Console dashboard / Account settings).
|
||||
4) Buy (or select) a Twilio phone number with **Voice** capability:
|
||||
- Console → Phone Numbers → Manage → Buy a number
|
||||
5) (Trial accounts) You may need to verify the destination `to` numbers before Twilio allows outbound calling.
|
||||
|
||||
Tip: keep `twilio.authToken` out of git; store it in `~/.clawdbot/clawdbot.json` or your Control UI config, not in repo files.
|
||||
|
||||
## Install
|
||||
|
||||
### Option A: install from npm (recommended)
|
||||
@@ -53,34 +35,13 @@ Tip: keep `twilio.authToken` out of git; store it in `~/.clawdbot/clawdbot.json`
|
||||
clawdbot plugins install @clawdbot/voice-call
|
||||
```
|
||||
|
||||
This downloads the package, extracts it into `~/.clawdbot/extensions/`, and enables it in `clawdbot.json`.
|
||||
|
||||
Restart the Gateway afterwards.
|
||||
|
||||
### Option B: install from a local folder (dev, no copying)
|
||||
|
||||
This keeps the plugin in-place (great for iterating locally) and adds the folder
|
||||
to `plugins.load.paths`.
|
||||
|
||||
```bash
|
||||
clawdbot plugins install /absolute/path/to/voice-call
|
||||
```
|
||||
|
||||
If your plugin has dependencies, install them in that folder (so it has a
|
||||
`node_modules`):
|
||||
|
||||
```bash
|
||||
cd /absolute/path/to/voice-call && pnpm install
|
||||
```
|
||||
|
||||
Restart the Gateway afterwards.
|
||||
|
||||
### Option C: copy into the global extensions folder (dev)
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.clawdbot/extensions
|
||||
cp -R extensions/voice-call ~/.clawdbot/extensions/voice-call
|
||||
cd ~/.clawdbot/extensions/voice-call && pnpm install
|
||||
clawdbot plugins install ./extensions/voice-call
|
||||
cd ./extensions/voice-call && pnpm install
|
||||
```
|
||||
|
||||
Restart the Gateway afterwards.
|
||||
@@ -96,13 +57,33 @@ Set config under `plugins.entries.voice-call.config`:
|
||||
"voice-call": {
|
||||
enabled: true,
|
||||
config: {
|
||||
provider: "twilio",
|
||||
provider: "twilio", // or "telnyx" | "mock"
|
||||
fromNumber: "+15550001234",
|
||||
toNumber: "+15550005678",
|
||||
|
||||
twilio: {
|
||||
accountSid: "ACxxxxxxxx",
|
||||
authToken: "…",
|
||||
from: "+15551234567",
|
||||
statusCallbackUrl: "https://example.com/twilio-status", // optional
|
||||
twimlUrl: "https://example.com/twiml" // optional
|
||||
authToken: "..."
|
||||
},
|
||||
|
||||
// Webhook server
|
||||
serve: {
|
||||
port: 3334,
|
||||
path: "/voice/webhook"
|
||||
},
|
||||
|
||||
// Public exposure (pick one)
|
||||
// publicUrl: "https://example.ngrok.app/voice/webhook",
|
||||
// tunnel: { provider: "ngrok" },
|
||||
// tailscale: { mode: "funnel", path: "/voice/webhook" }
|
||||
|
||||
outbound: {
|
||||
defaultMode: "notify" // notify | conversation
|
||||
},
|
||||
|
||||
streaming: {
|
||||
enabled: true,
|
||||
streamPath: "/voice/stream"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,34 +92,57 @@ Set config under `plugins.entries.voice-call.config`:
|
||||
}
|
||||
```
|
||||
|
||||
Dev fallback:
|
||||
Notes:
|
||||
- Twilio/Telnyx require a **publicly reachable** webhook URL.
|
||||
- `mock` is a local dev provider (no network calls).
|
||||
- `skipSignatureVerification` is for local testing only.
|
||||
|
||||
## Inbound calls
|
||||
|
||||
Inbound policy defaults to `disabled`. To enable inbound calls, set:
|
||||
|
||||
```json5
|
||||
{ provider: "log" }
|
||||
{
|
||||
inboundPolicy: "allowlist",
|
||||
allowFrom: ["+15550001234"],
|
||||
inboundGreeting: "Hello! How can I help?"
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `twilio.authToken` is treated as sensitive in the Control UI schema hints.
|
||||
Auto-responses use the agent system. Tune with:
|
||||
- `responseModel`
|
||||
- `responseSystemPrompt`
|
||||
- `responseTimeoutMs`
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
clawdbot voicecall start --to "+15555550123" --message "Hello from Clawdbot"
|
||||
clawdbot voicecall status --sid CAxxxxxxxx
|
||||
clawdbot voicecall call --to "+15555550123" --message "Hello from Clawdbot"
|
||||
clawdbot voicecall continue --call-id <id> --message "Any questions?"
|
||||
clawdbot voicecall speak --call-id <id> --message "One moment"
|
||||
clawdbot voicecall end --call-id <id>
|
||||
clawdbot voicecall status --call-id <id>
|
||||
clawdbot voicecall tail
|
||||
clawdbot voicecall expose --mode funnel
|
||||
```
|
||||
|
||||
## Agent tool
|
||||
|
||||
Tool name: `voice_call`
|
||||
|
||||
- `mode`: `"call" | "status"` (default: `call`)
|
||||
- `to`: required for `call`
|
||||
- `sid`: required for `status`
|
||||
- `message`: optional
|
||||
Actions:
|
||||
- `initiate_call` (message, to?, mode?)
|
||||
- `continue_call` (callId, message)
|
||||
- `speak_to_user` (callId, message)
|
||||
- `end_call` (callId)
|
||||
- `get_status` (callId)
|
||||
|
||||
This repo ships a matching skill doc at `skills/voice-call/SKILL.md`.
|
||||
|
||||
## Gateway RPC
|
||||
|
||||
- `voicecall.start` (`to`, optional `message`)
|
||||
- `voicecall.status` (`sid`)
|
||||
- `voicecall.initiate` (`to?`, `message`, `mode?`)
|
||||
- `voicecall.continue` (`callId`, `message`)
|
||||
- `voicecall.speak` (`callId`, `message`)
|
||||
- `voicecall.end` (`callId`)
|
||||
- `voicecall.status` (`callId`)
|
||||
@@ -47,7 +47,7 @@ Minimal config:
|
||||
- To ignore all DMs: set `discord.dm.enabled=false` or `discord.dm.policy="disabled"`.
|
||||
8. Group DMs are ignored by default; enable via `discord.dm.groupEnabled` and optionally restrict by `discord.dm.groupChannels`.
|
||||
9. Optional guild rules: set `discord.guilds` keyed by guild id (preferred) or slug, with per-channel rules.
|
||||
10. Optional native commands: set `commands.native: true` to register native commands in Discord; set `commands.native: false` to clear previously registered native commands. Text commands are controlled by `commands.text` and must be sent as standalone `/...` messages. Use `commands.useAccessGroups: false` to bypass access-group checks for commands.
|
||||
10. Optional native commands: `commands.native` defaults to `"auto"` (on for Discord/Telegram, off for Slack). Override with `discord.commands.native: true|false|"auto"`; `false` clears previously registered commands. Text commands are controlled by `commands.text` and must be sent as standalone `/...` messages. Use `commands.useAccessGroups: false` to bypass access-group checks for commands.
|
||||
- Full command list + config: [Slash commands](/tools/slash-commands)
|
||||
11. Optional guild context history: set `discord.historyLimit` (default 20, falls back to `messages.groupChat.historyLimit`) to include the last N guild messages as context when replying to a mention. Set `0` to disable.
|
||||
12. Reactions: the agent can trigger reactions via the `discord` tool (gated by `discord.actions.*`).
|
||||
@@ -158,6 +158,8 @@ Notes:
|
||||
- `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`) also count as mentions for guild messages.
|
||||
- Multi-agent override: set per-agent patterns on `agents.list[].groupChat.mentionPatterns`.
|
||||
- If `channels` is present, any channel not listed is denied by default.
|
||||
- Bot-authored messages are ignored by default; set `discord.allowBots=true` to allow them (own messages remain filtered).
|
||||
- Warning: If you allow replies to other bots (`discord.allowBots=true`), prevent bot-to-bot reply loops with `requireMention`, `discord.guilds.*.channels.<id>.users` allowlists, and/or clear guardrails in `AGENTS.md` and `SOUL.md`.
|
||||
|
||||
### 6) Verify it works
|
||||
1. Start the gateway.
|
||||
|
||||
@@ -29,6 +29,9 @@ MiniMax highlights these improvements in M2.1:
|
||||
|
||||
- **Speed:** Lightning is the “fast” variant in MiniMax’s pricing docs.
|
||||
- **Cost:** Pricing shows the same input cost, but Lightning has higher output cost.
|
||||
- **Coding plan routing:** The Lightning back-end isn’t directly available on the MiniMax
|
||||
coding plan. MiniMax auto-routes most requests to Lightning, but falls back to the
|
||||
regular M2.1 back-end during traffic spikes.
|
||||
|
||||
## Choose a setup
|
||||
|
||||
|
||||
@@ -26,10 +26,12 @@ model as `provider/model`.
|
||||
- [Anthropic (API + Claude CLI)](/providers/anthropic)
|
||||
- [OpenRouter](/providers/openrouter)
|
||||
- [Moonshot AI (Kimi)](/providers/moonshot)
|
||||
- [Synthetic](/providers/synthetic)
|
||||
- [OpenCode Zen](/providers/opencode)
|
||||
- [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).
|
||||
@@ -9,7 +9,13 @@ read_when:
|
||||
Moonshot provides the Kimi API with OpenAI-compatible endpoints. Configure the
|
||||
provider and set the default model to `moonshot/kimi-k2-0905-preview`.
|
||||
|
||||
## CLI setup
|
||||
Current Kimi K2 model IDs:
|
||||
{/* moonshot-kimi-k2-ids:start */}
|
||||
- `kimi-k2-0905-preview`
|
||||
- `kimi-k2-turbo-preview`
|
||||
- `kimi-k2-thinking`
|
||||
- `kimi-k2-thinking-turbo`
|
||||
{/* moonshot-kimi-k2-ids:end */}
|
||||
|
||||
```bash
|
||||
clawdbot onboard --auth-choice moonshot-api-key
|
||||
@@ -24,7 +30,12 @@ clawdbot onboard --auth-choice moonshot-api-key
|
||||
defaults: {
|
||||
model: { primary: "moonshot/kimi-k2-0905-preview" },
|
||||
models: {
|
||||
"moonshot/kimi-k2-0905-preview": { alias: "Kimi K2" }
|
||||
// moonshot-kimi-k2-aliases:start
|
||||
"moonshot/kimi-k2-0905-preview": { alias: "Kimi K2" },
|
||||
"moonshot/kimi-k2-turbo-preview": { alias: "Kimi K2 Turbo" },
|
||||
"moonshot/kimi-k2-thinking": { alias: "Kimi K2 Thinking" },
|
||||
"moonshot/kimi-k2-thinking-turbo": { alias: "Kimi K2 Thinking Turbo" }
|
||||
// moonshot-kimi-k2-aliases:end
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -36,6 +47,7 @@ clawdbot onboard --auth-choice moonshot-api-key
|
||||
apiKey: "${MOONSHOT_API_KEY}",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
// moonshot-kimi-k2-models:start
|
||||
{
|
||||
id: "kimi-k2-0905-preview",
|
||||
name: "Kimi K2 0905 Preview",
|
||||
@@ -44,7 +56,35 @@ clawdbot onboard --auth-choice moonshot-api-key
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 256000,
|
||||
maxTokens: 8192
|
||||
},
|
||||
{
|
||||
id: "kimi-k2-turbo-preview",
|
||||
name: "Kimi K2 Turbo",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 256000,
|
||||
maxTokens: 8192
|
||||
},
|
||||
{
|
||||
id: "kimi-k2-thinking",
|
||||
name: "Kimi K2 Thinking",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 256000,
|
||||
maxTokens: 8192
|
||||
},
|
||||
{
|
||||
id: "kimi-k2-thinking-turbo",
|
||||
name: "Kimi K2 Thinking Turbo",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 256000,
|
||||
maxTokens: 8192
|
||||
}
|
||||
// moonshot-kimi-k2-models:end
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -56,4 +96,6 @@ clawdbot onboard --auth-choice moonshot-api-key
|
||||
|
||||
- Model refs use `moonshot/<modelId>`.
|
||||
- Override pricing and context metadata in `models.providers` if needed.
|
||||
- If Moonshot publishes different context limits for a model, adjust
|
||||
`contextWindow` accordingly.
|
||||
- Use `https://api.moonshot.cn/v1` if you need the China endpoint.
|
||||
@@ -33,7 +33,7 @@ Minimal config:
|
||||
- `channel_rename`
|
||||
- `pin_added`, `pin_removed`
|
||||
5) Invite the bot to channels you want it to read.
|
||||
6) Slash Commands → create `/clawd` if you use `slack.slashCommand`. If you enable `commands.native`, add slash commands for the built-in chat commands (same names as `/help`).
|
||||
6) Slash Commands → create `/clawd` if you use `slack.slashCommand`. If you enable native commands, add one slash command per built-in command (same names as `/help`). Native defaults to off for Slack unless you set `slack.commands.native: true` (global `commands.native` is `"auto"` which leaves Slack off).
|
||||
7) App Home → enable the **Messages Tab** so users can DM the bot.
|
||||
|
||||
Use the manifest below so scopes and events stay in sync.
|
||||
@@ -138,7 +138,7 @@ Use this Slack app manifest to create the app quickly (adjust the name/command i
|
||||
}
|
||||
```
|
||||
|
||||
If you enable `commands.native`, add one `slash_commands` entry per command you want to expose (matching the `/help` list).
|
||||
If you enable native commands, add one `slash_commands` entry per command you want to expose (matching the `/help` list). Override with `slack.commands.native`.
|
||||
|
||||
## Scopes (current vs optional)
|
||||
Slack's Conversations API is type-scoped: you only need the scopes for the
|
||||
@@ -257,7 +257,7 @@ For fine-grained control, use these tags in agent responses:
|
||||
- DMs share the `main` session (like WhatsApp/Telegram).
|
||||
- Channels map to `agent:<agentId>:slack:channel:<channelId>` sessions.
|
||||
- Slash commands use `agent:<agentId>:slack:slash:<userId>` sessions (prefix configurable via `slack.slashCommand.sessionPrefix`).
|
||||
- Native command registration is controlled by `commands.native`; text commands require standalone `/...` messages and can be disabled with `commands.text: false`. Slack slash commands are managed in the Slack app and are not removed automatically. Use `commands.useAccessGroups: false` to bypass access-group checks for commands.
|
||||
- Native command registration uses `commands.native` (global default `"auto"` → Slack off) and can be overridden per-workspace with `slack.commands.native`. Text commands require standalone `/...` messages and can be disabled with `commands.text: false`. Slack slash commands are managed in the Slack app and are not removed automatically. Use `commands.useAccessGroups: false` to bypass access-group checks for commands.
|
||||
- Full command list + config: [Slash commands](/tools/slash-commands)
|
||||
|
||||
## DM security (pairing)
|
||||
@@ -299,5 +299,6 @@ Slack tool actions can be gated with `slack.actions.*`:
|
||||
- Multi-agent override: set per-agent patterns on `agents.list[].groupChat.mentionPatterns`.
|
||||
- Reaction notifications follow `slack.reactionNotifications` (use `reactionAllowlist` with mode `allowlist`).
|
||||
- Bot-authored messages are ignored by default; enable via `slack.allowBots` or `slack.channels.<id>.allowBots`.
|
||||
- Warning: If you allow replies to other bots (`slack.allowBots=true` or `slack.channels.<id>.allowBots=true`), prevent bot-to-bot reply loops with `requireMention`, `slack.channels.<id>.users` allowlists, and/or clear guardrails in `AGENTS.md` and `SOUL.md`.
|
||||
- For the Slack tool, reaction removal semantics are in [/tools/reactions](/tools/reactions).
|
||||
- Attachments are downloaded to the media store when permitted and under the size limit.
|
||||
@@ -0,0 +1,97 @@
|
||||
---
|
||||
summary: "Use Synthetic's Anthropic-compatible API in Clawdbot"
|
||||
read_when:
|
||||
- You want to use Synthetic as a model provider
|
||||
- You need a Synthetic API key or base URL setup
|
||||
---
|
||||
# Synthetic
|
||||
|
||||
Synthetic exposes Anthropic-compatible endpoints. Clawdbot registers it as the
|
||||
`synthetic` provider and uses the Anthropic Messages API.
|
||||
|
||||
## Quick setup
|
||||
|
||||
1) Set `SYNTHETIC_API_KEY` (or run the wizard below).
|
||||
2) Run onboarding:
|
||||
|
||||
```bash
|
||||
clawdbot onboard --auth-choice synthetic-api-key
|
||||
```
|
||||
|
||||
The default model is set to:
|
||||
|
||||
```
|
||||
synthetic/hf:MiniMaxAI/MiniMax-M2.1
|
||||
```
|
||||
|
||||
## Config example
|
||||
|
||||
```json5
|
||||
{
|
||||
env: { SYNTHETIC_API_KEY: "sk-..." },
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "synthetic/hf:MiniMaxAI/MiniMax-M2.1" },
|
||||
models: { "synthetic/hf:MiniMaxAI/MiniMax-M2.1": { alias: "MiniMax M2.1" } }
|
||||
}
|
||||
},
|
||||
models: {
|
||||
mode: "merge",
|
||||
providers: {
|
||||
synthetic: {
|
||||
baseUrl: "https://api.synthetic.new/anthropic",
|
||||
apiKey: "${SYNTHETIC_API_KEY}",
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "hf:MiniMaxAI/MiniMax-M2.1",
|
||||
name: "MiniMax M2.1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 192000,
|
||||
maxTokens: 65536
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Note: Clawdbot's Anthropic client appends `/v1` to the base URL, so use
|
||||
`https://api.synthetic.new/anthropic` (not `/anthropic/v1`). If Synthetic changes
|
||||
its base URL, override `models.providers.synthetic.baseUrl`.
|
||||
|
||||
## Model catalog
|
||||
|
||||
All models below use cost `0` (input/output/cache).
|
||||
|
||||
| Model ID | Context window | Max tokens | Reasoning | Input |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `hf:MiniMaxAI/MiniMax-M2.1` | 192000 | 65536 | false | text |
|
||||
| `hf:moonshotai/Kimi-K2-Thinking` | 256000 | 8192 | true | text |
|
||||
| `hf:zai-org/GLM-4.7` | 198000 | 128000 | false | text |
|
||||
| `hf:deepseek-ai/DeepSeek-R1-0528` | 128000 | 8192 | false | text |
|
||||
| `hf:deepseek-ai/DeepSeek-V3-0324` | 128000 | 8192 | false | text |
|
||||
| `hf:deepseek-ai/DeepSeek-V3.1` | 128000 | 8192 | false | text |
|
||||
| `hf:deepseek-ai/DeepSeek-V3.1-Terminus` | 128000 | 8192 | false | text |
|
||||
| `hf:deepseek-ai/DeepSeek-V3.2` | 159000 | 8192 | false | text |
|
||||
| `hf:meta-llama/Llama-3.3-70B-Instruct` | 128000 | 8192 | false | text |
|
||||
| `hf:meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8` | 524000 | 8192 | false | text |
|
||||
| `hf:moonshotai/Kimi-K2-Instruct-0905` | 256000 | 8192 | false | text |
|
||||
| `hf:openai/gpt-oss-120b` | 128000 | 8192 | false | text |
|
||||
| `hf:Qwen/Qwen3-235B-A22B-Instruct-2507` | 256000 | 8192 | false | text |
|
||||
| `hf:Qwen/Qwen3-Coder-480B-A35B-Instruct` | 256000 | 8192 | false | text |
|
||||
| `hf:Qwen/Qwen3-VL-235B-A22B-Instruct` | 250000 | 8192 | false | text + image |
|
||||
| `hf:zai-org/GLM-4.5` | 128000 | 128000 | false | text |
|
||||
| `hf:zai-org/GLM-4.6` | 198000 | 128000 | false | text |
|
||||
| `hf:deepseek-ai/DeepSeek-V3` | 128000 | 8192 | false | text |
|
||||
| `hf:Qwen/Qwen3-235B-A22B-Thinking-2507` | 256000 | 8192 | true | text |
|
||||
|
||||
## Notes
|
||||
|
||||
- Model refs use `synthetic/<modelId>`.
|
||||
- If you enable a model allowlist (`agents.defaults.models`), add every model you
|
||||
plan to use.
|
||||
- See [Model providers](/concepts/model-providers) for provider rules.
|
||||
@@ -174,7 +174,7 @@ Private chats can include `message_thread_id` in some edge cases. Clawdbot keeps
|
||||
- `clawdbot pairing list telegram`
|
||||
- `clawdbot pairing approve telegram <CODE>`
|
||||
- Pairing is the default token exchange used for Telegram DMs. Details: [Pairing](/start/pairing)
|
||||
- `telegram.allowFrom` accepts numeric user IDs (recommended) or `@username` entries.
|
||||
- `telegram.allowFrom` accepts numeric user IDs (recommended) or `@username` entries. It is **not** the bot username; use the human sender’s ID (get it from `@userinfobot` or the `from.id` field in the gateway log).
|
||||
|
||||
### Group access
|
||||
|
||||
@@ -315,5 +315,5 @@ Provider options:
|
||||
Related global options:
|
||||
- `agents.list[].groupChat.mentionPatterns` (mention gating patterns).
|
||||
- `messages.groupChat.mentionPatterns` (global fallback).
|
||||
- `commands.native`, `commands.text`, `commands.useAccessGroups` (command behavior).
|
||||
- `commands.native` (defaults to `"auto"` → on for Telegram/Discord, off for Slack), `commands.text`, `commands.useAccessGroups` (command behavior). Override with `telegram.commands.native`.
|
||||
- `messages.responsePrefix`, `messages.ackReaction`, `messages.ackReactionScope`, `messages.removeAckAfterReply`.
|
||||
@@ -0,0 +1,127 @@
|
||||
---
|
||||
summary: "Vector memory search design plan (per-agent, watch/lazy sync, storage)"
|
||||
read_when:
|
||||
- Designing or implementing vector memory search
|
||||
- Adding embedding providers or sync behavior
|
||||
---
|
||||
|
||||
# Vector Memory Search — Design Plan
|
||||
|
||||
Goal: semantic search over **agent memory files** only, with minimal deps and
|
||||
good UX defaults. Default enabled. Per-agent overrides.
|
||||
|
||||
## Scope
|
||||
- Sources: `MEMORY.md` + `memory/YYYY-MM-DD.md` inside the agent workspace.
|
||||
- No indexing outside the workspace. No hidden paths.
|
||||
- No QMD-style query expansion or rerank in v1.
|
||||
|
||||
## Config Shape
|
||||
Location: `agents.defaults.memorySearch` + `agents.list[].memorySearch`.
|
||||
|
||||
```json5
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
enabled: true,
|
||||
provider: "openai", // "openai" | "local"
|
||||
fallback: "openai", // "openai" | "none"
|
||||
model: "text-embedding-3-small",
|
||||
store: {
|
||||
driver: "sqlite",
|
||||
path: "~/.clawdbot/memory/{agentId}.sqlite"
|
||||
},
|
||||
chunking: {
|
||||
tokens: 400,
|
||||
overlap: 80
|
||||
},
|
||||
sync: {
|
||||
onSessionStart: true,
|
||||
onSearch: true, // LazySync
|
||||
watch: true, // default on
|
||||
watchDebounceMs: 1500,
|
||||
intervalMinutes: 0
|
||||
},
|
||||
query: {
|
||||
maxResults: 6,
|
||||
minScore: 0.35
|
||||
}
|
||||
}
|
||||
},
|
||||
list: [
|
||||
{ id: "peter", memorySearch: { provider: "local", sync: { watch: false } } }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Storage
|
||||
Per-agent DB (default): `~/.clawdbot/memory/{agentId}.sqlite`.
|
||||
|
||||
Tables (v1):
|
||||
- `files(path PRIMARY KEY, hash, mtime, size)`
|
||||
- `chunks(id PRIMARY KEY, path, start_line, end_line, hash, text, embedding, updated_at)`
|
||||
|
||||
Notes:
|
||||
- `hash` = content hash of chunk text.
|
||||
- `embedding` stored as float[] (sqlite vec extension optional); if not using vec,
|
||||
store as JSON and do linear scan in memory for small corpora.
|
||||
|
||||
## Embedding Providers
|
||||
Interface (core):
|
||||
- `embedQuery(text): number[]`
|
||||
- `embedBatch(texts[]): number[][]`
|
||||
|
||||
Providers:
|
||||
- `openai` (default): OpenAI embeddings via existing keys.
|
||||
- `local` (optional): node-llama-cpp (GGUF).
|
||||
- Fallback: when `provider: "local"` fails, fallback to OpenAI unless `fallback: "none"`.
|
||||
|
||||
## Index Pipeline
|
||||
1) Resolve memory file list (workspace only).
|
||||
2) Read file, compute file hash/mtime.
|
||||
3) Chunk by headings + token cap (overlap).
|
||||
4) Embed only changed chunks (hash compare).
|
||||
5) Upsert `chunks` rows, prune deleted files.
|
||||
|
||||
Chunking:
|
||||
- Prefer heading-aware splits.
|
||||
- Max tokens + overlap; keep line ranges for snippets.
|
||||
|
||||
## Sync Strategy
|
||||
Default: **watch + lazy + session-start**
|
||||
- `watch`: chokidar on `MEMORY.md` + `memory/**/*.md` (debounced).
|
||||
- `onSearch`: if dirty, sync before search (LazySync).
|
||||
- `onSessionStart`: warm index once per session.
|
||||
- `intervalMinutes`: optional for long-lived sessions.
|
||||
|
||||
If workspace access is read-only or missing: disable writes; return “not indexed”.
|
||||
|
||||
## Query Flow
|
||||
1) Embed query.
|
||||
2) Cosine similarity over all chunk embeddings.
|
||||
3) Return top K with `{path, startLine, endLine, snippet, score}`.
|
||||
4) Model may call `memory_get` when full context needed.
|
||||
|
||||
Optional v2: add FTS5 + RRF merge (FTS + vector) for quality.
|
||||
|
||||
## Tool + CLI
|
||||
Tools:
|
||||
- `memory_search { query, maxResults?, minScore? }`
|
||||
- `memory_get { path, from?, lines? }`
|
||||
|
||||
CLI (optional):
|
||||
- `clawdbot memory index|search|status`
|
||||
|
||||
## Security + Permissions
|
||||
- Indexer reads only memory files in workspace.
|
||||
- No scanning outside workspace; no “sneak” reads.
|
||||
- Respect sandbox `workspaceAccess` (ro = read-only; none = disabled).
|
||||
|
||||
## Tests
|
||||
- Chunking boundaries + line ranges.
|
||||
- Hash-based incremental updates.
|
||||
- Search ranking (cosine).
|
||||
- Watcher debounce (fake fs).
|
||||
|
||||
## Rollout
|
||||
- Default enabled; if no memory files, index is empty (silent).
|
||||
- No migration needed.
|
||||
@@ -88,7 +88,6 @@ git commit -m "Add Clawd workspace"
|
||||
- **Peekaboo** — Fast macOS screenshots with optional AI vision analysis.
|
||||
- **camsnap** — Capture frames, clips, or motion alerts from RTSP/ONVIF security cams.
|
||||
- **oracle** — OpenAI-ready agent CLI with session replay and browser control.
|
||||
- **qmd** — Hybrid markdown search (BM25 + vectors + rerank) with a local server for agents.
|
||||
- **eightctl** — Control your sleep, from the terminal.
|
||||
- **imsg** — Send, read, stream iMessage & SMS.
|
||||
- **wacli** — WhatsApp CLI: sync, search, send.
|
||||
|
||||
@@ -46,16 +46,6 @@ Capture what matters. Decisions, context, things to remember. Skip the secrets u
|
||||
- When you make a mistake → document it so future-you doesn't repeat it
|
||||
- **Text > Brain** 📝
|
||||
|
||||
### 🧠 Memory Recall - Use qmd!
|
||||
When you need to remember something from the past, use `qmd` instead of grepping files:
|
||||
```bash
|
||||
qmd query "what happened at Christmas" # Semantic search with reranking
|
||||
qmd search "specific phrase" # BM25 keyword search
|
||||
qmd vsearch "conceptual question" # Pure vector similarity
|
||||
```
|
||||
Index your memory folder: `qmd index memory/`
|
||||
Vectors + BM25 + reranking finds things even with different wording.
|
||||
|
||||
## Safety
|
||||
|
||||
- Don't exfiltrate private data. Ever.
|
||||
|
||||
@@ -89,6 +89,8 @@ clawdbot gateway --port 18789
|
||||
|
||||
Now message the assistant number from your allowlisted phone.
|
||||
|
||||
When onboarding finishes, we auto-open the dashboard with your gateway token and print the tokenized link. To reopen later: `clawdbot dashboard`.
|
||||
|
||||
## Give the agent a workspace (AGENTS)
|
||||
|
||||
Clawd reads operating instructions and “memory” from its workspace directory.
|
||||
|
||||
+154
-5
@@ -5,6 +5,90 @@ 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, in one paragraph?](#what-is-clawdbot-in-one-paragraph)
|
||||
- [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)
|
||||
- [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)
|
||||
- [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 does Clawdbot store its data?](#where-does-clawdbot-store-its-data)
|
||||
- [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)
|
||||
- [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)
|
||||
- [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 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)
|
||||
- [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)
|
||||
- [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)
|
||||
- [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)
|
||||
- [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)
|
||||
- [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)
|
||||
- [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)
|
||||
- [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)
|
||||
- [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)
|
||||
- [My skill generated an image/PDF, but nothing was sent](#my-skill-generated-an-imagepdf-but-nothing-was-sent)
|
||||
- [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)
|
||||
- [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)
|
||||
- [“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)
|
||||
|
||||
## First 60 seconds if something's broken
|
||||
|
||||
1) **Quick status (first check)**
|
||||
@@ -83,6 +167,24 @@ pnpm clawdbot onboard
|
||||
|
||||
The wizard can also build UI assets automatically. After onboarding, you typically run the Gateway on port **18789**.
|
||||
|
||||
### How do I open the dashboard after onboarding?
|
||||
|
||||
The wizard now opens your browser with a tokenized dashboard URL right after onboarding and also prints the full link (with token) in the summary. Keep that tab open; if it didn’t launch, copy/paste the printed URL on the same machine. Tokens stay local to your host—nothing is fetched from the browser.
|
||||
|
||||
### How do I authenticate the dashboard (token) on localhost vs remote?
|
||||
|
||||
**Localhost (same machine):**
|
||||
- Open `http://127.0.0.1:18789/`.
|
||||
- If it asks for auth, run `clawdbot dashboard` and use the tokenized link (`?token=...`).
|
||||
- The token is the same value as `gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`) and is stored by the UI after first load.
|
||||
|
||||
**Not on localhost:**
|
||||
- **Tailscale Serve** (recommended): keep bind loopback, run `clawdbot gateway --tailscale serve`, open `https://<magicdns>/`. If `gateway.auth.allowTailscale` is `true`, identity headers satisfy auth (no token).
|
||||
- **Tailnet bind**: run `clawdbot gateway --bind tailnet --token "<token>"`, open `http://<tailscale-ip>:18789/`, paste token in dashboard settings.
|
||||
- **SSH tunnel**: `ssh -N -L 18789:127.0.0.1:18789 user@host` then open `http://127.0.0.1:18789/?token=...` from `clawdbot dashboard`.
|
||||
|
||||
See [Dashboard](/web/dashboard) and [Web surfaces](/web) for bind modes and auth details.
|
||||
|
||||
### What runtime do I need?
|
||||
|
||||
Node **>= 22** is required. `pnpm` is recommended; `bun` is optional.
|
||||
@@ -110,16 +212,28 @@ Yes. Clawdbot can **reuse Claude Code CLI credentials** (OAuth) and also support
|
||||
|
||||
### 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?
|
||||
|
||||
Clawdbot supports **OpenAI Code (Codex)** via OAuth or by reusing your Codex CLI login (`~/.codex/auth.json`). The wizard can import the CLI login or run the OAuth flow and will set the default model to `openai-codex/gpt-5.2` when appropriate. See [Model providers](/concepts/model-providers) and [Wizard](/start/wizard).
|
||||
|
||||
### Is a local model OK for casual chats?
|
||||
|
||||
Usually no. Clawdbot needs large context + strong safety; small cards truncate and leak. If you must, run the **largest** MiniMax M2.1 build you can locally (LM Studio) and see [/gateway/local-models](/gateway/local-models). Smaller/quantized models increase prompt-injection risk — see [Security](/gateway/security).
|
||||
|
||||
### How do I keep hosted model traffic in a specific region?
|
||||
|
||||
Pick region-pinned endpoints. OpenRouter exposes US-hosted options for MiniMax, Kimi, and GLM; choose the US-hosted variant to keep data in-region. You can still list Anthropic/OpenAI alongside these by using `models.mode: "merge"` so fallbacks stay available while respecting the regioned provider you select.
|
||||
|
||||
### 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.
|
||||
|
||||
### Telegram: what goes in `allowFrom`?
|
||||
|
||||
`telegram.allowFrom` is **the human sender’s Telegram user ID** (numeric, recommended) or `@username`. It is not the bot username. To find your ID, DM `@userinfobot` or read the `from.id` in the gateway log for a DM. See [/providers/telegram](/providers/telegram#access-control-dms--groups).
|
||||
|
||||
### Can multiple people use one WhatsApp number with different Clawdbots?
|
||||
|
||||
Yes, via **multi‑agent routing**. Bind each sender’s WhatsApp **DM** (peer `kind: "dm"`, sender E.164 like `+15551234567`) to a different `agentId`, so each person gets their own workspace and session store. Replies still come from the **same WhatsApp account**, and DM access control (`whatsapp.dmPolicy` / `whatsapp.allowFrom`) is global per WhatsApp account. See [Multi-Agent Routing](/concepts/multi-agent) and [WhatsApp](/providers/whatsapp).
|
||||
@@ -170,6 +284,19 @@ Doctor detects a gateway service entrypoint mismatch and offers to rewrite the s
|
||||
|
||||
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.
|
||||
|
||||
### Can I load skills from a custom folder?
|
||||
|
||||
Yes. Add extra directories via `skills.load.extraDirs` in `~/.clawdbot/clawdbot.json` (lowest precedence). Default precedence remains: `<workspace>/skills` → `~/.clawdbot/skills` → bundled → `skills.load.extraDirs`. `clawdhub` installs into `./skills` by default, which Clawdbot treats as `<workspace>/skills`.
|
||||
|
||||
### How can I use different models for different tasks?
|
||||
|
||||
Today the supported patterns are:
|
||||
- **Cron jobs**: isolated jobs can set a `model` override per job.
|
||||
- **Sub-agents**: route tasks to separate agents with different default models.
|
||||
- **On-demand switch**: use `/model` to switch the current session model at any time.
|
||||
|
||||
See [Cron jobs](/automation/cron-jobs), [Multi-Agent Routing](/concepts/multi-agent), and [Slash commands](/tools/slash-commands).
|
||||
|
||||
### 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.
|
||||
@@ -195,12 +322,16 @@ clawdhub install <skill-slug>
|
||||
clawdhub update --all
|
||||
```
|
||||
|
||||
ClawdHub installs into `./skills` under your current directory; 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).
|
||||
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).
|
||||
|
||||
### 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).
|
||||
|
||||
### How do I bind a host folder into the sandbox?
|
||||
|
||||
Set `agents.defaults.sandbox.docker.binds` to `["host:path:mode"]` (e.g., `"/home/user/src:/src:ro"`). Global + per-agent binds merge; per-agent binds are ignored when `scope: "shared"`. Use `:ro` for anything sensitive and remember binds bypass the sandbox filesystem walls. See [Sandboxing](/gateway/sandboxing#custom-bind-mounts) and [Sandbox vs Tool Policy vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated#bind-mounts-security-quick-check) for examples and safety notes.
|
||||
|
||||
### How does memory work?
|
||||
|
||||
Clawdbot memory is just Markdown files in the agent workspace:
|
||||
@@ -211,6 +342,17 @@ Clawdbot also runs a **silent pre-compaction memory flush** to remind the model
|
||||
to write durable notes before auto-compaction. This only runs when the workspace
|
||||
is writable (read-only sandboxes skip it). See [Memory](/concepts/memory).
|
||||
|
||||
### Does semantic memory search require an OpenAI API key?
|
||||
|
||||
Only if you use **remote embeddings** (OpenAI). Codex OAuth covers
|
||||
chat/completions and does **not** grant embeddings access, so **signing in with
|
||||
Codex (OAuth or the Codex CLI login)** does not help for semantic memory search.
|
||||
Remote memory search still needs a real OpenAI API key (`OPENAI_API_KEY` or
|
||||
`models.providers.openai.apiKey`). If you’d rather stay local, set
|
||||
`memorySearch.provider = "local"` (and optionally `memorySearch.fallback =
|
||||
"none"`). We support **remote or local embedding models** — see [Memory](/concepts/memory)
|
||||
for the setup details.
|
||||
|
||||
## Where things live on disk
|
||||
|
||||
### Where does Clawdbot store its data?
|
||||
@@ -756,6 +898,8 @@ Facts (from code):
|
||||
- The UI can import `?token=...` (and/or `?password=...`) once, then strips it from the URL.
|
||||
|
||||
Fix:
|
||||
- Fastest: `clawdbot dashboard` (prints + copies tokenized link, tries to open; shows SSH hint if headless).
|
||||
- If remote, tunnel first: `ssh -N -L 18789:127.0.0.1:18789 user@host` then open `http://127.0.0.1:18789/?token=...`.
|
||||
- Set `gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`) on the gateway host.
|
||||
- In the Control UI settings, paste the same token (or refresh with a one-time `?token=...` link).
|
||||
|
||||
@@ -769,6 +913,8 @@ Fix:
|
||||
|
||||
### Can I run multiple Gateways on the same host?
|
||||
|
||||
Usually no — one Gateway can run multiple messaging channels and agents. Use multiple Gateways only when you need redundancy (ex: rescue bot) or hard isolation.
|
||||
|
||||
Yes, but you must isolate:
|
||||
|
||||
- `CLAWDBOT_CONFIG_PATH` (per‑instance config)
|
||||
@@ -776,9 +922,12 @@ Yes, but you must isolate:
|
||||
- `agents.defaults.workspace` (workspace isolation)
|
||||
- `gateway.port` (unique ports)
|
||||
|
||||
There are convenience CLI flags like `--dev` and `--profile <name>` that shift state dirs and ports.
|
||||
When using profiles, service names are suffixed (`com.clawdbot.<profile>`, `clawdbot-gateway-<profile>.service`,
|
||||
`Clawdbot Gateway (<profile>)`).
|
||||
Quick setup (recommended):
|
||||
- Use `clawdbot --profile <name> …` per instance (auto-creates `~/.clawdbot-<name>`).
|
||||
- Set a unique `gateway.port` in each profile config (or pass `--port` for manual runs).
|
||||
- Install a per-profile daemon: `clawdbot --profile <name> daemon install`.
|
||||
|
||||
Profiles also suffix service names (`com.clawdbot.<profile>`, `clawdbot-gateway-<profile>.service`, `Clawdbot Gateway (<profile>)`).
|
||||
|
||||
## Logging and debugging
|
||||
|
||||
|
||||
@@ -42,6 +42,22 @@ Full setup walkthrough (28m) by VelvetShark.
|
||||
|
||||
<CardGroup cols={2}>
|
||||
|
||||
<Card title="PR Review → Telegram Feedback" icon="code-pull-request" href="https://x.com/i/status/2010878524543131691">
|
||||
**@bangnokia** • `review` `github` `telegram`
|
||||
|
||||
OpenCode finishes the change → opens a PR → Clawdbot reviews the diff and replies in Telegram with “minor suggestions” plus a clear merge verdict (including critical fixes to apply first).
|
||||
|
||||
<img src="/assets/showcase/pr-review-telegram.jpg" alt="Clawdbot PR review feedback delivered in Telegram" />
|
||||
</Card>
|
||||
|
||||
<Card title="Wine Cellar Skill in Minutes" icon="wine-glass" href="https://x.com/i/status/2010916352454791216">
|
||||
**@prades_maxime** • `skills` `local` `csv`
|
||||
|
||||
Asked “Robby” (@clawdbot) for a local wine cellar skill. It requests a sample CSV export + where to store it, then builds/tests the skill fast (962 bottles in the example).
|
||||
|
||||
<img src="/assets/showcase/wine-cellar-skill.jpg" alt="Clawdbot building a local wine cellar skill from CSV" />
|
||||
</Card>
|
||||
|
||||
<Card title="Tesco Shop Autopilot" icon="cart-shopping" href="https://x.com/i/status/2009724862470689131">
|
||||
**@marchattonhere** • `automation` `browser` `shopping`
|
||||
|
||||
@@ -196,6 +212,18 @@ Full setup walkthrough (28m) by VelvetShark.
|
||||
Searches job listings, matches against CV keywords, and returns relevant opportunities with links. Built in 30 minutes using JSearch API.
|
||||
</Card>
|
||||
|
||||
<Card title="Jira Skill Builder" icon="diagram-project" href="https://x.com/jdrhyne/status/2008336434827002232">
|
||||
**@jdrhyne** • `automation` `jira` `skill` `devtools`
|
||||
|
||||
Clawdbot connected to Jira, then generated a new skill on the fly (before it existed on ClawdHub).
|
||||
</Card>
|
||||
|
||||
<Card title="Todoist Skill via Telegram" icon="list-check" href="https://x.com/iamsubhrajyoti/status/2009949389884920153">
|
||||
**@iamsubhrajyoti** • `automation` `todoist` `skill` `telegram`
|
||||
|
||||
Automated Todoist tasks and had Clawdbot generate the skill directly in Telegram chat.
|
||||
</Card>
|
||||
|
||||
<Card title="TradingView Analysis" icon="chart-line">
|
||||
**@bheem1798** • `finance` `browser` `automation`
|
||||
|
||||
|
||||
@@ -64,6 +64,8 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` (
|
||||
|
||||
1) **Existing config detection**
|
||||
- If `~/.clawdbot/clawdbot.json` exists, choose **Keep / Modify / Reset**.
|
||||
- If the config is invalid or contains legacy keys, the wizard stops and asks
|
||||
you to run `clawdbot doctor` before continuing.
|
||||
- Reset uses `trash` (never `rm`) and offers scopes:
|
||||
- Config only
|
||||
- Config + credentials + sessions
|
||||
@@ -82,6 +84,8 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` (
|
||||
- **API key**: stores the key for you.
|
||||
- **MiniMax M2.1**: config is auto-written.
|
||||
- More detail: [MiniMax](/providers/minimax)
|
||||
- **Synthetic (Anthropic-compatible)**: prompts for `SYNTHETIC_API_KEY`.
|
||||
- More detail: [Synthetic](/providers/synthetic)
|
||||
- **Moonshot (Kimi K2)**: config is auto-written.
|
||||
- More detail: [Moonshot AI](/providers/moonshot)
|
||||
- **Skip**: no auth configured yet.
|
||||
@@ -212,6 +216,17 @@ clawdbot onboard --non-interactive \
|
||||
--gateway-bind loopback
|
||||
```
|
||||
|
||||
Synthetic example:
|
||||
|
||||
```bash
|
||||
clawdbot onboard --non-interactive \
|
||||
--mode local \
|
||||
--auth-choice synthetic-api-key \
|
||||
--synthetic-api-key "$SYNTHETIC_API_KEY" \
|
||||
--gateway-port 18789 \
|
||||
--gateway-bind loopback
|
||||
```
|
||||
|
||||
OpenCode Zen example:
|
||||
|
||||
```bash
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ Clawdbot assembles its own system prompt on every run. It includes:
|
||||
- Tool list + short descriptions
|
||||
- Skills list (only metadata; instructions are loaded on demand with `read`)
|
||||
- Self-update instructions
|
||||
- Workspace + bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md` when new)
|
||||
- Workspace + bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md` when new). Large files are truncated by `agents.defaults.bootstrapMaxChars` (default: 20000).
|
||||
- Time (UTC + user timezone)
|
||||
- Reply tags + heartbeat behavior
|
||||
- Runtime metadata (host/OS/model/thinking)
|
||||
|
||||
@@ -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
|
||||
@@ -150,7 +150,13 @@ HTTP API:
|
||||
- Snapshot/screenshot: `GET /snapshot`, `POST /screenshot`
|
||||
- Actions: `POST /navigate`, `POST /act`
|
||||
- Hooks: `POST /hooks/file-chooser`, `POST /hooks/dialog`
|
||||
- Downloads: `POST /download`, `POST /wait/download`
|
||||
- Debugging: `GET /console`, `POST /pdf`
|
||||
- Debugging: `GET /errors`, `GET /requests`, `POST /trace/start`, `POST /trace/stop`, `POST /highlight`
|
||||
- Network: `POST /response/body`
|
||||
- State: `GET /cookies`, `POST /cookies/set`, `POST /cookies/clear`
|
||||
- State: `GET /storage/:kind`, `POST /storage/:kind/set`, `POST /storage/:kind/clear`
|
||||
- Settings: `POST /set/offline`, `POST /set/headers`, `POST /set/credentials`, `POST /set/geolocation`, `POST /set/media`, `POST /set/timezone`, `POST /set/locale`, `POST /set/device`
|
||||
|
||||
All endpoints accept `?profile=<name>`.
|
||||
|
||||
@@ -175,12 +181,17 @@ you swap local/remote browsers and profiles.
|
||||
## CLI quick reference
|
||||
|
||||
All commands accept `--browser-profile <name>` to target a specific profile.
|
||||
All commands also accept `--json` for machine-readable output (stable payloads).
|
||||
|
||||
Basics:
|
||||
- `clawdbot browser status`
|
||||
- `clawdbot browser start`
|
||||
- `clawdbot browser stop`
|
||||
- `clawdbot browser tabs`
|
||||
- `clawdbot browser tab`
|
||||
- `clawdbot browser tab new`
|
||||
- `clawdbot browser tab select 2`
|
||||
- `clawdbot browser tab close 2`
|
||||
- `clawdbot browser open https://example.com`
|
||||
- `clawdbot browser focus abcd1234`
|
||||
- `clawdbot browser close abcd1234`
|
||||
@@ -194,8 +205,12 @@ Inspection:
|
||||
- `clawdbot browser snapshot --format aria --limit 200`
|
||||
- `clawdbot browser snapshot --interactive --compact --depth 6`
|
||||
- `clawdbot browser snapshot --selector "#main" --interactive`
|
||||
- `clawdbot browser snapshot --frame "iframe#main" --interactive`
|
||||
- `clawdbot browser console --level error`
|
||||
- `clawdbot browser errors --clear`
|
||||
- `clawdbot browser requests --filter api --clear`
|
||||
- `clawdbot browser pdf`
|
||||
- `clawdbot browser responsebody "**/api" --max-chars 5000`
|
||||
|
||||
Actions:
|
||||
- `clawdbot browser navigate https://example.com`
|
||||
@@ -205,13 +220,38 @@ Actions:
|
||||
- `clawdbot browser type 23 "hello" --submit`
|
||||
- `clawdbot browser press Enter`
|
||||
- `clawdbot browser hover 44`
|
||||
- `clawdbot browser scrollintoview e12`
|
||||
- `clawdbot browser drag 10 11`
|
||||
- `clawdbot browser select 9 OptionA OptionB`
|
||||
- `clawdbot browser download e12 /tmp/report.pdf`
|
||||
- `clawdbot browser waitfordownload /tmp/report.pdf`
|
||||
- `clawdbot browser upload /tmp/file.pdf`
|
||||
- `clawdbot browser fill --fields '[{"ref":"1","type":"text","value":"Ada"}]'`
|
||||
- `clawdbot browser dialog --accept`
|
||||
- `clawdbot browser wait --text "Done"`
|
||||
- `clawdbot browser wait "#main" --url "**/dash" --load networkidle --fn "window.ready===true"`
|
||||
- `clawdbot browser evaluate --fn '(el) => el.textContent' --ref 7`
|
||||
- `clawdbot browser highlight e12`
|
||||
- `clawdbot browser trace start`
|
||||
- `clawdbot browser trace stop`
|
||||
|
||||
State:
|
||||
- `clawdbot browser cookies`
|
||||
- `clawdbot browser cookies set session abc123 --url "https://example.com"`
|
||||
- `clawdbot browser cookies clear`
|
||||
- `clawdbot browser storage local get`
|
||||
- `clawdbot browser storage local set theme dark`
|
||||
- `clawdbot browser storage session clear`
|
||||
- `clawdbot browser set offline on`
|
||||
- `clawdbot browser set headers --json '{"X-Debug":"1"}'`
|
||||
- `clawdbot browser set credentials user pass`
|
||||
- `clawdbot browser set credentials --clear`
|
||||
- `clawdbot browser set geo 37.7749 -122.4194 --origin "https://example.com"`
|
||||
- `clawdbot browser set geo --clear`
|
||||
- `clawdbot browser set media dark`
|
||||
- `clawdbot browser set timezone America/New_York`
|
||||
- `clawdbot browser set locale en-US`
|
||||
- `clawdbot browser set device "iPhone 14"`
|
||||
|
||||
Notes:
|
||||
- `upload` and `dialog` are **arming** calls; run them before the click/press
|
||||
@@ -221,10 +261,98 @@ Notes:
|
||||
- `--format ai` (default when Playwright is installed): returns an AI snapshot with numeric refs (`aria-ref="<n>"`).
|
||||
- `--format aria`: returns the accessibility tree (no refs; inspection only).
|
||||
- Role snapshot options (`--interactive`, `--compact`, `--depth`, `--selector`) force a role-based snapshot with refs like `ref=e12`.
|
||||
- `--frame "<iframe selector>"` scopes role snapshots to an iframe (pairs with role refs like `e12`).
|
||||
- `--interactive` outputs a flat, easy-to-pick list of interactive elements (best for driving actions).
|
||||
- `click`/`type`/etc require a `ref` from `snapshot` (either numeric `12` or role ref `e12`).
|
||||
CSS selectors are intentionally not supported for actions.
|
||||
|
||||
## Snapshots and refs
|
||||
|
||||
Clawdbot supports two “snapshot” styles:
|
||||
|
||||
- **AI snapshot (numeric refs)**: `clawdbot browser snapshot` (default; `--format ai`)
|
||||
- Output: a text snapshot that includes numeric refs.
|
||||
- Actions: `clawdbot browser click 12`, `clawdbot browser type 23 "hello"`.
|
||||
- Internally, the ref is resolved via Playwright’s `aria-ref`.
|
||||
|
||||
- **Role snapshot (role refs like `e12`)**: `clawdbot browser snapshot --interactive` (or `--compact`, `--depth`, `--selector`, `--frame`)
|
||||
- Output: a role-based list/tree with `[ref=e12]` (and optional `[nth=1]`).
|
||||
- Actions: `clawdbot browser click e12`, `clawdbot browser highlight e12`.
|
||||
- Internally, the ref is resolved via `getByRole(...)` (plus `nth()` for duplicates).
|
||||
|
||||
Ref behavior:
|
||||
- Refs are **not stable across navigations**; if something fails, re-run `snapshot` and use a fresh ref.
|
||||
- If the role snapshot was taken with `--frame`, role refs are scoped to that iframe until the next role snapshot.
|
||||
|
||||
## Wait power-ups
|
||||
|
||||
You can wait on more than just time/text:
|
||||
|
||||
- Wait for URL (globs supported by Playwright):
|
||||
- `clawdbot browser wait --url "**/dash"`
|
||||
- Wait for load state:
|
||||
- `clawdbot browser wait --load networkidle`
|
||||
- Wait for a JS predicate:
|
||||
- `clawdbot browser wait --fn "window.ready===true"`
|
||||
- Wait for a selector to become visible:
|
||||
- `clawdbot browser wait "#main"`
|
||||
|
||||
These can be combined:
|
||||
|
||||
```bash
|
||||
clawdbot browser wait "#main" \
|
||||
--url "**/dash" \
|
||||
--load networkidle \
|
||||
--fn "window.ready===true" \
|
||||
--timeout-ms 15000
|
||||
```
|
||||
|
||||
## Debug workflows
|
||||
|
||||
When an action fails (e.g. “not visible”, “strict mode violation”, “covered”):
|
||||
|
||||
1. `clawdbot browser snapshot --interactive`
|
||||
2. Use `click <ref>` / `type <ref>` (prefer role refs in interactive mode)
|
||||
3. If it still fails: `clawdbot browser highlight <ref>` to see what Playwright is targeting
|
||||
4. If the page behaves oddly:
|
||||
- `clawdbot browser errors --clear`
|
||||
- `clawdbot browser requests --filter api --clear`
|
||||
5. For deep debugging: record a trace:
|
||||
- `clawdbot browser trace start`
|
||||
- reproduce the issue
|
||||
- `clawdbot browser trace stop` (prints `TRACE:<path>`)
|
||||
|
||||
## JSON output
|
||||
|
||||
`--json` is for scripting and structured tooling.
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
clawdbot browser status --json
|
||||
clawdbot browser snapshot --interactive --json
|
||||
clawdbot browser requests --filter api --json
|
||||
clawdbot browser cookies --json
|
||||
```
|
||||
|
||||
Role snapshots in JSON include `refs` plus a small `stats` block (lines/chars/refs/interactive) so tools can reason about payload size and density.
|
||||
|
||||
## State and environment knobs
|
||||
|
||||
These are useful for “make the site behave like X” workflows:
|
||||
|
||||
- Cookies: `cookies`, `cookies set`, `cookies clear`
|
||||
- Storage: `storage local|session get|set|clear`
|
||||
- Offline: `set offline on|off`
|
||||
- Headers: `set headers --json '{"X-Debug":"1"}'` (or `--clear`)
|
||||
- HTTP basic auth: `set credentials user pass` (or `--clear`)
|
||||
- Geolocation: `set geo <lat> <lon> --origin "https://example.com"` (or `--clear`)
|
||||
- Media: `set media dark|light|no-preference|none`
|
||||
- Timezone / locale: `set timezone ...`, `set locale ...`
|
||||
- Device / viewport:
|
||||
- `set device "iPhone 14"` (Playwright device presets)
|
||||
- `set viewport 1280 720`
|
||||
|
||||
## Security & privacy
|
||||
|
||||
- The clawd browser profile may contain logged-in sessions; treat it as sensitive.
|
||||
|
||||
@@ -48,7 +48,7 @@ bun add -g clawdhub
|
||||
|
||||
## How it fits into Clawdbot
|
||||
|
||||
By default, the CLI installs skills into `./skills` under your current working directory. 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.
|
||||
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.
|
||||
|
||||
For more detail on how skills are loaded, shared, and gated, see
|
||||
[Skills](/tools/skills).
|
||||
@@ -67,7 +67,7 @@ For more detail on how skills are loaded, shared, and gated, see
|
||||
|
||||
Global options (apply to all commands):
|
||||
|
||||
- `--workdir <dir>`: Working directory (default: current dir).
|
||||
- `--workdir <dir>`: Working directory (default: current dir; falls back to Clawdbot workspace).
|
||||
- `--dir <dir>`: Skills directory, relative to workdir (default: `skills`).
|
||||
- `--site <url>`: Site base URL (browser login).
|
||||
- `--registry <url>`: Registry API base URL.
|
||||
@@ -201,4 +201,5 @@ export CLAWDHUB_DISABLE_TELEMETRY=1
|
||||
- `CLAWDHUB_SITE`: Override the site URL.
|
||||
- `CLAWDHUB_REGISTRY`: Override the registry API URL.
|
||||
- `CLAWDHUB_CONFIG_PATH`: Override where the CLI stores the token/config.
|
||||
- `CLAWDHUB_WORKDIR`: Override the default workdir.
|
||||
- `CLAWDHUB_DISABLE_TELEMETRY=1`: Disable telemetry on `sync`.
|
||||
+73
-1
@@ -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.
|
||||
@@ -153,7 +224,7 @@ Core parameters:
|
||||
- `maxBytesMb` (optional size cap)
|
||||
|
||||
Notes:
|
||||
- Only available when `agents.defaults.imageModel` is configured (primary or fallbacks).
|
||||
- Only available when `agents.defaults.imageModel` is configured (primary or fallbacks), or when an implicit image model can be inferred from your default model + configured auth (best-effort pairing).
|
||||
- Uses the image model directly (independent of the main chat model).
|
||||
|
||||
### `message`
|
||||
@@ -179,6 +250,7 @@ Core actions:
|
||||
Notes:
|
||||
- `send` routes WhatsApp via the Gateway; other providers go direct.
|
||||
- `poll` uses the Gateway for WhatsApp and MS Teams; Discord polls go direct.
|
||||
- When a message tool call is bound to an active chat session, sends are constrained to that session’s target to avoid cross-context leaks.
|
||||
|
||||
### `cron`
|
||||
Manage Gateway cron jobs and wakeups.
|
||||
|
||||
@@ -58,7 +58,8 @@ Common flows:
|
||||
- `clawdhub sync --all`
|
||||
|
||||
By default, `clawdhub` installs into `./skills` under your current working
|
||||
directory; Clawdbot picks that up as `<workspace>/skills` on the next session.
|
||||
directory (or falls back to the configured Clawdbot workspace). Clawdbot picks
|
||||
that up as `<workspace>/skills` on the next session.
|
||||
|
||||
## Format (AgentSkills + Pi-compatible)
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ They run immediately, are stripped before the model sees the message, and the re
|
||||
```json5
|
||||
{
|
||||
commands: {
|
||||
native: false,
|
||||
native: "auto",
|
||||
text: true,
|
||||
config: false,
|
||||
debug: false,
|
||||
@@ -36,9 +36,10 @@ They run immediately, are stripped before the model sees the message, and the re
|
||||
|
||||
- `commands.text` (default `true`) enables parsing `/...` in chat messages.
|
||||
- On surfaces without native commands (WhatsApp/WebChat/Signal/iMessage/MS Teams), text commands still work even if you set this to `false`.
|
||||
- `commands.native` (default `false`) registers native commands on Discord/Slack/Telegram.
|
||||
- `false` clears previously registered commands on Discord/Telegram at startup.
|
||||
- Slack commands are managed in the Slack app and are not removed automatically.
|
||||
- `commands.native` (default `"auto"`) registers native commands.
|
||||
- Auto: on for Discord/Telegram; off for Slack (until you add slash commands); ignored for providers without native support.
|
||||
- Set `discord.commands.native`, `telegram.commands.native`, or `slack.commands.native` to override per provider (bool or `"auto"`).
|
||||
- `false` clears previously registered commands on Discord/Telegram at startup. Slack commands are managed in the Slack app and are not removed automatically.
|
||||
- `commands.config` (default `false`) enables `/config` (reads/writes `clawdbot.json`).
|
||||
- `commands.debug` (default `false`) enables `/debug` (runtime-only overrides).
|
||||
- `commands.useAccessGroups` (default `true`) enforces allowlists/policies for commands.
|
||||
@@ -59,7 +60,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`)
|
||||
|
||||
@@ -20,6 +20,7 @@ Primary goals:
|
||||
Use `sessions_spawn`:
|
||||
- Starts a sub-agent run (`deliver: false`, global lane: `subagent`)
|
||||
- Then runs an announce step and posts the announce reply to the requester chat provider
|
||||
- Default model: inherits the caller unless you set `agents.defaults.subagents.model` (or per-agent `agents.list[].subagents.model`); an explicit `sessions_spawn.model` still wins.
|
||||
|
||||
Tool params:
|
||||
- `task` (required)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -77,8 +77,12 @@ clawdbot gateway --tailscale serve
|
||||
Open:
|
||||
- `https://<magicdns>/` (or your configured `gateway.controlUi.basePath`)
|
||||
|
||||
By default, the gateway trusts Tailscale identity headers in serve mode. You can still set
|
||||
`gateway.auth` (or `CLAWDBOT_GATEWAY_TOKEN`) if you want a shared secret instead.
|
||||
By default, Serve requests can authenticate via Tailscale identity headers
|
||||
(`tailscale-user-login`) when `gateway.auth.allowTailscale` is `true`. Clawdbot
|
||||
only accepts these when the request hits loopback with Tailscale’s
|
||||
`x-forwarded-*` headers. Set `gateway.auth.allowTailscale: false` (or force
|
||||
`gateway.auth.mode: "password"`) if you want to require a token/password even
|
||||
for Serve traffic.
|
||||
|
||||
### Bind to tailnet + token
|
||||
|
||||
|
||||
@@ -18,3 +18,21 @@ Key references:
|
||||
|
||||
Authentication is enforced at the WebSocket handshake via `connect.params.auth`
|
||||
(token or password). See `gateway.auth` in [Gateway configuration](/gateway/configuration).
|
||||
|
||||
## Fast path (recommended)
|
||||
|
||||
- After onboarding, the CLI now auto-opens the dashboard with your token and prints the same tokenized link.
|
||||
- Re-open anytime: `clawdbot dashboard` (copies link, opens browser if possible, shows SSH hint if headless).
|
||||
- The token stays local (query param only); the UI strips it after first load and saves it in localStorage.
|
||||
|
||||
## Token basics (local vs remote)
|
||||
|
||||
- **Localhost**: open `http://127.0.0.1:18789/`. If you see “unauthorized,” run `clawdbot dashboard` and use the tokenized link (`?token=...`).
|
||||
- **Token source**: `gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`); the UI stores it after first load.
|
||||
- **Not localhost**: use Tailscale Serve (tokenless if `gateway.auth.allowTailscale: true`), tailnet bind with a token, or an SSH tunnel. See [Web surfaces](/web).
|
||||
|
||||
## If you see “unauthorized” / 1008
|
||||
|
||||
- Run `clawdbot dashboard` to get a fresh tokenized link.
|
||||
- Ensure the gateway is reachable (local: `clawdbot status`; remote: SSH tunnel `ssh -N -L 18789:127.0.0.1:18789 user@host` then open `http://127.0.0.1:18789/?token=...`).
|
||||
- In the dashboard settings, paste the same token you configured in `gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`).
|
||||
+4
-1
@@ -94,7 +94,10 @@ Open:
|
||||
- Binding the Gateway to a non-loopback address **requires** auth (`gateway.auth` or `CLAWDBOT_GATEWAY_TOKEN`).
|
||||
- The wizard generates a gateway token by default (even on loopback).
|
||||
- The UI sends `connect.params.auth.token` or `connect.params.auth.password`.
|
||||
- Use `gateway.auth.allowTailscale: false` to require explicit credentials even in Serve mode.
|
||||
- With Serve, Tailscale identity headers can satisfy auth when
|
||||
`gateway.auth.allowTailscale` is `true` (no token/password required). Set
|
||||
`gateway.auth.allowTailscale: false` to require explicit credentials. See
|
||||
[Tailscale](/gateway/tailscale) and [Security](/gateway/security).
|
||||
- `gateway.tailscale.mode: "funnel"` requires `gateway.auth.mode: "password"` (shared password).
|
||||
|
||||
## Building the UI
|
||||
|
||||
+1
-1
@@ -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>`
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.0
|
||||
|
||||
### Highlights
|
||||
- Initial release of the @clawdbot/voice-call plugin.
|
||||
|
||||
### Changes
|
||||
- Providers: Twilio (Programmable Voice + Media Streams), Telnyx (Call Control v2), and mock dev provider.
|
||||
- Outbound calls: notify mode with auto-hangup + conversation mode for multi-turn calls.
|
||||
- Inbound calls: policies (disabled/allowlist/open), allowlist matching, and inbound greeting.
|
||||
- Webhooks: local server with configurable bind/port/path plus publicUrl override.
|
||||
- Exposure: ngrok and Tailscale (serve/funnel) tunnel helpers; dev-only signature bypass for ngrok free tier.
|
||||
- Streaming: OpenAI Realtime STT with media stream WebSocket + partial/final transcripts.
|
||||
- Speech: OpenAI TTS (model/voice/instructions) with Twilio <Say> fallback.
|
||||
- Tooling: `voice_call` tool with action-based API for initiate/continue/speak/end/status.
|
||||
- CLI: `clawdbot voicecall` commands (call/start/continue/speak/end/status/tail/expose).
|
||||
- RPC: gateway methods (`voicecall.initiate|continue|speak|end|status` + legacy `voicecall.start`).
|
||||
- Persistence: JSONL call logs with `voicecall tail` for live inspection.
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
Official Voice Call plugin for **Clawdbot**.
|
||||
|
||||
- Provider: **Twilio** (real outbound calls)
|
||||
- Dev fallback: `log` (no network)
|
||||
Providers:
|
||||
- **Twilio** (Programmable Voice + Media Streams)
|
||||
- **Telnyx** (Call Control v2)
|
||||
- **Mock** (dev/no network)
|
||||
|
||||
Docs: `https://docs.clawd.bot/plugins/voice-call`
|
||||
Plugin system: `https://docs.clawd.bot/plugin`
|
||||
@@ -26,83 +28,80 @@ cp -R extensions/voice-call ~/.clawdbot/extensions/voice-call
|
||||
cd ~/.clawdbot/extensions/voice-call && pnpm install
|
||||
```
|
||||
|
||||
### Option C: add via config (custom path)
|
||||
|
||||
```json5
|
||||
{
|
||||
plugins: {
|
||||
load: { paths: ["/absolute/path/to/voice-call/index.ts"] },
|
||||
entries: { "voice-call": { enabled: true, config: { provider: "log" } } }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Restart the Gateway after changes.
|
||||
|
||||
## Config
|
||||
|
||||
Put under `plugins.entries.voice-call.config`:
|
||||
|
||||
```json5
|
||||
{
|
||||
provider: "twilio",
|
||||
provider: "twilio", // or "telnyx" | "mock"
|
||||
fromNumber: "+15550001234",
|
||||
toNumber: "+15550005678",
|
||||
|
||||
twilio: {
|
||||
accountSid: "ACxxxxxxxx",
|
||||
authToken: "your_token",
|
||||
from: "+15551234567",
|
||||
statusCallbackUrl: "https://example.com/twilio-status", // optional
|
||||
twimlUrl: "https://example.com/twiml" // optional, else auto-generates <Say>
|
||||
authToken: "your_token"
|
||||
},
|
||||
|
||||
// Webhook server
|
||||
serve: {
|
||||
port: 3334,
|
||||
path: "/voice/webhook"
|
||||
},
|
||||
|
||||
// Public exposure (pick one):
|
||||
// publicUrl: "https://example.ngrok.app/voice/webhook",
|
||||
// tunnel: { provider: "ngrok" },
|
||||
// tailscale: { mode: "funnel", path: "/voice/webhook" }
|
||||
|
||||
outbound: {
|
||||
defaultMode: "notify" // or "conversation"
|
||||
},
|
||||
|
||||
streaming: {
|
||||
enabled: true,
|
||||
streamPath: "/voice/stream"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Dev fallback (no network):
|
||||
|
||||
```json5
|
||||
{ provider: "log" }
|
||||
```
|
||||
|
||||
## Twilio credentials (quick notes)
|
||||
|
||||
You’ll need a Twilio account, your **Account SID**, your **Auth Token**, and a Twilio **Voice-capable** phone number to use as `from`.
|
||||
|
||||
- Signup: `https://www.twilio.com/`
|
||||
- Console: `https://console.twilio.com/`
|
||||
|
||||
Full setup guide: `https://docs.clawd.bot/plugins/voice-call`
|
||||
Notes:
|
||||
- Twilio/Telnyx require a **publicly reachable** webhook URL.
|
||||
- `mock` is a local dev provider (no network calls).
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
clawdbot voicecall start --to "+15555550123" --message "Hello from Clawdbot"
|
||||
clawdbot voicecall status --sid CAxxxxxxxx
|
||||
clawdbot voicecall call --to "+15555550123" --message "Hello from Clawdbot"
|
||||
clawdbot voicecall continue --call-id <id> --message "Any questions?"
|
||||
clawdbot voicecall speak --call-id <id> --message "One moment"
|
||||
clawdbot voicecall end --call-id <id>
|
||||
clawdbot voicecall status --call-id <id>
|
||||
clawdbot voicecall tail
|
||||
clawdbot voicecall expose --mode funnel
|
||||
```
|
||||
|
||||
## Tool
|
||||
|
||||
Tool name: `voice_call`
|
||||
|
||||
Parameters:
|
||||
- `mode`: `"call" | "status"` (default: `call`)
|
||||
- `to`: target string (required for call)
|
||||
- `sid`: call SID (required for status)
|
||||
- `message`: optional intro text
|
||||
Actions:
|
||||
- `initiate_call` (message, to?, mode?)
|
||||
- `continue_call` (callId, message)
|
||||
- `speak_to_user` (callId, message)
|
||||
- `end_call` (callId)
|
||||
- `get_status` (callId)
|
||||
|
||||
## Gateway RPC
|
||||
|
||||
- `voicecall.start` (to, message?)
|
||||
- `voicecall.status` (sid)
|
||||
|
||||
## Skill
|
||||
|
||||
The repo includes `skills/voice-call/SKILL.md` for agent guidance. Enable it by
|
||||
setting:
|
||||
|
||||
```json5
|
||||
{ plugins: { entries: { "voice-call": { enabled: true } } } }
|
||||
```
|
||||
- `voicecall.initiate` (to?, message, mode?)
|
||||
- `voicecall.continue` (callId, message)
|
||||
- `voicecall.speak` (callId, message)
|
||||
- `voicecall.end` (callId)
|
||||
- `voicecall.status` (callId)
|
||||
|
||||
## Notes
|
||||
|
||||
- Uses Twilio REST API via fetch (no SDK). Provide valid SID/token/from.
|
||||
- Use `voicecall.*` for RPC names and `voice_call` for tool naming consistency.
|
||||
- Uses webhook signature verification for Twilio/Telnyx.
|
||||
- `responseModel` / `responseSystemPrompt` control AI auto-responses.
|
||||
- Media streaming requires `ws` and OpenAI Realtime API key.
|
||||
+422
-273
@@ -1,325 +1,474 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
type VoiceCallConfig =
|
||||
| {
|
||||
provider: "twilio";
|
||||
twilio: {
|
||||
accountSid: string;
|
||||
authToken: string;
|
||||
from: string;
|
||||
statusCallbackUrl?: string;
|
||||
twimlUrl?: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
provider?: "log";
|
||||
};
|
||||
|
||||
type VoiceCallStartParams = {
|
||||
to: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
type VoiceCallStatus = {
|
||||
sid: string;
|
||||
status: string;
|
||||
provider: string;
|
||||
to?: string;
|
||||
from?: string;
|
||||
};
|
||||
import type { CoreConfig } from "./src/core-bridge.js";
|
||||
import {
|
||||
VoiceCallConfigSchema,
|
||||
validateProviderConfig,
|
||||
type VoiceCallConfig,
|
||||
} from "./src/config.js";
|
||||
import { registerVoiceCallCli } from "./src/cli.js";
|
||||
import { createVoiceCallRuntime, type VoiceCallRuntime } from "./src/runtime.js";
|
||||
|
||||
const voiceCallConfigSchema = {
|
||||
parse(value: unknown): VoiceCallConfig {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return { provider: "log" };
|
||||
}
|
||||
const cfg = value as Record<string, unknown>;
|
||||
const provider =
|
||||
cfg.provider === "twilio" || cfg.provider === "log"
|
||||
? cfg.provider
|
||||
: "log";
|
||||
if (provider === "twilio") {
|
||||
const twilio = cfg.twilio as Record<string, unknown> | undefined;
|
||||
if (
|
||||
!twilio ||
|
||||
typeof twilio.accountSid !== "string" ||
|
||||
typeof twilio.authToken !== "string" ||
|
||||
typeof twilio.from !== "string"
|
||||
) {
|
||||
throw new Error(
|
||||
"twilio provider requires twilio.accountSid, twilio.authToken, twilio.from",
|
||||
);
|
||||
}
|
||||
return {
|
||||
provider: "twilio",
|
||||
twilio: {
|
||||
accountSid: twilio.accountSid,
|
||||
authToken: twilio.authToken,
|
||||
from: twilio.from,
|
||||
statusCallbackUrl:
|
||||
typeof twilio.statusCallbackUrl === "string"
|
||||
? twilio.statusCallbackUrl
|
||||
: undefined,
|
||||
twimlUrl:
|
||||
typeof twilio.twimlUrl === "string" ? twilio.twimlUrl : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
return { provider: "log" };
|
||||
const raw =
|
||||
value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
const twilio = raw.twilio as Record<string, unknown> | undefined;
|
||||
const legacyFrom = typeof twilio?.from === "string" ? twilio.from : undefined;
|
||||
|
||||
const enabled = typeof raw.enabled === "boolean" ? raw.enabled : true;
|
||||
const providerRaw = raw.provider === "log" ? "mock" : raw.provider;
|
||||
const provider = providerRaw ?? (enabled ? "mock" : undefined);
|
||||
|
||||
return VoiceCallConfigSchema.parse({
|
||||
...raw,
|
||||
enabled,
|
||||
provider,
|
||||
fromNumber: raw.fromNumber ?? legacyFrom,
|
||||
});
|
||||
},
|
||||
uiHints: {
|
||||
provider: {
|
||||
label: "Provider",
|
||||
help: 'Use "twilio" for real calls or "log" for dev/no-network.',
|
||||
help: "Use twilio, telnyx, or mock for dev/no-network.",
|
||||
},
|
||||
"twilio.accountSid": {
|
||||
label: "Twilio Account SID",
|
||||
placeholder: "ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
fromNumber: { label: "From Number", placeholder: "+15550001234" },
|
||||
toNumber: { label: "Default To Number", placeholder: "+15550001234" },
|
||||
inboundPolicy: { label: "Inbound Policy" },
|
||||
allowFrom: { label: "Inbound Allowlist" },
|
||||
inboundGreeting: { label: "Inbound Greeting", advanced: true },
|
||||
"telnyx.apiKey": { label: "Telnyx API Key", sensitive: true },
|
||||
"telnyx.connectionId": { label: "Telnyx Connection ID" },
|
||||
"telnyx.publicKey": { label: "Telnyx Public Key", sensitive: true },
|
||||
"twilio.accountSid": { label: "Twilio Account SID" },
|
||||
"twilio.authToken": { label: "Twilio Auth Token", sensitive: true },
|
||||
"outbound.defaultMode": { label: "Default Call Mode" },
|
||||
"outbound.notifyHangupDelaySec": {
|
||||
label: "Notify Hangup Delay (sec)",
|
||||
advanced: true,
|
||||
},
|
||||
"twilio.authToken": {
|
||||
label: "Twilio Auth Token",
|
||||
"serve.port": { label: "Webhook Port" },
|
||||
"serve.bind": { label: "Webhook Bind" },
|
||||
"serve.path": { label: "Webhook Path" },
|
||||
"tailscale.mode": { label: "Tailscale Mode", advanced: true },
|
||||
"tailscale.path": { label: "Tailscale Path", advanced: true },
|
||||
"tunnel.provider": { label: "Tunnel Provider", advanced: true },
|
||||
"tunnel.ngrokAuthToken": {
|
||||
label: "ngrok Auth Token",
|
||||
sensitive: true,
|
||||
placeholder: "••••••••••••••••",
|
||||
},
|
||||
"twilio.from": {
|
||||
label: "Twilio From (E.164)",
|
||||
placeholder: "+15551234567",
|
||||
},
|
||||
"twilio.statusCallbackUrl": {
|
||||
label: "Status Callback URL",
|
||||
placeholder: "https://example.com/twilio-status",
|
||||
advanced: true,
|
||||
},
|
||||
"twilio.twimlUrl": {
|
||||
label: "TwiML URL",
|
||||
placeholder: "https://example.com/twiml",
|
||||
"tunnel.ngrokDomain": { label: "ngrok Domain", advanced: true },
|
||||
"tunnel.allowNgrokFreeTier": {
|
||||
label: "Allow ngrok Free Tier",
|
||||
advanced: true,
|
||||
},
|
||||
"streaming.enabled": { label: "Enable Streaming", advanced: true },
|
||||
"streaming.openaiApiKey": {
|
||||
label: "OpenAI Realtime API Key",
|
||||
sensitive: true,
|
||||
advanced: true,
|
||||
},
|
||||
"streaming.sttModel": { label: "Realtime STT Model", advanced: true },
|
||||
"streaming.streamPath": { label: "Media Stream Path", advanced: true },
|
||||
"tts.model": { label: "TTS Model", advanced: true },
|
||||
"tts.voice": { label: "TTS Voice", advanced: true },
|
||||
"tts.instructions": { label: "TTS Instructions", advanced: true },
|
||||
publicUrl: { label: "Public Webhook URL", advanced: true },
|
||||
skipSignatureVerification: {
|
||||
label: "Skip Signature Verification",
|
||||
advanced: true,
|
||||
},
|
||||
store: { label: "Call Log Store Path", advanced: true },
|
||||
responseModel: { label: "Response Model", advanced: true },
|
||||
responseSystemPrompt: { label: "Response System Prompt", advanced: true },
|
||||
responseTimeoutMs: { label: "Response Timeout (ms)", advanced: true },
|
||||
},
|
||||
};
|
||||
|
||||
const escapeXml = (input: string): string =>
|
||||
input
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
|
||||
async function startTwilioCall(
|
||||
cfg: Exclude<VoiceCallConfig, { provider?: "log" }>["twilio"],
|
||||
params: VoiceCallStartParams,
|
||||
): Promise<VoiceCallStatus> {
|
||||
const body = new URLSearchParams();
|
||||
body.set("To", params.to);
|
||||
body.set("From", cfg.from);
|
||||
|
||||
if (cfg.twimlUrl) {
|
||||
body.set("Url", cfg.twimlUrl);
|
||||
} else {
|
||||
const say = escapeXml(params.message ?? "Hello from Clawdbot.");
|
||||
body.set("Twiml", `<Response><Say>${say}</Say></Response>`);
|
||||
}
|
||||
if (cfg.statusCallbackUrl) {
|
||||
body.set("StatusCallback", cfg.statusCallbackUrl);
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`https://api.twilio.com/2010-04-01/Accounts/${cfg.accountSid}/Calls.json`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Basic ${Buffer.from(`${cfg.accountSid}:${cfg.authToken}`).toString("base64")}`,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body,
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`twilio call failed: ${res.status} ${text}`);
|
||||
}
|
||||
const payload = (await res.json()) as Record<string, unknown>;
|
||||
return {
|
||||
sid: String(payload.sid ?? ""),
|
||||
status: String(payload.status ?? "unknown"),
|
||||
provider: "twilio",
|
||||
to: String(payload.to ?? params.to),
|
||||
from: String(payload.from ?? cfg.from),
|
||||
};
|
||||
}
|
||||
|
||||
async function getTwilioStatus(
|
||||
cfg: Exclude<VoiceCallConfig, { provider?: "log" }>["twilio"],
|
||||
sid: string,
|
||||
): Promise<VoiceCallStatus> {
|
||||
const res = await fetch(
|
||||
`https://api.twilio.com/2010-04-01/Accounts/${cfg.accountSid}/Calls/${sid}.json`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Basic ${Buffer.from(`${cfg.accountSid}:${cfg.authToken}`).toString("base64")}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`twilio status failed: ${res.status} ${text}`);
|
||||
}
|
||||
const payload = (await res.json()) as Record<string, unknown>;
|
||||
return {
|
||||
sid,
|
||||
status: String(payload.status ?? "unknown"),
|
||||
provider: "twilio",
|
||||
to: String(payload.to ?? ""),
|
||||
from: String(payload.from ?? cfg.from),
|
||||
};
|
||||
}
|
||||
|
||||
async function startCall(
|
||||
cfg: VoiceCallConfig,
|
||||
params: VoiceCallStartParams,
|
||||
): Promise<VoiceCallStatus> {
|
||||
if (cfg.provider === "twilio") {
|
||||
return startTwilioCall(cfg.twilio, params);
|
||||
}
|
||||
return {
|
||||
sid: `log-${Date.now()}`,
|
||||
status: "queued",
|
||||
provider: "log",
|
||||
to: params.to,
|
||||
};
|
||||
}
|
||||
|
||||
async function getStatus(
|
||||
cfg: VoiceCallConfig,
|
||||
sid: string,
|
||||
): Promise<VoiceCallStatus> {
|
||||
if (cfg.provider === "twilio") {
|
||||
return getTwilioStatus(cfg.twilio, sid);
|
||||
}
|
||||
return { sid, status: "mock", provider: "log" };
|
||||
}
|
||||
const VoiceCallToolSchema = Type.Union([
|
||||
Type.Object({
|
||||
action: Type.Literal("initiate_call"),
|
||||
to: Type.Optional(Type.String({ description: "Call target" })),
|
||||
message: Type.String({ description: "Intro message" }),
|
||||
mode: Type.Optional(Type.Union([Type.Literal("notify"), Type.Literal("conversation")])),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("continue_call"),
|
||||
callId: Type.String({ description: "Call ID" }),
|
||||
message: Type.String({ description: "Follow-up message" }),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("speak_to_user"),
|
||||
callId: Type.String({ description: "Call ID" }),
|
||||
message: Type.String({ description: "Message to speak" }),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("end_call"),
|
||||
callId: Type.String({ description: "Call ID" }),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("get_status"),
|
||||
callId: Type.String({ description: "Call ID" }),
|
||||
}),
|
||||
Type.Object({
|
||||
mode: Type.Optional(Type.Union([Type.Literal("call"), Type.Literal("status")])),
|
||||
to: Type.Optional(Type.String({ description: "Call target" })),
|
||||
sid: Type.Optional(Type.String({ description: "Call SID" })),
|
||||
message: Type.Optional(Type.String({ description: "Optional intro message" })),
|
||||
}),
|
||||
]);
|
||||
|
||||
const voiceCallPlugin = {
|
||||
id: "voice-call",
|
||||
name: "Voice Call",
|
||||
description: "Voice-call plugin with Twilio/log providers",
|
||||
description: "Voice-call plugin with Telnyx/Twilio providers",
|
||||
configSchema: voiceCallConfigSchema,
|
||||
register(api) {
|
||||
const cfg = voiceCallConfigSchema.parse(api.pluginConfig);
|
||||
const validation = validateProviderConfig(cfg);
|
||||
|
||||
api.registerGatewayMethod("voicecall.start", async ({ params, respond }) => {
|
||||
const to = typeof params?.to === "string" ? params.to.trim() : "";
|
||||
const message =
|
||||
typeof params?.message === "string" ? params.message.trim() : undefined;
|
||||
if (!to) {
|
||||
respond(false, { error: "to required" });
|
||||
return;
|
||||
if (api.pluginConfig && typeof api.pluginConfig === "object") {
|
||||
const raw = api.pluginConfig as Record<string, unknown>;
|
||||
const twilio = raw.twilio as Record<string, unknown> | undefined;
|
||||
if (raw.provider === "log") {
|
||||
api.logger.warn(
|
||||
"[voice-call] provider \"log\" is deprecated; use \"mock\" instead",
|
||||
);
|
||||
}
|
||||
if (typeof twilio?.from === "string") {
|
||||
api.logger.warn(
|
||||
"[voice-call] twilio.from is deprecated; use fromNumber instead",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let runtimePromise: Promise<VoiceCallRuntime> | null = null;
|
||||
let runtime: VoiceCallRuntime | null = null;
|
||||
|
||||
const ensureRuntime = async () => {
|
||||
if (!cfg.enabled) {
|
||||
throw new Error("Voice call disabled in plugin config");
|
||||
}
|
||||
if (!validation.valid) {
|
||||
throw new Error(validation.errors.join("; "));
|
||||
}
|
||||
if (runtime) return runtime;
|
||||
if (!runtimePromise) {
|
||||
runtimePromise = createVoiceCallRuntime({
|
||||
config: cfg,
|
||||
coreConfig: api.config as CoreConfig,
|
||||
logger: api.logger,
|
||||
});
|
||||
}
|
||||
runtime = await runtimePromise;
|
||||
return runtime;
|
||||
};
|
||||
|
||||
const sendError = (respond: (ok: boolean, payload?: unknown) => void, err: unknown) => {
|
||||
respond(false, { error: err instanceof Error ? err.message : String(err) });
|
||||
};
|
||||
|
||||
api.registerGatewayMethod("voicecall.initiate", async ({ params, respond }) => {
|
||||
try {
|
||||
const result = await startCall(cfg, { to, message });
|
||||
respond(true, result);
|
||||
const message =
|
||||
typeof params?.message === "string" ? params.message.trim() : "";
|
||||
if (!message) {
|
||||
respond(false, { error: "message required" });
|
||||
return;
|
||||
}
|
||||
const rt = await ensureRuntime();
|
||||
const to =
|
||||
typeof params?.to === "string" && params.to.trim()
|
||||
? params.to.trim()
|
||||
: rt.config.toNumber;
|
||||
if (!to) {
|
||||
respond(false, { error: "to required" });
|
||||
return;
|
||||
}
|
||||
const mode =
|
||||
params?.mode === "notify" || params?.mode === "conversation"
|
||||
? params.mode
|
||||
: undefined;
|
||||
const result = await rt.manager.initiateCall(to, undefined, {
|
||||
message,
|
||||
mode,
|
||||
});
|
||||
if (!result.success) {
|
||||
respond(false, { error: result.error || "initiate failed" });
|
||||
return;
|
||||
}
|
||||
respond(true, { callId: result.callId, initiated: true });
|
||||
} catch (err) {
|
||||
respond(false, { error: String(err) });
|
||||
sendError(respond, err);
|
||||
}
|
||||
});
|
||||
|
||||
api.registerGatewayMethod(
|
||||
"voicecall.status",
|
||||
async ({ params, respond }) => {
|
||||
const sid = typeof params?.sid === "string" ? params.sid.trim() : "";
|
||||
if (!sid) {
|
||||
respond(false, { error: "sid required" });
|
||||
api.registerGatewayMethod("voicecall.continue", async ({ params, respond }) => {
|
||||
try {
|
||||
const callId =
|
||||
typeof params?.callId === "string" ? params.callId.trim() : "";
|
||||
const message =
|
||||
typeof params?.message === "string" ? params.message.trim() : "";
|
||||
if (!callId || !message) {
|
||||
respond(false, { error: "callId and message required" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await getStatus(cfg, sid);
|
||||
respond(true, result);
|
||||
} catch (err) {
|
||||
respond(false, { error: String(err) });
|
||||
const rt = await ensureRuntime();
|
||||
const result = await rt.manager.continueCall(callId, message);
|
||||
if (!result.success) {
|
||||
respond(false, { error: result.error || "continue failed" });
|
||||
return;
|
||||
}
|
||||
},
|
||||
);
|
||||
respond(true, { success: true, transcript: result.transcript });
|
||||
} catch (err) {
|
||||
sendError(respond, err);
|
||||
}
|
||||
});
|
||||
|
||||
api.registerTool(
|
||||
{
|
||||
name: "voice_call",
|
||||
label: "Voice Call",
|
||||
description: "Start or inspect a voice call via the voice-call plugin",
|
||||
parameters: Type.Object({
|
||||
mode: Type.Optional(
|
||||
Type.Union([Type.Literal("call"), Type.Literal("status")]),
|
||||
),
|
||||
to: Type.Optional(Type.String({ description: "Call target" })),
|
||||
sid: Type.Optional(Type.String({ description: "Call SID" })),
|
||||
message: Type.Optional(
|
||||
Type.String({ description: "Optional intro message" }),
|
||||
),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const mode = params.mode ?? "call";
|
||||
if (mode === "status") {
|
||||
if (typeof params.sid !== "string") {
|
||||
throw new Error("sid required for status");
|
||||
api.registerGatewayMethod("voicecall.speak", async ({ params, respond }) => {
|
||||
try {
|
||||
const callId =
|
||||
typeof params?.callId === "string" ? params.callId.trim() : "";
|
||||
const message =
|
||||
typeof params?.message === "string" ? params.message.trim() : "";
|
||||
if (!callId || !message) {
|
||||
respond(false, { error: "callId and message required" });
|
||||
return;
|
||||
}
|
||||
const rt = await ensureRuntime();
|
||||
const result = await rt.manager.speak(callId, message);
|
||||
if (!result.success) {
|
||||
respond(false, { error: result.error || "speak failed" });
|
||||
return;
|
||||
}
|
||||
respond(true, { success: true });
|
||||
} catch (err) {
|
||||
sendError(respond, err);
|
||||
}
|
||||
});
|
||||
|
||||
api.registerGatewayMethod("voicecall.end", async ({ params, respond }) => {
|
||||
try {
|
||||
const callId =
|
||||
typeof params?.callId === "string" ? params.callId.trim() : "";
|
||||
if (!callId) {
|
||||
respond(false, { error: "callId required" });
|
||||
return;
|
||||
}
|
||||
const rt = await ensureRuntime();
|
||||
const result = await rt.manager.endCall(callId);
|
||||
if (!result.success) {
|
||||
respond(false, { error: result.error || "end failed" });
|
||||
return;
|
||||
}
|
||||
respond(true, { success: true });
|
||||
} catch (err) {
|
||||
sendError(respond, err);
|
||||
}
|
||||
});
|
||||
|
||||
api.registerGatewayMethod("voicecall.status", async ({ params, respond }) => {
|
||||
try {
|
||||
const raw =
|
||||
typeof params?.callId === "string"
|
||||
? params.callId.trim()
|
||||
: typeof params?.sid === "string"
|
||||
? params.sid.trim()
|
||||
: "";
|
||||
if (!raw) {
|
||||
respond(false, { error: "callId required" });
|
||||
return;
|
||||
}
|
||||
const rt = await ensureRuntime();
|
||||
const call =
|
||||
rt.manager.getCall(raw) || rt.manager.getCallByProviderCallId(raw);
|
||||
if (!call) {
|
||||
respond(true, { found: false });
|
||||
return;
|
||||
}
|
||||
respond(true, { found: true, call });
|
||||
} catch (err) {
|
||||
sendError(respond, err);
|
||||
}
|
||||
});
|
||||
|
||||
api.registerGatewayMethod("voicecall.start", async ({ params, respond }) => {
|
||||
try {
|
||||
const to = typeof params?.to === "string" ? params.to.trim() : "";
|
||||
const message =
|
||||
typeof params?.message === "string" ? params.message.trim() : "";
|
||||
if (!to) {
|
||||
respond(false, { error: "to required" });
|
||||
return;
|
||||
}
|
||||
const rt = await ensureRuntime();
|
||||
const result = await rt.manager.initiateCall(to, undefined, {
|
||||
message: message || undefined,
|
||||
});
|
||||
if (!result.success) {
|
||||
respond(false, { error: result.error || "initiate failed" });
|
||||
return;
|
||||
}
|
||||
respond(true, { callId: result.callId, initiated: true });
|
||||
} catch (err) {
|
||||
sendError(respond, err);
|
||||
}
|
||||
});
|
||||
|
||||
api.registerTool({
|
||||
name: "voice_call",
|
||||
label: "Voice Call",
|
||||
description:
|
||||
"Make phone calls and have voice conversations via the voice-call plugin.",
|
||||
parameters: VoiceCallToolSchema,
|
||||
async execute(_toolCallId, params) {
|
||||
const json = (payload: unknown) => ({
|
||||
content: [
|
||||
{ type: "text", text: JSON.stringify(payload, null, 2) },
|
||||
],
|
||||
details: payload,
|
||||
});
|
||||
|
||||
try {
|
||||
const rt = await ensureRuntime();
|
||||
|
||||
if (typeof params?.action === "string") {
|
||||
switch (params.action) {
|
||||
case "initiate_call": {
|
||||
const message = String(params.message || "").trim();
|
||||
if (!message) throw new Error("message required");
|
||||
const to =
|
||||
typeof params.to === "string" && params.to.trim()
|
||||
? params.to.trim()
|
||||
: rt.config.toNumber;
|
||||
if (!to) throw new Error("to required");
|
||||
const result = await rt.manager.initiateCall(to, undefined, {
|
||||
message,
|
||||
mode:
|
||||
params.mode === "notify" || params.mode === "conversation"
|
||||
? params.mode
|
||||
: undefined,
|
||||
});
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || "initiate failed");
|
||||
}
|
||||
return json({ callId: result.callId, initiated: true });
|
||||
}
|
||||
case "continue_call": {
|
||||
const callId = String(params.callId || "").trim();
|
||||
const message = String(params.message || "").trim();
|
||||
if (!callId || !message) {
|
||||
throw new Error("callId and message required");
|
||||
}
|
||||
const result = await rt.manager.continueCall(callId, message);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || "continue failed");
|
||||
}
|
||||
return json({ success: true, transcript: result.transcript });
|
||||
}
|
||||
case "speak_to_user": {
|
||||
const callId = String(params.callId || "").trim();
|
||||
const message = String(params.message || "").trim();
|
||||
if (!callId || !message) {
|
||||
throw new Error("callId and message required");
|
||||
}
|
||||
const result = await rt.manager.speak(callId, message);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || "speak failed");
|
||||
}
|
||||
return json({ success: true });
|
||||
}
|
||||
case "end_call": {
|
||||
const callId = String(params.callId || "").trim();
|
||||
if (!callId) throw new Error("callId required");
|
||||
const result = await rt.manager.endCall(callId);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || "end failed");
|
||||
}
|
||||
return json({ success: true });
|
||||
}
|
||||
case "get_status": {
|
||||
const callId = String(params.callId || "").trim();
|
||||
if (!callId) throw new Error("callId required");
|
||||
const call =
|
||||
rt.manager.getCall(callId) ||
|
||||
rt.manager.getCallByProviderCallId(callId);
|
||||
return json(call ? { found: true, call } : { found: false });
|
||||
}
|
||||
}
|
||||
const status = await getStatus(cfg, params.sid.trim());
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(status, null, 2) }],
|
||||
details: status,
|
||||
};
|
||||
}
|
||||
|
||||
const mode = params?.mode ?? "call";
|
||||
if (mode === "status") {
|
||||
const sid =
|
||||
typeof params.sid === "string" ? params.sid.trim() : "";
|
||||
if (!sid) throw new Error("sid required for status");
|
||||
const call =
|
||||
rt.manager.getCall(sid) || rt.manager.getCallByProviderCallId(sid);
|
||||
return json(call ? { found: true, call } : { found: false });
|
||||
}
|
||||
|
||||
const to =
|
||||
typeof params.to === "string" && params.to.trim()
|
||||
? params.to.trim()
|
||||
: null;
|
||||
: rt.config.toNumber;
|
||||
if (!to) throw new Error("to required for call");
|
||||
const result = await startCall(cfg, { to, message: params.message });
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
||||
details: result,
|
||||
};
|
||||
},
|
||||
},
|
||||
{ name: "voice_call" },
|
||||
);
|
||||
|
||||
api.registerCli(({ program }) => {
|
||||
const voicecall = program
|
||||
.command("voicecall")
|
||||
.description("Voice call plugin commands");
|
||||
|
||||
voicecall
|
||||
.command("start")
|
||||
.description("Start a voice call")
|
||||
.requiredOption("--to <target>", "Target to call")
|
||||
.option("--message <text>", "Optional intro message")
|
||||
.action(async (opts) => {
|
||||
const result = await startCall(cfg, {
|
||||
to: opts.to,
|
||||
message: opts.message,
|
||||
const result = await rt.manager.initiateCall(to, undefined, {
|
||||
message:
|
||||
typeof params.message === "string" && params.message.trim()
|
||||
? params.message.trim()
|
||||
: undefined,
|
||||
});
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
});
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || "initiate failed");
|
||||
}
|
||||
return json({ callId: result.callId, initiated: true });
|
||||
} catch (err) {
|
||||
return json({
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
voicecall
|
||||
.command("status")
|
||||
.description("Show voice-call status")
|
||||
.requiredOption("--sid <sid>", "Call SID")
|
||||
.action(async (opts) => {
|
||||
const result = await getStatus(cfg, opts.sid);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
});
|
||||
}, { commands: ["voicecall"] });
|
||||
api.registerCli(
|
||||
({ program }) =>
|
||||
registerVoiceCallCli({
|
||||
program,
|
||||
config: cfg,
|
||||
ensureRuntime,
|
||||
logger: api.logger,
|
||||
}),
|
||||
{ commands: ["voicecall"] },
|
||||
);
|
||||
|
||||
api.registerService({
|
||||
id: "voicecall",
|
||||
start: () => {
|
||||
api.logger.info(`voice-call provider: ${cfg.provider}`);
|
||||
start: async () => {
|
||||
if (!cfg.enabled) return;
|
||||
try {
|
||||
await ensureRuntime();
|
||||
} catch (err) {
|
||||
api.logger.error(
|
||||
`[voice-call] Failed to start runtime: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
stop: () => {
|
||||
api.logger.info("voice-call service stopped");
|
||||
stop: async () => {
|
||||
if (!runtimePromise) return;
|
||||
try {
|
||||
const rt = await runtimePromise;
|
||||
await rt.stop();
|
||||
} finally {
|
||||
runtimePromise = null;
|
||||
runtime = null;
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
"name": "@clawdbot/voice-call",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"description": "Clawdbot voice-call plugin (example)",
|
||||
"description": "Clawdbot voice-call plugin",
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "0.34.47"
|
||||
"@sinclair/typebox": "0.34.47",
|
||||
"ws": "^8.19.0",
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
"clawdbot": {
|
||||
"extensions": ["./index.ts"]
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import type { Command } from "commander";
|
||||
|
||||
import type { VoiceCallConfig } from "./config.js";
|
||||
import type { VoiceCallRuntime } from "./runtime.js";
|
||||
import { resolveUserPath } from "./utils.js";
|
||||
import {
|
||||
cleanupTailscaleExposureRoute,
|
||||
getTailscaleSelfInfo,
|
||||
setupTailscaleExposureRoute,
|
||||
} from "./webhook.js";
|
||||
|
||||
type Logger = {
|
||||
info: (message: string) => void;
|
||||
warn: (message: string) => void;
|
||||
error: (message: string) => void;
|
||||
};
|
||||
|
||||
function resolveMode(input: string): "off" | "serve" | "funnel" {
|
||||
const raw = input.trim().toLowerCase();
|
||||
if (raw === "serve" || raw === "off") return raw;
|
||||
return "funnel";
|
||||
}
|
||||
|
||||
function resolveDefaultStorePath(config: VoiceCallConfig): string {
|
||||
const base =
|
||||
config.store?.trim() || path.join(os.homedir(), "clawd", "voice-calls");
|
||||
return path.join(resolveUserPath(base), "calls.jsonl");
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export function registerVoiceCallCli(params: {
|
||||
program: Command;
|
||||
config: VoiceCallConfig;
|
||||
ensureRuntime: () => Promise<VoiceCallRuntime>;
|
||||
logger: Logger;
|
||||
}) {
|
||||
const { program, config, ensureRuntime, logger } = params;
|
||||
const root = program.command("voicecall").description("Voice call utilities");
|
||||
|
||||
root
|
||||
.command("call")
|
||||
.description("Initiate an outbound voice call")
|
||||
.requiredOption(
|
||||
"-m, --message <text>",
|
||||
"Message to speak when call connects",
|
||||
)
|
||||
.option(
|
||||
"-t, --to <phone>",
|
||||
"Phone number to call (E.164 format, uses config toNumber if not set)",
|
||||
)
|
||||
.option(
|
||||
"--mode <mode>",
|
||||
"Call mode: notify (hangup after message) or conversation (stay open)",
|
||||
"conversation",
|
||||
)
|
||||
.action(
|
||||
async (options: { message: string; to?: string; mode?: string }) => {
|
||||
const rt = await ensureRuntime();
|
||||
const to = options.to ?? rt.config.toNumber;
|
||||
if (!to) {
|
||||
throw new Error("Missing --to and no toNumber configured");
|
||||
}
|
||||
const result = await rt.manager.initiateCall(to, undefined, {
|
||||
message: options.message,
|
||||
mode:
|
||||
options.mode === "notify" || options.mode === "conversation"
|
||||
? options.mode
|
||||
: undefined,
|
||||
});
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || "initiate failed");
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(JSON.stringify({ callId: result.callId }, null, 2));
|
||||
},
|
||||
);
|
||||
|
||||
root
|
||||
.command("start")
|
||||
.description("Alias for voicecall call")
|
||||
.requiredOption("--to <phone>", "Phone number to call")
|
||||
.option("--message <text>", "Message to speak when call connects")
|
||||
.option(
|
||||
"--mode <mode>",
|
||||
"Call mode: notify (hangup after message) or conversation (stay open)",
|
||||
"conversation",
|
||||
)
|
||||
.action(
|
||||
async (options: { to: string; message?: string; mode?: string }) => {
|
||||
const rt = await ensureRuntime();
|
||||
const result = await rt.manager.initiateCall(options.to, undefined, {
|
||||
message: options.message,
|
||||
mode:
|
||||
options.mode === "notify" || options.mode === "conversation"
|
||||
? options.mode
|
||||
: undefined,
|
||||
});
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || "initiate failed");
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(JSON.stringify({ callId: result.callId }, null, 2));
|
||||
},
|
||||
);
|
||||
|
||||
root
|
||||
.command("continue")
|
||||
.description("Speak a message and wait for a response")
|
||||
.requiredOption("--call-id <id>", "Call ID")
|
||||
.requiredOption("--message <text>", "Message to speak")
|
||||
.action(async (options: { callId: string; message: string }) => {
|
||||
const rt = await ensureRuntime();
|
||||
const result = await rt.manager.continueCall(
|
||||
options.callId,
|
||||
options.message,
|
||||
);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || "continue failed");
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
});
|
||||
|
||||
root
|
||||
.command("speak")
|
||||
.description("Speak a message without waiting for response")
|
||||
.requiredOption("--call-id <id>", "Call ID")
|
||||
.requiredOption("--message <text>", "Message to speak")
|
||||
.action(async (options: { callId: string; message: string }) => {
|
||||
const rt = await ensureRuntime();
|
||||
const result = await rt.manager.speak(options.callId, options.message);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || "speak failed");
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
});
|
||||
|
||||
root
|
||||
.command("end")
|
||||
.description("Hang up an active call")
|
||||
.requiredOption("--call-id <id>", "Call ID")
|
||||
.action(async (options: { callId: string }) => {
|
||||
const rt = await ensureRuntime();
|
||||
const result = await rt.manager.endCall(options.callId);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || "end failed");
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
});
|
||||
|
||||
root
|
||||
.command("status")
|
||||
.description("Show call status")
|
||||
.requiredOption("--call-id <id>", "Call ID")
|
||||
.action(async (options: { callId: string }) => {
|
||||
const rt = await ensureRuntime();
|
||||
const call = rt.manager.getCall(options.callId);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(JSON.stringify(call ?? { found: false }, null, 2));
|
||||
});
|
||||
|
||||
root
|
||||
.command("tail")
|
||||
.description(
|
||||
"Tail voice-call JSONL logs (prints new lines; useful during provider tests)",
|
||||
)
|
||||
.option("--file <path>", "Path to calls.jsonl", resolveDefaultStorePath(config))
|
||||
.option("--since <n>", "Print last N lines first", "25")
|
||||
.option("--poll <ms>", "Poll interval in ms", "250")
|
||||
.action(
|
||||
async (options: { file: string; since?: string; poll?: string }) => {
|
||||
const file = options.file;
|
||||
const since = Math.max(0, Number(options.since ?? 0));
|
||||
const pollMs = Math.max(50, Number(options.poll ?? 250));
|
||||
|
||||
if (!fs.existsSync(file)) {
|
||||
logger.error(`No log file at ${file}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const initial = fs.readFileSync(file, "utf8");
|
||||
const lines = initial.split("\n").filter(Boolean);
|
||||
for (const line of lines.slice(Math.max(0, lines.length - since))) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(line);
|
||||
}
|
||||
|
||||
let offset = Buffer.byteLength(initial, "utf8");
|
||||
|
||||
for (;;) {
|
||||
try {
|
||||
const stat = fs.statSync(file);
|
||||
if (stat.size < offset) {
|
||||
offset = 0;
|
||||
}
|
||||
if (stat.size > offset) {
|
||||
const fd = fs.openSync(file, "r");
|
||||
try {
|
||||
const buf = Buffer.alloc(stat.size - offset);
|
||||
fs.readSync(fd, buf, 0, buf.length, offset);
|
||||
offset = stat.size;
|
||||
const text = buf.toString("utf8");
|
||||
for (const line of text.split("\n").filter(Boolean)) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(line);
|
||||
}
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore and retry
|
||||
}
|
||||
await sleep(pollMs);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
root
|
||||
.command("expose")
|
||||
.description("Enable/disable Tailscale serve/funnel for the webhook")
|
||||
.option("--mode <mode>", "off | serve (tailnet) | funnel (public)", "funnel")
|
||||
.option(
|
||||
"--path <path>",
|
||||
"Tailscale path to expose (recommend matching serve.path)",
|
||||
)
|
||||
.option("--port <port>", "Local webhook port")
|
||||
.option("--serve-path <path>", "Local webhook path")
|
||||
.action(
|
||||
async (options: {
|
||||
mode?: string;
|
||||
port?: string;
|
||||
path?: string;
|
||||
servePath?: string;
|
||||
}) => {
|
||||
const mode = resolveMode(options.mode ?? "funnel");
|
||||
const servePort = Number(options.port ?? config.serve.port ?? 3334);
|
||||
const servePath = String(
|
||||
options.servePath ?? config.serve.path ?? "/voice/webhook",
|
||||
);
|
||||
const tsPath = String(
|
||||
options.path ?? config.tailscale?.path ?? servePath,
|
||||
);
|
||||
|
||||
const localUrl = `http://127.0.0.1:${servePort}`;
|
||||
|
||||
if (mode === "off") {
|
||||
await cleanupTailscaleExposureRoute({ mode: "serve", path: tsPath });
|
||||
await cleanupTailscaleExposureRoute({ mode: "funnel", path: tsPath });
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(JSON.stringify({ ok: true, mode: "off", path: tsPath }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
const publicUrl = await setupTailscaleExposureRoute({
|
||||
mode,
|
||||
path: tsPath,
|
||||
localUrl,
|
||||
});
|
||||
|
||||
const tsInfo = publicUrl ? null : await getTailscaleSelfInfo();
|
||||
const enableUrl = tsInfo?.nodeId
|
||||
? `https://login.tailscale.com/f/${mode}?node=${tsInfo.nodeId}`
|
||||
: null;
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: Boolean(publicUrl),
|
||||
mode,
|
||||
path: tsPath,
|
||||
localUrl,
|
||||
publicUrl,
|
||||
hint: publicUrl
|
||||
? undefined
|
||||
: {
|
||||
note: "Tailscale serve/funnel may be disabled on this tailnet (or require admin enable).",
|
||||
enableUrl,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Phone Number Validation
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* E.164 phone number format: +[country code][number]
|
||||
* Examples use 555 prefix (reserved for fictional numbers)
|
||||
*/
|
||||
export const E164Schema = z
|
||||
.string()
|
||||
.regex(/^\+[1-9]\d{1,14}$/, "Expected E.164 format, e.g. +15550001234");
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Inbound Policy
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Controls how inbound calls are handled:
|
||||
* - "disabled": Block all inbound calls (outbound only)
|
||||
* - "allowlist": Only accept calls from numbers in allowFrom
|
||||
* - "pairing": Unknown callers can request pairing (future)
|
||||
* - "open": Accept all inbound calls (dangerous!)
|
||||
*/
|
||||
export const InboundPolicySchema = z.enum([
|
||||
"disabled",
|
||||
"allowlist",
|
||||
"pairing",
|
||||
"open",
|
||||
]);
|
||||
export type InboundPolicy = z.infer<typeof InboundPolicySchema>;
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Provider-Specific Configuration
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export const TelnyxConfigSchema = z.object({
|
||||
/** Telnyx API v2 key */
|
||||
apiKey: z.string().min(1).optional(),
|
||||
/** Telnyx connection ID (from Call Control app) */
|
||||
connectionId: z.string().min(1).optional(),
|
||||
/** Public key for webhook signature verification */
|
||||
publicKey: z.string().min(1).optional(),
|
||||
});
|
||||
export type TelnyxConfig = z.infer<typeof TelnyxConfigSchema>;
|
||||
|
||||
export const TwilioConfigSchema = z.object({
|
||||
/** Twilio Account SID */
|
||||
accountSid: z.string().min(1).optional(),
|
||||
/** Twilio Auth Token */
|
||||
authToken: z.string().min(1).optional(),
|
||||
});
|
||||
export type TwilioConfig = z.infer<typeof TwilioConfigSchema>;
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// STT/TTS Configuration
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export const SttConfigSchema = z
|
||||
.object({
|
||||
/** STT provider (currently only OpenAI supported) */
|
||||
provider: z.literal("openai").default("openai"),
|
||||
/** Whisper model to use */
|
||||
model: z.string().min(1).default("whisper-1"),
|
||||
})
|
||||
.default({ provider: "openai", model: "whisper-1" });
|
||||
export type SttConfig = z.infer<typeof SttConfigSchema>;
|
||||
|
||||
export const TtsConfigSchema = z
|
||||
.object({
|
||||
/** TTS provider (currently only OpenAI supported) */
|
||||
provider: z.literal("openai").default("openai"),
|
||||
/**
|
||||
* TTS model to use:
|
||||
* - gpt-4o-mini-tts: newest, supports instructions for tone/style control (recommended)
|
||||
* - tts-1: lower latency
|
||||
* - tts-1-hd: higher quality
|
||||
*/
|
||||
model: z.string().min(1).default("gpt-4o-mini-tts"),
|
||||
/**
|
||||
* Voice ID. For best quality, use marin or cedar.
|
||||
* All voices: alloy, ash, ballad, coral, echo, fable, nova, onyx, sage, shimmer, verse, marin, cedar
|
||||
*/
|
||||
voice: z.string().min(1).default("coral"),
|
||||
/**
|
||||
* Instructions for speech style (only works with gpt-4o-mini-tts).
|
||||
* Examples: "Speak in a cheerful tone", "Talk like a sympathetic customer service agent"
|
||||
*/
|
||||
instructions: z.string().optional(),
|
||||
})
|
||||
.default({ provider: "openai", model: "gpt-4o-mini-tts", voice: "coral" });
|
||||
export type TtsConfig = z.infer<typeof TtsConfigSchema>;
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Webhook Server Configuration
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export const VoiceCallServeConfigSchema = z
|
||||
.object({
|
||||
/** Port to listen on */
|
||||
port: z.number().int().positive().default(3334),
|
||||
/** Bind address */
|
||||
bind: z.string().default("127.0.0.1"),
|
||||
/** Webhook path */
|
||||
path: z.string().min(1).default("/voice/webhook"),
|
||||
})
|
||||
.default({ port: 3334, bind: "127.0.0.1", path: "/voice/webhook" });
|
||||
export type VoiceCallServeConfig = z.infer<typeof VoiceCallServeConfigSchema>;
|
||||
|
||||
export const VoiceCallTailscaleConfigSchema = z
|
||||
.object({
|
||||
/**
|
||||
* Tailscale exposure mode:
|
||||
* - "off": No Tailscale exposure
|
||||
* - "serve": Tailscale serve (private to tailnet)
|
||||
* - "funnel": Tailscale funnel (public HTTPS)
|
||||
*/
|
||||
mode: z.enum(["off", "serve", "funnel"]).default("off"),
|
||||
/** Path for Tailscale serve/funnel (should usually match serve.path) */
|
||||
path: z.string().min(1).default("/voice/webhook"),
|
||||
})
|
||||
.default({ mode: "off", path: "/voice/webhook" });
|
||||
export type VoiceCallTailscaleConfig = z.infer<
|
||||
typeof VoiceCallTailscaleConfigSchema
|
||||
>;
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Tunnel Configuration (unified ngrok/tailscale)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export const VoiceCallTunnelConfigSchema = z
|
||||
.object({
|
||||
/**
|
||||
* Tunnel provider:
|
||||
* - "none": No tunnel (use publicUrl if set, or manual setup)
|
||||
* - "ngrok": Use ngrok for public HTTPS tunnel
|
||||
* - "tailscale-serve": Tailscale serve (private to tailnet)
|
||||
* - "tailscale-funnel": Tailscale funnel (public HTTPS)
|
||||
*/
|
||||
provider: z
|
||||
.enum(["none", "ngrok", "tailscale-serve", "tailscale-funnel"])
|
||||
.default("none"),
|
||||
/** ngrok auth token (optional, enables longer sessions and more features) */
|
||||
ngrokAuthToken: z.string().min(1).optional(),
|
||||
/** ngrok custom domain (paid feature, e.g., "myapp.ngrok.io") */
|
||||
ngrokDomain: z.string().min(1).optional(),
|
||||
/**
|
||||
* Allow ngrok free tier compatibility mode.
|
||||
* When true, signature verification failures on ngrok-free.app URLs
|
||||
* will be logged but allowed through. Less secure, but necessary
|
||||
* for ngrok free tier which may modify URLs.
|
||||
*/
|
||||
allowNgrokFreeTier: z.boolean().default(true),
|
||||
})
|
||||
.default({ provider: "none", allowNgrokFreeTier: true });
|
||||
export type VoiceCallTunnelConfig = z.infer<typeof VoiceCallTunnelConfigSchema>;
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Outbound Call Configuration
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Call mode determines how outbound calls behave:
|
||||
* - "notify": Deliver message and auto-hangup after delay (one-way notification)
|
||||
* - "conversation": Stay open for back-and-forth until explicit end or timeout
|
||||
*/
|
||||
export const CallModeSchema = z.enum(["notify", "conversation"]);
|
||||
export type CallMode = z.infer<typeof CallModeSchema>;
|
||||
|
||||
export const OutboundConfigSchema = z
|
||||
.object({
|
||||
/** Default call mode for outbound calls */
|
||||
defaultMode: CallModeSchema.default("notify"),
|
||||
/** Seconds to wait after TTS before auto-hangup in notify mode */
|
||||
notifyHangupDelaySec: z.number().int().nonnegative().default(3),
|
||||
})
|
||||
.default({ defaultMode: "notify", notifyHangupDelaySec: 3 });
|
||||
export type OutboundConfig = z.infer<typeof OutboundConfigSchema>;
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Streaming Configuration (OpenAI Realtime STT)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export const VoiceCallStreamingConfigSchema = z
|
||||
.object({
|
||||
/** Enable real-time audio streaming (requires WebSocket support) */
|
||||
enabled: z.boolean().default(false),
|
||||
/** STT provider for real-time transcription */
|
||||
sttProvider: z.enum(["openai-realtime"]).default("openai-realtime"),
|
||||
/** OpenAI API key for Realtime API (uses OPENAI_API_KEY env if not set) */
|
||||
openaiApiKey: z.string().min(1).optional(),
|
||||
/** OpenAI transcription model (default: gpt-4o-transcribe) */
|
||||
sttModel: z.string().min(1).default("gpt-4o-transcribe"),
|
||||
/** VAD silence duration in ms before considering speech ended */
|
||||
silenceDurationMs: z.number().int().positive().default(800),
|
||||
/** VAD threshold 0-1 (higher = less sensitive) */
|
||||
vadThreshold: z.number().min(0).max(1).default(0.5),
|
||||
/** WebSocket path for media stream connections */
|
||||
streamPath: z.string().min(1).default("/voice/stream"),
|
||||
})
|
||||
.default({
|
||||
enabled: false,
|
||||
sttProvider: "openai-realtime",
|
||||
sttModel: "gpt-4o-transcribe",
|
||||
silenceDurationMs: 800,
|
||||
vadThreshold: 0.5,
|
||||
streamPath: "/voice/stream",
|
||||
});
|
||||
export type VoiceCallStreamingConfig = z.infer<
|
||||
typeof VoiceCallStreamingConfigSchema
|
||||
>;
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Main Voice Call Configuration
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export const VoiceCallConfigSchema = z.object({
|
||||
/** Enable voice call functionality */
|
||||
enabled: z.boolean().default(false),
|
||||
|
||||
/** Active provider (telnyx, twilio, or mock) */
|
||||
provider: z.enum(["telnyx", "twilio", "mock"]).optional(),
|
||||
|
||||
/** Telnyx-specific configuration */
|
||||
telnyx: TelnyxConfigSchema.optional(),
|
||||
|
||||
/** Twilio-specific configuration */
|
||||
twilio: TwilioConfigSchema.optional(),
|
||||
|
||||
/** Phone number to call from (E.164) */
|
||||
fromNumber: E164Schema.optional(),
|
||||
|
||||
/** Default phone number to call (E.164) */
|
||||
toNumber: E164Schema.optional(),
|
||||
|
||||
/** Inbound call policy */
|
||||
inboundPolicy: InboundPolicySchema.default("disabled"),
|
||||
|
||||
/** Allowlist of phone numbers for inbound calls (E.164) */
|
||||
allowFrom: z.array(E164Schema).default([]),
|
||||
|
||||
/** Greeting message for inbound calls */
|
||||
inboundGreeting: z.string().optional(),
|
||||
|
||||
/** Outbound call configuration */
|
||||
outbound: OutboundConfigSchema,
|
||||
|
||||
/** Maximum call duration in seconds */
|
||||
maxDurationSeconds: z.number().int().positive().default(300),
|
||||
|
||||
/** Silence timeout for end-of-speech detection (ms) */
|
||||
silenceTimeoutMs: z.number().int().positive().default(800),
|
||||
|
||||
/** Timeout for user transcript (ms) */
|
||||
transcriptTimeoutMs: z.number().int().positive().default(180000),
|
||||
|
||||
/** Ring timeout for outbound calls (ms) */
|
||||
ringTimeoutMs: z.number().int().positive().default(30000),
|
||||
|
||||
/** Maximum concurrent calls */
|
||||
maxConcurrentCalls: z.number().int().positive().default(1),
|
||||
|
||||
/** Webhook server configuration */
|
||||
serve: VoiceCallServeConfigSchema,
|
||||
|
||||
/** Tailscale exposure configuration (legacy, prefer tunnel config) */
|
||||
tailscale: VoiceCallTailscaleConfigSchema,
|
||||
|
||||
/** Tunnel configuration (unified ngrok/tailscale) */
|
||||
tunnel: VoiceCallTunnelConfigSchema,
|
||||
|
||||
/** Real-time audio streaming configuration */
|
||||
streaming: VoiceCallStreamingConfigSchema,
|
||||
|
||||
/** Public webhook URL override (if set, bypasses tunnel auto-detection) */
|
||||
publicUrl: z.string().url().optional(),
|
||||
|
||||
/** Skip webhook signature verification (development only, NOT for production) */
|
||||
skipSignatureVerification: z.boolean().default(false),
|
||||
|
||||
/** STT configuration */
|
||||
stt: SttConfigSchema,
|
||||
|
||||
/** TTS configuration */
|
||||
tts: TtsConfigSchema,
|
||||
|
||||
/** Store path for call logs */
|
||||
store: z.string().optional(),
|
||||
|
||||
/** Model for generating voice responses (e.g., "anthropic/claude-sonnet-4", "openai/gpt-4o") */
|
||||
responseModel: z.string().default("openai/gpt-4o-mini"),
|
||||
|
||||
/** System prompt for voice responses */
|
||||
responseSystemPrompt: z.string().optional(),
|
||||
|
||||
/** Timeout for response generation in ms (default 30s) */
|
||||
responseTimeoutMs: z.number().int().positive().default(30000),
|
||||
});
|
||||
|
||||
export type VoiceCallConfig = z.infer<typeof VoiceCallConfigSchema>;
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Configuration Helpers
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Validate that the configuration has all required fields for the selected provider.
|
||||
*/
|
||||
export function validateProviderConfig(config: VoiceCallConfig): {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
} {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!config.enabled) {
|
||||
return { valid: true, errors: [] };
|
||||
}
|
||||
|
||||
if (!config.provider) {
|
||||
errors.push("plugins.entries.voice-call.config.provider is required");
|
||||
}
|
||||
|
||||
if (!config.fromNumber && config.provider !== "mock") {
|
||||
errors.push("plugins.entries.voice-call.config.fromNumber is required");
|
||||
}
|
||||
|
||||
if (config.provider === "telnyx") {
|
||||
if (!config.telnyx?.apiKey) {
|
||||
errors.push(
|
||||
"plugins.entries.voice-call.config.telnyx.apiKey is required (or set TELNYX_API_KEY env)",
|
||||
);
|
||||
}
|
||||
if (!config.telnyx?.connectionId) {
|
||||
errors.push(
|
||||
"plugins.entries.voice-call.config.telnyx.connectionId is required (or set TELNYX_CONNECTION_ID env)",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (config.provider === "twilio") {
|
||||
if (!config.twilio?.accountSid) {
|
||||
errors.push(
|
||||
"plugins.entries.voice-call.config.twilio.accountSid is required (or set TWILIO_ACCOUNT_SID env)",
|
||||
);
|
||||
}
|
||||
if (!config.twilio?.authToken) {
|
||||
errors.push(
|
||||
"plugins.entries.voice-call.config.twilio.authToken is required (or set TWILIO_AUTH_TOKEN env)",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
export type CoreConfig = {
|
||||
session?: {
|
||||
store?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type CoreAgentDeps = {
|
||||
resolveAgentDir: (cfg: CoreConfig, agentId: string) => string;
|
||||
resolveAgentWorkspaceDir: (cfg: CoreConfig, agentId: string) => string;
|
||||
resolveAgentIdentity: (
|
||||
cfg: CoreConfig,
|
||||
agentId: string,
|
||||
) => { name?: string | null } | null | undefined;
|
||||
resolveThinkingDefault: (params: {
|
||||
cfg: CoreConfig;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
}) => string;
|
||||
runEmbeddedPiAgent: (params: {
|
||||
sessionId: string;
|
||||
sessionKey?: string;
|
||||
messageProvider?: string;
|
||||
sessionFile: string;
|
||||
workspaceDir: string;
|
||||
config?: CoreConfig;
|
||||
prompt: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
thinkLevel?: string;
|
||||
verboseLevel?: string;
|
||||
timeoutMs: number;
|
||||
runId: string;
|
||||
lane?: string;
|
||||
extraSystemPrompt?: string;
|
||||
agentDir?: string;
|
||||
}) => Promise<{
|
||||
payloads?: Array<{ text?: string; isError?: boolean }>;
|
||||
meta?: { aborted?: boolean };
|
||||
}>;
|
||||
resolveAgentTimeoutMs: (opts: { cfg: CoreConfig }) => number;
|
||||
ensureAgentWorkspace: (params?: { dir: string }) => Promise<void>;
|
||||
resolveStorePath: (store?: string, opts?: { agentId?: string }) => string;
|
||||
loadSessionStore: (storePath: string) => Record<string, unknown>;
|
||||
saveSessionStore: (
|
||||
storePath: string,
|
||||
store: Record<string, unknown>,
|
||||
) => Promise<void>;
|
||||
resolveSessionFilePath: (
|
||||
sessionId: string,
|
||||
entry: unknown,
|
||||
opts?: { agentId?: string },
|
||||
) => string;
|
||||
DEFAULT_MODEL: string;
|
||||
DEFAULT_PROVIDER: string;
|
||||
};
|
||||
|
||||
let coreRootCache: string | null = null;
|
||||
let coreDepsPromise: Promise<CoreAgentDeps> | null = null;
|
||||
|
||||
function findPackageRoot(startDir: string, name: string): string | null {
|
||||
let dir = startDir;
|
||||
for (;;) {
|
||||
const pkgPath = path.join(dir, "package.json");
|
||||
try {
|
||||
if (fs.existsSync(pkgPath)) {
|
||||
const raw = fs.readFileSync(pkgPath, "utf8");
|
||||
const pkg = JSON.parse(raw) as { name?: string };
|
||||
if (pkg.name === name) return dir;
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors and keep walking
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveClawdbotRoot(): string {
|
||||
if (coreRootCache) return coreRootCache;
|
||||
const override = process.env.CLAWDBOT_ROOT?.trim();
|
||||
if (override) {
|
||||
coreRootCache = override;
|
||||
return override;
|
||||
}
|
||||
|
||||
const candidates = new Set<string>();
|
||||
if (process.argv[1]) {
|
||||
candidates.add(path.dirname(process.argv[1]));
|
||||
}
|
||||
candidates.add(process.cwd());
|
||||
try {
|
||||
const urlPath = fileURLToPath(import.meta.url);
|
||||
candidates.add(path.dirname(urlPath));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
for (const start of candidates) {
|
||||
const found = findPackageRoot(start, "clawdbot");
|
||||
if (found) {
|
||||
coreRootCache = found;
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"Unable to resolve Clawdbot root. Set CLAWDBOT_ROOT to the package root.",
|
||||
);
|
||||
}
|
||||
|
||||
async function importCoreModule<T>(relativePath: string): Promise<T> {
|
||||
const root = resolveClawdbotRoot();
|
||||
const distPath = path.join(root, "dist", relativePath);
|
||||
if (!fs.existsSync(distPath)) {
|
||||
throw new Error(
|
||||
`Missing core module at ${distPath}. Run \`pnpm build\` or install the official package.`,
|
||||
);
|
||||
}
|
||||
return (await import(pathToFileURL(distPath).href)) as T;
|
||||
}
|
||||
|
||||
export async function loadCoreAgentDeps(): Promise<CoreAgentDeps> {
|
||||
if (coreDepsPromise) return coreDepsPromise;
|
||||
|
||||
coreDepsPromise = (async () => {
|
||||
const [
|
||||
agentScope,
|
||||
defaults,
|
||||
identity,
|
||||
modelSelection,
|
||||
piEmbedded,
|
||||
timeout,
|
||||
workspace,
|
||||
sessions,
|
||||
] = await Promise.all([
|
||||
importCoreModule<{
|
||||
resolveAgentDir: CoreAgentDeps["resolveAgentDir"];
|
||||
resolveAgentWorkspaceDir: CoreAgentDeps["resolveAgentWorkspaceDir"];
|
||||
}>("agents/agent-scope.js"),
|
||||
importCoreModule<{
|
||||
DEFAULT_MODEL: string;
|
||||
DEFAULT_PROVIDER: string;
|
||||
}>("agents/defaults.js"),
|
||||
importCoreModule<{
|
||||
resolveAgentIdentity: CoreAgentDeps["resolveAgentIdentity"];
|
||||
}>("agents/identity.js"),
|
||||
importCoreModule<{
|
||||
resolveThinkingDefault: CoreAgentDeps["resolveThinkingDefault"];
|
||||
}>("agents/model-selection.js"),
|
||||
importCoreModule<{
|
||||
runEmbeddedPiAgent: CoreAgentDeps["runEmbeddedPiAgent"];
|
||||
}>("agents/pi-embedded.js"),
|
||||
importCoreModule<{
|
||||
resolveAgentTimeoutMs: CoreAgentDeps["resolveAgentTimeoutMs"];
|
||||
}>("agents/timeout.js"),
|
||||
importCoreModule<{
|
||||
ensureAgentWorkspace: CoreAgentDeps["ensureAgentWorkspace"];
|
||||
}>("agents/workspace.js"),
|
||||
importCoreModule<{
|
||||
resolveStorePath: CoreAgentDeps["resolveStorePath"];
|
||||
loadSessionStore: CoreAgentDeps["loadSessionStore"];
|
||||
saveSessionStore: CoreAgentDeps["saveSessionStore"];
|
||||
resolveSessionFilePath: CoreAgentDeps["resolveSessionFilePath"];
|
||||
}>("config/sessions.js"),
|
||||
]);
|
||||
|
||||
return {
|
||||
resolveAgentDir: agentScope.resolveAgentDir,
|
||||
resolveAgentWorkspaceDir: agentScope.resolveAgentWorkspaceDir,
|
||||
resolveAgentIdentity: identity.resolveAgentIdentity,
|
||||
resolveThinkingDefault: modelSelection.resolveThinkingDefault,
|
||||
runEmbeddedPiAgent: piEmbedded.runEmbeddedPiAgent,
|
||||
resolveAgentTimeoutMs: timeout.resolveAgentTimeoutMs,
|
||||
ensureAgentWorkspace: workspace.ensureAgentWorkspace,
|
||||
resolveStorePath: sessions.resolveStorePath,
|
||||
loadSessionStore: sessions.loadSessionStore,
|
||||
saveSessionStore: sessions.saveSessionStore,
|
||||
resolveSessionFilePath: sessions.resolveSessionFilePath,
|
||||
DEFAULT_MODEL: defaults.DEFAULT_MODEL,
|
||||
DEFAULT_PROVIDER: defaults.DEFAULT_PROVIDER,
|
||||
};
|
||||
})();
|
||||
|
||||
return coreDepsPromise;
|
||||
}
|
||||
@@ -0,0 +1,846 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import fsp from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { resolveUserPath } from "./utils.js";
|
||||
import type { CallMode, VoiceCallConfig } from "./config.js";
|
||||
import type { VoiceCallProvider } from "./providers/base.js";
|
||||
import {
|
||||
type CallId,
|
||||
type CallRecord,
|
||||
CallRecordSchema,
|
||||
type CallState,
|
||||
type NormalizedEvent,
|
||||
type OutboundCallOptions,
|
||||
TerminalStates,
|
||||
type TranscriptEntry,
|
||||
} from "./types.js";
|
||||
import { escapeXml, mapVoiceToPolly } from "./voice-mapping.js";
|
||||
|
||||
/**
|
||||
* Manages voice calls: state machine, persistence, and provider coordination.
|
||||
*/
|
||||
export class CallManager {
|
||||
private activeCalls = new Map<CallId, CallRecord>();
|
||||
private providerCallIdMap = new Map<string, CallId>(); // providerCallId -> internal callId
|
||||
private processedEventIds = new Set<string>();
|
||||
private provider: VoiceCallProvider | null = null;
|
||||
private config: VoiceCallConfig;
|
||||
private storePath: string;
|
||||
private webhookUrl: string | null = null;
|
||||
private transcriptWaiters = new Map<
|
||||
CallId,
|
||||
{
|
||||
resolve: (text: string) => void;
|
||||
reject: (err: Error) => void;
|
||||
timeout: NodeJS.Timeout;
|
||||
}
|
||||
>();
|
||||
/** Max duration timers to auto-hangup calls after configured timeout */
|
||||
private maxDurationTimers = new Map<CallId, NodeJS.Timeout>();
|
||||
|
||||
constructor(config: VoiceCallConfig, storePath?: string) {
|
||||
this.config = config;
|
||||
// Resolve store path with tilde expansion (like other config values)
|
||||
const rawPath =
|
||||
storePath ||
|
||||
config.store ||
|
||||
path.join(os.homedir(), "clawd", "voice-calls");
|
||||
this.storePath = resolveUserPath(rawPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the call manager with a provider.
|
||||
*/
|
||||
initialize(provider: VoiceCallProvider, webhookUrl: string): void {
|
||||
this.provider = provider;
|
||||
this.webhookUrl = webhookUrl;
|
||||
|
||||
// Ensure store directory exists
|
||||
fs.mkdirSync(this.storePath, { recursive: true });
|
||||
|
||||
// Load any persisted active calls
|
||||
this.loadActiveCalls();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current provider.
|
||||
*/
|
||||
getProvider(): VoiceCallProvider | null {
|
||||
return this.provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate an outbound call.
|
||||
* @param to - The phone number to call
|
||||
* @param sessionKey - Optional session key for context
|
||||
* @param options - Optional call options (message, mode)
|
||||
*/
|
||||
async initiateCall(
|
||||
to: string,
|
||||
sessionKey?: string,
|
||||
options?: OutboundCallOptions | string,
|
||||
): Promise<{ callId: CallId; success: boolean; error?: string }> {
|
||||
// Support legacy string argument for initialMessage
|
||||
const opts: OutboundCallOptions =
|
||||
typeof options === "string" ? { message: options } : (options ?? {});
|
||||
const initialMessage = opts.message;
|
||||
const mode = opts.mode ?? this.config.outbound.defaultMode;
|
||||
if (!this.provider) {
|
||||
return { callId: "", success: false, error: "Provider not initialized" };
|
||||
}
|
||||
|
||||
if (!this.webhookUrl) {
|
||||
return {
|
||||
callId: "",
|
||||
success: false,
|
||||
error: "Webhook URL not configured",
|
||||
};
|
||||
}
|
||||
|
||||
// Check concurrent call limit
|
||||
const activeCalls = this.getActiveCalls();
|
||||
if (activeCalls.length >= this.config.maxConcurrentCalls) {
|
||||
return {
|
||||
callId: "",
|
||||
success: false,
|
||||
error: `Maximum concurrent calls (${this.config.maxConcurrentCalls}) reached`,
|
||||
};
|
||||
}
|
||||
|
||||
const callId = crypto.randomUUID();
|
||||
const from =
|
||||
this.config.fromNumber ||
|
||||
(this.provider?.name === "mock" ? "+15550000000" : undefined);
|
||||
if (!from) {
|
||||
return { callId: "", success: false, error: "fromNumber not configured" };
|
||||
}
|
||||
|
||||
// Create call record with mode in metadata
|
||||
const callRecord: CallRecord = {
|
||||
callId,
|
||||
provider: this.provider.name,
|
||||
direction: "outbound",
|
||||
state: "initiated",
|
||||
from,
|
||||
to,
|
||||
sessionKey,
|
||||
startedAt: Date.now(),
|
||||
transcript: [],
|
||||
processedEventIds: [],
|
||||
metadata: {
|
||||
...(initialMessage && { initialMessage }),
|
||||
mode,
|
||||
},
|
||||
};
|
||||
|
||||
this.activeCalls.set(callId, callRecord);
|
||||
this.persistCallRecord(callRecord);
|
||||
|
||||
try {
|
||||
// For notify mode with a message, use inline TwiML with <Say>
|
||||
let inlineTwiml: string | undefined;
|
||||
if (mode === "notify" && initialMessage) {
|
||||
const pollyVoice = mapVoiceToPolly(this.config.tts.voice);
|
||||
inlineTwiml = this.generateNotifyTwiml(initialMessage, pollyVoice);
|
||||
console.log(
|
||||
`[voice-call] Using inline TwiML for notify mode (voice: ${pollyVoice})`,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await this.provider.initiateCall({
|
||||
callId,
|
||||
from,
|
||||
to,
|
||||
webhookUrl: this.webhookUrl,
|
||||
inlineTwiml,
|
||||
});
|
||||
|
||||
callRecord.providerCallId = result.providerCallId;
|
||||
this.providerCallIdMap.set(result.providerCallId, callId); // Map providerCallId to internal callId
|
||||
this.persistCallRecord(callRecord);
|
||||
|
||||
return { callId, success: true };
|
||||
} catch (err) {
|
||||
callRecord.state = "failed";
|
||||
callRecord.endedAt = Date.now();
|
||||
callRecord.endReason = "failed";
|
||||
this.persistCallRecord(callRecord);
|
||||
this.activeCalls.delete(callId);
|
||||
if (callRecord.providerCallId) {
|
||||
this.providerCallIdMap.delete(callRecord.providerCallId);
|
||||
}
|
||||
|
||||
return {
|
||||
callId,
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Speak to user in an active call.
|
||||
*/
|
||||
async speak(
|
||||
callId: CallId,
|
||||
text: string,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const call = this.activeCalls.get(callId);
|
||||
if (!call) {
|
||||
return { success: false, error: "Call not found" };
|
||||
}
|
||||
|
||||
if (!this.provider || !call.providerCallId) {
|
||||
return { success: false, error: "Call not connected" };
|
||||
}
|
||||
|
||||
if (TerminalStates.has(call.state)) {
|
||||
return { success: false, error: "Call has ended" };
|
||||
}
|
||||
|
||||
try {
|
||||
// Update state
|
||||
call.state = "speaking";
|
||||
this.persistCallRecord(call);
|
||||
|
||||
// Add to transcript
|
||||
this.addTranscriptEntry(call, "bot", text);
|
||||
|
||||
// Play TTS
|
||||
await this.provider.playTts({
|
||||
callId,
|
||||
providerCallId: call.providerCallId,
|
||||
text,
|
||||
voice: this.config.tts.voice,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Speak the initial message for a call (called when media stream connects).
|
||||
* This is used to auto-play the message passed to initiateCall.
|
||||
* In notify mode, auto-hangup after the message is delivered.
|
||||
*/
|
||||
async speakInitialMessage(providerCallId: string): Promise<void> {
|
||||
const call = this.getCallByProviderCallId(providerCallId);
|
||||
if (!call) {
|
||||
console.warn(
|
||||
`[voice-call] speakInitialMessage: no call found for ${providerCallId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const initialMessage = call.metadata?.initialMessage as string | undefined;
|
||||
const mode = (call.metadata?.mode as CallMode) ?? "conversation";
|
||||
|
||||
if (!initialMessage) {
|
||||
console.log(
|
||||
`[voice-call] speakInitialMessage: no initial message for ${call.callId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear the initial message so we don't speak it again
|
||||
if (call.metadata) {
|
||||
delete call.metadata.initialMessage;
|
||||
this.persistCallRecord(call);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[voice-call] Speaking initial message for call ${call.callId} (mode: ${mode})`,
|
||||
);
|
||||
const result = await this.speak(call.callId, initialMessage);
|
||||
if (!result.success) {
|
||||
console.warn(
|
||||
`[voice-call] Failed to speak initial message: ${result.error}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// In notify mode, auto-hangup after delay
|
||||
if (mode === "notify") {
|
||||
const delaySec = this.config.outbound.notifyHangupDelaySec;
|
||||
console.log(
|
||||
`[voice-call] Notify mode: auto-hangup in ${delaySec}s for call ${call.callId}`,
|
||||
);
|
||||
setTimeout(async () => {
|
||||
const currentCall = this.getCall(call.callId);
|
||||
if (currentCall && !TerminalStates.has(currentCall.state)) {
|
||||
console.log(
|
||||
`[voice-call] Notify mode: hanging up call ${call.callId}`,
|
||||
);
|
||||
await this.endCall(call.callId);
|
||||
}
|
||||
}, delaySec * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start max duration timer for a call.
|
||||
* Auto-hangup when maxDurationSeconds is reached.
|
||||
*/
|
||||
private startMaxDurationTimer(callId: CallId): void {
|
||||
// Clear any existing timer
|
||||
this.clearMaxDurationTimer(callId);
|
||||
|
||||
const maxDurationMs = this.config.maxDurationSeconds * 1000;
|
||||
console.log(
|
||||
`[voice-call] Starting max duration timer (${this.config.maxDurationSeconds}s) for call ${callId}`,
|
||||
);
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
this.maxDurationTimers.delete(callId);
|
||||
const call = this.getCall(callId);
|
||||
if (call && !TerminalStates.has(call.state)) {
|
||||
console.log(
|
||||
`[voice-call] Max duration reached (${this.config.maxDurationSeconds}s), ending call ${callId}`,
|
||||
);
|
||||
call.endReason = "timeout";
|
||||
this.persistCallRecord(call);
|
||||
await this.endCall(callId);
|
||||
}
|
||||
}, maxDurationMs);
|
||||
|
||||
this.maxDurationTimers.set(callId, timer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear max duration timer for a call.
|
||||
*/
|
||||
private clearMaxDurationTimer(callId: CallId): void {
|
||||
const timer = this.maxDurationTimers.get(callId);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
this.maxDurationTimers.delete(callId);
|
||||
}
|
||||
}
|
||||
|
||||
private clearTranscriptWaiter(callId: CallId): void {
|
||||
const waiter = this.transcriptWaiters.get(callId);
|
||||
if (!waiter) return;
|
||||
clearTimeout(waiter.timeout);
|
||||
this.transcriptWaiters.delete(callId);
|
||||
}
|
||||
|
||||
private rejectTranscriptWaiter(callId: CallId, reason: string): void {
|
||||
const waiter = this.transcriptWaiters.get(callId);
|
||||
if (!waiter) return;
|
||||
this.clearTranscriptWaiter(callId);
|
||||
waiter.reject(new Error(reason));
|
||||
}
|
||||
|
||||
private resolveTranscriptWaiter(callId: CallId, transcript: string): void {
|
||||
const waiter = this.transcriptWaiters.get(callId);
|
||||
if (!waiter) return;
|
||||
this.clearTranscriptWaiter(callId);
|
||||
waiter.resolve(transcript);
|
||||
}
|
||||
|
||||
private waitForFinalTranscript(callId: CallId): Promise<string> {
|
||||
// Only allow one in-flight waiter per call.
|
||||
this.rejectTranscriptWaiter(callId, "Transcript waiter replaced");
|
||||
|
||||
const timeoutMs = this.config.transcriptTimeoutMs;
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
this.transcriptWaiters.delete(callId);
|
||||
reject(
|
||||
new Error(`Timed out waiting for transcript after ${timeoutMs}ms`),
|
||||
);
|
||||
}, timeoutMs);
|
||||
|
||||
this.transcriptWaiters.set(callId, { resolve, reject, timeout });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Continue call: speak prompt, then wait for user's final transcript.
|
||||
*/
|
||||
async continueCall(
|
||||
callId: CallId,
|
||||
prompt: string,
|
||||
): Promise<{ success: boolean; transcript?: string; error?: string }> {
|
||||
const call = this.activeCalls.get(callId);
|
||||
if (!call) {
|
||||
return { success: false, error: "Call not found" };
|
||||
}
|
||||
|
||||
if (!this.provider || !call.providerCallId) {
|
||||
return { success: false, error: "Call not connected" };
|
||||
}
|
||||
|
||||
if (TerminalStates.has(call.state)) {
|
||||
return { success: false, error: "Call has ended" };
|
||||
}
|
||||
|
||||
try {
|
||||
await this.speak(callId, prompt);
|
||||
|
||||
call.state = "listening";
|
||||
this.persistCallRecord(call);
|
||||
|
||||
await this.provider.startListening({
|
||||
callId,
|
||||
providerCallId: call.providerCallId,
|
||||
});
|
||||
|
||||
const transcript = await this.waitForFinalTranscript(callId);
|
||||
|
||||
// Best-effort: stop listening after final transcript.
|
||||
await this.provider.stopListening({
|
||||
callId,
|
||||
providerCallId: call.providerCallId,
|
||||
});
|
||||
|
||||
return { success: true, transcript };
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
} finally {
|
||||
this.clearTranscriptWaiter(callId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* End an active call.
|
||||
*/
|
||||
async endCall(callId: CallId): Promise<{ success: boolean; error?: string }> {
|
||||
const call = this.activeCalls.get(callId);
|
||||
if (!call) {
|
||||
return { success: false, error: "Call not found" };
|
||||
}
|
||||
|
||||
if (!this.provider || !call.providerCallId) {
|
||||
return { success: false, error: "Call not connected" };
|
||||
}
|
||||
|
||||
if (TerminalStates.has(call.state)) {
|
||||
return { success: true }; // Already ended
|
||||
}
|
||||
|
||||
try {
|
||||
await this.provider.hangupCall({
|
||||
callId,
|
||||
providerCallId: call.providerCallId,
|
||||
reason: "hangup-bot",
|
||||
});
|
||||
|
||||
call.state = "hangup-bot";
|
||||
call.endedAt = Date.now();
|
||||
call.endReason = "hangup-bot";
|
||||
this.persistCallRecord(call);
|
||||
this.clearMaxDurationTimer(callId);
|
||||
this.rejectTranscriptWaiter(callId, "Call ended: hangup-bot");
|
||||
this.activeCalls.delete(callId);
|
||||
if (call.providerCallId) {
|
||||
this.providerCallIdMap.delete(call.providerCallId);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an inbound call should be accepted based on policy.
|
||||
*/
|
||||
private shouldAcceptInbound(from: string | undefined): boolean {
|
||||
const { inboundPolicy: policy, allowFrom } = this.config;
|
||||
|
||||
switch (policy) {
|
||||
case "disabled":
|
||||
console.log("[voice-call] Inbound call rejected: policy is disabled");
|
||||
return false;
|
||||
|
||||
case "open":
|
||||
console.log("[voice-call] Inbound call accepted: policy is open");
|
||||
return true;
|
||||
|
||||
case "allowlist":
|
||||
case "pairing": {
|
||||
const normalized = from?.replace(/\D/g, "") || "";
|
||||
const allowed = (allowFrom || []).some((num) => {
|
||||
const normalizedAllow = num.replace(/\D/g, "");
|
||||
return (
|
||||
normalized.endsWith(normalizedAllow) ||
|
||||
normalizedAllow.endsWith(normalized)
|
||||
);
|
||||
});
|
||||
const status = allowed ? "accepted" : "rejected";
|
||||
console.log(
|
||||
`[voice-call] Inbound call ${status}: ${from} ${allowed ? "is in" : "not in"} allowlist`,
|
||||
);
|
||||
return allowed;
|
||||
}
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a call record for an inbound call.
|
||||
*/
|
||||
private createInboundCall(
|
||||
providerCallId: string,
|
||||
from: string,
|
||||
to: string,
|
||||
): CallRecord {
|
||||
const callId = crypto.randomUUID();
|
||||
|
||||
const callRecord: CallRecord = {
|
||||
callId,
|
||||
providerCallId,
|
||||
provider: this.provider?.name || "twilio",
|
||||
direction: "inbound",
|
||||
state: "ringing",
|
||||
from,
|
||||
to,
|
||||
startedAt: Date.now(),
|
||||
transcript: [],
|
||||
processedEventIds: [],
|
||||
metadata: {
|
||||
initialMessage:
|
||||
this.config.inboundGreeting || "Hello! How can I help you today?",
|
||||
},
|
||||
};
|
||||
|
||||
this.activeCalls.set(callId, callRecord);
|
||||
this.providerCallIdMap.set(providerCallId, callId); // Map providerCallId to internal callId
|
||||
this.persistCallRecord(callRecord);
|
||||
|
||||
console.log(
|
||||
`[voice-call] Created inbound call record: ${callId} from ${from}`,
|
||||
);
|
||||
return callRecord;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a call by either internal callId or providerCallId.
|
||||
*/
|
||||
private findCall(callIdOrProviderCallId: string): CallRecord | undefined {
|
||||
// Try direct lookup by internal callId
|
||||
const directCall = this.activeCalls.get(callIdOrProviderCallId);
|
||||
if (directCall) return directCall;
|
||||
|
||||
// Try lookup by providerCallId
|
||||
return this.getCallByProviderCallId(callIdOrProviderCallId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a webhook event.
|
||||
*/
|
||||
processEvent(event: NormalizedEvent): void {
|
||||
// Idempotency check
|
||||
if (this.processedEventIds.has(event.id)) {
|
||||
return;
|
||||
}
|
||||
this.processedEventIds.add(event.id);
|
||||
|
||||
let call = this.findCall(event.callId);
|
||||
|
||||
// Handle inbound calls - create record if it doesn't exist
|
||||
if (!call && event.direction === "inbound" && event.providerCallId) {
|
||||
// Check if we should accept this inbound call
|
||||
if (!this.shouldAcceptInbound(event.from)) {
|
||||
// TODO: Could hang up the call here
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a new call record for this inbound call
|
||||
call = this.createInboundCall(
|
||||
event.providerCallId,
|
||||
event.from || "unknown",
|
||||
event.to || this.config.fromNumber || "unknown",
|
||||
);
|
||||
|
||||
// Update the event's callId to use our internal ID
|
||||
event.callId = call.callId;
|
||||
}
|
||||
|
||||
if (!call) {
|
||||
// Still no call record - ignore event
|
||||
return;
|
||||
}
|
||||
|
||||
// Update provider call ID if we got it
|
||||
if (event.providerCallId && !call.providerCallId) {
|
||||
call.providerCallId = event.providerCallId;
|
||||
}
|
||||
|
||||
// Track processed event
|
||||
call.processedEventIds.push(event.id);
|
||||
|
||||
// Process event based on type
|
||||
switch (event.type) {
|
||||
case "call.initiated":
|
||||
this.transitionState(call, "initiated");
|
||||
break;
|
||||
|
||||
case "call.ringing":
|
||||
this.transitionState(call, "ringing");
|
||||
break;
|
||||
|
||||
case "call.answered":
|
||||
call.answeredAt = event.timestamp;
|
||||
this.transitionState(call, "answered");
|
||||
// Start max duration timer when call is answered
|
||||
this.startMaxDurationTimer(call.callId);
|
||||
break;
|
||||
|
||||
case "call.active":
|
||||
this.transitionState(call, "active");
|
||||
break;
|
||||
|
||||
case "call.speaking":
|
||||
this.transitionState(call, "speaking");
|
||||
break;
|
||||
|
||||
case "call.speech":
|
||||
if (event.isFinal) {
|
||||
this.addTranscriptEntry(call, "user", event.transcript);
|
||||
this.resolveTranscriptWaiter(call.callId, event.transcript);
|
||||
}
|
||||
this.transitionState(call, "listening");
|
||||
break;
|
||||
|
||||
case "call.ended":
|
||||
call.endedAt = event.timestamp;
|
||||
call.endReason = event.reason;
|
||||
this.transitionState(call, event.reason as CallState);
|
||||
this.clearMaxDurationTimer(call.callId);
|
||||
this.rejectTranscriptWaiter(call.callId, `Call ended: ${event.reason}`);
|
||||
this.activeCalls.delete(call.callId);
|
||||
if (call.providerCallId) {
|
||||
this.providerCallIdMap.delete(call.providerCallId);
|
||||
}
|
||||
break;
|
||||
|
||||
case "call.error":
|
||||
if (!event.retryable) {
|
||||
call.endedAt = event.timestamp;
|
||||
call.endReason = "error";
|
||||
this.transitionState(call, "error");
|
||||
this.clearMaxDurationTimer(call.callId);
|
||||
this.rejectTranscriptWaiter(
|
||||
call.callId,
|
||||
`Call error: ${event.error}`,
|
||||
);
|
||||
this.activeCalls.delete(call.callId);
|
||||
if (call.providerCallId) {
|
||||
this.providerCallIdMap.delete(call.providerCallId);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
this.persistCallRecord(call);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an active call by ID.
|
||||
*/
|
||||
getCall(callId: CallId): CallRecord | undefined {
|
||||
return this.activeCalls.get(callId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an active call by provider call ID (e.g., Twilio CallSid).
|
||||
*/
|
||||
getCallByProviderCallId(providerCallId: string): CallRecord | undefined {
|
||||
// Fast path: use the providerCallIdMap for O(1) lookup
|
||||
const callId = this.providerCallIdMap.get(providerCallId);
|
||||
if (callId) {
|
||||
return this.activeCalls.get(callId);
|
||||
}
|
||||
|
||||
// Fallback: linear search for cases where map wasn't populated
|
||||
// (e.g., providerCallId set directly on call record)
|
||||
for (const call of this.activeCalls.values()) {
|
||||
if (call.providerCallId === providerCallId) {
|
||||
return call;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active calls.
|
||||
*/
|
||||
getActiveCalls(): CallRecord[] {
|
||||
return Array.from(this.activeCalls.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get call history (from persisted logs).
|
||||
*/
|
||||
async getCallHistory(limit = 50): Promise<CallRecord[]> {
|
||||
const logPath = path.join(this.storePath, "calls.jsonl");
|
||||
|
||||
try {
|
||||
await fsp.access(logPath);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const content = await fsp.readFile(logPath, "utf-8");
|
||||
const lines = content.trim().split("\n").filter(Boolean);
|
||||
const calls: CallRecord[] = [];
|
||||
|
||||
// Parse last N lines
|
||||
for (const line of lines.slice(-limit)) {
|
||||
try {
|
||||
const parsed = CallRecordSchema.parse(JSON.parse(line));
|
||||
calls.push(parsed);
|
||||
} catch {
|
||||
// Skip invalid lines
|
||||
}
|
||||
}
|
||||
|
||||
return calls;
|
||||
}
|
||||
|
||||
// States that can cycle during multi-turn conversations
|
||||
private static readonly ConversationStates = new Set<CallState>([
|
||||
"speaking",
|
||||
"listening",
|
||||
]);
|
||||
|
||||
// Non-terminal state order for monotonic transitions
|
||||
private static readonly StateOrder: readonly CallState[] = [
|
||||
"initiated",
|
||||
"ringing",
|
||||
"answered",
|
||||
"active",
|
||||
"speaking",
|
||||
"listening",
|
||||
];
|
||||
|
||||
/**
|
||||
* Transition call state with monotonic enforcement.
|
||||
*/
|
||||
private transitionState(call: CallRecord, newState: CallState): void {
|
||||
// No-op for same state or already terminal
|
||||
if (call.state === newState || TerminalStates.has(call.state)) return;
|
||||
|
||||
// Terminal states can always be reached from non-terminal
|
||||
if (TerminalStates.has(newState)) {
|
||||
call.state = newState;
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow cycling between speaking and listening (multi-turn conversations)
|
||||
if (
|
||||
CallManager.ConversationStates.has(call.state) &&
|
||||
CallManager.ConversationStates.has(newState)
|
||||
) {
|
||||
call.state = newState;
|
||||
return;
|
||||
}
|
||||
|
||||
// Only allow forward transitions in state order
|
||||
const currentIndex = CallManager.StateOrder.indexOf(call.state);
|
||||
const newIndex = CallManager.StateOrder.indexOf(newState);
|
||||
|
||||
if (newIndex > currentIndex) {
|
||||
call.state = newState;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an entry to the call transcript.
|
||||
*/
|
||||
private addTranscriptEntry(
|
||||
call: CallRecord,
|
||||
speaker: "bot" | "user",
|
||||
text: string,
|
||||
): void {
|
||||
const entry: TranscriptEntry = {
|
||||
timestamp: Date.now(),
|
||||
speaker,
|
||||
text,
|
||||
isFinal: true,
|
||||
};
|
||||
call.transcript.push(entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a call record to disk (fire-and-forget async).
|
||||
*/
|
||||
private persistCallRecord(call: CallRecord): void {
|
||||
const logPath = path.join(this.storePath, "calls.jsonl");
|
||||
const line = `${JSON.stringify(call)}\n`;
|
||||
// Fire-and-forget async write to avoid blocking event loop
|
||||
fsp.appendFile(logPath, line).catch((err) => {
|
||||
console.error("[voice-call] Failed to persist call record:", err);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load active calls from persistence (for crash recovery).
|
||||
* Uses streaming to handle large log files efficiently.
|
||||
*/
|
||||
private loadActiveCalls(): void {
|
||||
const logPath = path.join(this.storePath, "calls.jsonl");
|
||||
if (!fs.existsSync(logPath)) return;
|
||||
|
||||
// Read file synchronously and parse lines
|
||||
const content = fs.readFileSync(logPath, "utf-8");
|
||||
const lines = content.split("\n");
|
||||
|
||||
// Build map of latest state per call
|
||||
const callMap = new Map<CallId, CallRecord>();
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const call = CallRecordSchema.parse(JSON.parse(line));
|
||||
callMap.set(call.callId, call);
|
||||
} catch {
|
||||
// Skip invalid lines
|
||||
}
|
||||
}
|
||||
|
||||
// Only keep non-terminal calls
|
||||
for (const [callId, call] of callMap) {
|
||||
if (!TerminalStates.has(call.state)) {
|
||||
this.activeCalls.set(callId, call);
|
||||
// Populate providerCallId mapping for lookups
|
||||
if (call.providerCallId) {
|
||||
this.providerCallIdMap.set(call.providerCallId, callId);
|
||||
}
|
||||
// Populate processed event IDs
|
||||
for (const eventId of call.processedEventIds) {
|
||||
this.processedEventIds.add(eventId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate TwiML for notify mode (speak message and hang up).
|
||||
*/
|
||||
private generateNotifyTwiml(message: string, voice: string): string {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Response>
|
||||
<Say voice="${voice}">${escapeXml(message)}</Say>
|
||||
<Hangup/>
|
||||
</Response>`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* Media Stream Handler
|
||||
*
|
||||
* Handles bidirectional audio streaming between Twilio and the AI services.
|
||||
* - Receives mu-law audio from Twilio via WebSocket
|
||||
* - Forwards to OpenAI Realtime STT for transcription
|
||||
* - Sends TTS audio back to Twilio
|
||||
*/
|
||||
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import type { Duplex } from "node:stream";
|
||||
|
||||
import { WebSocket, WebSocketServer } from "ws";
|
||||
|
||||
import type {
|
||||
OpenAIRealtimeSTTProvider,
|
||||
RealtimeSTTSession,
|
||||
} from "./providers/stt-openai-realtime.js";
|
||||
|
||||
/**
|
||||
* Configuration for the media stream handler.
|
||||
*/
|
||||
export interface MediaStreamConfig {
|
||||
/** STT provider for transcription */
|
||||
sttProvider: OpenAIRealtimeSTTProvider;
|
||||
/** Callback when transcript is received */
|
||||
onTranscript?: (callId: string, transcript: string) => void;
|
||||
/** Callback for partial transcripts (streaming UI) */
|
||||
onPartialTranscript?: (callId: string, partial: string) => void;
|
||||
/** Callback when stream connects */
|
||||
onConnect?: (callId: string, streamSid: string) => void;
|
||||
/** Callback when stream disconnects */
|
||||
onDisconnect?: (callId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Active media stream session.
|
||||
*/
|
||||
interface StreamSession {
|
||||
callId: string;
|
||||
streamSid: string;
|
||||
ws: WebSocket;
|
||||
sttSession: RealtimeSTTSession;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages WebSocket connections for Twilio media streams.
|
||||
*/
|
||||
export class MediaStreamHandler {
|
||||
private wss: WebSocketServer | null = null;
|
||||
private sessions = new Map<string, StreamSession>();
|
||||
private config: MediaStreamConfig;
|
||||
|
||||
constructor(config: MediaStreamConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle WebSocket upgrade for media stream connections.
|
||||
*/
|
||||
handleUpgrade(request: IncomingMessage, socket: Duplex, head: Buffer): void {
|
||||
if (!this.wss) {
|
||||
this.wss = new WebSocketServer({ noServer: true });
|
||||
this.wss.on("connection", (ws, req) => this.handleConnection(ws, req));
|
||||
}
|
||||
|
||||
this.wss.handleUpgrade(request, socket, head, (ws) => {
|
||||
this.wss?.emit("connection", ws, request);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle new WebSocket connection from Twilio.
|
||||
*/
|
||||
private async handleConnection(
|
||||
ws: WebSocket,
|
||||
_request: IncomingMessage,
|
||||
): Promise<void> {
|
||||
let session: StreamSession | null = null;
|
||||
|
||||
ws.on("message", async (data: Buffer) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString()) as TwilioMediaMessage;
|
||||
|
||||
switch (message.event) {
|
||||
case "connected":
|
||||
console.log("[MediaStream] Twilio connected");
|
||||
break;
|
||||
|
||||
case "start":
|
||||
session = await this.handleStart(ws, message);
|
||||
break;
|
||||
|
||||
case "media":
|
||||
if (session && message.media?.payload) {
|
||||
// Forward audio to STT
|
||||
const audioBuffer = Buffer.from(message.media.payload, "base64");
|
||||
session.sttSession.sendAudio(audioBuffer);
|
||||
}
|
||||
break;
|
||||
|
||||
case "stop":
|
||||
if (session) {
|
||||
this.handleStop(session);
|
||||
session = null;
|
||||
}
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[MediaStream] Error processing message:", error);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
if (session) {
|
||||
this.handleStop(session);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("error", (error) => {
|
||||
console.error("[MediaStream] WebSocket error:", error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle stream start event.
|
||||
*/
|
||||
private async handleStart(
|
||||
ws: WebSocket,
|
||||
message: TwilioMediaMessage,
|
||||
): Promise<StreamSession> {
|
||||
const streamSid = message.streamSid || "";
|
||||
const callSid = message.start?.callSid || "";
|
||||
|
||||
console.log(
|
||||
`[MediaStream] Stream started: ${streamSid} (call: ${callSid})`,
|
||||
);
|
||||
|
||||
// Create STT session
|
||||
const sttSession = this.config.sttProvider.createSession();
|
||||
|
||||
// Set up transcript callbacks
|
||||
sttSession.onPartial((partial) => {
|
||||
this.config.onPartialTranscript?.(callSid, partial);
|
||||
});
|
||||
|
||||
sttSession.onTranscript((transcript) => {
|
||||
this.config.onTranscript?.(callSid, transcript);
|
||||
});
|
||||
|
||||
const session: StreamSession = {
|
||||
callId: callSid,
|
||||
streamSid,
|
||||
ws,
|
||||
sttSession,
|
||||
};
|
||||
|
||||
this.sessions.set(streamSid, session);
|
||||
|
||||
// Notify connection BEFORE STT connect so TTS can work even if STT fails
|
||||
this.config.onConnect?.(callSid, streamSid);
|
||||
|
||||
// Connect to OpenAI STT (non-blocking, log errors but don't fail the call)
|
||||
sttSession.connect().catch((err) => {
|
||||
console.warn(
|
||||
`[MediaStream] STT connection failed (TTS still works):`,
|
||||
err.message,
|
||||
);
|
||||
});
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle stream stop event.
|
||||
*/
|
||||
private handleStop(session: StreamSession): void {
|
||||
console.log(`[MediaStream] Stream stopped: ${session.streamSid}`);
|
||||
|
||||
session.sttSession.close();
|
||||
this.sessions.delete(session.streamSid);
|
||||
this.config.onDisconnect?.(session.callId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an active session with an open WebSocket, or undefined if unavailable.
|
||||
*/
|
||||
private getOpenSession(streamSid: string): StreamSession | undefined {
|
||||
const session = this.sessions.get(streamSid);
|
||||
return session?.ws.readyState === WebSocket.OPEN ? session : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to a stream's WebSocket if available.
|
||||
*/
|
||||
private sendToStream(streamSid: string, message: unknown): void {
|
||||
const session = this.getOpenSession(streamSid);
|
||||
session?.ws.send(JSON.stringify(message));
|
||||
}
|
||||
|
||||
/**
|
||||
* Send audio to a specific stream (for TTS playback).
|
||||
* Audio should be mu-law encoded at 8kHz mono.
|
||||
*/
|
||||
sendAudio(streamSid: string, muLawAudio: Buffer): void {
|
||||
this.sendToStream(streamSid, {
|
||||
event: "media",
|
||||
streamSid,
|
||||
media: { payload: muLawAudio.toString("base64") },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a mark event to track audio playback position.
|
||||
*/
|
||||
sendMark(streamSid: string, name: string): void {
|
||||
this.sendToStream(streamSid, {
|
||||
event: "mark",
|
||||
streamSid,
|
||||
mark: { name },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear audio buffer (interrupt playback).
|
||||
*/
|
||||
clearAudio(streamSid: string): void {
|
||||
this.sendToStream(streamSid, { event: "clear", streamSid });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active session by call ID.
|
||||
*/
|
||||
getSessionByCallId(callId: string): StreamSession | undefined {
|
||||
return [...this.sessions.values()].find(
|
||||
(session) => session.callId === callId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close all sessions.
|
||||
*/
|
||||
closeAll(): void {
|
||||
for (const session of this.sessions.values()) {
|
||||
session.sttSession.close();
|
||||
session.ws.close();
|
||||
}
|
||||
this.sessions.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Twilio Media Stream message format.
|
||||
*/
|
||||
interface TwilioMediaMessage {
|
||||
event: "connected" | "start" | "media" | "stop" | "mark" | "clear";
|
||||
sequenceNumber?: string;
|
||||
streamSid?: string;
|
||||
start?: {
|
||||
streamSid: string;
|
||||
accountSid: string;
|
||||
callSid: string;
|
||||
tracks: string[];
|
||||
mediaFormat: {
|
||||
encoding: string;
|
||||
sampleRate: number;
|
||||
channels: number;
|
||||
};
|
||||
};
|
||||
media?: {
|
||||
track?: string;
|
||||
chunk?: string;
|
||||
timestamp?: string;
|
||||
payload?: string;
|
||||
};
|
||||
mark?: {
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type {
|
||||
HangupCallInput,
|
||||
InitiateCallInput,
|
||||
InitiateCallResult,
|
||||
PlayTtsInput,
|
||||
ProviderName,
|
||||
ProviderWebhookParseResult,
|
||||
StartListeningInput,
|
||||
StopListeningInput,
|
||||
WebhookContext,
|
||||
WebhookVerificationResult,
|
||||
} from "../types.js";
|
||||
|
||||
/**
|
||||
* Abstract base interface for voice call providers.
|
||||
*
|
||||
* Each provider (Telnyx, Twilio, etc.) implements this interface to provide
|
||||
* a consistent API for the call manager.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Webhook verification and event parsing
|
||||
* - Outbound call initiation and hangup
|
||||
* - Media control (TTS playback, STT listening)
|
||||
*/
|
||||
export interface VoiceCallProvider {
|
||||
/** Provider identifier */
|
||||
readonly name: ProviderName;
|
||||
|
||||
/**
|
||||
* Verify webhook signature/HMAC before processing.
|
||||
* Must be called before parseWebhookEvent.
|
||||
*/
|
||||
verifyWebhook(ctx: WebhookContext): WebhookVerificationResult;
|
||||
|
||||
/**
|
||||
* Parse provider-specific webhook payload into normalized events.
|
||||
* Returns events and optional response to send back to provider.
|
||||
*/
|
||||
parseWebhookEvent(ctx: WebhookContext): ProviderWebhookParseResult;
|
||||
|
||||
/**
|
||||
* Initiate an outbound call.
|
||||
* @returns Provider call ID and status
|
||||
*/
|
||||
initiateCall(input: InitiateCallInput): Promise<InitiateCallResult>;
|
||||
|
||||
/**
|
||||
* Hang up an active call.
|
||||
*/
|
||||
hangupCall(input: HangupCallInput): Promise<void>;
|
||||
|
||||
/**
|
||||
* Play TTS audio to the caller.
|
||||
* The provider should handle streaming if supported.
|
||||
*/
|
||||
playTts(input: PlayTtsInput): Promise<void>;
|
||||
|
||||
/**
|
||||
* Start listening for user speech (activate STT).
|
||||
*/
|
||||
startListening(input: StartListeningInput): Promise<void>;
|
||||
|
||||
/**
|
||||
* Stop listening for user speech (deactivate STT).
|
||||
*/
|
||||
stopListening(input: StopListeningInput): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export type { VoiceCallProvider } from "./base.js";
|
||||
export { MockProvider } from "./mock.js";
|
||||
export {
|
||||
OpenAIRealtimeSTTProvider,
|
||||
type RealtimeSTTConfig,
|
||||
type RealtimeSTTSession,
|
||||
} from "./stt-openai-realtime.js";
|
||||
export { TelnyxProvider } from "./telnyx.js";
|
||||
export { TwilioProvider } from "./twilio.js";
|
||||
@@ -0,0 +1,168 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
import type {
|
||||
EndReason,
|
||||
HangupCallInput,
|
||||
InitiateCallInput,
|
||||
InitiateCallResult,
|
||||
NormalizedEvent,
|
||||
PlayTtsInput,
|
||||
ProviderWebhookParseResult,
|
||||
StartListeningInput,
|
||||
StopListeningInput,
|
||||
WebhookContext,
|
||||
WebhookVerificationResult,
|
||||
} from "../types.js";
|
||||
import type { VoiceCallProvider } from "./base.js";
|
||||
|
||||
/**
|
||||
* Mock voice call provider for local testing.
|
||||
*
|
||||
* Events are driven via webhook POST with JSON body:
|
||||
* - { events: NormalizedEvent[] } for bulk events
|
||||
* - { event: NormalizedEvent } for single event
|
||||
*/
|
||||
export class MockProvider implements VoiceCallProvider {
|
||||
readonly name = "mock" as const;
|
||||
|
||||
verifyWebhook(_ctx: WebhookContext): WebhookVerificationResult {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
parseWebhookEvent(ctx: WebhookContext): ProviderWebhookParseResult {
|
||||
try {
|
||||
const payload = JSON.parse(ctx.rawBody);
|
||||
const events: NormalizedEvent[] = [];
|
||||
|
||||
if (Array.isArray(payload.events)) {
|
||||
for (const evt of payload.events) {
|
||||
const normalized = this.normalizeEvent(evt);
|
||||
if (normalized) events.push(normalized);
|
||||
}
|
||||
} else if (payload.event) {
|
||||
const normalized = this.normalizeEvent(payload.event);
|
||||
if (normalized) events.push(normalized);
|
||||
}
|
||||
|
||||
return { events, statusCode: 200 };
|
||||
} catch {
|
||||
return { events: [], statusCode: 400 };
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeEvent(
|
||||
evt: Partial<NormalizedEvent>,
|
||||
): NormalizedEvent | null {
|
||||
if (!evt.type || !evt.callId) return null;
|
||||
|
||||
const base = {
|
||||
id: evt.id || crypto.randomUUID(),
|
||||
callId: evt.callId,
|
||||
providerCallId: evt.providerCallId,
|
||||
timestamp: evt.timestamp || Date.now(),
|
||||
};
|
||||
|
||||
switch (evt.type) {
|
||||
case "call.initiated":
|
||||
case "call.ringing":
|
||||
case "call.answered":
|
||||
case "call.active":
|
||||
return { ...base, type: evt.type };
|
||||
|
||||
case "call.speaking": {
|
||||
const payload = evt as Partial<NormalizedEvent & { text?: string }>;
|
||||
return {
|
||||
...base,
|
||||
type: evt.type,
|
||||
text: payload.text || "",
|
||||
};
|
||||
}
|
||||
|
||||
case "call.speech": {
|
||||
const payload = evt as Partial<
|
||||
NormalizedEvent & {
|
||||
transcript?: string;
|
||||
isFinal?: boolean;
|
||||
confidence?: number;
|
||||
}
|
||||
>;
|
||||
return {
|
||||
...base,
|
||||
type: evt.type,
|
||||
transcript: payload.transcript || "",
|
||||
isFinal: payload.isFinal ?? true,
|
||||
confidence: payload.confidence,
|
||||
};
|
||||
}
|
||||
|
||||
case "call.silence": {
|
||||
const payload = evt as Partial<
|
||||
NormalizedEvent & { durationMs?: number }
|
||||
>;
|
||||
return {
|
||||
...base,
|
||||
type: evt.type,
|
||||
durationMs: payload.durationMs || 0,
|
||||
};
|
||||
}
|
||||
|
||||
case "call.dtmf": {
|
||||
const payload = evt as Partial<NormalizedEvent & { digits?: string }>;
|
||||
return {
|
||||
...base,
|
||||
type: evt.type,
|
||||
digits: payload.digits || "",
|
||||
};
|
||||
}
|
||||
|
||||
case "call.ended": {
|
||||
const payload = evt as Partial<
|
||||
NormalizedEvent & { reason?: EndReason }
|
||||
>;
|
||||
return {
|
||||
...base,
|
||||
type: evt.type,
|
||||
reason: payload.reason || "completed",
|
||||
};
|
||||
}
|
||||
|
||||
case "call.error": {
|
||||
const payload = evt as Partial<
|
||||
NormalizedEvent & { error?: string; retryable?: boolean }
|
||||
>;
|
||||
return {
|
||||
...base,
|
||||
type: evt.type,
|
||||
error: payload.error || "unknown error",
|
||||
retryable: payload.retryable,
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async initiateCall(input: InitiateCallInput): Promise<InitiateCallResult> {
|
||||
return {
|
||||
providerCallId: `mock-${input.callId}`,
|
||||
status: "initiated",
|
||||
};
|
||||
}
|
||||
|
||||
async hangupCall(_input: HangupCallInput): Promise<void> {
|
||||
// No-op for mock
|
||||
}
|
||||
|
||||
async playTts(_input: PlayTtsInput): Promise<void> {
|
||||
// No-op for mock
|
||||
}
|
||||
|
||||
async startListening(_input: StartListeningInput): Promise<void> {
|
||||
// No-op for mock
|
||||
}
|
||||
|
||||
async stopListening(_input: StopListeningInput): Promise<void> {
|
||||
// No-op for mock
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* OpenAI Realtime STT Provider
|
||||
*
|
||||
* Uses the OpenAI Realtime API for streaming transcription with:
|
||||
* - Direct mu-law audio support (no conversion needed)
|
||||
* - Built-in server-side VAD for turn detection
|
||||
* - Low-latency streaming transcription
|
||||
* - Partial transcript callbacks for real-time UI updates
|
||||
*/
|
||||
|
||||
import WebSocket from "ws";
|
||||
|
||||
/**
|
||||
* Configuration for OpenAI Realtime STT.
|
||||
*/
|
||||
export interface RealtimeSTTConfig {
|
||||
/** OpenAI API key */
|
||||
apiKey: string;
|
||||
/** Model to use (default: gpt-4o-transcribe) */
|
||||
model?: string;
|
||||
/** Silence duration in ms before considering speech ended (default: 800) */
|
||||
silenceDurationMs?: number;
|
||||
/** VAD threshold 0-1 (default: 0.5) */
|
||||
vadThreshold?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session for streaming audio and receiving transcripts.
|
||||
*/
|
||||
export interface RealtimeSTTSession {
|
||||
/** Connect to the transcription service */
|
||||
connect(): Promise<void>;
|
||||
/** Send mu-law audio data (8kHz mono) */
|
||||
sendAudio(audio: Buffer): void;
|
||||
/** Wait for next complete transcript (after VAD detects end of speech) */
|
||||
waitForTranscript(timeoutMs?: number): Promise<string>;
|
||||
/** Set callback for partial transcripts (streaming) */
|
||||
onPartial(callback: (partial: string) => void): void;
|
||||
/** Set callback for final transcripts */
|
||||
onTranscript(callback: (transcript: string) => void): void;
|
||||
/** Close the session */
|
||||
close(): void;
|
||||
/** Check if session is connected */
|
||||
isConnected(): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider factory for OpenAI Realtime STT sessions.
|
||||
*/
|
||||
export class OpenAIRealtimeSTTProvider {
|
||||
readonly name = "openai-realtime";
|
||||
private apiKey: string;
|
||||
private model: string;
|
||||
private silenceDurationMs: number;
|
||||
private vadThreshold: number;
|
||||
|
||||
constructor(config: RealtimeSTTConfig) {
|
||||
if (!config.apiKey) {
|
||||
throw new Error("OpenAI API key required for Realtime STT");
|
||||
}
|
||||
this.apiKey = config.apiKey;
|
||||
this.model = config.model || "gpt-4o-transcribe";
|
||||
this.silenceDurationMs = config.silenceDurationMs || 800;
|
||||
this.vadThreshold = config.vadThreshold || 0.5;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new realtime transcription session.
|
||||
*/
|
||||
createSession(): RealtimeSTTSession {
|
||||
return new OpenAIRealtimeSTTSession(
|
||||
this.apiKey,
|
||||
this.model,
|
||||
this.silenceDurationMs,
|
||||
this.vadThreshold,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket-based session for real-time speech-to-text.
|
||||
*/
|
||||
class OpenAIRealtimeSTTSession implements RealtimeSTTSession {
|
||||
private static readonly MAX_RECONNECT_ATTEMPTS = 5;
|
||||
private static readonly RECONNECT_DELAY_MS = 1000;
|
||||
|
||||
private ws: WebSocket | null = null;
|
||||
private connected = false;
|
||||
private closed = false;
|
||||
private reconnectAttempts = 0;
|
||||
private pendingTranscript = "";
|
||||
private onTranscriptCallback: ((transcript: string) => void) | null = null;
|
||||
private onPartialCallback: ((partial: string) => void) | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly apiKey: string,
|
||||
private readonly model: string,
|
||||
private readonly silenceDurationMs: number,
|
||||
private readonly vadThreshold: number,
|
||||
) {}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
this.closed = false;
|
||||
this.reconnectAttempts = 0;
|
||||
return this.doConnect();
|
||||
}
|
||||
|
||||
private async doConnect(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = "wss://api.openai.com/v1/realtime?intent=transcription";
|
||||
|
||||
this.ws = new WebSocket(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
"OpenAI-Beta": "realtime=v1",
|
||||
},
|
||||
});
|
||||
|
||||
this.ws.on("open", () => {
|
||||
console.log("[RealtimeSTT] WebSocket connected");
|
||||
this.connected = true;
|
||||
this.reconnectAttempts = 0;
|
||||
|
||||
// Configure the transcription session
|
||||
this.sendEvent({
|
||||
type: "transcription_session.update",
|
||||
session: {
|
||||
input_audio_format: "g711_ulaw",
|
||||
input_audio_transcription: {
|
||||
model: this.model,
|
||||
},
|
||||
turn_detection: {
|
||||
type: "server_vad",
|
||||
threshold: this.vadThreshold,
|
||||
prefix_padding_ms: 300,
|
||||
silence_duration_ms: this.silenceDurationMs,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
resolve();
|
||||
});
|
||||
|
||||
this.ws.on("message", (data: Buffer) => {
|
||||
try {
|
||||
const event = JSON.parse(data.toString());
|
||||
this.handleEvent(event);
|
||||
} catch (e) {
|
||||
console.error("[RealtimeSTT] Failed to parse event:", e);
|
||||
}
|
||||
});
|
||||
|
||||
this.ws.on("error", (error) => {
|
||||
console.error("[RealtimeSTT] WebSocket error:", error);
|
||||
if (!this.connected) reject(error);
|
||||
});
|
||||
|
||||
this.ws.on("close", (code, reason) => {
|
||||
console.log(
|
||||
`[RealtimeSTT] WebSocket closed (code: ${code}, reason: ${reason?.toString() || "none"})`,
|
||||
);
|
||||
this.connected = false;
|
||||
|
||||
// Attempt reconnection if not intentionally closed
|
||||
if (!this.closed) {
|
||||
void this.attemptReconnect();
|
||||
}
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
if (!this.connected) {
|
||||
reject(new Error("Realtime STT connection timeout"));
|
||||
}
|
||||
}, 10000);
|
||||
});
|
||||
}
|
||||
|
||||
private async attemptReconnect(): Promise<void> {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
this.reconnectAttempts >= OpenAIRealtimeSTTSession.MAX_RECONNECT_ATTEMPTS
|
||||
) {
|
||||
console.error(
|
||||
`[RealtimeSTT] Max reconnect attempts (${OpenAIRealtimeSTTSession.MAX_RECONNECT_ATTEMPTS}) reached`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.reconnectAttempts++;
|
||||
const delay =
|
||||
OpenAIRealtimeSTTSession.RECONNECT_DELAY_MS *
|
||||
2 ** (this.reconnectAttempts - 1);
|
||||
console.log(
|
||||
`[RealtimeSTT] Reconnecting ${this.reconnectAttempts}/${OpenAIRealtimeSTTSession.MAX_RECONNECT_ATTEMPTS} in ${delay}ms...`,
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.doConnect();
|
||||
console.log("[RealtimeSTT] Reconnected successfully");
|
||||
} catch (error) {
|
||||
console.error("[RealtimeSTT] Reconnect failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
private handleEvent(event: {
|
||||
type: string;
|
||||
delta?: string;
|
||||
transcript?: string;
|
||||
error?: unknown;
|
||||
}): void {
|
||||
switch (event.type) {
|
||||
case "transcription_session.created":
|
||||
case "transcription_session.updated":
|
||||
case "input_audio_buffer.speech_stopped":
|
||||
case "input_audio_buffer.committed":
|
||||
console.log(`[RealtimeSTT] ${event.type}`);
|
||||
break;
|
||||
|
||||
case "conversation.item.input_audio_transcription.delta":
|
||||
if (event.delta) {
|
||||
this.pendingTranscript += event.delta;
|
||||
this.onPartialCallback?.(this.pendingTranscript);
|
||||
}
|
||||
break;
|
||||
|
||||
case "conversation.item.input_audio_transcription.completed":
|
||||
if (event.transcript) {
|
||||
console.log(`[RealtimeSTT] Transcript: ${event.transcript}`);
|
||||
this.onTranscriptCallback?.(event.transcript);
|
||||
}
|
||||
this.pendingTranscript = "";
|
||||
break;
|
||||
|
||||
case "input_audio_buffer.speech_started":
|
||||
console.log("[RealtimeSTT] Speech started");
|
||||
this.pendingTranscript = "";
|
||||
break;
|
||||
|
||||
case "error":
|
||||
console.error("[RealtimeSTT] Error:", event.error);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private sendEvent(event: unknown): void {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(event));
|
||||
}
|
||||
}
|
||||
|
||||
sendAudio(muLawData: Buffer): void {
|
||||
if (!this.connected) return;
|
||||
this.sendEvent({
|
||||
type: "input_audio_buffer.append",
|
||||
audio: muLawData.toString("base64"),
|
||||
});
|
||||
}
|
||||
|
||||
onPartial(callback: (partial: string) => void): void {
|
||||
this.onPartialCallback = callback;
|
||||
}
|
||||
|
||||
onTranscript(callback: (transcript: string) => void): void {
|
||||
this.onTranscriptCallback = callback;
|
||||
}
|
||||
|
||||
async waitForTranscript(timeoutMs = 30000): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
this.onTranscriptCallback = null;
|
||||
reject(new Error("Transcript timeout"));
|
||||
}, timeoutMs);
|
||||
|
||||
this.onTranscriptCallback = (transcript) => {
|
||||
clearTimeout(timeout);
|
||||
this.onTranscriptCallback = null;
|
||||
resolve(transcript);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
this.connected = false;
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.connected;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
import type { TelnyxConfig } from "../config.js";
|
||||
import type {
|
||||
EndReason,
|
||||
HangupCallInput,
|
||||
InitiateCallInput,
|
||||
InitiateCallResult,
|
||||
NormalizedEvent,
|
||||
PlayTtsInput,
|
||||
ProviderWebhookParseResult,
|
||||
StartListeningInput,
|
||||
StopListeningInput,
|
||||
WebhookContext,
|
||||
WebhookVerificationResult,
|
||||
} from "../types.js";
|
||||
import type { VoiceCallProvider } from "./base.js";
|
||||
|
||||
/**
|
||||
* Telnyx Voice API provider implementation.
|
||||
*
|
||||
* Uses Telnyx Call Control API v2 for managing calls.
|
||||
* @see https://developers.telnyx.com/docs/api/v2/call-control
|
||||
*/
|
||||
export class TelnyxProvider implements VoiceCallProvider {
|
||||
readonly name = "telnyx" as const;
|
||||
|
||||
private readonly apiKey: string;
|
||||
private readonly connectionId: string;
|
||||
private readonly publicKey: string | undefined;
|
||||
private readonly baseUrl = "https://api.telnyx.com/v2";
|
||||
|
||||
constructor(config: TelnyxConfig) {
|
||||
if (!config.apiKey) {
|
||||
throw new Error("Telnyx API key is required");
|
||||
}
|
||||
if (!config.connectionId) {
|
||||
throw new Error("Telnyx connection ID is required");
|
||||
}
|
||||
|
||||
this.apiKey = config.apiKey;
|
||||
this.connectionId = config.connectionId;
|
||||
this.publicKey = config.publicKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an authenticated request to the Telnyx API.
|
||||
*/
|
||||
private async apiRequest<T = unknown>(
|
||||
endpoint: string,
|
||||
body: Record<string, unknown>,
|
||||
options?: { allowNotFound?: boolean },
|
||||
): Promise<T> {
|
||||
const response = await fetch(`${this.baseUrl}${endpoint}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (options?.allowNotFound && response.status === 404) {
|
||||
return undefined as T;
|
||||
}
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Telnyx API error: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
return text ? (JSON.parse(text) as T) : (undefined as T);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify Telnyx webhook signature using Ed25519.
|
||||
*/
|
||||
verifyWebhook(ctx: WebhookContext): WebhookVerificationResult {
|
||||
if (!this.publicKey) {
|
||||
// No public key configured, skip verification (not recommended for production)
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const signature = ctx.headers["telnyx-signature-ed25519"];
|
||||
const timestamp = ctx.headers["telnyx-timestamp"];
|
||||
|
||||
if (!signature || !timestamp) {
|
||||
return { ok: false, reason: "Missing signature or timestamp header" };
|
||||
}
|
||||
|
||||
const signatureStr = Array.isArray(signature) ? signature[0] : signature;
|
||||
const timestampStr = Array.isArray(timestamp) ? timestamp[0] : timestamp;
|
||||
|
||||
if (!signatureStr || !timestampStr) {
|
||||
return { ok: false, reason: "Empty signature or timestamp" };
|
||||
}
|
||||
|
||||
try {
|
||||
const signedPayload = `${timestampStr}|${ctx.rawBody}`;
|
||||
const signatureBuffer = Buffer.from(signatureStr, "base64");
|
||||
const publicKeyBuffer = Buffer.from(this.publicKey, "base64");
|
||||
|
||||
const isValid = crypto.verify(
|
||||
null, // Ed25519 doesn't use a digest
|
||||
Buffer.from(signedPayload),
|
||||
{
|
||||
key: publicKeyBuffer,
|
||||
format: "der",
|
||||
type: "spki",
|
||||
},
|
||||
signatureBuffer,
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
return { ok: false, reason: "Invalid signature" };
|
||||
}
|
||||
|
||||
// Check timestamp is within 5 minutes
|
||||
const eventTime = parseInt(timestampStr, 10) * 1000;
|
||||
const now = Date.now();
|
||||
if (Math.abs(now - eventTime) > 5 * 60 * 1000) {
|
||||
return { ok: false, reason: "Timestamp too old" };
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `Verification error: ${err instanceof Error ? err.message : String(err)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Telnyx webhook event into normalized format.
|
||||
*/
|
||||
parseWebhookEvent(ctx: WebhookContext): ProviderWebhookParseResult {
|
||||
try {
|
||||
const payload = JSON.parse(ctx.rawBody);
|
||||
const data = payload.data;
|
||||
|
||||
if (!data || !data.event_type) {
|
||||
return { events: [], statusCode: 200 };
|
||||
}
|
||||
|
||||
const event = this.normalizeEvent(data);
|
||||
return {
|
||||
events: event ? [event] : [],
|
||||
statusCode: 200,
|
||||
};
|
||||
} catch {
|
||||
return { events: [], statusCode: 400 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Telnyx event to normalized event format.
|
||||
*/
|
||||
private normalizeEvent(data: TelnyxEvent): NormalizedEvent | null {
|
||||
// Decode client_state from Base64 (we encode it in initiateCall)
|
||||
let callId = "";
|
||||
if (data.payload?.client_state) {
|
||||
try {
|
||||
callId = Buffer.from(data.payload.client_state, "base64").toString(
|
||||
"utf8",
|
||||
);
|
||||
} catch {
|
||||
// Fallback if not valid Base64
|
||||
callId = data.payload.client_state;
|
||||
}
|
||||
}
|
||||
if (!callId) {
|
||||
callId = data.payload?.call_control_id || "";
|
||||
}
|
||||
|
||||
const baseEvent = {
|
||||
id: data.id || crypto.randomUUID(),
|
||||
callId,
|
||||
providerCallId: data.payload?.call_control_id,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
switch (data.event_type) {
|
||||
case "call.initiated":
|
||||
return { ...baseEvent, type: "call.initiated" };
|
||||
|
||||
case "call.ringing":
|
||||
return { ...baseEvent, type: "call.ringing" };
|
||||
|
||||
case "call.answered":
|
||||
return { ...baseEvent, type: "call.answered" };
|
||||
|
||||
case "call.bridged":
|
||||
return { ...baseEvent, type: "call.active" };
|
||||
|
||||
case "call.speak.started":
|
||||
return {
|
||||
...baseEvent,
|
||||
type: "call.speaking",
|
||||
text: data.payload?.text || "",
|
||||
};
|
||||
|
||||
case "call.transcription":
|
||||
return {
|
||||
...baseEvent,
|
||||
type: "call.speech",
|
||||
transcript: data.payload?.transcription || "",
|
||||
isFinal: data.payload?.is_final ?? true,
|
||||
confidence: data.payload?.confidence,
|
||||
};
|
||||
|
||||
case "call.hangup":
|
||||
return {
|
||||
...baseEvent,
|
||||
type: "call.ended",
|
||||
reason: this.mapHangupCause(data.payload?.hangup_cause),
|
||||
};
|
||||
|
||||
case "call.dtmf.received":
|
||||
return {
|
||||
...baseEvent,
|
||||
type: "call.dtmf",
|
||||
digits: data.payload?.digit || "",
|
||||
};
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Telnyx hangup cause to normalized end reason.
|
||||
* @see https://developers.telnyx.com/docs/api/v2/call-control/Call-Commands#hangup-causes
|
||||
*/
|
||||
private mapHangupCause(cause?: string): EndReason {
|
||||
switch (cause) {
|
||||
case "normal_clearing":
|
||||
case "normal_unspecified":
|
||||
return "completed";
|
||||
case "originator_cancel":
|
||||
return "hangup-bot";
|
||||
case "call_rejected":
|
||||
case "user_busy":
|
||||
return "busy";
|
||||
case "no_answer":
|
||||
case "no_user_response":
|
||||
return "no-answer";
|
||||
case "destination_out_of_order":
|
||||
case "network_out_of_order":
|
||||
case "service_unavailable":
|
||||
case "recovery_on_timer_expire":
|
||||
return "failed";
|
||||
case "machine_detected":
|
||||
case "fax_detected":
|
||||
return "voicemail";
|
||||
case "user_hangup":
|
||||
case "subscriber_absent":
|
||||
return "hangup-user";
|
||||
default:
|
||||
// Unknown cause - log it for debugging and return completed
|
||||
if (cause) {
|
||||
console.warn(`[telnyx] Unknown hangup cause: ${cause}`);
|
||||
}
|
||||
return "completed";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate an outbound call via Telnyx API.
|
||||
*/
|
||||
async initiateCall(input: InitiateCallInput): Promise<InitiateCallResult> {
|
||||
const result = await this.apiRequest<TelnyxCallResponse>("/calls", {
|
||||
connection_id: this.connectionId,
|
||||
to: input.to,
|
||||
from: input.from,
|
||||
webhook_url: input.webhookUrl,
|
||||
webhook_url_method: "POST",
|
||||
client_state: Buffer.from(input.callId).toString("base64"),
|
||||
timeout_secs: 30,
|
||||
});
|
||||
|
||||
return {
|
||||
providerCallId: result.data.call_control_id,
|
||||
status: "initiated",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hang up a call via Telnyx API.
|
||||
*/
|
||||
async hangupCall(input: HangupCallInput): Promise<void> {
|
||||
await this.apiRequest(
|
||||
`/calls/${input.providerCallId}/actions/hangup`,
|
||||
{ command_id: crypto.randomUUID() },
|
||||
{ allowNotFound: true },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Play TTS audio via Telnyx speak action.
|
||||
*/
|
||||
async playTts(input: PlayTtsInput): Promise<void> {
|
||||
await this.apiRequest(`/calls/${input.providerCallId}/actions/speak`, {
|
||||
command_id: crypto.randomUUID(),
|
||||
payload: input.text,
|
||||
voice: input.voice || "female",
|
||||
language: input.locale || "en-US",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start transcription (STT) via Telnyx.
|
||||
*/
|
||||
async startListening(input: StartListeningInput): Promise<void> {
|
||||
await this.apiRequest(
|
||||
`/calls/${input.providerCallId}/actions/transcription_start`,
|
||||
{
|
||||
command_id: crypto.randomUUID(),
|
||||
language: input.language || "en",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop transcription via Telnyx.
|
||||
*/
|
||||
async stopListening(input: StopListeningInput): Promise<void> {
|
||||
await this.apiRequest(
|
||||
`/calls/${input.providerCallId}/actions/transcription_stop`,
|
||||
{ command_id: crypto.randomUUID() },
|
||||
{ allowNotFound: true },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Telnyx-specific types
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
interface TelnyxEvent {
|
||||
id?: string;
|
||||
event_type: string;
|
||||
payload?: {
|
||||
call_control_id?: string;
|
||||
client_state?: string;
|
||||
text?: string;
|
||||
transcription?: string;
|
||||
is_final?: boolean;
|
||||
confidence?: number;
|
||||
hangup_cause?: string;
|
||||
digit?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
interface TelnyxCallResponse {
|
||||
data: {
|
||||
call_control_id: string;
|
||||
call_leg_id: string;
|
||||
call_session_id: string;
|
||||
is_alive: boolean;
|
||||
record_type: string;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* OpenAI TTS Provider
|
||||
*
|
||||
* Generates speech audio using OpenAI's text-to-speech API.
|
||||
* Handles audio format conversion for telephony (mu-law 8kHz).
|
||||
*
|
||||
* Best practices from OpenAI docs:
|
||||
* - Use gpt-4o-mini-tts for intelligent realtime applications (supports instructions)
|
||||
* - Use tts-1 for lower latency, tts-1-hd for higher quality
|
||||
* - Use marin or cedar voices for best quality
|
||||
* - Use pcm or wav format for fastest response times
|
||||
*
|
||||
* @see https://platform.openai.com/docs/guides/text-to-speech
|
||||
*/
|
||||
|
||||
/**
|
||||
* OpenAI TTS configuration.
|
||||
*/
|
||||
export interface OpenAITTSConfig {
|
||||
/** OpenAI API key (uses OPENAI_API_KEY env if not set) */
|
||||
apiKey?: string;
|
||||
/**
|
||||
* TTS model:
|
||||
* - gpt-4o-mini-tts: newest, supports instructions for tone/style control (recommended)
|
||||
* - tts-1: lower latency
|
||||
* - tts-1-hd: higher quality
|
||||
*/
|
||||
model?: string;
|
||||
/**
|
||||
* Voice to use. For best quality, use marin or cedar.
|
||||
* All 13 voices: alloy, ash, ballad, coral, echo, fable, nova, onyx, sage, shimmer, verse, marin, cedar
|
||||
* Note: tts-1/tts-1-hd only support: alloy, ash, coral, echo, fable, onyx, nova, sage, shimmer
|
||||
*/
|
||||
voice?: string;
|
||||
/** Speed multiplier (0.25 to 4.0) */
|
||||
speed?: number;
|
||||
/**
|
||||
* Instructions for speech style (only works with gpt-4o-mini-tts model).
|
||||
* Examples: "Speak in a cheerful tone", "Talk like a sympathetic customer service agent"
|
||||
*/
|
||||
instructions?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Supported OpenAI TTS voices (all 13 built-in voices).
|
||||
* For best quality, use marin or cedar.
|
||||
* Note: tts-1 and tts-1-hd support a smaller set.
|
||||
*/
|
||||
export const OPENAI_TTS_VOICES = [
|
||||
"alloy",
|
||||
"ash",
|
||||
"ballad",
|
||||
"coral",
|
||||
"echo",
|
||||
"fable",
|
||||
"nova",
|
||||
"onyx",
|
||||
"sage",
|
||||
"shimmer",
|
||||
"verse",
|
||||
"marin",
|
||||
"cedar",
|
||||
] as const;
|
||||
|
||||
export type OpenAITTSVoice = (typeof OPENAI_TTS_VOICES)[number];
|
||||
|
||||
/**
|
||||
* OpenAI TTS Provider for generating speech audio.
|
||||
*/
|
||||
export class OpenAITTSProvider {
|
||||
private apiKey: string;
|
||||
private model: string;
|
||||
private voice: OpenAITTSVoice;
|
||||
private speed: number;
|
||||
private instructions?: string;
|
||||
|
||||
constructor(config: OpenAITTSConfig = {}) {
|
||||
this.apiKey = config.apiKey || process.env.OPENAI_API_KEY || "";
|
||||
// Default to gpt-4o-mini-tts for intelligent realtime applications
|
||||
this.model = config.model || "gpt-4o-mini-tts";
|
||||
// Default to coral - good balance of quality and natural tone
|
||||
this.voice = (config.voice as OpenAITTSVoice) || "coral";
|
||||
this.speed = config.speed || 1.0;
|
||||
this.instructions = config.instructions;
|
||||
|
||||
if (!this.apiKey) {
|
||||
throw new Error(
|
||||
"OpenAI API key required (set OPENAI_API_KEY or pass apiKey)",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate speech audio from text.
|
||||
* Returns raw PCM audio data (24kHz, mono, 16-bit).
|
||||
*/
|
||||
async synthesize(text: string, instructions?: string): Promise<Buffer> {
|
||||
// Build request body
|
||||
const body: Record<string, unknown> = {
|
||||
model: this.model,
|
||||
input: text,
|
||||
voice: this.voice,
|
||||
response_format: "pcm", // Raw PCM audio (24kHz, mono, 16-bit signed LE)
|
||||
speed: this.speed,
|
||||
};
|
||||
|
||||
// Add instructions if using gpt-4o-mini-tts model
|
||||
const effectiveInstructions = instructions || this.instructions;
|
||||
if (effectiveInstructions && this.model.includes("gpt-4o-mini-tts")) {
|
||||
body.instructions = effectiveInstructions;
|
||||
}
|
||||
|
||||
const response = await fetch("https://api.openai.com/v1/audio/speech", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`OpenAI TTS failed: ${response.status} - ${error}`);
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
return Buffer.from(arrayBuffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate speech and convert to mu-law format for Twilio.
|
||||
* Twilio Media Streams expect 8kHz mono mu-law audio.
|
||||
*/
|
||||
async synthesizeForTwilio(text: string): Promise<Buffer> {
|
||||
// Get raw PCM from OpenAI (24kHz, 16-bit signed LE, mono)
|
||||
const pcm24k = await this.synthesize(text);
|
||||
|
||||
// Resample from 24kHz to 8kHz
|
||||
const pcm8k = resample24kTo8k(pcm24k);
|
||||
|
||||
// Encode to mu-law
|
||||
return pcmToMulaw(pcm8k);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resample 24kHz PCM to 8kHz using linear interpolation.
|
||||
* Input/output: 16-bit signed little-endian mono.
|
||||
*/
|
||||
function resample24kTo8k(input: Buffer): Buffer {
|
||||
const inputSamples = input.length / 2;
|
||||
const outputSamples = Math.floor(inputSamples / 3);
|
||||
const output = Buffer.alloc(outputSamples * 2);
|
||||
|
||||
for (let i = 0; i < outputSamples; i++) {
|
||||
// Calculate position in input (3:1 ratio)
|
||||
const srcPos = i * 3;
|
||||
const srcIdx = srcPos * 2;
|
||||
|
||||
if (srcIdx + 3 < input.length) {
|
||||
// Linear interpolation between samples
|
||||
const s0 = input.readInt16LE(srcIdx);
|
||||
const s1 = input.readInt16LE(srcIdx + 2);
|
||||
const frac = srcPos % 1 || 0;
|
||||
const sample = Math.round(s0 + frac * (s1 - s0));
|
||||
output.writeInt16LE(clamp16(sample), i * 2);
|
||||
} else {
|
||||
// Last sample
|
||||
output.writeInt16LE(input.readInt16LE(srcIdx), i * 2);
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamp value to 16-bit signed integer range.
|
||||
*/
|
||||
function clamp16(value: number): number {
|
||||
return Math.max(-32768, Math.min(32767, value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert 16-bit PCM to 8-bit mu-law.
|
||||
* Standard G.711 mu-law encoding for telephony.
|
||||
*/
|
||||
function pcmToMulaw(pcm: Buffer): Buffer {
|
||||
const samples = pcm.length / 2;
|
||||
const mulaw = Buffer.alloc(samples);
|
||||
|
||||
for (let i = 0; i < samples; i++) {
|
||||
const sample = pcm.readInt16LE(i * 2);
|
||||
mulaw[i] = linearToMulaw(sample);
|
||||
}
|
||||
|
||||
return mulaw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a single 16-bit linear sample to 8-bit mu-law.
|
||||
* Implements ITU-T G.711 mu-law encoding.
|
||||
*/
|
||||
function linearToMulaw(sample: number): number {
|
||||
const BIAS = 132;
|
||||
const CLIP = 32635;
|
||||
|
||||
// Get sign bit
|
||||
const sign = sample < 0 ? 0x80 : 0;
|
||||
if (sample < 0) sample = -sample;
|
||||
|
||||
// Clip to prevent overflow
|
||||
if (sample > CLIP) sample = CLIP;
|
||||
|
||||
// Add bias and find segment
|
||||
sample += BIAS;
|
||||
let exponent = 7;
|
||||
for (
|
||||
let expMask = 0x4000;
|
||||
(sample & expMask) === 0 && exponent > 0;
|
||||
exponent--, expMask >>= 1
|
||||
) {
|
||||
// Find the segment (exponent)
|
||||
}
|
||||
|
||||
// Extract mantissa bits
|
||||
const mantissa = (sample >> (exponent + 3)) & 0x0f;
|
||||
|
||||
// Combine into mu-law byte (inverted for transmission)
|
||||
return ~(sign | (exponent << 4) | mantissa) & 0xff;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert 8-bit mu-law to 16-bit linear PCM.
|
||||
* Useful for decoding incoming audio.
|
||||
*/
|
||||
export function mulawToLinear(mulaw: number): number {
|
||||
// mu-law is transmitted inverted
|
||||
mulaw = ~mulaw & 0xff;
|
||||
|
||||
const sign = mulaw & 0x80;
|
||||
const exponent = (mulaw >> 4) & 0x07;
|
||||
const mantissa = mulaw & 0x0f;
|
||||
|
||||
let sample = ((mantissa << 3) + 132) << exponent;
|
||||
sample -= 132;
|
||||
|
||||
return sign ? -sample : sample;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chunk audio buffer into 20ms frames for streaming.
|
||||
* At 8kHz mono, 20ms = 160 samples = 160 bytes (mu-law).
|
||||
*/
|
||||
export function chunkAudio(
|
||||
audio: Buffer,
|
||||
chunkSize = 160,
|
||||
): Generator<Buffer, void, unknown> {
|
||||
return (function* () {
|
||||
for (let i = 0; i < audio.length; i += chunkSize) {
|
||||
yield audio.subarray(i, Math.min(i + chunkSize, audio.length));
|
||||
}
|
||||
})();
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
import type { TwilioConfig } from "../config.js";
|
||||
import type { MediaStreamHandler } from "../media-stream.js";
|
||||
import type {
|
||||
HangupCallInput,
|
||||
InitiateCallInput,
|
||||
InitiateCallResult,
|
||||
NormalizedEvent,
|
||||
PlayTtsInput,
|
||||
ProviderWebhookParseResult,
|
||||
StartListeningInput,
|
||||
StopListeningInput,
|
||||
WebhookContext,
|
||||
WebhookVerificationResult,
|
||||
} from "../types.js";
|
||||
import { escapeXml, mapVoiceToPolly } from "../voice-mapping.js";
|
||||
import { verifyTwilioWebhook } from "../webhook-security.js";
|
||||
import type { VoiceCallProvider } from "./base.js";
|
||||
import type { OpenAITTSProvider } from "./tts-openai.js";
|
||||
import { chunkAudio } from "./tts-openai.js";
|
||||
|
||||
/**
|
||||
* Twilio Voice API provider implementation.
|
||||
*
|
||||
* Uses Twilio Programmable Voice API with Media Streams for real-time
|
||||
* bidirectional audio streaming.
|
||||
*
|
||||
* @see https://www.twilio.com/docs/voice
|
||||
* @see https://www.twilio.com/docs/voice/media-streams
|
||||
*/
|
||||
export interface TwilioProviderOptions {
|
||||
/** Allow ngrok free tier compatibility mode (less secure) */
|
||||
allowNgrokFreeTier?: boolean;
|
||||
/** Override public URL for signature verification */
|
||||
publicUrl?: string;
|
||||
/** Path for media stream WebSocket (e.g., /voice/stream) */
|
||||
streamPath?: string;
|
||||
/** Skip webhook signature verification (development only) */
|
||||
skipVerification?: boolean;
|
||||
}
|
||||
|
||||
export class TwilioProvider implements VoiceCallProvider {
|
||||
readonly name = "twilio" as const;
|
||||
|
||||
private readonly accountSid: string;
|
||||
private readonly authToken: string;
|
||||
private readonly baseUrl: string;
|
||||
private readonly callWebhookUrls = new Map<string, string>();
|
||||
private readonly options: TwilioProviderOptions;
|
||||
|
||||
/** Current public webhook URL (set when tunnel starts or from config) */
|
||||
private currentPublicUrl: string | null = null;
|
||||
|
||||
/** Optional OpenAI TTS provider for streaming TTS */
|
||||
private ttsProvider: OpenAITTSProvider | null = null;
|
||||
|
||||
/** Optional media stream handler for sending audio */
|
||||
private mediaStreamHandler: MediaStreamHandler | null = null;
|
||||
|
||||
/** Map of call SID to stream SID for media streams */
|
||||
private callStreamMap = new Map<string, string>();
|
||||
|
||||
constructor(config: TwilioConfig, options: TwilioProviderOptions = {}) {
|
||||
if (!config.accountSid) {
|
||||
throw new Error("Twilio Account SID is required");
|
||||
}
|
||||
if (!config.authToken) {
|
||||
throw new Error("Twilio Auth Token is required");
|
||||
}
|
||||
|
||||
this.accountSid = config.accountSid;
|
||||
this.authToken = config.authToken;
|
||||
this.baseUrl = `https://api.twilio.com/2010-04-01/Accounts/${this.accountSid}`;
|
||||
this.options = options;
|
||||
|
||||
if (options.publicUrl) {
|
||||
this.currentPublicUrl = options.publicUrl;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current public webhook URL (called when tunnel starts).
|
||||
*/
|
||||
setPublicUrl(url: string): void {
|
||||
this.currentPublicUrl = url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current public webhook URL.
|
||||
*/
|
||||
getPublicUrl(): string | null {
|
||||
return this.currentPublicUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the OpenAI TTS provider for streaming TTS.
|
||||
* When set, playTts will use OpenAI audio via media streams.
|
||||
*/
|
||||
setTTSProvider(provider: OpenAITTSProvider): void {
|
||||
this.ttsProvider = provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the media stream handler for sending audio.
|
||||
*/
|
||||
setMediaStreamHandler(handler: MediaStreamHandler): void {
|
||||
this.mediaStreamHandler = handler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a call's stream SID for audio routing.
|
||||
*/
|
||||
registerCallStream(callSid: string, streamSid: string): void {
|
||||
this.callStreamMap.set(callSid, streamSid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a call's stream SID.
|
||||
*/
|
||||
unregisterCallStream(callSid: string): void {
|
||||
this.callStreamMap.delete(callSid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an authenticated request to the Twilio API.
|
||||
*/
|
||||
private async apiRequest<T = unknown>(
|
||||
endpoint: string,
|
||||
params: Record<string, string>,
|
||||
options?: { allowNotFound?: boolean },
|
||||
): Promise<T> {
|
||||
const response = await fetch(`${this.baseUrl}${endpoint}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Basic ${Buffer.from(`${this.accountSid}:${this.authToken}`).toString("base64")}`,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams(params),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (options?.allowNotFound && response.status === 404) {
|
||||
return undefined as T;
|
||||
}
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Twilio API error: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
return text ? (JSON.parse(text) as T) : (undefined as T);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify Twilio webhook signature using HMAC-SHA1.
|
||||
*
|
||||
* Handles reverse proxy scenarios (Tailscale, nginx, ngrok) by reconstructing
|
||||
* the public URL from forwarding headers.
|
||||
*
|
||||
* @see https://www.twilio.com/docs/usage/webhooks/webhooks-security
|
||||
*/
|
||||
verifyWebhook(ctx: WebhookContext): WebhookVerificationResult {
|
||||
const result = verifyTwilioWebhook(ctx, this.authToken, {
|
||||
publicUrl: this.currentPublicUrl || undefined,
|
||||
allowNgrokFreeTier: this.options.allowNgrokFreeTier ?? true,
|
||||
skipVerification: this.options.skipVerification,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
console.warn(`[twilio] Webhook verification failed: ${result.reason}`);
|
||||
if (result.verificationUrl) {
|
||||
console.warn(`[twilio] Verification URL: ${result.verificationUrl}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: result.ok,
|
||||
reason: result.reason,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Twilio webhook event into normalized format.
|
||||
*/
|
||||
parseWebhookEvent(ctx: WebhookContext): ProviderWebhookParseResult {
|
||||
try {
|
||||
const params = new URLSearchParams(ctx.rawBody);
|
||||
const callIdFromQuery =
|
||||
typeof ctx.query?.callId === "string" && ctx.query.callId.trim()
|
||||
? ctx.query.callId.trim()
|
||||
: undefined;
|
||||
const event = this.normalizeEvent(params, callIdFromQuery);
|
||||
|
||||
// For Twilio, we must return TwiML. Most actions are driven by Calls API updates,
|
||||
// so the webhook response is typically a pause to keep the call alive.
|
||||
const twiml = this.generateTwimlResponse(ctx);
|
||||
|
||||
return {
|
||||
events: event ? [event] : [],
|
||||
providerResponseBody: twiml,
|
||||
providerResponseHeaders: { "Content-Type": "application/xml" },
|
||||
statusCode: 200,
|
||||
};
|
||||
} catch {
|
||||
return { events: [], statusCode: 400 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Twilio direction to normalized format.
|
||||
*/
|
||||
private static parseDirection(
|
||||
direction: string | null,
|
||||
): "inbound" | "outbound" | undefined {
|
||||
if (direction === "inbound") return "inbound";
|
||||
if (direction === "outbound-api" || direction === "outbound-dial")
|
||||
return "outbound";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Twilio webhook params to normalized event format.
|
||||
*/
|
||||
private normalizeEvent(
|
||||
params: URLSearchParams,
|
||||
callIdOverride?: string,
|
||||
): NormalizedEvent | null {
|
||||
const callSid = params.get("CallSid") || "";
|
||||
|
||||
const baseEvent = {
|
||||
id: crypto.randomUUID(),
|
||||
callId: callIdOverride || callSid,
|
||||
providerCallId: callSid,
|
||||
timestamp: Date.now(),
|
||||
direction: TwilioProvider.parseDirection(params.get("Direction")),
|
||||
from: params.get("From") || undefined,
|
||||
to: params.get("To") || undefined,
|
||||
};
|
||||
|
||||
// Handle speech result (from <Gather>)
|
||||
const speechResult = params.get("SpeechResult");
|
||||
if (speechResult) {
|
||||
return {
|
||||
...baseEvent,
|
||||
type: "call.speech",
|
||||
transcript: speechResult,
|
||||
isFinal: true,
|
||||
confidence: parseFloat(params.get("Confidence") || "0.9"),
|
||||
};
|
||||
}
|
||||
|
||||
// Handle DTMF
|
||||
const digits = params.get("Digits");
|
||||
if (digits) {
|
||||
return { ...baseEvent, type: "call.dtmf", digits };
|
||||
}
|
||||
|
||||
// Handle call status changes
|
||||
const callStatus = params.get("CallStatus");
|
||||
switch (callStatus) {
|
||||
case "initiated":
|
||||
return { ...baseEvent, type: "call.initiated" };
|
||||
case "ringing":
|
||||
return { ...baseEvent, type: "call.ringing" };
|
||||
case "in-progress":
|
||||
return { ...baseEvent, type: "call.answered" };
|
||||
case "completed":
|
||||
case "busy":
|
||||
case "no-answer":
|
||||
case "failed":
|
||||
return { ...baseEvent, type: "call.ended", reason: callStatus };
|
||||
case "canceled":
|
||||
return { ...baseEvent, type: "call.ended", reason: "hangup-bot" };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly EMPTY_TWIML =
|
||||
'<?xml version="1.0" encoding="UTF-8"?><Response></Response>';
|
||||
|
||||
private static readonly PAUSE_TWIML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Response>
|
||||
<Pause length="30"/>
|
||||
</Response>`;
|
||||
|
||||
/**
|
||||
* Generate TwiML response for webhook.
|
||||
* When a call is answered, connects to media stream for bidirectional audio.
|
||||
*/
|
||||
private generateTwimlResponse(ctx?: WebhookContext): string {
|
||||
if (!ctx) return TwilioProvider.EMPTY_TWIML;
|
||||
|
||||
const params = new URLSearchParams(ctx.rawBody);
|
||||
const callStatus = params.get("CallStatus");
|
||||
const direction = params.get("Direction");
|
||||
|
||||
console.log(
|
||||
`[voice-call] generateTwimlResponse: status=${callStatus} direction=${direction}`,
|
||||
);
|
||||
|
||||
// For inbound calls, answer immediately with stream
|
||||
if (direction === "inbound") {
|
||||
const streamUrl = this.getStreamUrl();
|
||||
return streamUrl
|
||||
? this.getStreamConnectXml(streamUrl)
|
||||
: TwilioProvider.PAUSE_TWIML;
|
||||
}
|
||||
|
||||
// For outbound calls, only connect to stream when call is in-progress
|
||||
if (callStatus !== "in-progress") {
|
||||
return TwilioProvider.EMPTY_TWIML;
|
||||
}
|
||||
|
||||
const streamUrl = this.getStreamUrl();
|
||||
return streamUrl
|
||||
? this.getStreamConnectXml(streamUrl)
|
||||
: TwilioProvider.PAUSE_TWIML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the WebSocket URL for media streaming.
|
||||
* Derives from the public URL origin + stream path.
|
||||
*/
|
||||
private getStreamUrl(): string | null {
|
||||
if (!this.currentPublicUrl || !this.options.streamPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract just the origin (host) from the public URL, ignoring any path
|
||||
const url = new URL(this.currentPublicUrl);
|
||||
const origin = url.origin;
|
||||
|
||||
// Convert https:// to wss:// for WebSocket
|
||||
const wsOrigin = origin
|
||||
.replace(/^https:\/\//, "wss://")
|
||||
.replace(/^http:\/\//, "ws://");
|
||||
|
||||
// Append the stream path
|
||||
const path = this.options.streamPath.startsWith("/")
|
||||
? this.options.streamPath
|
||||
: `/${this.options.streamPath}`;
|
||||
|
||||
return `${wsOrigin}${path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate TwiML to connect a call to a WebSocket media stream.
|
||||
* This enables bidirectional audio streaming for real-time STT/TTS.
|
||||
*
|
||||
* @param streamUrl - WebSocket URL (wss://...) for the media stream
|
||||
*/
|
||||
getStreamConnectXml(streamUrl: string): string {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Response>
|
||||
<Connect>
|
||||
<Stream url="${escapeXml(streamUrl)}" />
|
||||
</Connect>
|
||||
</Response>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate an outbound call via Twilio API.
|
||||
* If inlineTwiml is provided, uses that directly (for notify mode).
|
||||
* Otherwise, uses webhook URL for dynamic TwiML.
|
||||
*/
|
||||
async initiateCall(input: InitiateCallInput): Promise<InitiateCallResult> {
|
||||
const url = new URL(input.webhookUrl);
|
||||
url.searchParams.set("callId", input.callId);
|
||||
|
||||
// Build request params
|
||||
const params: Record<string, string> = {
|
||||
To: input.to,
|
||||
From: input.from,
|
||||
StatusCallback: url.toString(),
|
||||
StatusCallbackEvent: "initiated ringing answered completed",
|
||||
Timeout: "30",
|
||||
};
|
||||
|
||||
// Use inline TwiML for notify mode (simpler, no webhook needed)
|
||||
if (input.inlineTwiml) {
|
||||
params.Twiml = input.inlineTwiml;
|
||||
} else {
|
||||
params.Url = url.toString();
|
||||
}
|
||||
|
||||
const result = await this.apiRequest<TwilioCallResponse>(
|
||||
"/Calls.json",
|
||||
params,
|
||||
);
|
||||
|
||||
this.callWebhookUrls.set(result.sid, url.toString());
|
||||
|
||||
return {
|
||||
providerCallId: result.sid,
|
||||
status: result.status === "queued" ? "queued" : "initiated",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hang up a call via Twilio API.
|
||||
*/
|
||||
async hangupCall(input: HangupCallInput): Promise<void> {
|
||||
this.callWebhookUrls.delete(input.providerCallId);
|
||||
|
||||
await this.apiRequest(
|
||||
`/Calls/${input.providerCallId}.json`,
|
||||
{ Status: "completed" },
|
||||
{ allowNotFound: true },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Play TTS audio via Twilio.
|
||||
*
|
||||
* Two modes:
|
||||
* 1. OpenAI TTS + Media Streams: If TTS provider and media stream are available,
|
||||
* generates audio via OpenAI and streams it through WebSocket (preferred).
|
||||
* 2. TwiML <Say>: Falls back to Twilio's native TTS with Polly voices.
|
||||
* Note: This may not work on all Twilio accounts.
|
||||
*/
|
||||
async playTts(input: PlayTtsInput): Promise<void> {
|
||||
// Try OpenAI TTS via media stream first (if configured)
|
||||
const streamSid = this.callStreamMap.get(input.providerCallId);
|
||||
if (this.ttsProvider && this.mediaStreamHandler && streamSid) {
|
||||
try {
|
||||
await this.playTtsViaStream(input.text, streamSid);
|
||||
return;
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[voice-call] OpenAI TTS failed, falling back to Twilio <Say>:`,
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
// Fall through to TwiML <Say> fallback
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to TwiML <Say> (may not work on all accounts)
|
||||
const webhookUrl = this.callWebhookUrls.get(input.providerCallId);
|
||||
if (!webhookUrl) {
|
||||
throw new Error(
|
||||
"Missing webhook URL for this call (provider state not initialized)",
|
||||
);
|
||||
}
|
||||
|
||||
console.warn(
|
||||
"[voice-call] Using TwiML <Say> fallback - OpenAI TTS not configured or media stream not active",
|
||||
);
|
||||
|
||||
const pollyVoice = mapVoiceToPolly(input.voice);
|
||||
const twiml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Response>
|
||||
<Say voice="${pollyVoice}" language="${input.locale || "en-US"}">${escapeXml(input.text)}</Say>
|
||||
<Gather input="speech" speechTimeout="auto" action="${escapeXml(webhookUrl)}" method="POST">
|
||||
<Say>.</Say>
|
||||
</Gather>
|
||||
</Response>`;
|
||||
|
||||
await this.apiRequest(`/Calls/${input.providerCallId}.json`, {
|
||||
Twiml: twiml,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Play TTS via OpenAI and Twilio Media Streams.
|
||||
* Generates audio with OpenAI TTS, converts to mu-law, and streams via WebSocket.
|
||||
* Uses a jitter buffer to smooth out timing variations.
|
||||
*/
|
||||
private async playTtsViaStream(
|
||||
text: string,
|
||||
streamSid: string,
|
||||
): Promise<void> {
|
||||
if (!this.ttsProvider || !this.mediaStreamHandler) {
|
||||
throw new Error("TTS provider and media stream handler required");
|
||||
}
|
||||
|
||||
// Generate audio with OpenAI TTS (returns mu-law at 8kHz)
|
||||
const muLawAudio = await this.ttsProvider.synthesizeForTwilio(text);
|
||||
|
||||
// Stream audio in 20ms chunks (160 bytes at 8kHz mu-law)
|
||||
const CHUNK_SIZE = 160;
|
||||
const CHUNK_DELAY_MS = 20;
|
||||
|
||||
for (const chunk of chunkAudio(muLawAudio, CHUNK_SIZE)) {
|
||||
this.mediaStreamHandler.sendAudio(streamSid, chunk);
|
||||
|
||||
// Pace the audio to match real-time playback
|
||||
await new Promise((resolve) => setTimeout(resolve, CHUNK_DELAY_MS));
|
||||
}
|
||||
|
||||
// Send a mark to track when audio finishes
|
||||
this.mediaStreamHandler.sendMark(streamSid, `tts-${Date.now()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start listening for speech via Twilio <Gather>.
|
||||
*/
|
||||
async startListening(input: StartListeningInput): Promise<void> {
|
||||
const webhookUrl = this.callWebhookUrls.get(input.providerCallId);
|
||||
if (!webhookUrl) {
|
||||
throw new Error(
|
||||
"Missing webhook URL for this call (provider state not initialized)",
|
||||
);
|
||||
}
|
||||
|
||||
const twiml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Response>
|
||||
<Gather input="speech" speechTimeout="auto" language="${input.language || "en-US"}" action="${escapeXml(webhookUrl)}" method="POST">
|
||||
</Gather>
|
||||
</Response>`;
|
||||
|
||||
await this.apiRequest(`/Calls/${input.providerCallId}.json`, {
|
||||
Twiml: twiml,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop listening - for Twilio this is a no-op as <Gather> auto-ends.
|
||||
*/
|
||||
async stopListening(_input: StopListeningInput): Promise<void> {
|
||||
// Twilio's <Gather> automatically stops on speech end
|
||||
// No explicit action needed
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Twilio-specific types
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
interface TwilioCallResponse {
|
||||
sid: string;
|
||||
status: string;
|
||||
direction: string;
|
||||
from: string;
|
||||
to: string;
|
||||
uri: string;
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Voice call response generator - uses the embedded Pi agent for tool support.
|
||||
* Routes voice responses through the same agent infrastructure as messaging.
|
||||
*/
|
||||
|
||||
import crypto from "node:crypto";
|
||||
|
||||
import { loadCoreAgentDeps, type CoreConfig } from "./core-bridge.js";
|
||||
|
||||
import type { VoiceCallConfig } from "./config.js";
|
||||
|
||||
export type VoiceResponseParams = {
|
||||
/** Voice call config */
|
||||
voiceConfig: VoiceCallConfig;
|
||||
/** Core Clawdbot config */
|
||||
coreConfig: CoreConfig;
|
||||
/** Call ID for session tracking */
|
||||
callId: string;
|
||||
/** Caller's phone number */
|
||||
from: string;
|
||||
/** Conversation transcript */
|
||||
transcript: Array<{ speaker: "user" | "bot"; text: string }>;
|
||||
/** Latest user message */
|
||||
userMessage: string;
|
||||
};
|
||||
|
||||
export type VoiceResponseResult = {
|
||||
text: string | null;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type SessionEntry = {
|
||||
sessionId: string;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a voice response using the embedded Pi agent with full tool support.
|
||||
* Uses the same agent infrastructure as messaging for consistent behavior.
|
||||
*/
|
||||
export async function generateVoiceResponse(
|
||||
params: VoiceResponseParams,
|
||||
): Promise<VoiceResponseResult> {
|
||||
const { voiceConfig, callId, from, transcript, userMessage, coreConfig } =
|
||||
params;
|
||||
|
||||
if (!coreConfig) {
|
||||
return { text: null, error: "Core config unavailable for voice response" };
|
||||
}
|
||||
|
||||
let deps: Awaited<ReturnType<typeof loadCoreAgentDeps>>;
|
||||
try {
|
||||
deps = await loadCoreAgentDeps();
|
||||
} catch (err) {
|
||||
return {
|
||||
text: null,
|
||||
error:
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Unable to load core agent dependencies",
|
||||
};
|
||||
}
|
||||
const cfg = coreConfig;
|
||||
|
||||
// Build voice-specific session key based on phone number
|
||||
const normalizedPhone = from.replace(/\D/g, "");
|
||||
const sessionKey = `voice:${normalizedPhone}`;
|
||||
const agentId = "main";
|
||||
|
||||
// Resolve paths
|
||||
const storePath = deps.resolveStorePath(cfg.session?.store, { agentId });
|
||||
const agentDir = deps.resolveAgentDir(cfg, agentId);
|
||||
const workspaceDir = deps.resolveAgentWorkspaceDir(cfg, agentId);
|
||||
|
||||
// Ensure workspace exists
|
||||
await deps.ensureAgentWorkspace({ dir: workspaceDir });
|
||||
|
||||
// Load or create session entry
|
||||
const sessionStore = deps.loadSessionStore(storePath);
|
||||
const now = Date.now();
|
||||
let sessionEntry = sessionStore[sessionKey] as SessionEntry | undefined;
|
||||
|
||||
if (!sessionEntry) {
|
||||
sessionEntry = {
|
||||
sessionId: crypto.randomUUID(),
|
||||
updatedAt: now,
|
||||
};
|
||||
sessionStore[sessionKey] = sessionEntry;
|
||||
await deps.saveSessionStore(storePath, sessionStore);
|
||||
}
|
||||
|
||||
const sessionId = sessionEntry.sessionId;
|
||||
const sessionFile = deps.resolveSessionFilePath(sessionId, sessionEntry, {
|
||||
agentId,
|
||||
});
|
||||
|
||||
// Resolve model from config
|
||||
const modelRef =
|
||||
voiceConfig.responseModel ||
|
||||
`${deps.DEFAULT_PROVIDER}/${deps.DEFAULT_MODEL}`;
|
||||
const slashIndex = modelRef.indexOf("/");
|
||||
const provider =
|
||||
slashIndex === -1 ? deps.DEFAULT_PROVIDER : modelRef.slice(0, slashIndex);
|
||||
const model = slashIndex === -1 ? modelRef : modelRef.slice(slashIndex + 1);
|
||||
|
||||
// Resolve thinking level
|
||||
const thinkLevel = deps.resolveThinkingDefault({ cfg, provider, model });
|
||||
|
||||
// Resolve agent identity for personalized prompt
|
||||
const identity = deps.resolveAgentIdentity(cfg, agentId);
|
||||
const agentName = identity?.name?.trim() || "assistant";
|
||||
|
||||
// Build system prompt with conversation history
|
||||
const basePrompt =
|
||||
voiceConfig.responseSystemPrompt ??
|
||||
`You are ${agentName}, a helpful voice assistant on a phone call. Keep responses brief and conversational (1-2 sentences max). Be natural and friendly. The caller's phone number is ${from}. You have access to tools - use them when helpful.`;
|
||||
|
||||
let extraSystemPrompt = basePrompt;
|
||||
if (transcript.length > 0) {
|
||||
const history = transcript
|
||||
.map(
|
||||
(entry) =>
|
||||
`${entry.speaker === "bot" ? "You" : "Caller"}: ${entry.text}`,
|
||||
)
|
||||
.join("\n");
|
||||
extraSystemPrompt = `${basePrompt}\n\nConversation so far:\n${history}`;
|
||||
}
|
||||
|
||||
// Resolve timeout
|
||||
const timeoutMs =
|
||||
voiceConfig.responseTimeoutMs ?? deps.resolveAgentTimeoutMs({ cfg });
|
||||
const runId = `voice:${callId}:${Date.now()}`;
|
||||
|
||||
try {
|
||||
const result = await deps.runEmbeddedPiAgent({
|
||||
sessionId,
|
||||
sessionKey,
|
||||
messageProvider: "voice",
|
||||
sessionFile,
|
||||
workspaceDir,
|
||||
config: cfg,
|
||||
prompt: userMessage,
|
||||
provider,
|
||||
model,
|
||||
thinkLevel,
|
||||
verboseLevel: "off",
|
||||
timeoutMs,
|
||||
runId,
|
||||
lane: "voice",
|
||||
extraSystemPrompt,
|
||||
agentDir,
|
||||
});
|
||||
|
||||
// Extract text from payloads
|
||||
const texts = (result.payloads ?? [])
|
||||
.filter((p) => p.text && !p.isError)
|
||||
.map((p) => p.text?.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const text = texts.join(" ") || null;
|
||||
|
||||
if (!text && result.meta.aborted) {
|
||||
return { text: null, error: "Response generation was aborted" };
|
||||
}
|
||||
|
||||
return { text };
|
||||
} catch (err) {
|
||||
console.error(`[voice-call] Response generation failed:`, err);
|
||||
return { text: null, error: String(err) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import type { CoreConfig } from "./core-bridge.js";
|
||||
import type { VoiceCallConfig } from "./config.js";
|
||||
import { validateProviderConfig } from "./config.js";
|
||||
import { CallManager } from "./manager.js";
|
||||
import type { VoiceCallProvider } from "./providers/base.js";
|
||||
import { MockProvider } from "./providers/mock.js";
|
||||
import { TelnyxProvider } from "./providers/telnyx.js";
|
||||
import { OpenAITTSProvider } from "./providers/tts-openai.js";
|
||||
import { TwilioProvider } from "./providers/twilio.js";
|
||||
import { startTunnel, type TunnelResult } from "./tunnel.js";
|
||||
import {
|
||||
cleanupTailscaleExposure,
|
||||
setupTailscaleExposure,
|
||||
VoiceCallWebhookServer,
|
||||
} from "./webhook.js";
|
||||
|
||||
export type VoiceCallRuntime = {
|
||||
config: VoiceCallConfig;
|
||||
provider: VoiceCallProvider;
|
||||
manager: CallManager;
|
||||
webhookServer: VoiceCallWebhookServer;
|
||||
webhookUrl: string;
|
||||
publicUrl: string | null;
|
||||
stop: () => Promise<void>;
|
||||
};
|
||||
|
||||
type Logger = {
|
||||
info: (message: string) => void;
|
||||
warn: (message: string) => void;
|
||||
error: (message: string) => void;
|
||||
debug: (message: string) => void;
|
||||
};
|
||||
|
||||
function resolveProvider(config: VoiceCallConfig): VoiceCallProvider {
|
||||
switch (config.provider) {
|
||||
case "telnyx":
|
||||
return new TelnyxProvider({
|
||||
apiKey: config.telnyx?.apiKey ?? process.env.TELNYX_API_KEY,
|
||||
connectionId:
|
||||
config.telnyx?.connectionId ?? process.env.TELNYX_CONNECTION_ID,
|
||||
publicKey: config.telnyx?.publicKey ?? process.env.TELNYX_PUBLIC_KEY,
|
||||
});
|
||||
case "twilio":
|
||||
return new TwilioProvider(
|
||||
{
|
||||
accountSid:
|
||||
config.twilio?.accountSid ?? process.env.TWILIO_ACCOUNT_SID,
|
||||
authToken: config.twilio?.authToken ?? process.env.TWILIO_AUTH_TOKEN,
|
||||
},
|
||||
{
|
||||
allowNgrokFreeTier: config.tunnel?.allowNgrokFreeTier ?? true,
|
||||
publicUrl: config.publicUrl,
|
||||
skipVerification: config.skipSignatureVerification,
|
||||
streamPath: config.streaming?.enabled
|
||||
? config.streaming.streamPath
|
||||
: undefined,
|
||||
},
|
||||
);
|
||||
case "mock":
|
||||
return new MockProvider();
|
||||
default:
|
||||
throw new Error(
|
||||
`Unsupported voice-call provider: ${String(config.provider)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createVoiceCallRuntime(params: {
|
||||
config: VoiceCallConfig;
|
||||
coreConfig: CoreConfig;
|
||||
logger?: Logger;
|
||||
}): Promise<VoiceCallRuntime> {
|
||||
const { config, coreConfig, logger } = params;
|
||||
const log = logger ?? {
|
||||
info: console.log,
|
||||
warn: console.warn,
|
||||
error: console.error,
|
||||
debug: console.debug,
|
||||
};
|
||||
|
||||
if (!config.enabled) {
|
||||
throw new Error(
|
||||
"Voice call disabled. Enable the plugin entry in config.",
|
||||
);
|
||||
}
|
||||
|
||||
const validation = validateProviderConfig(config);
|
||||
if (!validation.valid) {
|
||||
throw new Error(`Invalid voice-call config: ${validation.errors.join("; ")}`);
|
||||
}
|
||||
|
||||
const provider = resolveProvider(config);
|
||||
const manager = new CallManager(config);
|
||||
const webhookServer = new VoiceCallWebhookServer(
|
||||
config,
|
||||
manager,
|
||||
provider,
|
||||
coreConfig,
|
||||
);
|
||||
|
||||
const localUrl = await webhookServer.start();
|
||||
|
||||
// Determine public URL - priority: config.publicUrl > tunnel > legacy tailscale
|
||||
let publicUrl: string | null = config.publicUrl ?? null;
|
||||
let tunnelResult: TunnelResult | null = null;
|
||||
|
||||
if (!publicUrl && config.tunnel?.provider && config.tunnel.provider !== "none") {
|
||||
try {
|
||||
tunnelResult = await startTunnel({
|
||||
provider: config.tunnel.provider,
|
||||
port: config.serve.port,
|
||||
path: config.serve.path,
|
||||
ngrokAuthToken:
|
||||
config.tunnel.ngrokAuthToken ?? process.env.NGROK_AUTHTOKEN,
|
||||
ngrokDomain: config.tunnel.ngrokDomain ?? process.env.NGROK_DOMAIN,
|
||||
});
|
||||
publicUrl = tunnelResult?.publicUrl ?? null;
|
||||
} catch (err) {
|
||||
log.error(
|
||||
`[voice-call] Tunnel setup failed: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!publicUrl && config.tailscale?.mode !== "off") {
|
||||
publicUrl = await setupTailscaleExposure(config);
|
||||
}
|
||||
|
||||
const webhookUrl = publicUrl ?? localUrl;
|
||||
|
||||
if (publicUrl && provider.name === "twilio") {
|
||||
(provider as TwilioProvider).setPublicUrl(publicUrl);
|
||||
}
|
||||
|
||||
if (provider.name === "twilio" && config.streaming?.enabled) {
|
||||
const twilioProvider = provider as TwilioProvider;
|
||||
const openaiApiKey =
|
||||
config.streaming.openaiApiKey || process.env.OPENAI_API_KEY;
|
||||
if (openaiApiKey) {
|
||||
try {
|
||||
const ttsProvider = new OpenAITTSProvider({
|
||||
apiKey: openaiApiKey,
|
||||
voice: config.tts.voice,
|
||||
model: config.tts.model,
|
||||
instructions: config.tts.instructions,
|
||||
});
|
||||
twilioProvider.setTTSProvider(ttsProvider);
|
||||
log.info("[voice-call] OpenAI TTS provider configured");
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
`[voice-call] Failed to initialize OpenAI TTS: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
log.warn("[voice-call] OpenAI TTS key missing; streaming TTS disabled");
|
||||
}
|
||||
|
||||
const mediaHandler = webhookServer.getMediaStreamHandler();
|
||||
if (mediaHandler) {
|
||||
twilioProvider.setMediaStreamHandler(mediaHandler);
|
||||
log.info("[voice-call] Media stream handler wired to provider");
|
||||
}
|
||||
}
|
||||
|
||||
manager.initialize(provider, webhookUrl);
|
||||
|
||||
const stop = async () => {
|
||||
if (tunnelResult) {
|
||||
await tunnelResult.stop();
|
||||
}
|
||||
await cleanupTailscaleExposure(config);
|
||||
await webhookServer.stop();
|
||||
};
|
||||
|
||||
log.info("[voice-call] Runtime initialized");
|
||||
log.info(`[voice-call] Webhook URL: ${webhookUrl}`);
|
||||
if (publicUrl) {
|
||||
log.info(`[voice-call] Public URL: ${publicUrl}`);
|
||||
}
|
||||
|
||||
return {
|
||||
config,
|
||||
provider,
|
||||
manager,
|
||||
webhookServer,
|
||||
webhookUrl,
|
||||
publicUrl,
|
||||
stop,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
import { getTailscaleDnsName } from "./webhook.js";
|
||||
|
||||
/**
|
||||
* Tunnel configuration for exposing the webhook server.
|
||||
*/
|
||||
export interface TunnelConfig {
|
||||
/** Tunnel provider: ngrok, tailscale-serve, or tailscale-funnel */
|
||||
provider: "ngrok" | "tailscale-serve" | "tailscale-funnel" | "none";
|
||||
/** Local port to tunnel */
|
||||
port: number;
|
||||
/** Path prefix for the tunnel (e.g., /voice/webhook) */
|
||||
path: string;
|
||||
/** ngrok auth token (optional, enables longer sessions) */
|
||||
ngrokAuthToken?: string;
|
||||
/** ngrok custom domain (paid feature) */
|
||||
ngrokDomain?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of starting a tunnel.
|
||||
*/
|
||||
export interface TunnelResult {
|
||||
/** The public URL */
|
||||
publicUrl: string;
|
||||
/** Function to stop the tunnel */
|
||||
stop: () => Promise<void>;
|
||||
/** Tunnel provider name */
|
||||
provider: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start an ngrok tunnel to expose the local webhook server.
|
||||
*
|
||||
* Uses the ngrok CLI which must be installed: https://ngrok.com/download
|
||||
*
|
||||
* @example
|
||||
* const tunnel = await startNgrokTunnel({ port: 3334, path: '/voice/webhook' });
|
||||
* console.log('Public URL:', tunnel.publicUrl);
|
||||
* // Later: await tunnel.stop();
|
||||
*/
|
||||
export async function startNgrokTunnel(config: {
|
||||
port: number;
|
||||
path: string;
|
||||
authToken?: string;
|
||||
domain?: string;
|
||||
}): Promise<TunnelResult> {
|
||||
// Set auth token if provided
|
||||
if (config.authToken) {
|
||||
await runNgrokCommand(["config", "add-authtoken", config.authToken]);
|
||||
}
|
||||
|
||||
// Build ngrok command args
|
||||
const args = [
|
||||
"http",
|
||||
String(config.port),
|
||||
"--log",
|
||||
"stdout",
|
||||
"--log-format",
|
||||
"json",
|
||||
];
|
||||
|
||||
// Add custom domain if provided (paid ngrok feature)
|
||||
if (config.domain) {
|
||||
args.push("--domain", config.domain);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn("ngrok", args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let resolved = false;
|
||||
let publicUrl: string | null = null;
|
||||
let outputBuffer = "";
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
proc.kill("SIGTERM");
|
||||
reject(new Error("ngrok startup timed out (30s)"));
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
const processLine = (line: string) => {
|
||||
try {
|
||||
const log = JSON.parse(line);
|
||||
|
||||
// ngrok logs the public URL in a 'started tunnel' message
|
||||
if (log.msg === "started tunnel" && log.url) {
|
||||
publicUrl = log.url;
|
||||
}
|
||||
|
||||
// Also check for the URL field directly
|
||||
if (log.addr && log.url && !publicUrl) {
|
||||
publicUrl = log.url;
|
||||
}
|
||||
|
||||
// Check for ready state
|
||||
if (publicUrl && !resolved) {
|
||||
resolved = true;
|
||||
clearTimeout(timeout);
|
||||
|
||||
// Add path to the public URL
|
||||
const fullUrl = publicUrl + config.path;
|
||||
|
||||
console.log(`[voice-call] ngrok tunnel active: ${fullUrl}`);
|
||||
|
||||
resolve({
|
||||
publicUrl: fullUrl,
|
||||
provider: "ngrok",
|
||||
stop: async () => {
|
||||
proc.kill("SIGTERM");
|
||||
await new Promise<void>((res) => {
|
||||
proc.on("close", () => res());
|
||||
setTimeout(res, 2000); // Fallback timeout
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, might be startup message
|
||||
}
|
||||
};
|
||||
|
||||
proc.stdout.on("data", (data: Buffer) => {
|
||||
outputBuffer += data.toString();
|
||||
const lines = outputBuffer.split("\n");
|
||||
outputBuffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
processLine(line);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
proc.stderr.on("data", (data: Buffer) => {
|
||||
const msg = data.toString();
|
||||
// Check for common errors
|
||||
if (msg.includes("ERR_NGROK")) {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
clearTimeout(timeout);
|
||||
reject(new Error(`ngrok error: ${msg}`));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
proc.on("error", (err) => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
clearTimeout(timeout);
|
||||
reject(new Error(`Failed to start ngrok: ${err.message}`));
|
||||
}
|
||||
});
|
||||
|
||||
proc.on("close", (code) => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
clearTimeout(timeout);
|
||||
reject(new Error(`ngrok exited unexpectedly with code ${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an ngrok command and wait for completion.
|
||||
*/
|
||||
async function runNgrokCommand(args: string[]): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn("ngrok", args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
proc.stdout.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
proc.stderr.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve(stdout);
|
||||
} else {
|
||||
reject(new Error(`ngrok command failed: ${stderr || stdout}`));
|
||||
}
|
||||
});
|
||||
|
||||
proc.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if ngrok is installed and available.
|
||||
*/
|
||||
export async function isNgrokAvailable(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn("ngrok", ["version"], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
proc.on("close", (code) => {
|
||||
resolve(code === 0);
|
||||
});
|
||||
|
||||
proc.on("error", () => {
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a Tailscale serve/funnel tunnel.
|
||||
*/
|
||||
export async function startTailscaleTunnel(config: {
|
||||
mode: "serve" | "funnel";
|
||||
port: number;
|
||||
path: string;
|
||||
}): Promise<TunnelResult> {
|
||||
// Get Tailscale DNS name
|
||||
const dnsName = await getTailscaleDnsName();
|
||||
if (!dnsName) {
|
||||
throw new Error("Could not get Tailscale DNS name. Is Tailscale running?");
|
||||
}
|
||||
|
||||
const localUrl = `http://127.0.0.1:${config.port}`;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(
|
||||
"tailscale",
|
||||
[config.mode, "--bg", "--yes", "--set-path", config.path, localUrl],
|
||||
{ stdio: ["ignore", "pipe", "pipe"] },
|
||||
);
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
proc.kill("SIGKILL");
|
||||
reject(new Error(`Tailscale ${config.mode} timed out`));
|
||||
}, 10000);
|
||||
|
||||
proc.on("close", (code) => {
|
||||
clearTimeout(timeout);
|
||||
if (code === 0) {
|
||||
const publicUrl = `https://${dnsName}${config.path}`;
|
||||
console.log(
|
||||
`[voice-call] Tailscale ${config.mode} active: ${publicUrl}`,
|
||||
);
|
||||
|
||||
resolve({
|
||||
publicUrl,
|
||||
provider: `tailscale-${config.mode}`,
|
||||
stop: async () => {
|
||||
await stopTailscaleTunnel(config.mode, config.path);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
reject(new Error(`Tailscale ${config.mode} failed with code ${code}`));
|
||||
}
|
||||
});
|
||||
|
||||
proc.on("error", (err) => {
|
||||
clearTimeout(timeout);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a Tailscale serve/funnel tunnel.
|
||||
*/
|
||||
async function stopTailscaleTunnel(
|
||||
mode: "serve" | "funnel",
|
||||
path: string,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn("tailscale", [mode, "off", path], {
|
||||
stdio: "ignore",
|
||||
});
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
proc.kill("SIGKILL");
|
||||
resolve();
|
||||
}, 5000);
|
||||
|
||||
proc.on("close", () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a tunnel based on configuration.
|
||||
*/
|
||||
export async function startTunnel(
|
||||
config: TunnelConfig,
|
||||
): Promise<TunnelResult | null> {
|
||||
switch (config.provider) {
|
||||
case "ngrok":
|
||||
return startNgrokTunnel({
|
||||
port: config.port,
|
||||
path: config.path,
|
||||
authToken: config.ngrokAuthToken,
|
||||
domain: config.ngrokDomain,
|
||||
});
|
||||
|
||||
case "tailscale-serve":
|
||||
return startTailscaleTunnel({
|
||||
mode: "serve",
|
||||
port: config.port,
|
||||
path: config.path,
|
||||
});
|
||||
|
||||
case "tailscale-funnel":
|
||||
return startTailscaleTunnel({
|
||||
mode: "funnel",
|
||||
port: config.port,
|
||||
path: config.path,
|
||||
});
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { CallMode } from "./config.js";
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Provider Identifiers
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export const ProviderNameSchema = z.enum(["telnyx", "twilio", "mock"]);
|
||||
export type ProviderName = z.infer<typeof ProviderNameSchema>;
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Core Call Identifiers
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/** Internal call identifier (UUID) */
|
||||
export type CallId = string;
|
||||
|
||||
/** Provider-specific call identifier */
|
||||
export type ProviderCallId = string;
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Call Lifecycle States
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export const CallStateSchema = z.enum([
|
||||
// Non-terminal states
|
||||
"initiated",
|
||||
"ringing",
|
||||
"answered",
|
||||
"active",
|
||||
"speaking",
|
||||
"listening",
|
||||
// Terminal states
|
||||
"completed",
|
||||
"hangup-user",
|
||||
"hangup-bot",
|
||||
"timeout",
|
||||
"error",
|
||||
"failed",
|
||||
"no-answer",
|
||||
"busy",
|
||||
"voicemail",
|
||||
]);
|
||||
export type CallState = z.infer<typeof CallStateSchema>;
|
||||
|
||||
export const TerminalStates = new Set<CallState>([
|
||||
"completed",
|
||||
"hangup-user",
|
||||
"hangup-bot",
|
||||
"timeout",
|
||||
"error",
|
||||
"failed",
|
||||
"no-answer",
|
||||
"busy",
|
||||
"voicemail",
|
||||
]);
|
||||
|
||||
export const EndReasonSchema = z.enum([
|
||||
"completed",
|
||||
"hangup-user",
|
||||
"hangup-bot",
|
||||
"timeout",
|
||||
"error",
|
||||
"failed",
|
||||
"no-answer",
|
||||
"busy",
|
||||
"voicemail",
|
||||
]);
|
||||
export type EndReason = z.infer<typeof EndReasonSchema>;
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Normalized Call Events
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
const BaseEventSchema = z.object({
|
||||
id: z.string(),
|
||||
callId: z.string(),
|
||||
providerCallId: z.string().optional(),
|
||||
timestamp: z.number(),
|
||||
// Optional fields for inbound call detection
|
||||
direction: z.enum(["inbound", "outbound"]).optional(),
|
||||
from: z.string().optional(),
|
||||
to: z.string().optional(),
|
||||
});
|
||||
|
||||
export const NormalizedEventSchema = z.discriminatedUnion("type", [
|
||||
BaseEventSchema.extend({
|
||||
type: z.literal("call.initiated"),
|
||||
}),
|
||||
BaseEventSchema.extend({
|
||||
type: z.literal("call.ringing"),
|
||||
}),
|
||||
BaseEventSchema.extend({
|
||||
type: z.literal("call.answered"),
|
||||
}),
|
||||
BaseEventSchema.extend({
|
||||
type: z.literal("call.active"),
|
||||
}),
|
||||
BaseEventSchema.extend({
|
||||
type: z.literal("call.speaking"),
|
||||
text: z.string(),
|
||||
}),
|
||||
BaseEventSchema.extend({
|
||||
type: z.literal("call.speech"),
|
||||
transcript: z.string(),
|
||||
isFinal: z.boolean(),
|
||||
confidence: z.number().min(0).max(1).optional(),
|
||||
}),
|
||||
BaseEventSchema.extend({
|
||||
type: z.literal("call.silence"),
|
||||
durationMs: z.number(),
|
||||
}),
|
||||
BaseEventSchema.extend({
|
||||
type: z.literal("call.dtmf"),
|
||||
digits: z.string(),
|
||||
}),
|
||||
BaseEventSchema.extend({
|
||||
type: z.literal("call.ended"),
|
||||
reason: EndReasonSchema,
|
||||
}),
|
||||
BaseEventSchema.extend({
|
||||
type: z.literal("call.error"),
|
||||
error: z.string(),
|
||||
retryable: z.boolean().optional(),
|
||||
}),
|
||||
]);
|
||||
export type NormalizedEvent = z.infer<typeof NormalizedEventSchema>;
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Call Direction
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export const CallDirectionSchema = z.enum(["outbound", "inbound"]);
|
||||
export type CallDirection = z.infer<typeof CallDirectionSchema>;
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Call Record
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export const TranscriptEntrySchema = z.object({
|
||||
timestamp: z.number(),
|
||||
speaker: z.enum(["bot", "user"]),
|
||||
text: z.string(),
|
||||
isFinal: z.boolean().default(true),
|
||||
});
|
||||
export type TranscriptEntry = z.infer<typeof TranscriptEntrySchema>;
|
||||
|
||||
export const CallRecordSchema = z.object({
|
||||
callId: z.string(),
|
||||
providerCallId: z.string().optional(),
|
||||
provider: ProviderNameSchema,
|
||||
direction: CallDirectionSchema,
|
||||
state: CallStateSchema,
|
||||
from: z.string(),
|
||||
to: z.string(),
|
||||
sessionKey: z.string().optional(),
|
||||
startedAt: z.number(),
|
||||
answeredAt: z.number().optional(),
|
||||
endedAt: z.number().optional(),
|
||||
endReason: EndReasonSchema.optional(),
|
||||
transcript: z.array(TranscriptEntrySchema).default([]),
|
||||
processedEventIds: z.array(z.string()).default([]),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
export type CallRecord = z.infer<typeof CallRecordSchema>;
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Webhook Types
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export type WebhookVerificationResult = {
|
||||
ok: boolean;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export type WebhookContext = {
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
rawBody: string;
|
||||
url: string;
|
||||
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
|
||||
query?: Record<string, string | string[] | undefined>;
|
||||
};
|
||||
|
||||
export type ProviderWebhookParseResult = {
|
||||
events: NormalizedEvent[];
|
||||
providerResponseBody?: string;
|
||||
providerResponseHeaders?: Record<string, string>;
|
||||
statusCode?: number;
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Provider Method Types
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export type InitiateCallInput = {
|
||||
callId: CallId;
|
||||
from: string;
|
||||
to: string;
|
||||
webhookUrl: string;
|
||||
clientState?: Record<string, string>;
|
||||
/** Inline TwiML to execute (skips webhook, used for notify mode) */
|
||||
inlineTwiml?: string;
|
||||
};
|
||||
|
||||
export type InitiateCallResult = {
|
||||
providerCallId: ProviderCallId;
|
||||
status: "initiated" | "queued";
|
||||
};
|
||||
|
||||
export type HangupCallInput = {
|
||||
callId: CallId;
|
||||
providerCallId: ProviderCallId;
|
||||
reason: EndReason;
|
||||
};
|
||||
|
||||
export type PlayTtsInput = {
|
||||
callId: CallId;
|
||||
providerCallId: ProviderCallId;
|
||||
text: string;
|
||||
voice?: string;
|
||||
locale?: string;
|
||||
};
|
||||
|
||||
export type StartListeningInput = {
|
||||
callId: CallId;
|
||||
providerCallId: ProviderCallId;
|
||||
language?: string;
|
||||
};
|
||||
|
||||
export type StopListeningInput = {
|
||||
callId: CallId;
|
||||
providerCallId: ProviderCallId;
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Outbound Call Options
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export type OutboundCallOptions = {
|
||||
/** Message to speak when call connects */
|
||||
message?: string;
|
||||
/** Call mode (overrides config default) */
|
||||
mode?: CallMode;
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Tool Result Types
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export type InitiateCallToolResult = {
|
||||
success: boolean;
|
||||
callId?: string;
|
||||
status?: "initiated" | "queued" | "no-answer" | "busy" | "failed";
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type ContinueCallToolResult = {
|
||||
success: boolean;
|
||||
transcript?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type SpeakToUserToolResult = {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type EndCallToolResult = {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
export function resolveUserPath(input: string): string {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return trimmed;
|
||||
if (trimmed.startsWith("~")) {
|
||||
const expanded = trimmed.replace(/^~(?=$|[\\/])/, os.homedir());
|
||||
return path.resolve(expanded);
|
||||
}
|
||||
return path.resolve(trimmed);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Voice mapping and XML utilities for voice call providers.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Escape XML special characters for TwiML and other XML responses.
|
||||
*/
|
||||
export function escapeXml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* Map of OpenAI voice names to similar Twilio Polly voices.
|
||||
*/
|
||||
const OPENAI_TO_POLLY_MAP: Record<string, string> = {
|
||||
alloy: "Polly.Joanna", // neutral, warm
|
||||
echo: "Polly.Matthew", // male, warm
|
||||
fable: "Polly.Amy", // British, expressive
|
||||
onyx: "Polly.Brian", // deep male
|
||||
nova: "Polly.Salli", // female, friendly
|
||||
shimmer: "Polly.Kimberly", // female, clear
|
||||
};
|
||||
|
||||
/**
|
||||
* Default Polly voice when no mapping is found.
|
||||
*/
|
||||
export const DEFAULT_POLLY_VOICE = "Polly.Joanna";
|
||||
|
||||
/**
|
||||
* Map OpenAI voice names to Twilio Polly equivalents.
|
||||
* Falls through if already a valid Polly/Google voice.
|
||||
*
|
||||
* @param voice - OpenAI voice name (alloy, echo, etc.) or Polly voice name
|
||||
* @returns Polly voice name suitable for Twilio TwiML
|
||||
*/
|
||||
export function mapVoiceToPolly(voice: string | undefined): string {
|
||||
if (!voice) return DEFAULT_POLLY_VOICE;
|
||||
|
||||
// Already a Polly/Google voice - pass through
|
||||
if (voice.startsWith("Polly.") || voice.startsWith("Google.")) {
|
||||
return voice;
|
||||
}
|
||||
|
||||
// Map OpenAI voices to Polly equivalents
|
||||
return OPENAI_TO_POLLY_MAP[voice.toLowerCase()] || DEFAULT_POLLY_VOICE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a voice name is a known OpenAI voice.
|
||||
*/
|
||||
export function isOpenAiVoice(voice: string): boolean {
|
||||
return voice.toLowerCase() in OPENAI_TO_POLLY_MAP;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all supported OpenAI voice names.
|
||||
*/
|
||||
export function getOpenAiVoiceNames(): string[] {
|
||||
return Object.keys(OPENAI_TO_POLLY_MAP);
|
||||
}
|
||||
Loaded 100 of 389 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user