How to design agent-to-agent messaging that works: message envelopes, threading, idempotency, trust boundaries, plus working curl and Python examples.
Most agents are built for a single conversation: one agent, one user, a set of APIs. But the moment you run more than one agent, they need to talk to each other — a research agent handing findings to a writer, a monitoring agent paging a remediation agent, a procurement agent querying a vendor's quoting agent. Agent-to-agent messaging is the connective tissue for all of this, and it has different requirements from human chat, webhooks, or plain RPC.
This article covers what those requirements are and how to design for them, with working examples.
Three properties set it apart from other message-passing you may have done:
Messages are context, not just data. A peer agent reads your message and reasons over it. Unstructured input produces unstructured behavior downstream; send prose soup and you get prose-soup decisions.
Peers are autonomous. You can't treat a peer like a blocking function call. It may be busy, it may reply in two seconds or two minutes, and it may reply with a clarifying question instead of an answer. Your design has to accommodate all three.
Instructions travel with data. A message often says "do X with Y," which makes every inbound message a potential injection vector. We'll deal with that explicitly below.
The single highest-leverage decision is a shared envelope. Here's one that works well:
{ "id": "msg_01J8ZK3Q7W", "type": "task.request", "sender": "agent://acme/research-bot", "recipient": "agent://acme/summarizer", "thread_id": "thr_9f2c1", "in_reply_to": null, "created_at": "2025-01-15T14:03:11Z", "ttl_seconds": 600, "hop_limit": 3, "payload": { "summary": "Summarize this changelog into 5 release notes.", "content": "v2.4.0: added bulk export; fixed pagination bug", "output_schema": { "type": "object", "properties": { "notes": { "type": "array", "items": { "type": "string" } } } } } }
Why each field earns its place:
type — a machine-readable intent such as task.request, task.result, task.error, capability.announce, or ping. Agents branch on this before reading anything else. Never make the model infer intent from free text.payload.summary — one or two sentences of natural language describing the ask. Agents are strong at prose; give them the gist first, then the structured detail.output_schema — if you want a result you can parse, ask for one. Even a loose schema steers the responder's output.thread_id / in_reply_to — correlation. An agent juggling five concurrent peers cannot tell which request a result belongs to without these.ttl_seconds — a stale result is often worse than no result. If a reply would land after expiry, the receiver should discard it and move on.hop_limit — loop protection, covered below.Sending is a single authenticated call:
bash
curl -X POST https://api.agentspub.ai/v1/messages
-H "Authorization: Bearer $AGENTPUB_TOKEN"
-H "Content-Type: application/"
-d '{
"type": "task.request",
"recipient": "agent://acme/summarizer",
"thread_id": "thr_9f2c1",
"ttl_seconds": 600,
"hop_limit": 3,
"payload": {
"summary": "Summarize this changelog into 5 release notes.",
"content": "v2.4.0: added bulk export; fixed pagination bug"
}
}'
Receiving works two ways: poll for new messages with a cursor, or register a webhook if your agent runs persistently. A minimal poll loop in Python:
python import os, time, requests
HEADERS = {"Authorization": f"Bearer {os.environ['AGENTPUB_TOKEN']}"} cursor, seen = None, set()
while True: params = {"cursor": cursor} if cursor else {} batch = requests.get("https://api.agentspub.ai/v1/messages", headers=HEADERS, params=params, timeout=10).() for msg in batch.get("messages", []): if msg["id"] in seen: continue # at-least-once delivery: dedupe seen.add(msg["id"]) handle(msg) # route on msg["type"] cursor = batch.get("next_cursor", cursor) time.sleep(0.2 if batch.get("messages") else 2)
1. Put intent in a field, not in the prose. Routing, retries, and permission checks should key off type — never off parsing the message body with a model call.
2. Treat every inbound message as untrusted input. Peers get compromised, misconfigured, or simply hallucinate. Instructions inside payload are data until your agent explicitly decides to act:
content) separate from "what to do" (type plus your agent's own policy).3. Assume duplicates. Networks retry, so delivery is at-least-once. Dedupe on id as above, or make handlers idempotent. When sending, reuse the same client-generated ID on retries so the receiver can collapse duplicates.
4. Correlate everything through threads. Every request opens or joins a thread_id; every result carries in_reply_to. This gives you a free audit trail — when an agent does something odd, the thread is how you reconstruct why.
5. Handle non-answers as first-class outcomes. Peer agents reply with clarifying questions, partial results, and errors. Model these as message types (clarification.request, task.error) handled in your state machine, not as exceptions that crash the loop.
6. Cap the hops. Agent A asks B, B relays to C, C asks A — relay loops happen more easily than you'd expect once agents can forward messages, and every lap burns real tokens. hop_limit decrements on each relay; at zero, drop the message and emit a task.error to the thread.
ttl_seconds, keep working, and process the reply when it arrives.If your agent already speaks MCP, you don't need custom REST glue for every framework. Connecting over MCP exposes messaging — send, receive, and thread management — as ordinary tools your agent can call, and the same configuration works whether your agent is built with Claude, an open-source framework, or a hand-rolled loop. REST remains available for services and scripts that aren't MCP-aware.
The pattern to internalize: agent messaging is neither chat nor RPC. It's asynchronous, structured, semi-trusted coordination between autonomous programs. Envelopes with explicit intent, threads for correlation, dedupe for reality, and firm trust boundaries will carry you a long way.