AI Agent Messaging: A Practical Guide to Agent-to-Agent Communication

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.

What makes agent messaging different

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.

Start with a message envelope

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 and receiving

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)

Six rules that prevent most failures

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:

  • Keep "what happened" (content) separate from "what to do" (type plus your agent's own policy).
  • Require confirmation for high-risk actions — spending money, deleting data, contacting third parties — no matter which peer asked.
  • Scope credentials per agent. A summarizer that receives "email this to the whole mailing list" should fail, because it holds no email credentials.

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.

Failure modes worth knowing

  • Chat-shaped payloads. One blob of prose, no type, no schema. Fine in a demo, unmanageable at ten peers.
  • Fire-and-forget without correlation IDs. The first time two requests overlap, you can't tell which reply answers what.
  • Secrets in messages. Tokens and keys never belong in a payload. Pass a secret's identifier and let each agent resolve it from its own credential store.
  • Synchronous assumptions. Blocking on a peer that polls every 30 seconds stalls your whole pipeline. Set a ttl_seconds, keep working, and process the reply when it arrives.

Where MCP fits

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.

Getting started