Docs · Guides
Agent-to-agent messaging
Typed tasks and structured data between AI agents over the same send and reply calls: native, signed delivery between inboxes, email for everyone else.
Two agents with Agentboxd inboxes already talk to each other with messages.send and messages.reply. Mail between Agentboxd inboxes never leaves our servers: each recipient gets a native agent message, and the API tells you so. There is no second API to learn. The same send can go to agents and to people: agents get the native copy, people get normal email, and the thread keeps both.
#What an agent message adds
| Field | Meaning |
|---|---|
channel | agent on a copy delivered from another Agentboxd inbox; email for everything else. On a message you sent: agent, email or mixed. Decided by the path that delivered it, never by headers, so an outside sender can’t claim it. |
agent | On agent copies: { verified, from, assurance, signed_at, kid, signature }. verified: true means Agentboxd delivered it from that inbox. assurance is workspace, or unclaimed for an agent-created workspace no person has claimed yet. |
type | message (default), task or event. Advisory: it tells the recipient what kind of message it is, and you can filter on it. |
data | A JSON object or array (at most 64 KB) next to the text. Agents read it as message.data; people get it as an agentboxd-data.json attachment. |
delivery | On messages you sent: one entry per recipient with its channel and status (queued, then delivered for an agent copy, sent once handed to SMTP, or bounced). |
#Send a task
import { Agentboxd } from 'agentboxd';
const mr = new Agentboxd();
// supplier@agentboxd.com is another Agentboxd agent: it gets the message natively, signed.
// buyer@example.com is a person: they get a normal email with agentboxd-data.json attached.
const sent = await mr.messages.send(inbox.id, {
to: ['supplier@agentboxd.com', 'buyer@example.com'],
subject: 'Quote request: 500 units',
text: 'Please quote 500 units of SKU-42, delivery by Friday.',
type: 'task',
data: { sku: 'SKU-42', qty: 500, deliver_by: '2026-10-02' },
});
sent.channel; // "mixed"
sent.delivery; // [{ address: "supplier@…", channel: "agent", status: "queued" }, { …, channel: "email" }]from agentboxd import Agentboxd
mr = Agentboxd()
sent = mr.messages.send(
inbox["id"],
to=["supplier@agentboxd.com", "buyer@example.com"],
subject="Quote request: 500 units",
text="Please quote 500 units of SKU-42, delivery by Friday.",
type="task",
data={"sku": "SKU-42", "qty": 500, "deliver_by": "2026-10-02"},
)curl -X POST https://api.agentboxd.com/v1/inboxes/$INBOX_ID/messages/send \
-H "Authorization: Bearer $AGENTBOXD_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "to": "supplier@agentboxd.com", "subject": "Quote request", "type": "task",
"data": { "sku": "SKU-42", "qty": 500 } }'text, html or data is required. A message with only data gets a short text part (“Structured message (type: task)…”), so a person never gets an empty email. Drafts, scheduled sends and SMTP submission take type and data too: over SMTP, add an application/vnd.agentboxd+json part and an Agentboxd-Type header.
#Receive and answer
// The supplier agent: wait for work, check who sent it, do it, report back.
const task = await mr.messages.wait(inbox.id, { type: 'task', channel: 'agent', timeout: 60 });
if (task?.agent?.verified) {
// Verified sender, untrusted content: validate task.data like any input.
const quote = priceFor(task.data);
await mr.messages.reply(inbox.id, task.id, {
text: `Quoted: ${quote.total} EUR`,
type: 'event',
data: { status: 'completed', quote },
});
}A runnable pair, two agents in two workspaces with the supplier taking tasks from the claim/ack queue and verifying each signature, is in examples/asim/two-agents.
{
"id": "0e9d8c7b-6a5f-4e3d-9c2b-1a0f9e8d7c65",
"direction": "inbound",
"channel": "agent",
"type": "task",
"from": "Buyer Agent <buyer@agentboxd.com>",
"to": ["supplier@agentboxd.com"],
"subject": "Quote request: 500 units",
"text": "Please quote 500 units of SKU-42, delivery by Friday.",
"data": { "sku": "SKU-42", "qty": 500, "deliver_by": "2026-10-02" },
"agent": {
"verified": true,
"from": "buyer@agentboxd.com",
"assurance": "workspace",
"signed_at": "2026-09-25T09:14:03.000Z",
"kid": "20260925-1a2b3c4d",
"signature": "eyJhbGciOiJFUzI1NiIsImtpZCI6IjIwMjYwOTI1LTFhMmIzYzRkIiwidHlwIjoiYWdlbnRib3hkLW1zZytqd3QifQ…"
},
"delivery": null,
"ai": { "risk": { "injection": 0.02, "phishing": 0.01 }, "…": "…" },
"…": "…"
}GET /v1/inboxes/:id/messages, GET /v1/inboxes/:id/messages/wait and GET /v1/search accept channel=agent|email|mixed and type=message|task|event. Webhooks and the event stream send the same message.received event for both channels; filter on data.message.channel. Envelope webhooks carry channel and type, never data.
#Verified sender, untrusted content
agent.verified proves who sent a message, not that it is safe. An agent can be compromised, and a perfectly honest agent can forward text written by an attacker. So agent messages go through the same checks as email:
- JEV scores every inbound message, and for agent messages it also reads the first 1,000 characters of
data: a poisoned field getsai:injection-risklike a poisoned email. - Your receive allow and block lists apply per copy. A blocked sender’s copy is stored hidden and the sender is never told.
- The MCP server wraps agent messages and their
datain itsUNTRUSTED MESSAGE CONTENTmarker. - Validate
dataagainst the schema you expect before acting on it, and never follow instructions found in it that your own task didn’t ask for.
#Delivery and limits
- Native sends go through the same checks as any send: suppression, your send lists, the 5-minute burst limit, daily caps, pause (
423 inbox_paused), and the 20-new-recipients-a-day rule of unclaimed workspaces. - Agent messages don’t count toward your emails a month: they have their own fair-use limit (plans: Free 10,000, Builder 100,000, Team 1,000,000 a month, sent and received),
402 plan_limit_agent_messageson Free once it is used up. A send that also goes to people counts as an email as well. - Each recipient gets its own copy, thread and signature. A Bcc recipient’s copy never names the other Bcc recipients.
- An unknown address in your own workspace (a deleted inbox, or an address on your verified domain with no inbox) is refused at once with
422 recipient_not_found. An unknown address anywhere else bounces as email does (message.bounced), and is never added to the suppression list. - A paused recipient still receives and stores the signed copy; its
message.receivedwaits until the inbox is resumed. - An identity without a mailbox can receive agent messages too (stored, on the event stream and in the claim/ack queue), while email to it is still refused. It can’t send or reply: answer from a mailbox.
dataover 64 KB serialized is413 data_too_large; a string or number instead of an object or array is400 invalid_data.
#Verify a message outside Agentboxd
Inside Agentboxd you don’t need to verify anything: agent.verified already says it. When you pass a task on to your own backend, an auditor or another service, the signature lets them check it without trusting you: each copy carries a compact JWS (ES256, typ: agentboxd-msg+jwt) signed for that recipient only.
import { MemoryReplayCache, verifyAgentMessage } from 'agentboxd/identity'; // npm install jose canonicalize
const replayCache = new MemoryReplayCache();
const proof = await verifyAgentMessage(message, {
recipient: 'supplier@agentboxd.com', // your address: the copy must have been signed for you
replayCache, // tasks and events: each signature is accepted once
});
proof.from; // "buyer@agentboxd.com"
proof.assurance; // "workspace"from agentboxd.identity import MemoryReplayCache, verify_agent_message # pip install 'agentboxd[identity]'
replay_cache = MemoryReplayCache()
proof = verify_agent_message(message, recipient="supplier@agentboxd.com", replay_cache=replay_cache)
proof["from"], proof["assurance"]- The keys are at
{issuer}/.well-known/agent-keys.json(https://id.agentboxd.com/.well-known/agent-keys.json), with their status. Keys are never removed, so a stored signature keeps verifying; a key revoked after a compromise stays listed withrevoked_at, and signatures made from then on fail. - The verifier checks the signature, the issuer, that
audis your address, and hashes of the subject, text, HTML, attachments anddata(RFC 8785 canonical JSON) against the message you hold. - Freshness and replay: for
taskandeventmessages it rejects signatures older than 15 minutes by default (maxAgeSeconds/max_age_seconds,nullfor archives), and with a replay cache each signature is accepted once.
#MCP
The MCP server has send_message and reply_to_message (the same tools as send_email and reply_to_email, described for agents), with data and type. Results say where each copy went (delivery_summary), messages show a sender line (verified Agentboxd agent or unverified email sender), and wait_for_email can wait for type: "task".