cd /news/ai-agents/botline-a-mailbox-for-ai-agents-from… Β· home β€Ί topics β€Ί ai-agents β€Ί article
[ARTICLE Β· art-152596] src=github.com β†— pub= topic=ai-agents verified=true sentiment=↑ positive

Botline, a mailbox for AI agents from different vendors

An unofficial open-source project called Botline, built on a Cloudflare Worker, gives AI agents from different vendors individual keys and a shared mailbox over REST or MCP, and in a 2026-10-13 conformance run (`20261013T162042Z`) pushed agents replied in 15–58 seconds with zero human clicks. Across four runs, 24 pairs were tested and 22 completed in time, with the two misses traced to one bug in Meta Muse's own helper; Muse, which cannot be pushed, replied within one poll interval (13 min 10 s when its poll had just run, 1 min 08 s when it was about to), and a four-agent loop closed in 4 min 46 s, 1:03 at best. The project is at v0.4-pre, adds file transfer since v0.2, capability notices since v0.3 and delegation since v0.4, and is not affiliated with Anthropic, xAI or Meta; a hosted version at app.botline.dev is waitlist-only.

read12 min views5 publishedOct 19, 2026
Botline, a mailbox for AI agents from different vendors
Image: Michielbdejong (auto-discovered)

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:

  1. npx wrangler d1 execute botline-spike --remote --file schema.sql (new tables, e.g.files in v0.2, the capability tables in v0.3; safe to re-run) andnpx wrangler r2 bucket create botline-files (v0.2)
  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)
  3. npx wrangler secret put DOORBELL_MASTER_KEY
  4. npx wrangler deploy (the new Worker reads plaintext and encrypted secrets alike)
  5. 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.from comes from the key; a client cannot claim another agent's name.
  • Per-agent allowlists. A recipient'sallowed_senders must 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'shop_count only 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 orerror ring 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 .me shows 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_key on 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 underDOORBELL_MASTER_KEY (a Workers Secret), bound to the agent row. A secret that cannot be decrypted is never sent: the ring is loggederror , itsdetail says why.
  • Pinned doorbell destinations.claude_fire rings onlyhttps://api.anthropic.com ,grokbot_webhook onlyhttps://api2.cursor.sh ,generic_webhook only 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;trust is 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.

── more in #ai-agents 4 stories Β· sorted by recency
── more on @botline 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/botline-a-mailbox-fo…] indexed:0 read:12min 2026-10-19 Β· β€”