# Chamade — Complete Documentation > Voice and chat gateway for AI agents. Chamade joins meetings, phone calls, and DMs across 10 platforms (Discord, Teams, Meet, Zoom, Telegram, WhatsApp, Slack, Matrix, Nextcloud Talk, SIP) and runs the speech pipeline for you with your own STT/TTS key — or hands you raw PCM on a WebSocket if you'd rather run the speech layer yourself. One uniform API joins meetings, phone calls, and DMs; speech and chat just work. You bring an AI agent; Chamade is the gateway to the platforms. Works with any backend that speaks HTTP + WebSocket. **Chamade is in early access: free and open to everyone.** No plans, no quota, no billing. Pricing will be announced once the feature surface stabilizes; for production-scale or SLA discussions, contact@nafis.io (optional, not gated). --- ## Two ways to build an agent Decide this first — it determines everything below. Each Chamade **agent** (a persona, see [Agents](#agents)) answers events one of two mutually exclusive ways: 1. **Bring your own runtime (MCP client).** Your code holds an **agent key**, connects to the Chamade MCP server (or REST), and reacts to events — new DMs, ringing calls — with tool calls (`chamade_dm_chat`, `chamade_call_say`, …). You run the logic, the model, the tools. This is what the [Quick Start](#quick-start), [MCP Server](#mcp-server), and most of this doc describe. 2. **Inline hosted provider (Chamade runs the loop).** You attach a hosted LLM to the agent (OpenAI / Anthropic / Mistral / Bedrock / Gemini / Foundry) and Chamade orchestrates `event → provider → reply → act` for you — no client to keep online, no handler to deploy. Chamade is a *router, not a runtime*: it never executes tool calls; the agent's tools and memory live inside the provider. See [Configuring an inline provider](#configuring-an-inline-provider). Both modes share the same platforms, voice, files, and inbox. Mode 1 is the default; reach for mode 2 when you'd rather not host anything. --- ## Authentication Pass your key as `X-API-Key` (REST) or `Authorization: Bearer` (MCP). Keys start with `chmd_`, are shown only once, and come in **two kinds** — running an agent and configuring the account are different roles, deliberately split so the key an agent holds can't reconfigure the account it runs under. | | Agent key | Settings key | |---|---|---| | Purpose | Run the agent (runtime) | Configure the account (admin) | | Minted at | `/dashboard/agents` (per agent) | `/dashboard/account` → Settings API keys | | Scope | its agent's platform accounts | none — acts on nothing | | Can do | calls, DMs, inbox, files | agents, providers, platforms, voice, SIP, … | | Cannot do | configure the account (403) | act on the control-plane (sees nothing) | **Three authorization tiers** — every endpoint sits in exactly one: 1. **Control-plane** — any valid key (agent key, settings key, or MCP OAuth bearer). The act-on-conversations surface: calls, DMs, inbox, files. (A settings key authenticates but has empty scope, so it sees/does nothing here.) 2. **Config** — **session cookie or settings key only**; agent keys, MCP OAuth bearers, and identity tokens get **403**. Everything under [Account management](#account-management) and [Agents](#agents): providers, platform connects, bot tokens, voice presets, SIP, Vercel, data export, and minting/revoking agent keys. 3. **Account-owner** — **session cookie only**, no bearer (not even a settings key): delete account, change email/password, and minting/revoking settings keys. **OAuth platform connects** (Microsoft, Google, Discord, NC Talk — and the Gemini/Foundry providers) always need a one-time **browser** consent regardless of key: a settings key can *start* the flow (you get a URL back) but a human approves it in a browser. Everything credential-paste based (BYOB bot tokens, SIP, voice keys, OpenAI/Anthropic/Mistral/Bedrock providers) is fully headless with a settings key. Most agents only need an **agent key**. Use a settings key only when a script must configure the account headlessly instead of using the dashboard. --- ## Quick Start ### 1. Get a key Sign up at https://chamade.io/dashboard. Mint an **agent key** on `/dashboard/agents` — that's your runtime bearer. (Need to script account setup too? Mint a **settings key** on `/dashboard/account`; see [Authentication](#authentication).) ### 2. Wire up — MCP (recommended) or REST **MCP over Streamable HTTP** (Claude Desktop, Claude Code, Cursor, Windsurf, most 2026+ clients): ```json { "mcpServers": { "chamade": { "type": "http", "url": "https://mcp.chamade.io/mcp/", "headers": { "Authorization": "Bearer chmd_..." } } } } ``` **MCP stdio shim** for stdio-only clients — the `@chamade/mcp-server@5.1.0` npm package bridges stdio to the same hosted endpoint: ```json { "mcpServers": { "chamade": { "command": "npx", "args": ["-y", "@chamade/mcp-server@5.1.0"], "env": { "CHAMADE_API_KEY": "chmd_..." } } } } ``` **REST** — direct HTTP: ```bash curl -X POST https://chamade.io/api/call \ -H "X-API-Key: chmd_..." \ -H "Content-Type: application/json" \ -d '{"platform":"discord","meeting_url":"https://discord.com/channels/..."}' ``` By default the MCP runs **stateless** (polling-based, restart-proof). For real-time push on Claude Code, see [Push mode](#push-mode). ### 3. Bootstrap Call `chamade_account` (or `GET /api/account`) once on cold start. It returns your plan, the global `features` block, per-platform `{status, capabilities, files}`, the identity map, and `last_message_cursor`. Then connect the platforms you want ([Platforms](#platforms)) and start handling events ([Inbox](#inbox-dms), [Calls](#calls-voice)). --- ## Platforms Chamade supports **10 platforms**. Capabilities are reported **per-platform and authoritatively** by `chamade_account` — the `capabilities` array on each platform entry is the source of truth at runtime; the table below is a quick reference. | Platform | Voice | Chat | Files | How to connect | |----------|-------|------|-------|----------------| | Discord | audio_in, audio_out | read, write, typing | ✅ | Shared bot out of the box, or BYOB; OAuth connect for DMs | | Microsoft Teams | audio_in, audio_out | read, write, typing | gated* | Install Teams app + connect Microsoft (OAuth) | | Google Meet | audio_in only | read, write | gated* | Connect Google (OAuth). Media API in Developer Preview | | Zoom | audio_in, audio_out | read, write | — | Connect Zoom (OAuth, beta — invite-only) | | Telegram | — | read, write, typing | ✅ | Shared bot (deeplink) or BYOB | | WhatsApp | — | read, write | ✅ | Invite link — no setup | | Slack | — | read, write | ✅ | Install Slack app or BYOB. No typing indicator | | Matrix | — | read, write, typing | ✅ | BYOB only (any homeserver). Unencrypted rooms only | | Nextcloud Talk | audio_in, audio_out | read, write, typing | ✅ (addon ≥ 2.4.0 for inbound) | Install addon + connect | | SIP / Phone | audio_in, audio_out | — | — | BYO trunk + DIDs (BYOT only) | \* Teams/Meet files are code-ready but gated behind server env flags (`CHAMADE_FILES_ENABLE_TEAMS` / `_MEET`) until AppSource cert / Google OAuth verification clear. Don't send `attachments` to a platform whose `files` bool is `false`. **Capability vocabulary:** `audio_in` (Chamade streams raw PCM *from* the platform to you), `audio_out` (you push raw PCM *into* the platform), `read`/`write` (receive/send chat), `typing`, `files` (send + receive, 25 MB cap). Where `audio_out` is missing (Meet), send text via `chamade_call_chat` instead. **Native audio sample rates** (PCM s16le mono, 20 ms frames): Discord / Meet / NC Talk **48 kHz**; Teams / Zoom / Telegram **16 kHz**; SIP **8 kHz**. Chamade resamples to any rate you declare via `audio_config` (see [Direct audio](#direct-audio-byo-stttts)). **Message length & formatting** (advisory — Chamade accepts up to 10,000 chars and does not enforce per-platform limits except Discord auto-split; the platform rejects oversize sends, so split yourself): | Platform | Max | Formatting | |----------|-----|------------| | Discord | 2,000 | Markdown | | Telegram | 4,096 | Markdown | | Teams | ~28,000 | Markdown | | WhatsApp | 1,600 | Basic (`*bold*`, `_italic_`, `~strike~`, `` `mono` ``; needs spaces around markers) | | Slack | 3,000/block (40k total) | mrkdwn | | Matrix | ~32,000 (64 KB event cap) | Plain text | | NC Talk | 32,000 | Markdown | Cross-platform safe subset: `*bold*`, `_italic_`, `` `code` ``, ` ```code blocks``` `. ### Per-platform setup **Discord** — three modes: (1) DM-only: `POST /api/oauth/start/discord` → user opens the returned `auth_url`. (2) Shared bot voice+text: same OAuth, then invite the Chamade bot to the server (invite link in `GET /api/platforms`). (3) BYOB: create a Discord app (enable Message Content Intent; scopes Connect/Speak/Send Messages/Read History), register via `POST /api/bot-tokens`. **Microsoft Teams** — download the app at https://chamade.io/download/teams-app, sideload it (Teams → Apps → Upload a custom app; admin must allow sideloading), then connect Microsoft via `POST /api/oauth/start/microsoft`. **Google Meet** — connect Google via `POST /api/oauth/start/google`. `audio_in` + chat only (no audio injection). ⚠️ Real-time audio uses Google's Meet Media API (Developer Preview): every participant must be enrolled in Google's preview program or audio capture fails — in practice voice only works between enrolled accounts. Chat works for everyone. For production voice, prefer Teams / Discord / NC Talk / SIP. **Zoom (beta)** — invite-only during Marketplace review (contact@nafis.io for tester access). Install the app, connect via `POST /api/oauth/start/zoom`, then pass a meeting URL (`https://zoom.us/j/123?pwd=…`, personal-room URLs ok). Joins with the user's ZAK identity. **Telegram** — shared bot: `POST /api/telegram/connect` → `{"deeplink"}`; user opens it and sends `/start`. Until then the account shows `available (shared bot, user account not connected)`. BYOB: create a bot via @BotFather, register via `POST /api/bot-tokens`. **WhatsApp** — no setup; generate an invite link (`POST /api/invite-link`, 15 min single-use). Enforces a 24-hour window — see [WhatsApp window](#whatsapp-24-hour-window). **Slack** — shared bot: `POST /api/slack/install` → authorize in the workspace. BYOB: create an app at api.slack.com/apps (scopes `chat:write`, `im:history`, `im:read`, `channels:history`, `channels:read`, `groups:history`, `groups:read`, `users:read`, `files:read`, `files:write`; events to `https://chamade.io/api/slack/webhook`; subscribe `message.im`, `message.channels`, `message.groups`, `app_mention`). **Matrix** — BYOB only. Create a dedicated Matrix account for the agent on any homeserver, grab its access token (Element: Settings → Help & About → Access Token, or `POST /_matrix/client/v3/login`), register via `POST /api/bot-tokens` with `{"platform": "matrix", "bot_token": "", "matrix_homeserver": "matrix.org"}`. The bot auto-joins rooms it's invited to. **Unencrypted rooms only** (no olm/megolm) — Element creates DMs encrypted by default, so create the DM without encryption. No voice (Element Call has no bot API). **Nextcloud Talk** — install the Chamade Talk addon (admin + SSH), then `POST /api/nctalk/preflight` (checks the addon) and `POST /api/nctalk/connect`. Bot is scoped to your NC account: only you can DM it; in group rooms type `/activate` / `/deactivate`. **SIP / Phone** — BYOT only (Chamade provides no numbers). `POST /api/sip/trunk` with your trunk credentials (realm usually required, e.g. `sip-domain.io` for OVH), then `POST /api/sip/trunk/dids` for each E.164 number — **no DID, no inbound** (unknown DIDs are rejected). Per-DID `auto_answer` skips the `ringing` state. Inbound: caller dials a DID → `call_invite` event → answer via `POST /api/call/{id}/accept` (60 s → missed) unless `auto_answer`. Outbound: `{"platform":"sip","meeting_url":"sip:+33…@sip.example.com"}`. --- ## Calls (Voice) ### Voice modes Two ways to handle audio — pick per call: - **Hosted STT/TTS (recommended, BYOK).** Add an STT/TTS provider key once in /dashboard → Voice providers (STT: ElevenLabs, Deepgram, Cartesia, AssemblyAI, OpenAI · TTS: ElevenLabs, Deepgram, Cartesia, OpenAI). Chamade runs the pipeline server-side — free, you pay your provider. Transcripts arrive as `call_transcript` events; `chamade_call_say` speaks; `chamade_call_stop_speaking` interrupts. This is the **BYOK** model referenced throughout. - **Raw PCM WebSocket (full control).** Run your own speech stack (OpenAI Realtime, LiveKit Agents, Pipecat, Whisper/Deepgram + ElevenLabs/Cartesia cascade…). The call response carries an `audio` block; you pipe PCM both ways on the call WebSocket. See [Direct audio](#direct-audio-byo-stttts). Both work identically over MCP and REST. With no hosted preset, `chamade_call_say` / `/say` return 400 — fall back to raw PCM. ### Create / join — `POST /api/call` ```json { "platform": "discord", "meeting_url": "https://discord.com/channels/...", "agent_name": "AI Agent", "transcripts": true } ``` | Field | Req | Description | |-------|-----|-------------| | `platform` | yes | `discord`, `teams`, `meet`, `zoom`, `telegram`, `nctalk`, `sip`, `whatsapp` | | `meeting_url` | most | Meeting URL or SIP URI | | `agent_name` | no | Display name (default "AI Agent") | | `transcripts` | no | `false` to disable hosted STT (BYO-STT — Chamade incurs no STT cost). Default true | | `account_id` | no | Disambiguate when ≥2 accounts on the same platform (see [Multi-account](#multi-account)) | Returns `call_id`, `state`, `capabilities`, `meeting_url`, and an `audio` block (`sample_rate`, `format`, `channels`, `frame_duration_ms`, `frame_bytes`) for the raw-PCM path. ### Other call endpoints ``` GET /api/calls list active calls GET /api/call/{id}?since=N state + transcript delta (pass since=transcript_length to get only new lines) DELETE /api/call/{id} hang up POST /api/call/{id}/accept answer a ringing inbound call POST /api/call/{id}/refuse reject a ringing inbound call POST /api/call/{id}/say {text} — speak via hosted TTS (BYOK; 400 if no preset) POST /api/call/{id}/stop-speaking interrupt the current utterance POST /api/call/{id}/chat {text, sender_name?} — send meeting chat (write capability) POST /api/call/{id}/typing typing indicator (typing capability) ``` ### Call WebSocket ``` WS /api/call/{call_id}/stream?api_key=chmd_... ``` Real-time alternative to polling. Text frames (JSON): ```json // server → client {"type": "call_transcript", "speaker": "Alice", "text": "Hello"} // hosted STT only {"type": "call_chat", "sender": "Bob", "text": "Hi"} {"type": "call_state", "state": "active"} {"type": "call_typing", "sender": "Bob"} {"type": "call_error", "code": "bridge_disconnected", "message": "..."} // client → server {"type": "say", "text": "Hello everyone"} {"type": "send_chat", "text": "Check this link"} ``` ### Direct audio (BYO STT/TTS) The same WebSocket carries the room's raw PCM bidirectionally — you receive what humans say (binary frames) and push what your agent says (binary frames). Chamade is pure transport; you run STT + TTS. - **Send (recommended):** raw PCM s16le mono as **binary** frames at the native rate (from the `audio` block). Any frame size; Chamade re-aligns to 20 ms. Cap 1 MB/frame. - **Send (JSON):** `{"type":"audio","audio":"","sample_rate":24000}` — for OpenAI-Realtime-style pipelines. - **Receive (mix-minus):** opt in with `{"type":"audio_config","sample_rate":24000,"receive":true}` → you get binary PCM frames at your rate (Chamade resamples), excluding your own injected audio. `call_transcript`/`call_chat`/`call_state` still arrive as JSON in parallel. - **Query rate:** `{"type":"audio_config"}` → `{sample_rate, native_sample_rate, resampling, frame_bytes, ready, ...}`. > **Why audio isn't on MCP:** MCP (JSON-RPC over stdio/HTTP) is a control plane — no primitive for continuous binary streams. Like every voice stack, Chamade keeps audio on a native WebSocket and uses MCP/REST only for control. Your host code (not the LLM) connects both and pipes bytes. **On `bridge_disconnected`:** the platform bridge dropped (Maquisard restart, network blip). Re-create the call with the same `meeting_url`; a new `call_id` is issued, transcript starts fresh. ```python # Minimal BYO-audio client: open WS, declare 24 kHz, receive mix-minus, send TTS PCM. import asyncio, json, httpx, websockets API_KEY = "chmd_..." async def main(): async with httpx.AsyncClient() as http: r = await http.post("https://chamade.io/api/call", headers={"X-API-Key": API_KEY}, json={"platform": "discord", "meeting_url": "https://discord.com/channels/.../...", "transcripts": False}) # BYO-STT call_id = r.json()["call_id"] url = f"wss://chamade.io/api/call/{call_id}/stream?api_key={API_KEY}" # wss host = your Chamade instance async with websockets.connect(url) as ws: await ws.send(json.dumps({"type": "audio_config", "sample_rate": 24000, "receive": True})) async def reader(): async for m in ws: if isinstance(m, bytes): ... # raw PCM s16le @24kHz, 20ms, mix-minus → your STT else: ev = json.loads(m) if ev.get("code") == "bridge_disconnected": return # re-create the call async def writer(): await ws.send(b"\x00\x00" * 24000) # your TTS output (1 s silence stub) await asyncio.sleep(60) await asyncio.gather(reader(), writer()) asyncio.run(main()) ``` --- ## Inbox (DMs) Read and answer direct messages from Discord, Telegram, Teams, WhatsApp, Slack, Matrix, NC Talk. ``` GET /api/inbox list conversations (snapshot / per-platform detail / delta) GET /api/inbox/{conversation_id} full message history (+ attachments[]) POST /api/dm/chat send a DM POST /api/dm/typing typing indicator DELETE /api/inbox/{conversation_id} close a conversation ``` **List / poll** `GET /api/inbox` params: `platform` (filter, or detail mode for that platform), `last_message_cursor` (delta — accepts `|` or a plain ISO ts), `wait` (long-poll seconds, capped 55), `limit` (50), `status` (active), `offset`. The response carries `last_message_cursor` for the next poll and `call_event_cursor` for voice events — one loop covers DMs and call events. **Send** `POST /api/dm/chat` `{platform, text, keep_typing?, account_id?, attachments?}`. Two outcomes: - `200 {"status":"delivered","message_id","delivery":"sync"}` — on the platform, move on. - `202 {"status":"queued",...}` — WhatsApp outside its window; see below. ### Reply timeout (60 s) After an inbound DM you have **60 s** to reply via `POST /api/dm/chat`, else the user sees a timeout. For longer work: send a short ack with `keep_typing:true` first, do the work, send the full answer; if it exceeds 60 s, call `POST /api/dm/typing` every ~55 s. ### WhatsApp 24-hour window WhatsApp only allows free-form messages within 24 h of the user's last inbound. Outside it, `POST /api/dm/chat` returns **`202 queued`**: Chamade stores the message and fires one pre-approved re-engagement template (`agent_followup`). When the user replies, all pending messages flush in order and you get a `dm_delivered` event listing `delivered_ids`, immediately followed by their reply — no retry on your side. Only the first out-of-window message triggers a template (subsequent ones just queue); pending messages older than 7 days are dropped. Each WhatsApp conversation carries a `whatsapp_window` block so you can check before sending: `{open: bool, last_inbound_at, pending_count}`. Non-WhatsApp conversations omit it. --- ## Files & attachments Chamade relays attachments both ways on DMs and meeting chat. Inbound files are fetched eagerly and re-served from Chamade-signed URLs (you never touch platform tokens). Outbound, you upload once and reference a `file_id`. **Cap 25 MB.** Send + receive works on Discord, Telegram, Slack, Matrix, WhatsApp (incl. voice notes), NC Talk; Teams/Meet are gated; SIP/Zoom have no file channel — check the `files` bool on `chamade_account`. **Upload — `POST /api/files`** (three inputs): - multipart (`-F file=@path`) — best for local files; streams bytes, tiny request. - JSON `{url, name?, mime?}` — Chamade fetches server-side (SSRF-guarded). - JSON `{bytes_b64, name, mime}` — tiny files only (base64 stalls the model). Returns `{file_id, url, name, mime, size, expires_at}`. Uploads are user-private. **Pre-signed upload — `POST /api/files/reserve`** → `{file_id, upload_url}` (5 min, single-use, **no auth header** on the upload — the URL is the auth). This is what the `chamade_file_upload_url` MCP tool exposes; the uploader (shell/CI) never sees your API key. ```bash curl -X POST https://chamade.io/api/files/reserve -H "X-API-Key: chmd_..." \ -H "Content-Type: application/json" -d '{"name":"report.pdf","mime":"application/pdf"}' curl -F "file=@./report.pdf" "" # no auth header # then reference it: # POST /api/dm/chat {"platform":"nctalk","attachments":[{"file_id":"f_..."}]} ``` **Send with `attachments`** on `POST /api/dm/chat` or `POST /api/call/{id}/chat` — array of `{file_id}` | `{url,name,mime}` | `{bytes_b64,name,mime}`. `text` becomes the first attachment's caption. **Serve — `GET /api/files/{token}`** — the token *is* the auth (no header); hand the URL to vision/OCR tools. Platform-evicted files return 410. **Inbound:** every message from `GET /api/inbox/{id}` (and `dm_chat`/`call_chat` events, and `chamade_inbox`) carries `attachments[]` with `{name, mime, size, url}`. --- ## MCP Server Hosted at `https://mcp.chamade.io/mcp/` (Streamable HTTP, MCP spec 2025-03-26). **18 tools + 1 resource template.** Nothing to install — your client needs only an HTTP connection and a key. | Tool | Purpose | |------|---------| | `chamade_call_join` | Join a call; returns `call_id`, capabilities, and (voice) the raw-PCM `audio` block | | `chamade_call_chat` | Send meeting chat | | `chamade_call_say` | **[BYOK]** Speak via hosted TTS (400 without a preset) | | `chamade_call_stop_speaking` | Interrupt the current TTS utterance | | `chamade_call_status` | Call state + transcript delta | | `chamade_call_typing` | Typing indicator in meeting chat | | `chamade_call_accept` | Answer a ringing inbound call | | `chamade_call_refuse` | Reject a ringing inbound call | | `chamade_call_leave` | Hang up | | `chamade_call_list` | List active calls | | `chamade_inbox` | DM conversations — snapshot / detail / delta with optional long-poll; shows WhatsApp window inline | | `chamade_dm_chat` | Send a DM (202 queued on WhatsApp outside the window) | | `chamade_dm_typing` | Typing indicator in a DM | | `chamade_agents` | List sibling agents and saved external A2A peers | | `chamade_agent_dm` | Message a sibling agent or A2A peer; optionally wait or continue a task-scoped session | | `chamade_agent_spawn` | Start a client-mode sibling session and return its task handle | | `chamade_file_upload_url` | Mint a pre-signed upload URL (shell streams the file; tool call stays tiny) | | `chamade_account` | Bootstrap: plan, `features`, per-platform readiness + capabilities, identity map | | Resource | Purpose | |----------|---------| | `chamade://calls/{call_id}/transcript` | **[BYOK]** Live hosted-STT transcript (empty in BYO-audio mode) | **Auth — two paths:** - **Static bearer** (`Authorization: Bearer chmd_*`) — your **agent key**. Best for config-file clients. - **OAuth 2.1** (RFC 9728/7591/8414 + PKCE) — for in-browser sign-in clients like **claude.ai Custom Connectors**: paste the URL alone (`https://mcp.chamade.io/mcp/`), approve once at `https://chamade.io/mcp-auth/consent`, done. Tokens rotate (~1 h / ~30 d); revoke from the dashboard. **stdio shim** (`@chamade/mcp-server@5.1.0`, Node 18+) — config in [Quick Start](#quick-start). Env vars (shim only): `CHAMADE_API_KEY` (required), `CHAMADE_URL` (default `https://chamade.io`; set to `https://chamade.io` for this instance), `CHAMADE_MCP_URL` (explicit override, rarely needed). ### Push mode By default the MCP is **stateless**: each tool call is a self-contained HTTP request, restart-proof, polling-based. This is recommended for most setups. To get real-time server push instead of polling, opt in two ways together: 1. **Server:** append `?stateful` to the MCP URL (`https://mcp.chamade.io/mcp/?stateful`, or `--stateful` on the stdio shim). Chamade then keeps a persistent session and advertises `experimental.claude/channel`. 2. **Client (Claude Code):** launch with the channel flag every session: ```bash claude --dangerously-load-development-channels server:chamade --continue ``` `server:chamade` must match your `.mcp.json` key; pass once per entry for multiple. Without the flag, push is silently dropped and tools fall back to polling. When both are on, the client's `GET /mcp/` SSE stream triggers a per-session bridge on the user's inbox hub; events (new DMs, call state, SIP/Teams invites, WhatsApp `dm_delivered`) arrive as `notifications/claude/channel` JSON-RPC notifications. `call_transcript` pushes only with hosted STT. Don't poll `chamade_inbox`/`chamade_call_status` in a loop while push is active. **Cost:** a Chamade redeploy kills the session; Claude Code may show a brief disconnect until it reconnects — only enable push if you need it. **Codex** consumes this same channel without the Claude Code flag: the `@chamade/mcp-server@5.1.0` shim ships a headless autonomous agent, `chamade-mcp agent run --runtime codex --key chmd_… --cwd /absolute/project/path`, that lazily spawns its own `codex app-server`, manages task-scoped threads, opens the stateful channel, and relays each pushed event to Codex as a turn (the agent replies via the `chamade_*` tools, which it injects into the engine — no `codex mcp add` needed). Model and `sandbox_mode` come from the operator's own `~/.codex/config.toml`. `agent run --agent ` resumes across restarts, `agent install-service --agent ` runs it at boot, and it self-heals (reconnect + thread resume) in-process. The Chamade agent holding the key must have no inline provider configured, or both Codex and Chamade would answer. Full guide: https://chamade.io/docs/codex. **Polling (everything else).** Clients without channel support work unchanged: call `chamade_inbox` with `last_message_cursor` (+ optional `wait=55` for near-real-time long-poll) and save the returned cursor; `chamade_call_status` for transcript deltas; `chamade_call_list` for new ringing calls. Starter system-prompt for a polling agent: > On startup call `chamade_account` once and remember `last_message_cursor`. Then loop: call `chamade_inbox` with that cursor and `wait=55` (blocks server-side up to 55 s, returns on a new DM), save the new cursor, and for every conversation with new messages reply via `chamade_dm_chat` within 60 s. If a `whatsapp_window` reports `open=false` your send returns 202 queued and a template fires automatically — nothing to do. Keep the loop running indefinitely; spend every idle moment in a long-poll. ### Self-test (`chamade_probe`) Clicking **Test event** on `/dashboard/agents` enqueues a synthetic `agent.test` delivery and shows the outcome — verifying the Chamade → agent → reply loop, attributed to the bearer that replied. Handle it like any DM: `chamade_dm_chat(platform="chamade_probe", text="…")` with any short text. Don't invent a contact, memorize it, or escalate. Timeout 30 s. --- ## Agents An **agent** is a named persona that ties credentials to platform accounts and (optionally) an inline LLM provider. A runtime bearer — agent key or MCP OAuth client — sees and acts on exactly its agents' platforms, nothing else. (Settings keys are the exception: they belong to no agent and have empty control-plane scope — they administer, they don't act.) **The default agent.** Exactly one agent per user carries the `is_default` badge — newly connected platforms auto-attach there. Created automatically on the first key or OAuth client; orphan accounts get absorbed. For a casual user with one bearer, scoping is invisible — that bearer sees every connected platform. Move the badge in the dashboard. **Multiple agents** — one per role or platform (different personas/prompts/providers), multi-tenancy (two Teams tenants, each its own bearer), or OAuth clients with selective reach (the consent screen picks the agent). **Scope effects you'll observe:** - `chamade_inbox` / `GET /api/inbox` / `/api/calls` return only conversations whose account shares an agent with your bearer. - `chamade_dm_chat` / `chamade_call_*` return **403 "no agent for the account"** when your bearer shares no agent with the target account. - Hitting that 403, or an empty inbox despite activity, means the user scoped this bearer — ask them to attach the platform from `/dashboard/agents`. It's intent, not a bug. **Multi-account.** When a user has ≥2 accounts on one platform (two Teams tenants, two Google accounts, multiple BYOBs), pass an optional `account_id` to `chamade_call_join` / `chamade_dm_chat` / `chamade_dm_typing` (and the matching REST endpoints) to pick one. `chamade_account` lists the `accounts[]` per platform. With a single account it's inferred. ### Configuring an inline provider This is mode 2 from [Two ways to build an agent](#two-ways-to-build-an-agent): instead of running your own client, attach a hosted LLM and Chamade runs the loop. All of this is **config-tier** (settings key or dashboard). ``` PATCH /api/agents/{id}/provider {provider, config, system_prompt?, event_filter?, revalidate?} POST /api/agents/{id}/provider/healthcheck probe the endpoint POST /api/agents/{id}/test fire a synthetic event end-to-end ``` `config` is validated and its secrets encrypted at rest. Provider matrix: | `provider` | `config` essentials | Headless via settings key? | |---|---|---| | `openai_responses` | `endpoint_url`, `api_key`, + one of `model` / `prompt_id` (`pmpt_…`) / `agent_reference` | ✅ paste credentials. Also the preset for **Azure OpenAI** and any **Custom** Responses endpoint | | `anthropic_managed_agents` | `endpoint_url`, `api_key`, `model` (+ optional `mcp_server_urls`) | ✅ paste key. Browse existing: `POST /api/agents/{id}/anthropic/agents {api_key}` → pick `agent_*` | | `mistral_agents` | `endpoint_url`, `api_key`, `model` | ✅ paste key. Browse: `POST /api/agents/{id}/mistral/agents {api_key}` → pick `ag_*` | | `bedrock_agents` / `bedrock_agentcore` | `region`, `aws_role_arn` (+ `agent_runtime_arn` for AgentCore) | ⚠️ no OAuth, but paste an IAM trust policy into AWS first; then browse `POST /api/agents/{id}/agentcore/runtimes` | | `gemini` (Vertex Agent Engine) | `reasoning_engine_resource_name`, `auth_mode` (`oauth`/`wif`) | ❌ one-time browser Google consent (`GET /api/agents/{id}/google/connect`) first | | Microsoft **Foundry** | stored as `openai_responses` after the picker | ❌ one-time browser Entra consent (`GET /api/agents/{id}/azure/connect`) → browse → `POST /api/agents/{id}/azure/configure` | So OpenAI / Anthropic / Mistral / Bedrock are fully API-configurable with a settings key; Gemini / Foundry need a one-time browser OAuth to link the cloud account, then are API-driven. **Auto-MCP.** For providers that take MCP servers natively (OpenAI Responses, Anthropic, Mistral) Chamade auto-injects its own MCP server with an agent-scoped internal bearer, so the inline provider can call back to act on platforms. For the others (Bedrock, Gemini) that bearer is in the dashboard (`GET /api/agents/{id}/internal-bearer`) to paste into your runtime. Replies are parsed via Chamade's XML tag convention (``, ``, ``, …) — full catalog + output spec at https://chamade.io/docs/agents. ### Agent management (REST) Config-tier (settings key or session; agent keys get 403): ``` GET /api/agents list agents with keys[], oauth_clients[], accounts[] GET /api/agents/_candidates accounts available to attach POST /api/agents {name} create an empty agent PATCH /api/agents/{id} {name} rename DELETE /api/agents/{id} delete (400 if default or would orphan) POST /api/agents/{id}/set-default move the default badge POST /api/agents/{id}/keys mint a fresh agent key in this agent POST /api/agents/{id}/keys/{key_id} attach an existing key DELETE /api/agents/{id}/keys/{key_id} detach (400 if its last agent) POST /api/agents/{id}/accounts {account_type, account_id} attach a platform account DELETE /api/agents/{id}/accounts/{type}/{id} detach POST /api/agents/{id}/reset-conversations drop all provider-side sessions GET /api/agents/{id}/deliveries delivery log ``` `account_type` ∈ {`connection`, `bot_token`, `sip_number`}. MCP OAuth clients attach via the `/mcp-auth/consent` flow (not REST); revoke with `DELETE /api/mcp-authorizations/{client_id}`. --- ## Account management All **config-tier** (session or settings key — an agent key gets **403**), except the account-owner block which is session-only. See [Authentication](#authentication). ### Agent keys (`kind='user'`) ``` GET /api/api-keys list (prefix, agent name, dates) POST /api/api-keys mint a new agent key (+ its agent). Body {"name":"My bot"}. Returns the key once DELETE /api/api-keys/{key_id} revoke ``` A settings key or session can mint agent keys; an agent key cannot mint its own successors. `POST` also needs a verified email. ### Settings keys (`kind='settings'`) — account-owner tier (session only) ``` GET /api/settings-keys list (prefix, label, dates) POST /api/settings-keys mint. Body {"name":"ci"} (optional label). Returns the key once DELETE /api/settings-keys/{key_id} revoke ``` ### Bot tokens (BYOB) ``` GET /api/bot-tokens list POST /api/bot-tokens register {platform: discord|telegram|slack|matrix, bot_token, discord_app_id?, slack_signing_secret?, matrix_homeserver?} DELETE /api/bot-tokens/{bot_id} unregister ``` ### Platform connections ``` GET /api/platforms connections + available providers + invite links POST /api/oauth/start/{provider} start OAuth (microsoft|google|discord|zoom) → {auth_url} (browser) DELETE /api/connections/{connection_id} disconnect POST /api/telegram/connect → {deeplink} POST /api/slack/install → {url} POST /api/invite-link temporary WhatsApp/Telegram invite (15 min, single-use) ``` ### SIP (BYOT) ``` POST /api/sip/trunk connect a trunk {sip_host, sip_port?, sip_username, sip_password, sip_realm?, sip_caller_id?} GET /api/sip/trunk trunk info (no password) DELETE /api/sip/trunk disconnect + release DIDs POST /api/sip/trunk/dids add a DID (E.164) DELETE /api/sip/trunk/dids/{did_id} remove a DID POST /api/sip/trunk/dids/{did_id}/settings {auto_answer} ``` ### Voice presets, Vercel, export ``` GET /api/voice-configs list presets POST /api/voice-configs add an STT/TTS preset (provider + key) PATCH /api/voice-configs/{id} update DELETE /api/voice-configs/{id} delete POST /api/vercel/connect | GET /api/vercel/status | DELETE /api/vercel/disconnect GET /api/export full-account JSON (GDPR portability) ``` ### Account owner (session cookie only — refuses every bearer) ``` POST /api/change-password {current_password, new_password} POST /api/change-email sends a verification link to the new address DELETE /api/account delete account + all data (GDPR erasure; password confirmation) ``` ### Usage `GET /api/usage` — call count, concurrent limits, activity. Informational in early access (no billing cutoff). `GET /api/account` is the bootstrap call (see [Quick Start](#quick-start)): `plan`, `features` (`transport`/`audio_in`/`audio_out`/`text_chat`/`typing_indicators`/`files` = `ready`; `hosted_stt`/`hosted_tts` = `ready` with a preset else `byok`), per-platform `{status, capabilities, files}`, `identities` (which handle is your agent vs the human — for self-chat disambiguation), `last_message_cursor`. --- ## Error codes | Code | Cause | Fix | |------|-------|-----| | `401` missing/invalid key | bad or missing `X-API-Key` | check the key | | `403` "no agent for the account" | bearer acted on a conversation outside its agent scope | attach the platform on `/dashboard/agents` | | `403` config requires a settings key or session | an **agent key** hit a config endpoint | use a settings key or the dashboard | | `401`/`403` session required | a bearer (even a settings key) hit an account-owner endpoint | do it in the dashboard | | `400` hosted TTS not configured | `/say` with no TTS preset | add a preset, or use raw-PCM audio | | `400` no bot registered | no Discord/Telegram bot | add a bot token, or use the shared bot | | `400` meeting_url required | no URL and no OAuth connection | provide a URL or connect the account | | `400` no SIP trunk | outbound SIP without a trunk | connect a trunk | | `404` call not found | call ended or wrong id | `GET /api/calls` | | `409` no active WebSocket | bridge not ready | wait a moment | | `429` concurrent call limit | rate limit | end a call or retry shortly | **Bridge recovery.** On `bridge_disconnected` / `disconnected` state, re-create the call with the same `meeting_url` — a new `call_id` is issued, transcript starts fresh. An agent polling `chamade_call_status` detects this automatically.