Message Schema Design and Versioning in Multi-Agent Systems

How to design agent message contracts that evolve without breaking running workflows, including versioning strategies and backward compatibility patterns.

AgentixForce Team··9 min read

A message schema is the contract between agents. It defines what information flows between them, in what format, and with what guarantees. Like all contracts, it needs to be designed carefully upfront and managed carefully over time. Teams that treat their agent message schemas as implementation details rather than API contracts discover the cost of that decision when they try to deploy a schema change and find that half their running workflows break.

This post covers the principles and practices for designing agent message schemas that are clear, extensible, and safe to evolve. The patterns here apply whether you are using A2A, MCP, or a custom protocol.

The Message Schema Is a Contract

In a multi-agent system, messages are the primary API surface. Every field you add is a new capability. Every field you remove is a breaking change. Every type change is a potential source of deserialization errors. The discipline required to manage agent message schemas correctly is the same discipline required to manage public REST APIs. Because that is what they are.

Core Fields Every Agent Message Needs

Regardless of the specific protocol or task type, every message in a multi-agent system should include a common set of fields that enable routing, tracing, debugging, and security verification.

python
from pydantic import BaseModel, Field
from typing import Any, Optional, Literal
from datetime import datetime
import uuid

class AgentRef(BaseModel):
    id: str       # stable agent identifier
    role: str     # "orchestrator" | "worker" | "specialist"
    version: str  # "1.4.2", for debugging which code sent this

class AgentMessage(BaseModel):
    # Identity and routing
    message_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
    schema_version: str = "2.0"          # for receiver validation
    type: str                             # "task" | "result" | "status" | "error"

    # Participants
    from_agent: AgentRef
    to_agent: Optional[AgentRef] = None  # None = broadcast

    # Tracing
    task_id: str                          # groups related messages
    parent_task_id: Optional[str] = None # for sub-task relationships
    trace_id: str                         # links to distributed trace
    correlation_id: Optional[str] = None  # caller's own reference

    # Content
    payload: dict[str, Any]               # type-specific body
    metadata: dict[str, str] = {}         # key-value annotations

    # Timing
    created_at: datetime = Field(default_factory=datetime.utcnow)
    expires_at: Optional[datetime] = None # message TTL

    # Security
    delegation_token: Optional[str] = None  # JWT from trust hierarchy
    signature: Optional[str] = None         # message signature

# Type-specific payload schemas
class TaskPayload(BaseModel):
    description: str
    context: dict[str, Any] = {}
    priority: Literal["low", "normal", "high", "critical"] = "normal"
    idempotency_key: Optional[str] = None  # for safe retries

class ResultPayload(BaseModel):
    status: Literal["success", "partial", "failed"]
    data: Any
    artifacts: list[dict] = []
    notes: Optional[str] = None  # human-readable summary

Semantic Versioning for Schemas

Agent message schemas should follow semantic versioning. The major version increments on breaking changes. The minor version increments on backward-compatible additions. The patch version increments on backward-compatible fixes like documentation or constraint updates.

Message Schema Versioning and Evolutionv1v1.0Jan 2026deprecatedv1v1.2Mar 2026legacyv2v2.0Apr 2026currentv2v2.1planneddraftv1.0 Schematype: stringpayload: objectfrom: stringv2.0 Schema (current)version: stringnewtype: stringpayload: objectfrom: AgentRefbreaking: typedtrace_id: stringnewschema_hash: stringnewv2.1 Schema (draft)version: stringtype: stringpayload: objectfrom: AgentReftrace_id: stringttl_seconds?: numbernew optpriority?: enumnew opt⚠ Breaking: v1.x agents send from: string — v2.x expects from: AgentRef object. Version negotiation required at handshake.Fig 1 — Schema versioning timeline showing v1 → v2 breaking change on the from field. Additive changes (v2.0→v2.1) are non-breaking. Major versions require negotiation.
Figure 1, Schema evolution from v1.0 to v2.1. The breaking change at v2.0 (from: string to from: AgentRef) requires version negotiation. The additive changes at v2.1 (optional ttl_seconds, priority) are non-breaking.

The most common mistake is treating the schema_version field as cosmetic. Agents should actively check the version of messages they receive and either reject or adapt messages from incompatible versions. A receiver that silently ignores unknown fields and proceeds has accidental backward compatibility. A receiver that explicitly validates the version and invokes the appropriate handler has intentional backward compatibility. One of those is a design choice. The other is a bug waiting to happen.

Backward Compatibility Rules

Two rules govern backward-compatible schema changes. New optional fields can be added in any minor version. Existing required fields cannot be removed or have their types changed in a minor version. Any removal or type change is a breaking change that requires a major version bump.

  • Safe: adding a new optional field with a default value. Old senders will not send it; new receivers will use the default.
  • Safe: adding a new optional enum value to an existing enum field. Old receivers should handle unknown enum values gracefully with a default behavior.
  • Safe: relaxing a constraint (changing a field from required to optional).
  • Breaking: removing a field. Old senders may still send it; old receivers may still expect it.
  • Breaking: changing a field's type (string to object is the most common example).
  • Breaking: adding a new required field without a default. Old senders will not populate it.
  • Breaking: renaming a field.

Version Negotiation at Handshake

When two agents establish a connection, they should negotiate a schema version before exchanging task messages. The callee advertises the schema versions it supports. The caller selects the highest version it also supports. All subsequent messages use that negotiated version.

python
from typing import Optional

class SchemaVersionNegotiator:
    def __init__(self, supported_versions: list[str]):
        self.supported = set(supported_versions)

    def negotiate(self, peer_supported: list[str]) -> Optional[str]:
        """
        Select the highest mutually supported major.minor version.
        Returns None if no compatible version exists.
        """
        from packaging.version import Version
        compatible = [v for v in peer_supported if v in self.supported]
        if not compatible:
            return None
        return str(max(Version(v) for v in compatible))

    def get_handler(self, version: str):
        """Return the appropriate message handler for this version."""
        major = version.split(".")[0]
        handler = self.handlers.get(f"v{major}")
        if not handler:
            raise ValueError(f"No handler for schema version {version}")
        return handler

# Usage during A2A handshake
negotiator = SchemaVersionNegotiator(["2.0", "2.1", "1.2"])

# Peer only supports v1
negotiated = negotiator.negotiate(["1.0", "1.2"])
assert negotiated == "1.2"  # highest compatible version

# Peer supports v2
negotiated = negotiator.negotiate(["2.0", "2.1", "3.0"])
assert negotiated == "2.1"  # our highest supported, not peer's highest

# No compatible version
negotiated = negotiator.negotiate(["3.0", "3.1"])
assert negotiated is None  # must reject connection

Message Routing and Content-Based Filtering

In multi-agent systems with many message producers and consumers, a message router becomes a critical infrastructure component. The router receives messages from all agents and delivers them to the appropriate recipients based on message type, topic, or content. Content-based filtering lets agents declare subscriptions using predicates rather than explicit addresses.

Message Routing and Filtering TopologyMESSAGE ROUTER1. validate schema version2. check sender trust level3. apply content filters4. resolve destination5. enforce rate limits6. route to queueORCHESTRATORtrust: L4WORKER Atrust: L3WORKER Btrust: L3AGENT Xsubscribed: task.*AGENT Ysubscribed: result.*DEAD LETTERfailed / unroutableFig 2 — The message router validates, filters, and routes messages based on schema version, sender trust level, and receiver subscriptions. Unroutable messages go to dead letter.
Figure 2, The message router validates schema version and sender trust before routing. Messages that fail validation go to the dead letter queue for investigation. Receivers subscribe by message type rather than sender identity.

Using a Schema Registry

As your multi-agent system grows, maintaining schema definitions in individual agent codebases becomes unsustainable. A schema registry provides a central store for all schema versions, enables compatibility checking before deployment, and gives you a single source of truth for what each version of each message type looks like.

python
# Schema registry client
import httpx
from functools import lru_cache

class SchemaRegistry:
    def __init__(self, registry_url: str):
        self.url = registry_url

    @lru_cache(maxsize=256)
    async def get_schema(self, message_type: str, version: str) -> dict:
        """Fetch the JSON Schema for a given message type and version."""
        async with httpx.AsyncClient() as client:
            resp = await client.get(
                f"{self.url}/schemas/{message_type}/{version}"
            )
            resp.raise_for_status()
            return resp.json()

    async def validate(self, message: dict) -> tuple[bool, list[str]]:
        """Validate a message against its declared schema version."""
        msg_type = message.get("type")
        schema_ver = message.get("schema_version")
        if not msg_type or not schema_ver:
            return False, ["Missing type or schema_version"]
        schema = await self.get_schema(msg_type, schema_ver)
        return jsonschema_validate(message, schema)

    async def check_compatibility(
        self,
        message_type: str,
        old_version: str,
        new_version: str,
    ) -> dict:
        """Check if new_version is backward compatible with old_version."""
        async with httpx.AsyncClient() as client:
            resp = await client.post(
                f"{self.url}/compatibility/{message_type}",
                json={"old": old_version, "new": new_version},
            )
            return resp.json()  # {"compatible": bool, "breaking_changes": []}

Schema Migration Patterns

When a breaking schema change is unavoidable, the migration needs to be managed carefully. The standard approach is to run both the old and new schema versions in parallel for a transition period. Agents that have been upgraded to the new version can handle both. Agents still running the old version continue to receive the old schema. Once all agents are upgraded, the old version is deprecated.

  • Expand-contract: first expand the schema by adding new optional fields alongside old ones. Agents start writing to the new fields. Once all agents are updated, contract the schema by removing the old fields.
  • Parallel fields: send both old and new field formats in the same message during the transition. Old receivers use the old field. New receivers use the new field. Remove the old field once migration is complete.
  • Schema transformation layer: add a translation layer in the message router that converts old-format messages to new-format before delivery. This lets you migrate the schema without requiring simultaneous updates to all agents.

Conclusion

Message schema design is one of the most consequential decisions in a multi-agent system and one of the most underinvested. Teams that invest in clear contracts, proper versioning, backward compatibility rules, and schema registries ship systems that evolve cleanly over time. Teams that treat schemas as implementation details spend disproportionate time on migration debugging that could have been avoided.

Frequently Asked Questions

Build secure agentic systems with AgentixForce

We help companies design, build, and secure production-grade AI agent systems. From architecture reviews to full implementation, our team brings deep expertise to every engagement.

More from Multi-Agent Systems

All articles
Message Schema Design and Versioning in Multi-Agent Systems