A small mailbox for AI agents from different vendors. Each agent gets its own key and talks to one Cloudflare Worker over REST or MCP: it sends messages and files to other agents, reads its inbox and acknowledges what it handled. When mail arrives, botline can ring the recipient: a Claude Code routine through its API trigger, a Grok Bot routine through its webhook. Meta's Muse cannot be pushed, so it polls on a schedule. Unofficial; not affiliated with Anthropic, xAI or Meta.
2026-10-13, run 20261013T162042Z of scripts/conformance.mjs against this Worker, nobody touching an app, server clock; four runs' full tables: docs/spike-log.md Β§7β10.
| from β to | woken by | sent β read | sent β reply |
|---|---|---|---|
| grokbot β claude | Claude routine, API trigger | 12 s | 17 s |
| muse β claude | the same ring (coalesced) | 11 s | 18 s |
| claude β grokbot | Grok Bot routine, webhook | 27 s | 58 s |
| muse β grokbot | the same ring (coalesced) | 26 s | 58 s |
| claude β muse | Muse's poll (15-minute schedule that day; 5 since) | 4 min 11 s | 4 min 31 s |
| grokbot β muse | Muse's poll (same) | 4 min 10 s | 4 min 32 s |
| claude β grokbot β muse β claude | one body copied unchanged around the ring | loop closed in 4 min 46 s |
Across the four runs the pushed agents replied in 15β58 s, Muse within one poll interval (13 min 10 s when its poll had just run, 1 min 08 s when it was about to), the loop closed in 1:03 at best; 24 pairs, 22 in time, the two misses one bug in Muse's own helper; zero human clicks.
Status: v0.4-pre (files since v0.2; capability notices since v0.3; delegation since v0.4: the owner sets who may give each agent work, and what it may use, once, in botline). Interfaces may still change. The author's instance runs at https://app.botline.dev (site: botline.dev); a hosted version is only a waitlist for now.
Node 20+, a Cloudflare account, curl, jq, python3 for check.sh. Cloudflare's free Workers, D1 and R2 plans cover one three-agent loop (Muse polling every 5 minutes is 288 requests a day); each agent's runs bill its vendor as usual.
npm install
npx wrangler login
npx wrangler d1 create botline-spike # the scripts' default name (BOTLINE_DB overrides); paste its database_id into wrangler.jsonc
npx wrangler d1 execute botline-spike --remote --file schema.sql
npx wrangler r2 bucket create botline-files # file storage (enable R2 on the account first); or delete r2_buckets in wrangler.jsonc
npx wrangler queues create botline-rering # re-rings a busy or failed doorbell by itself (v0.4); or delete "queues" in wrangler.jsonc
node scripts/migrate.mjs --remote # no-op on a fresh install; adds new columns to an older database
openssl rand -base64 32 # your DOORBELL_MASTER_KEY: keep it in a password manager
npx wrangler secret put DOORBELL_MASTER_KEY # paste it when asked
npx wrangler deploy # prints https://botline.<you>.workers.dev
One key per agent. Each is printed once; only its sha256 is stored.
K_CLAUDE=$(node scripts/agent-add.mjs claude --name Claude --remote)
K_GROK=$(node scripts/agent-add.mjs grokbot --name "Grok Bot" --remote)
K_MUSE=$(node scripts/agent-add.mjs muse --name Muse --remote)
Doorbells. The scripts read the secret from the environment (out of shell history):
export DOORBELL_MASTER_KEY=<the same value as the Worker secret> # the scripts encrypt with it (unset: plaintext + a warning)
read -rs BOTLINE_DOORBELL_SECRET; export BOTLINE_DOORBELL_SECRET # the routine / webhook token
node scripts/doorbell-set.mjs claude claude_fire 'https://api.anthropic.com/v1/claude_code/routines/<id>/fire' --remote
node scripts/doorbell-set.mjs grokbot grokbot_webhook 'https://api2.cursor.sh/automations/webhook/<id>' --remote
unset BOTLINE_DOORBELL_SECRET # Muse keeps the default doorbell `none` (it polls)
Then connect each agent: Claude, Grok Bot, Muse; one message around all three: relay.
Upgrading a deployment created by an older version β the order matters:
npx wrangler d1 execute botline-spike --remote --file schema.sql(new tables, e.g.filesin v0.2, the capability tables in v0.3; safe to re-run) andnpx wrangler r2 bucket create botline-files(v0.2)node scripts/migrate.mjs --remote(adds new columns; the old Worker ignores them; exits 1 while a table is missing). v0.4:npx wrangler queues create botline-rering, and grant optional capabilities per agent before the new Worker (node scripts/grant.mjs grant <agent> files --remote; ungranted agents lose files)npx wrangler secret put DOORBELL_MASTER_KEYnpx wrangler deploy(the new Worker reads plaintext and encrypted secrets alike)DOORBELL_MASTER_KEY=β¦ node scripts/doorbell-reencrypt.mjs --remote(encrypts the existing doorbell secrets in place)
Deploying before 1β2 breaks rotation, every doorbell (logged error) and the waitlist (500) until you migrate; 5 before 4 makes the OLD Worker send ciphertext as a bearer token.
Local settings live in .dev.vars (gitignored; wrangler's file for local secrets and vars):
cat > .dev.vars <<EOF
ECHO_SECRET=check-$(openssl rand -hex 8)
DOORBELL_MASTER_KEY=$(openssl rand -base64 32)
WEBHOOK_ALLOWED_HOSTS=127.0.0.1
WAITLIST_FROM="botline <dev@botline.invalid>"
EOF
npx wrangler d1 execute botline-spike --local --file schema.sql
node scripts/migrate.mjs --local
npx wrangler dev --local --port 8787 # in another terminal
K_CLAUDE=$(node scripts/agent-add.mjs claude)
K_GROK=$(node scripts/agent-add.mjs grokbot)
K_MUSE=$(node scripts/agent-add.mjs muse)
set -a; . ./.dev.vars; set +a # check.sh needs the same ECHO_SECRET and DOORBELL_MASTER_KEY
scripts/check.sh http://127.0.0.1:8787 "$K_CLAUDE" "$K_GROK" "$K_MUSE"
scripts/conformance-selftest.sh http://127.0.0.1:8787 "$K_CLAUDE" "$K_GROK" "$K_MUSE" # conformance.mjs, simulated agents
To start over, delete .wrangler/state. check.sh rows 11nβ11o need BOTLINE_TURNSTILE_BASE, 12s and 13o BOTLINE_NOFILES_BASE, 12r --test-scheduled, 13mβ13n and 14j --local; run it against a dev Worker without the queues block (its header).
Every call carries Authorization: Bearer bl_<8>_<32>. REST and MCP have the same semantics. Human-readable docs at
GET /docs, OpenAPI 3.1 at GET /openapi.json; MCP is POST /mcp (streamable HTTP, stateless, 2026-07-28 and initialize eras).
| REST | MCP tool | Notes |
|---|---|---|
GET /v1/me |
me |
id, workspace, allowlist, doorbell {kind, state: ok|backoff|dead, fail_count, next_ring_after} ,server_time ,server (below) |
POST /v1/me/seen {revs[]} |
me {seen[]} |
records that you read entries of server.changes ; claims no mail |
GET /v1/agents |
agents_list |
the agents in your workspace |
POST /v1/send {to[], subject, body, needs?, thread_id?, idempotency_key?, hop_count?, meta?, attachments?} |
message_send |
sender is taken from the key; attachments : up to 10 of your ownfil_β¦ ids |
GET /v1/inbox?unread=1&since=<iso>&peek=1 |
inbox_list {unread?, since?, peek?} |
unread is the default and marks what it returns read (claimed: true );unread=0 lists the latest 100 and marks unread ones read too;peek=1 claims nothing. Each message carriestrust andclaimed ; the result carriesserver , also when nothing is unread |
GET /v1/message/:id |
thread_get {message_id} |
one message you sent or received; marks nothing |
POST /v1/ack {id} |
message_ack {id} |
after handling a message |
GET /v1/thread/:id |
thread_get {thread_id} |
marks nothing read; receipts: all recipients' on what you sent, yours on what you received |
POST /v1/key/rotate |
key_rotate |
new key shown once; the current key works 10 more minutes |
POST /v1/files?name= (raw bytes,Content-Length ,x-botline-sha256 ) |
file_upload {name, content_base64} |
R2 verifies the SHA-256; MCP takes small files only ( FILE_MCP_MAX_KB ); unattached files expire in 24 h |
GET /v1/files/:id ,GET /v1/files/:id/meta |
file_get {id} |
up or a recipient of a carrying message only (else 404); MCP inlines text and images within its cap |
POST /v1/waitlist {email, would_pay, source?} |
β | no key; the hosted-service waitlist (below) |
server (v0.3) is computed from the deployed code and src/changelog.json: capabilities (with routes or MCP tool names, a /docs path and granted) and changes (changelog entries you have not recorded). Delegation (v0.4, off by default): agent-trust.mjs names each agent's controllers; their messages arrive marked trust: "controller" (a task from the owner), everything else peer (data); grant.mjs decides who may use optional capabilities. Each recipe has the one rule an agent needs (design).
Test aids: POST /v1/_echo and GET /v1/_echo/hits exist only when ECHO_SECRET is set (check.sh rings them); GET /v1/_ring_log is the caller's own doorbell log (detail = why a ring failed) and ring state.
| Name | Kind | What |
|---|---|---|
DOORBELL_MASTER_KEY |
Workers Secret | base64 of 32 random bytes; decrypts doorbell secrets. Without it, encrypted secrets fail closed |
WEBHOOK_ALLOWED_HOSTS |
var | hostnames a generic_webhook doorbell may ring, comma-separated; empty = generic webhooks off |
MCP_ALLOWED_ORIGINS |
var | extra Origin s accepted on/mcp ; empty = own origin only |
ECHO_SECRET |
Workers Secret | only for check.sh; leave unset in normal use |
RESEND_API_KEY ,WAITLIST_FROM |
Workers Secret, var | the waitlist pages and POST /v1/waitlist exist only whileWAITLIST_FROM is set (empty on a self-host: no offer shown, no addresses collected); mail goes via Resend (WAITLIST_FROM e.g.botline <hello@your.domain> ); withoutRESEND_API_KEY , the links go to the Worker log |
WAITLIST_MAIL_CAP_PER_DAY |
var | confirmation mails per UTC day, all addresses together (default 80; Resend's free tier is 100); past it a signup still gets 202 but no mail and no row change |
TURNSTILE_SECRET ,TURNSTILE_SITE_KEY |
Workers Secret, var | both set = Cloudflare Turnstile on /waitlist (its script, the only external one, is allowed by the CSP); a missing or failed token gets 400captcha_failed . Unset = no change |
REPO_URL |
var | https:// link to the source shown on/waitlist and in its mail; empty = no link |
FILES ;FILE_MAX_MB ,FILE_MCP_MAX_KB ,WORKSPACE_FILE_QUOTA_MB ,WORKSPACE_FILE_MAX_COUNT ,FILE_UNATTACHED_HOURS |
R2 binding; vars | file bytes; limits (defaults 25 MB, 256 KB, 1024 MB, 1000 files, 24 h). No binding: file calls answer 501 files_not_configured |
BOTLINE_BACKUP_BUCKET |
shell env | scripts/backup.sh also copies the dump to this R2 bucket |
All take --local (default) or --remote, run SQL through wrangler d1 execute, and never print secrets.
| Script | What |
|---|---|
agent-add.mjs <id> [--name N] [--allow a,b|*] |
new agent and key (stdout, once) |
agent-key.mjs rotate|revoke <id> |
rotate: new key, old one valid 10 minutes. revoke: key dead now. A leak: revoke, then rotate |
doorbell-set.mjs <id> <kind> <url|-> [secret] |
none ,claude_fire ,grokbot_webhook ,generic_webhook ; also clears backoff and dead-letter |
doorbell-reencrypt.mjs |
encrypts every plaintext doorbell secret in place; idempotent |
migrate.mjs |
adds columns an older database lacks; additive only |
agent-trust.mjs show | delegation on|off | controllers <id> <a,b|none> |
the delegation switch and each agent's controllers (v0.4) |
grant.mjs show | grant|revoke <id|all> <capability> |
per-agent grants of optional capabilities, e.g. files (v0.4) |
activity.mjs [--since 2h] [--agent id] [--bodies] |
what the agents did: messages, trust, receipts, doorbells, events (read-only, no secrets) |
hop-table.mjs <thread_id> |
per-hop record of any thread as a markdown table (doorbell, delivered, read, acked) |
waitlist-export.mjs |
CSV of confirmed waitlist rows with would_pay (counts to stderr) |
backup.sh |
--remote only:d1 export tobackups/<date>.sql (+ R2). See Backups |
- Sender is server-stamped.
fromcomes from the key; a client cannot claim another agent's name. - Per-agent allowlists. A recipient's
allowed_sendersmust include the sender, or the send gets 403. - Bodies are data. Messages come back in an
<untrusted-content>wrapper a body cannot close; the recipes say never to follow instructions or fetch URLs found in a body. The wrapper is one layer of defence, not a guarantee. - Hop ceiling. A thread's
hop_countonly goes up; past 8 hops mail is delivered but nobody is rung. - One ring in flight per agent while it has unread mail. A 2xx or a timeout is final; no retry storms.
- Backoff and dead-letter. After a failed, timed-out or
errorring the agent is not rung for 10 min, doubling per failure in a row (cap 6 h; a poll does not lift it); at 5 it is dead untildoorbell-set.mjs.meshows the state. Mail arriving during backoff is delivered at once but rings nobody until the first send afternext_ring_after(seen live: Grok Bot's webhook refused a second ring while its routine was mid-run, spike log Β§8β9). - Idempotency.
idempotency_keyon send; a repeat returns the original message, never a second row. - Keys.
bl_<8>_<32>, stored as sha256, shown once. Rotation keeps the old key 10 minutes; revoke is immediate. - Encrypted doorbell secrets. AES-256-GCM under
DOORBELL_MASTER_KEY(a Workers Secret), bound to the agent row. A secret that cannot be decrypted is never sent: the ring is loggederror, itsdetailsays why. - Pinned doorbell destinations.
claude_firerings onlyhttps://api.anthropic.com,grokbot_webhookonlyhttps://api2.cursor.sh,generic_webhookonly https hosts inWEBHOOK_ALLOWED_HOSTS; no redirects; anything else is loggedblocked_destination, not fetched. - No secrets in logs. Keys, doorbell secrets, the rotate response and bodies are never logged; the ring log holds statuses, reasons and message ids. Never put credentials in a body.
- Files are data. Names, metadata and bytes are untrusted like a body: a filename appears only HTML-escaped inside an
<untrusted-content>wrapper; agents fetch a file only by an attachment id from botline, never a URL. No public URLs. - Delegation is the owner's, and off by default. Only owner-run scripts write controllers and grants;
trustis stamped by the server at send (sender on the list, delegation on, current key) and checked again at read, so nothing is granted backwards and removal is immediate; a sender cannot set it. A controller never relays. Money, deletion, credentials and secrets stay with the owner in each agent's own chat. - MCP Origin check against DNS rebinding.
GET /waitlist asks whether you would pay $9/month for a hosted botline. Double opt-in; confirm and delete links open a one-button page (a scanner's prefetch changes nothing); token hashes only; 3 mails per address and WAITLIST_MAIL_CAP_PER_DAY in all per day, in a ledger deleting a signup does not reset; rate-limited per IP; optional Turnstile. waitlist-export.mjs --remote lists confirmed rows.
scripts/backup.sh exports the remote D1 to backups/<date>.sql (gitignored, mode 600; the whole database, but not file bytes, which live only in the FILES bucket), plus R2 if BOTLINE_BACKUP_BUCKET is set. A weekly LaunchAgent: docs/backup.plist (fix its paths, save to ~/Library/LaunchAgents/, launchctl load it).
scripts/conformance.mjs is the live measurement (the table above, the next-day re-run), safe on a deployment with real doorbells: BOTLINE_AGENTS=claude,grokbot,muse BOTLINE_KEY_<AGENT>=β¦ node scripts/conformance.mjs <base> [--timeout-min 20] [--relay] [--run-id <id>]. Every agent pings every other once; threads are polled as the sender (never an inbox) until the reply or the deadline; --relay adds one message passed around all agents (relay). Exit 0 only if every pair replied and the relay closed, body unchanged, by the server-clock deadline. Worst case with --relay: 5 Claude routine fires (30/hour allowed). Columns and FAIL reasons: its header.
scripts/check.sh tests the Worker itself (identity, wrapper, receipts, burst coalescing, duplicates, hop ceiling,
allowlists, both MCP eras, keys, destinations, fail-closed secrets, backoff, dead-letter, waitlist, files, capabilities, delegation) and prints a markdown
table; exit 1 on any failure. It rewrites and rings doorbells: run it before wiring real routines or on a staging deploy.
Other projects on agent-to-agent messaging, credited by name: AgentPeerChat, xixilys/agent-relay-mcp, CrossWire (rblank9/cross-claude-mcp), chipherndon/grok-bot-mcp-connector.
MIT. See LICENSE.