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.

A protocol is three layers, and most bugs live in the top twoPlanner agentstarts conversation c-71Flight agentowns its own state machinerequest(search flights) reply-with=m1agree in-reply-to=m1inform(result) in-reply-to=m1Interaction protocolwhich message may follow which: request -> agree|refuse -> inform|failureEnvelopemessage_id, conversation_id, sender, receiver, performative, reply_by, schemaTransportHTTP + JSON-RPC, gRPC, a broker topic, or a shared blackboard tableTransport moves bytes; the envelope makes a message addressable and traceable;the interaction protocol tells each side what it is allowed to say next.
Transport moves bytes, the envelope correlates and deduplicates messages, and the interaction protocol defines which replies are legal.

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:

PerformativeMeaningLegal replies
requestplease perform this actionagree, refuse, not-understood, then inform or failure
query-reftell me the value of somethinginform, refuse, failure
informI assert this is truenone required
cfpcall for proposals on a taskpropose, refuse
proposeI offer to do it on these termsaccept-proposal, reject-proposal
cancelstop the action I requestedinform, failure
not-understoodI could not parse or validate your messagenone; 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 = nxt

Two 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.

TopologyGood forWatch out for
Direct request/response (HTTP, gRPC)known peer, answer needed nowcaller blocks; timeouts must be shorter than the caller's own deadline
Broker topic (Kafka, NATS, a queue)fan-out, durable hand-off, spiky loadat-least-once delivery, ordering only within a partition
Blackboard (shared table or document)loosely coupled specialists, opportunistic worktwo agents claim the same item; needs compare-and-set claims
Orchestrator hubone place to enforce policy and budgetssingle 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 result

Ordering 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.

  1. planner to flight: request schema flight.search/v2, message m1. Flight agent answers agree within 200 ms, so the planner knows work has started and does not retry.
  2. planner to hotel: request m2. The hotel agent's process restarts before it replies. The planner's client times out after 5 seconds and resends m2 with attempt=2. The hotel agent's inbox has no record (it crashed before storing IN_PROGRESS), so it processes m2 once and answers agree.
  3. flight to planner: inform with three options, in reply to m1. Conversation c-71 moves to done.
  4. hotel to planner: failure, reason no availability. c-72 is done, and the planner knows it must ask the user rather than wait.
  5. user picks a flight. planner to flight: new conversation c-73, request schema flight.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 cancel and 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 inform that 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

  1. 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.
  2. Add message_id, conversation_id, in_reply_to, reply_by and trace_id to every message, and reuse message_id on retries.
  3. Pick at most eight performatives and write each protocol you use as a transition table that rejects illegal messages.
  4. Make every action-taking receiver idempotent on message_id, with a durable store and a TTL longer than your retry window.
  5. Propagate shrinking deadlines and a hop limit, and run a reaper that cancels conversations past their deadline.
  6. Version content schemas and validate them on receipt, answering not-understood instead of guessing.
  7. Kill one agent mid-conversation in a test and confirm the initiator ends in a recorded failure, not a hang.
Key takeaway: A multi-agent protocol is a transport, an envelope and an interaction protocol. Give every message a stable id, a conversation id, a reply target and a deadline; say what kind of act it is with a small, closed set of performatives; enforce legal sequences with explicit state machines; and make receivers idempotent so at-least-once delivery is safe. Wire protocols like A2A and MCP carry the bytes and some of the structure, but the semantics remain yours to design and test.