Agent Message Protocols & Contract Design

8 min read Module 3 of 10 Topic 8 of 30

What you'll learn

  • Design message envelopes with trace_id, correlation_id, version, and priority headers that support distributed tracing and routing
  • Build Pydantic v2 models for agent messages with discriminated unions for type-safe polymorphic message routing
  • Apply additive-only and field-aliasing schema evolution strategies to evolve message contracts without breaking consumers
  • Select between JSON and MessagePack serialization based on payload size, language heterogeneity, and schema enforcement needs
Building this at your company? For enterprise and company teams taking this to production: book a 30-minute session with our AI engineers for architecture guidance, code review, and a rollout plan for your use case.
Book a Team Session

Every agent boundary is a trust boundary. When Agent A hands off work to Agent B, the message carrying that work is the contract: and unlike an internal function call, there is no compiler to catch mismatches between what A sends and what B expects. Unvalidated, unversioned messages between agents are the leading cause of subtle cascade failures in enterprise multi-agent systems: a missing field propagates silently until it triggers a downstream exception hours or days later, deep in a batch job.

The Message Envelope Pattern

Every inter-agent message should be wrapped in an envelope that separates routing metadata (headers) from business payload. This lets infrastructure components, routers, loggers, monitors, process the envelope without deserializing the payload.

from pydantic import BaseModel, Field, ConfigDict
from typing import Literal, Annotated, Union
from uuid import uuid4
from datetime import datetime, UTC
import msgpack

class MessageHeader(BaseModel):
    # WHY trace_id separate from correlation_id: trace_id tracks a single
    # message's journey through the system (for distributed tracing spans).
    # correlation_id links messages that belong to the same business transaction
    #, e.g., all messages spawned by one user request share a correlation_id.
    trace_id: str = Field(default_factory=lambda: str(uuid4()))
    correlation_id: str
    version: str = "1.0"
    message_type: str
    source_agent: str
    target_agent: str | None = None  # None means broadcast / topic routing
    priority: int = Field(default=5, ge=1, le=10)
    timestamp: datetime = Field(default_factory=lambda: datetime.now(UTC))
    # WHY schema_hash: a hash of the schema version at publish time.
    # Consumers can detect schema drift without parsing the full payload.
    schema_hash: str | None = None

class AgentMessage(BaseModel):
    model_config = ConfigDict(extra="ignore")  # tolerate unknown fields

    header: MessageHeader
    payload: dict  # typed further by discriminated union (see below)
flowchart TD
    A["Producing Agent"] --> B["AgentMessage\n(envelope + payload)"]
    B --> C["Message Router\n(reads header only)"]
    C -->|"message_type: ContractAnalysis"| D["ComplianceAgent"]
    C -->|"message_type: RiskScore"| E["RiskScoringAgent"]
    C -->|"message_type: AuditEvent"| F["ArchiverAgent"]
    D --> G["Validate payload\nagainst Pydantic schema"]
    E --> G
    F --> G
    G -->|"ValidationError"| H["Dead-Letter Queue"]
    G -->|"Valid"| I["Process Task"]
    style A fill:#EEF0F7,stroke:#6366F1,color:#0F172A
    style C fill:#fff7ed,stroke:#f59e0b,color:#0F172A
    style G fill:#fff7ed,stroke:#f59e0b,color:#0F172A
    style H fill:#fff7ed,stroke:#f59e0b,color:#0F172A
    style I fill:#f0fdf9,stroke:#0D9488,color:#0F172A

Typed Payloads with Discriminated Unions

Discriminated unions let a single message bus carry multiple payload types while preserving type safety. The message_type field in the header acts as the discriminator, Pydantic selects the correct payload model automatically.

class ContractAnalysisPayload(BaseModel):
    model_config = ConfigDict(extra="ignore")
    message_type: Literal["ContractAnalysis"]
    contract_id: str
    document_text: str
    jurisdiction: str | None = None  # Optional: additive field, v1.1
    clauses_flagged: list[str] = Field(default_factory=list)

class RiskScorePayload(BaseModel):
    model_config = ConfigDict(extra="ignore")
    message_type: Literal["RiskScore"]
    entity_id: str
    score: float = Field(ge=0.0, le=1.0)
    contributing_factors: list[str]
    confidence: float = Field(ge=0.0, le=1.0)

# WHY Annotated + Field(discriminator=...): Pydantic v2 uses the
# message_type literal to select the correct model at validation time.
# This eliminates if/elif chains in consumer code and makes type
# checking work correctly across the entire payload handling path.
AgentPayload = Annotated[
    Union[ContractAnalysisPayload, RiskScorePayload],
    Field(discriminator="message_type"),
]

def parse_message(raw: bytes, encoding: str = "msgpack") -> AgentMessage:
    """
    WHY validate at the consumption boundary, not inside the handler:
    raising ValidationError here means the queue consumer can catch it
    and route to DLQ before any business logic runs. Validating inside
    the handler means partial processing may have already happened.
    """
    data = msgpack.unpackb(raw, raw=False) if encoding == "msgpack" else json.loads(raw)
    return AgentMessage.model_validate(data)

Schema Evolution Strategies

Schema evolution is the discipline of changing message contracts without breaking existing consumers. There are two safe strategies and one unsafe one to avoid.

flowchart TD
    A["Schema Change Needed"] --> B{"Breaking or\nnon-breaking?"}
    B -->|"New optional field\nor relaxed constraint"| C["Additive-only:\nAdd as Optional with default\nDeploy producer first\nConsumers update at leisure"]
    B -->|"Rename existing field"| D["Field aliasing:\nAdd new name, keep old via alias\nBoth names valid during migration\nRemove old name after all consumers updated"]
    B -->|"Remove required field\nor change type"| E["BREAKING CHANGE\nRequires versioned message type\nor coordinated deployment lockout"]
    C --> F["Safe: zero-downtime deploy"]
    D --> F
    E --> G["Risky: plan carefully\nor redesign to avoid"]
    style A fill:#EEF0F7,stroke:#6366F1,color:#0F172A
    style B fill:#fff7ed,stroke:#f59e0b,color:#0F172A
    style C fill:#f0fdf9,stroke:#0D9488,color:#0F172A
    style D fill:#f0fdf9,stroke:#0D9488,color:#0F172A
    style E fill:#fff7ed,stroke:#f59e0b,color:#0F172A
    style F fill:#f0fdf9,stroke:#0D9488,color:#0F172A
from pydantic import AliasChoices

class ContractAnalysisPayloadV2(BaseModel):
    """
    WHY AliasChoices for field renaming: this model accepts BOTH
    'jurisdiction_code' (new name) and 'jurisdiction' (old name).
    Producers can migrate to the new name while old consumers continue
    receiving their expected field name. Once all consumers accept the
    new name, the alias can be dropped in a future cleanup release.
    """
    model_config = ConfigDict(extra="ignore", populate_by_name=True)

    message_type: Literal["ContractAnalysis"]
    contract_id: str
    document_text: str
    jurisdiction_code: str | None = Field(
        default=None,
        validation_alias=AliasChoices("jurisdiction_code", "jurisdiction"),
    )
    clauses_flagged: list[str] = Field(default_factory=list)
    # New field in v1.2: Optional with default, safe to add
    regulatory_framework: str | None = None

Serialization: JSON vs MessagePack

JSON is the default for its human readability and universal tooling support. Switch to MessagePack when payload size or deserialization throughput becomes a bottleneck. At 50,000 messages per second, MessagePack’s 20-40% size reduction and 2-3x faster deserialization can meaningfully reduce infrastructure costs. The tradeoff: binary messages require tooling (msgpack CLI, hex viewers) for debugging instead of jq. A practical middle ground: use JSON in development and staging environments, MessagePack in production, toggled via a MESSAGE_ENCODING environment variable that both producers and consumers read.

Contract testing, writing tests that assert both producer output and consumer expectations against a shared schema, is the engineering practice that keeps all these patterns from drifting. Run contract tests in CI on every change to a message schema, and fail the build if a producer would generate a message that any registered consumer cannot parse.

Reliable message contracts remove an entire class of runtime surprises from your agent network. In the next lesson, you’ll use these durable, well-typed messages as the substrate for implementing async coordination patterns: scatter-gather, fan-out/fan-in, and saga orchestration, that let hundreds of agents work together without blocking each other.

Knowledge Check

3 questions to test your understanding

1 A contract analysis agent publishes messages consumed by three downstream agents: a compliance checker, a risk scorer, and an archiver. You need to add a new 'jurisdiction' field to the message. The risk scorer and archiver are not yet updated to handle it. What evolution strategy avoids breaking them?

2 An agent receives a raw JSON blob from a message queue and begins processing it immediately. Thirty minutes into a batch job, the agent fails because a required field is missing, corrupting 8,000 partially processed records. What practice would have caught this at the boundary?

3 Your agent network processes messages across Python, Java, and Go services. Messages average 4KB and are sent at 50,000 per second. You need to choose a serialization format. JSON is currently used but bandwidth costs are high. What is the best alternative and why?

Discussion

Questions and notes from learners on this topic

Loading discussion…

Go further with expert guidance

Ready to build production AI?
Talk to our R&D team.

These courses give you the foundation. Our embedded AI teams take you from prototype to production in 30–90 days, with your team, your codebase, your goals. Book a free strategy call to see how we can accelerate your AI initiative.

30 minutes · No obligation · Expert AI engineers, not sales reps

AI Architecture Review

Audit your current stack and identify high-impact improvements

Project Review

Get expert feedback on your AI implementation and codebase

Team Mentoring

Upskill your engineers with hands-on AI coaching sessions

AI Strategy

Define your AI roadmap, prioritization, and implementation plan