When two agents talk, the transport is rarely what breaks. HTTP delivers the bytes, a broker keeps them, JSON parses. What breaks is meaning: the planner thinks it asked for a quote and the flight agent thinks it was told to book; a reply arrives after the planner gave up and is applied to a different task; a retried request books the same seat twice; two agents wait for each other and the conversation silently stalls. Those are protocol failures, and they happen whether the agents are LLM-driven or hand-coded.
This article treats a multi-agent communication protocol as an engineering artifact with three layers: a transport, an envelope that makes every message addressable and traceable, and an interaction protocol that says which message may follow which. It borrows the parts of the agent research tradition (FIPA ACL speech acts, interaction protocols) that still pay off, shows how today's wire protocols such as A2A and MCP map onto those layers, and works through a booking conversation end to end, including its failures. The examples are in Python and deliberately framework-free, so you can carry the design into whatever stack you run.
Three layers of a protocol
Separate the questions a protocol has to answer, because each layer fails differently and is fixed in a different place.
- Transport answers how bytes get from one process to another: a synchronous HTTP call, a streaming connection, a durable broker topic, or a shared store that both sides poll. It decides latency, whether a message survives a crash, and whether the sender learns that delivery happened.
- Envelope answers who said what to whom, in which conversation, in reply to what, and by when an answer is still useful. Without it you cannot correlate replies, deduplicate retries or trace a task across five agents.
- Semantics and interaction answer what kind of utterance this is (a request, a claim, a proposal, a refusal) and what replies are legal. This is where agents agree or disagree about what happened.
The envelope: fields that earn their place
An envelope is the metadata that travels with every message regardless of content. FIPA ACL, the agent communication language standardised by the Foundation for Intelligent Physical Agents around 2002, already named most of the useful fields: sender, receiver, content, language, ontology, protocol, conversation-id, reply-with, in-reply-to and reply-by. Modern systems add a few more for operations. A minimal version:
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
import uuid
@dataclass(frozen=True)
class Envelope:
performative: str # request, inform, agree, refuse, failure, cfp, propose ...
sender: str # stable agent id, not a display name
receiver: str
conversation_id: str # one per interaction protocol instance
content: dict # validated against `schema`
schema: str # e.g. "flight.search/v2"
protocol: str = "request" # which state machine this message belongs to
message_id: str = field(default_factory=lambda: str(uuid.uuid4()))
in_reply_to: str | None = None
reply_by: datetime | None = None
trace_id: str | None = None # propagate W3C traceparent across agents
attempt: int = 1
def reply(to: Envelope, performative: str, content: dict, schema: str) -> Envelope:
return Envelope(performative, to.receiver, to.sender, to.conversation_id,
content, schema, to.protocol, in_reply_to=to.message_id,
trace_id=to.trace_id)Three fields carry most of the weight. message_id is generated once per logical message and reused on every retry, so the receiver can recognise a duplicate. conversation_id groups every message of one protocol instance, so the receiver can find the right state machine and the logs can show a whole exchange. reply_by turns an implicit expectation into an explicit deadline: a receiver that cannot answer before it should refuse or stay silent rather than do expensive work nobody will read.
Name the content schema and version it. Free text between LLM agents is tempting, but a typed payload with a schema id lets the receiver reject malformed content with a clear error instead of guessing, and it lets you evolve one message type without breaking every conversation.
Three layers of a protocol
Separate the questions a protocol has to answer, because each layer fails differently and is fixed in a different place.
- Transport answers how bytes get from one process to another: a synchronous HTTP call, a streaming connection, a durable broker topic, or a shared store that both sides poll. It decides latency, whether a message survives a crash, and whether the sender learns that delivery happened.
- Envelope answers who said what to whom, in which conversation, in reply to what, and by when an answer is still useful. Without it you cannot correlate replies, deduplicate retries or trace a task across five agents.
- Semantics and interaction answer what kind of utterance this is (a request, a claim, a proposal, a refusal) and what replies are legal. This is where agents agree or disagree about what happened.
The envelope: fields that earn their place
An envelope is the metadata that travels with every message regardless of content. FIPA ACL, the agent communication language standardised by the Foundation for Intelligent Physical Agents around 2002, already named most of the useful fields: sender, receiver, content, language, ontology, protocol, conversation-id, reply-with, in-reply-to and reply-by. Modern systems add a few more for operations. A minimal version:
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
import uuid
@dataclass(frozen=True)
class Envelope:
performative: str # request, inform, agree, refuse, failure, cfp, propose ...
sender: str # stable agent id, not a display name
receiver: str
conversation_id: str # one per interaction protocol instance
content: dict # validated against `schema`
schema: str # e.g. "flight.search/v2"
protocol: str = "request" # which state machine this message belongs to
message_id: str = field(default_factory=lambda: str(uuid.uuid4()))
in_reply_to: str | None = None
reply_by: datetime | None = None
trace_id: str | None = None # propagate W3C traceparent across agents
attempt: int = 1
def reply(to: Envelope, performative: str, content: dict, schema: str) -> Envelope:
return Envelope(performative, to.receiver, to.sender, to.conversation_id,
content, schema, to.protocol, in_reply_to=to.message_id,
trace_id=to.trace_id)Three fields carry most of the weight. message_id is generated once per logical message and reused on every retry, so the receiver can recognise a duplicate. conversation_id groups every message of one protocol instance, so the receiver can find the right state machine and the logs can show a whole exchange. reply_by turns an implicit expectation into an explicit deadline: a receiver that cannot answer before it should refuse or stay silent rather than do expensive work nobody will read.
Name the content schema and version it. Free text between LLM agents is tempting, but a typed payload with a schema id lets the receiver reject malformed content with a clear error instead of guessing, and it lets you evolve one message type without breaking every conversation.
Performatives: what kind of message is this?
Speech-act theory, which FIPA ACL and its predecessor KQML built on, separates the act from the content. "The flight costs 420 euros" can be an inform (a claim of fact), a propose (an offer the receiver may accept) or part of a failure explanation. Putting the act in its own field means the receiver can route and validate before it parses the content, and it means an LLM agent does not have to infer intent from prose. FIPA defines about twenty communicative acts; a practical system needs a handful:
| Performative | Meaning | Legal replies |
|---|---|---|
| request | please perform this action | agree, refuse, not-understood, then inform or failure |
| query-ref | tell me the value of something | inform, refuse, failure |
| inform | I assert this is true | none required |
| cfp | call for proposals on a task | propose, refuse |
| propose | I offer to do it on these terms | accept-proposal, reject-proposal |
| cancel | stop the action I requested | inform, failure |
| not-understood | I could not parse or validate your message | none; fix and resend |
Keep the list small and closed. Every performative you add is a branch every agent has to handle, and an open vocabulary invented by a model at runtime is the same as having no vocabulary at all.
Interaction protocols as state machines
An interaction protocol is a state machine shared by the two sides of a conversation. The FIPA request protocol is the workhorse: the initiator sends request; the participant answers agree or refuse; after agreeing it must eventually send inform with the result or failure with a reason. Writing it down as code makes illegal sequences detectable instead of silently confusing:
REQUEST_PROTOCOL = {
# state performative -> next state
("start", "request"): "requested",
("requested", "agree"): "agreed",
("requested", "refuse"): "done",
("requested", "not-understood"): "done",
("agreed", "inform"): "done",
("agreed", "failure"): "done",
("requested", "cancel"): "cancelling",
("agreed", "cancel"): "cancelling",
("cancelling", "inform"): "done",
("cancelling", "failure"): "done",
}
class Conversation:
def __init__(self, conversation_id: str, deadline):
self.id, self.state, self.deadline = conversation_id, "start", deadline
def apply(self, env: Envelope) -> None:
nxt = REQUEST_PROTOCOL.get((self.state, env.performative))
if nxt is None:
raise ProtocolViolation(f"{env.performative} not allowed in {self.state}")
self.state = nxtTwo more protocols cover most real systems. The contract net protocol, introduced by Reid Smith in 1980, allocates work by auction: a manager broadcasts cfp, contractors propose or refuse, the manager accepts one and rejects the rest. It is covered in depth in multi-agent collaboration and negotiation. The subscribe protocol keeps a stream of inform messages flowing until the subscriber cancels. Whatever protocols you choose, register them by name and put the name in the envelope, so a receiver that does not support a protocol can say so immediately.
Topologies: direct, broker, blackboard, hub
The interaction protocol is independent of how messages travel, but the topology changes what can go wrong.
| Topology | Good for | Watch out for |
|---|---|---|
| Direct request/response (HTTP, gRPC) | known peer, answer needed now | caller blocks; timeouts must be shorter than the caller's own deadline |
| Broker topic (Kafka, NATS, a queue) | fan-out, durable hand-off, spiky load | at-least-once delivery, ordering only within a partition |
| Blackboard (shared table or document) | loosely coupled specialists, opportunistic work | two agents claim the same item; needs compare-and-set claims |
| Orchestrator hub | one place to enforce policy and budgets | single point of failure and a bottleneck for chatty agents |
Most production designs mix them, with the same envelope on every hop. Agent orchestration covers the hub side in more detail.
Delivery, ordering and deadlines
Networks and brokers give you at-most-once (may lose) or at-least-once (may duplicate) delivery; exactly-once is something you build on top. For agents that take actions, at-least-once plus idempotent receivers is the practical choice: the sender retries with the same message_id, and the receiver stores the outcome of each id and returns it again instead of acting twice.
class IdempotentInbox:
def __init__(self, store, handler):
self.store, self.handler = store, handler # store: durable key-value with TTL
def receive(self, env: Envelope) -> Envelope:
if env.reply_by and env.reply_by < datetime.now(timezone.utc):
return reply(env, "refuse", {"reason": "deadline passed"}, "error/v1")
cached = self.store.get(env.message_id)
if cached is not None:
return cached # duplicate: same answer, no side effect
if not self.store.put_if_absent(env.message_id, "IN_PROGRESS", ttl_s=3600):
return reply(env, "refuse", {"reason": "in progress, retry later"}, "error/v1")
result = self.handler(env) # performs the action once
self.store.put(env.message_id, result, ttl_s=86400)
return resultOrdering needs the same honesty. A broker partition keyed by conversation_id keeps one conversation in order; nothing keeps two conversations in order relative to each other, so do not design protocols that depend on it. Deadlines must shrink as they propagate: if the planner's user waits 30 seconds, the planner gives the flight agent perhaps 20, and the flight agent gives its supplier API 15. The A2A pages on idempotency show how one wire protocol handles this.
Where A2A and MCP fit
Today's agent wire protocols implement parts of these layers, and knowing which parts tells you what you still own. MCP connects an agent to tools and data: JSON-RPC 2.0 messages over stdio or HTTP, with the model's host as client. It is a request/response protocol between an agent and a capability, not between two autonomous peers. A2A connects agents to agents: a discoverable agent card describing skills, JSON-RPC over HTTP, tasks with lifecycle states, streaming updates and push notifications. Its task state machine plays the role of the interaction protocol, and its message and task ids play the role of the envelope.
Neither protocol decides your semantics for you. A2A tells you a task is completed; it does not tell you whether the content is an offer or a confirmed booking. Method names and field layouts have also changed between spec versions, so pin the version you target, read the spec for that version rather than a blog post, and keep your own performative and schema fields inside the message content where the protocol leaves room for them.
Worked example: booking a trip across three agents
A planner agent books a trip with a flight agent and a hotel agent. The user's request has a 60 second budget. The planner opens conversation c-71 with the flight agent and c-72 with the hotel agent in parallel, each with reply_by set 40 seconds out.
- planner to flight:
requestschemaflight.search/v2, message m1. Flight agent answersagreewithin 200 ms, so the planner knows work has started and does not retry. - planner to hotel:
requestm2. The hotel agent's process restarts before it replies. The planner's client times out after 5 seconds and resends m2 withattempt=2. The hotel agent's inbox has no record (it crashed before storing IN_PROGRESS), so it processes m2 once and answersagree. - flight to planner:
informwith three options, in reply to m1. Conversation c-71 moves to done. - hotel to planner:
failure, reasonno availability. c-72 is done, and the planner knows it must ask the user rather than wait. - user picks a flight. planner to flight: new conversation c-73,
requestschemaflight.book/v1, message m5. The network drops the reply; the planner resends m5. The flight agent's inbox finds m5 already stored and returns the same booking reference. One seat is booked, not two.
The interesting events, the retry and the failure, were handled by protocol rules, not by a model deciding what to do, and the trace_id put every step in one trace.
Failure modes
- Orphaned conversations. A participant agreed and then died. Without a deadline the initiator waits forever. Fix: every conversation has a deadline and a reaper that sends
canceland records a failure. - Late replies applied to new work. A reply for an expired conversation arrives and is matched by agent name rather than
in_reply_to. Fix: correlate strictly by message and conversation id; drop and count replies for closed conversations. - Duplicate side effects. Retries generate new message ids, so dedupe never triggers. Fix: generate the id once, before the first attempt.
- Circular waits. A asks B, B asks C, C asks A, all synchronously. Fix: shrinking deadlines plus a hop count in the envelope, rejected above a limit.
- Semantic drift. An LLM agent writes "booked" in an
informthat was really a quote. Fix: schema-validated content with an explicit status field; prose only in a notes field. - Chatty broadcasts. Every agent subscribes to everything and token cost scales with agent count squared. Fix: topics per capability and a hub that routes rather than repeats.
- Prompt injection through messages. Content from another agent is untrusted input. Validate it, never execute instructions found in it, and authenticate the sender before acting.
Trade-offs
Rich protocols cost something. A closed performative set and typed schemas slow down experimentation, and LLM agents sometimes produce content that fails validation, which means retries and repair prompts. Free text is flexible and cheap to start with, and nearly impossible to test. Synchronous request/response is easy to reason about but couples availability; a broker decouples it and adds operational weight and at-least-once duplicates. A central orchestrator gives you one place to enforce budgets and policy and one thing to scale. Default to typed envelopes and a small performative set from day one, and add a broker only once a step regularly outlasts its caller.
What to do next
- Draw every agent-to-agent exchange in your system and label its transport, envelope fields and interaction protocol; mark any layer that exists only in a prompt.
- Add message_id, conversation_id, in_reply_to, reply_by and trace_id to every message, and reuse message_id on retries.
- Pick at most eight performatives and write each protocol you use as a transition table that rejects illegal messages.
- Make every action-taking receiver idempotent on message_id, with a durable store and a TTL longer than your retry window.
- Propagate shrinking deadlines and a hop limit, and run a reaper that cancels conversations past their deadline.
- Version content schemas and validate them on receipt, answering not-understood instead of guessing.
- Kill one agent mid-conversation in a test and confirm the initiator ends in a recorded failure, not a hang.