Skip to content
SDB
Agentic AI

Chapter 14 · advanced · 32 min

Multi-Agent Communication Protocols — Deep Dive

A2A, ACP, and ANP working together: interoperability, routing, and production patterns

Subhendu Datta BhowmikAI Tutorials

Why Multi-Agent Communication Protocols?

Individual chapters covered A2A, ACP, and ANP in isolation. In production, you rarely choose just one — you inherit an ecosystem of agents built on different stacks, by different teams, running on different platforms. Understanding how these protocols compare and how to bridge them is the real-world skill.

Protocol Comparison at a Glance

DimensionA2A (Google)ACP (IBM/BeeAI)ANP (AgentNetworkProtocol)
Primary goalCross-platform agent delegationStructured agent-to-agent messagingDecentralized agent discovery & trust
TransportHTTP/SSEREST + JSON-LDHTTP + DID-based identity
DiscoveryAgent Cards (JSON at /.well-known/)Registry-basedDID documents + resolver
IdentityURL-basedService IDDecentralized Identifiers (DIDs)
AuthOAuth 2.0 / API keysHMAC / API keysDID-based verifiable credentials
StreamingServer-Sent Events (SSE)Polling or webhookWebhook / polling
Best forCloud agent ecosystemsEnterprise multi-agent pipelinesOpen web, decentralized trust
MaturityOpen spec, growing adoptionActive IBM/open-source adoptionEarly adopters, research

When to Use Which

  • A2A: your agents are HTTP services and you need cross-vendor task delegation (Google, Salesforce, SAP ecosystems)
  • ACP: you're building an enterprise pipeline with rich structured messages and need JSON-LD semantics
  • ANP: you need verifiable agent identity across organizational boundaries without a central registry

Part 1: A2A Deep Dive with Working Code

Agent Cards — The Foundation of A2A Discovery

An Agent Card is a JSON document served at /.well-known/agent.json that describes an agent's identity, capabilities, and how to reach it. It is the A2A equivalent of an OpenAPI spec.

A2A: Full Agent Server with Capability Negotiation and SSE Streamingpython
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel
import asyncio
import json
import uuid
from datetime import datetime

app = FastAPI()

# ── Agent Card ──────────────────────────────────────────────────────────────
AGENT_CARD = {
    "name": "ResearchAgent",
    "description": "Searches the web and summarizes findings for a given query",
    "url": "https://research-agent.example.com",
    "version": "1.0.0",
    "capabilities": {
        "streaming": True,
        "pushNotifications": False,
        "stateTransitionHistory": True,
    },
    "defaultInputModes": ["text/plain"],
    "defaultOutputModes": ["text/plain", "application/json"],
    "skills": [
        {
            "id": "web-research",
            "name": "Web Research",
            "description": "Search the web and synthesize findings",
            "inputModes": ["text/plain"],
            "outputModes": ["text/plain"],
        }
    ],
    "authentication": {
        "schemes": ["Bearer"]
    },
}

@app.get("/.well-known/agent.json")
async def agent_card():
    return JSONResponse(AGENT_CARD)

# ── Task Models ──────────────────────────────────────────────────────────────
class Message(BaseModel):
    role: str  # "user" | "agent"
    parts: list[dict]

class TaskRequest(BaseModel):
    id: str | None = None
    message: Message
    sessionId: str | None = None

# In-memory task store (use Redis in production)
tasks: dict = {}

# ── Task Submission (non-streaming) ─────────────────────────────────────────
@app.post("/tasks/send")
async def send_task(task_req: TaskRequest):
    task_id = task_req.id or str(uuid.uuid4())
    query = task_req.message.parts[0].get("text", "")

    tasks[task_id] = {
        "id": task_id,
        "status": {"state": "working"},
        "history": [{"role": "user", "parts": task_req.message.parts}],
    }

    # Run research (simplified)
    result = await run_research(query)

    tasks[task_id]["status"]["state"] = "completed"
    tasks[task_id]["artifacts"] = [
        {"name": "summary", "parts": [{"type": "text", "text": result}]}
    ]

    return tasks[task_id]

# ── Task Streaming (SSE) ─────────────────────────────────────────────────────
@app.post("/tasks/sendSubscribe")
async def send_task_subscribe(task_req: TaskRequest):
    task_id = task_req.id or str(uuid.uuid4())
    query = task_req.message.parts[0].get("text", "")

    async def event_stream():
        # State: working
        yield f"data: {json.dumps({'id': task_id, 'status': {'state': 'working'}, 'final': False})}\n\n"

        # Stream intermediate results
        async for chunk in stream_research(query):
            yield f"data: {json.dumps({'id': task_id, 'artifact': {'parts': [{'type': 'text', 'text': chunk}]}, 'final': False})}\n\n"
            await asyncio.sleep(0.1)

        # State: completed
        yield f"data: {json.dumps({'id': task_id, 'status': {'state': 'completed'}, 'final': True})}\n\n"

    return StreamingResponse(event_stream(), media_type="text/event-stream")

async def run_research(query: str) -> str:
    await asyncio.sleep(0.5)  # simulate web search
    return f"Research findings for: {query} — [synthesized summary here]"

async def stream_research(query: str):
    steps = [f"Searching for '{query}'...", "Analyzing results...", "Synthesizing summary..."]
    for step in steps:
        yield step
        await asyncio.sleep(0.3)
A2A: Client Agent — Discover, Connect, and Stream Resultspython
import httpx
import json
import asyncio

class A2AClient:
    def __init__(self, agent_url: str, api_key: str | None = None):
        self.agent_url = agent_url.rstrip("/")
        self.headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}

    async def discover(self) -> dict:
        """Fetch and validate the remote agent's Agent Card."""
        async with httpx.AsyncClient() as client:
            resp = await client.get(f"{self.agent_url}/.well-known/agent.json")
            resp.raise_for_status()
            card = resp.json()
            print(f"Connected to: {card['name']} v{card['version']}")
            print(f"Streaming: {card['capabilities'].get('streaming', False)}")
            return card

    async def send_task(self, message: str, task_id: str | None = None) -> dict:
        """Send a task and wait for completion."""
        payload = {
            "id": task_id,
            "message": {"role": "user", "parts": [{"type": "text", "text": message}]},
        }
        async with httpx.AsyncClient(timeout=60) as client:
            resp = await client.post(
                f"{self.agent_url}/tasks/send",
                json=payload,
                headers=self.headers,
            )
            resp.raise_for_status()
            return resp.json()

    async def send_task_streaming(self, message: str) -> None:
        """Send a task and stream results via SSE."""
        payload = {
            "message": {"role": "user", "parts": [{"type": "text", "text": message}]},
        }
        async with httpx.AsyncClient(timeout=120) as client:
            async with client.stream(
                "POST",
                f"{self.agent_url}/tasks/sendSubscribe",
                json=payload,
                headers=self.headers,
            ) as resp:
                async for line in resp.aiter_lines():
                    if line.startswith("data: "):
                        event = json.loads(line[6:])
                        if "artifact" in event:
                            print(f"[chunk] {event['artifact']['parts'][0]['text']}")
                        if event.get("final"):
                            print("[done]")
                            break

async def main():
    client = A2AClient("https://research-agent.example.com", api_key="sk-...")
    await client.discover()
    await client.send_task_streaming("Latest advances in multi-agent systems 2025")

asyncio.run(main())

Part 2: ACP Deep Dive with Working Code

ACP (Agent Communication Protocol) uses structured JSON-LD messages with explicit semantic types. Unlike A2A's task-centric model, ACP focuses on messages — each message is a self-describing, semantically rich object.

Key Differences from A2A

  • Messages, not tasks: ACP sends individual messages; the application layer decides what constitutes a "task"
  • JSON-LD semantics: each message part carries an explicit @type for interoperability
  • Run model: agents run in an execution environment; the ACP server manages run lifecycle
ACP: Agent Definition and Message Exchangepython
# ACP Agent using the beeai-framework pattern
from dataclasses import dataclass
from typing import AsyncIterator
import httpx
import json

@dataclass
class ACPMessage:
    """ACP message with JSON-LD typed parts."""
    parts: list[dict]

    def text_content(self) -> str:
        for part in self.parts:
            if part.get("content_type") == "text/plain":
                return part.get("content", "")
        return ""

    def to_dict(self) -> dict:
        return {"parts": self.parts}

    @classmethod
    def from_text(cls, text: str) -> "ACPMessage":
        return cls(parts=[{
            "content_type": "text/plain",
            "content": text,
        }])

    @classmethod
    def from_structured(cls, data: dict, schema_type: str) -> "ACPMessage":
        return cls(parts=[
            {"content_type": "text/plain", "content": str(data)},
            {
                "content_type": "application/json",
                "content": json.dumps(data),
                "@type": schema_type,
            }
        ])

class ACPAgentClient:
    """Client for interacting with an ACP-compliant agent server."""

    def __init__(self, base_url: str, agent_name: str, api_key: str | None = None):
        self.base_url = base_url.rstrip("/")
        self.agent_name = agent_name
        self.headers = {}
        if api_key:
            self.headers["Authorization"] = f"Bearer {api_key}"

    async def run(self, input_message: ACPMessage) -> ACPMessage:
        """Create a run and wait for synchronous completion."""
        async with httpx.AsyncClient(timeout=60) as client:
            # Create run
            resp = await client.post(
                f"{self.base_url}/agents/{self.agent_name}/runs",
                json={"input": [input_message.to_dict()]},
                headers=self.headers,
            )
            resp.raise_for_status()
            run = resp.json()

            # Poll until done (for async runs)
            while run.get("status") not in ("completed", "failed"):
                await __import__("asyncio").sleep(1)
                poll = await client.get(
                    f"{self.base_url}/agents/{self.agent_name}/runs/{run['run_id']}",
                    headers=self.headers,
                )
                run = poll.json()

            if run["status"] == "failed":
                raise RuntimeError(f"Agent run failed: {run.get('error')}")

            return ACPMessage(parts=run["output"][0]["parts"])

    async def run_stream(self, input_message: ACPMessage) -> AsyncIterator[str]:
        """Stream output from an ACP run."""
        async with httpx.AsyncClient(timeout=120) as client:
            async with client.stream(
                "POST",
                f"{self.base_url}/agents/{self.agent_name}/runs/stream",
                json={"input": [input_message.to_dict()]},
                headers=self.headers,
            ) as resp:
                async for line in resp.aiter_lines():
                    if line.startswith("data:"):
                        data = json.loads(line[5:])
                        if chunk := data.get("chunk"):
                            for part in chunk.get("delta", {}).get("parts", []):
                                if part.get("content_type") == "text/plain":
                                    yield part["content"]

# Usage
async def acp_example():
    client = ACPAgentClient("https://acp-server.example.com", "research-agent")
    msg = ACPMessage.from_text("Summarize recent breakthroughs in quantum computing")
    async for chunk in client.run_stream(msg):
        print(chunk, end="", flush=True)

Part 3: ANP Deep Dive with Working Code

ANP (Agent Network Protocol) introduces decentralized identity into multi-agent communication. Agents are identified by DIDs (Decentralized Identifiers), and messages are signed with the agent's private key — eliminating the need for a central registry.

ANP Core Primitives

  • DID: a globally unique identifier controlled by the agent (e.g., did:web:agent.example.com)
  • DID Document: describes the agent's public keys, service endpoints, and capabilities
  • Signed messages: every ANP message is signed with the sender's private key
  • AgentConnect: the discovery handshake protocol
ANP: DID-Based Agent Identity and Signed Messagingpython
import json
import hashlib
import base64
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.serialization import (
    Encoding, PublicFormat, PrivateFormat, NoEncryption
)
from datetime import datetime, timezone
import httpx

class ANPAgent:
    """Agent with ANP-compliant DID-based identity."""

    def __init__(self, domain: str):
        self.domain = domain
        self.did = f"did:web:{domain}"
        # Generate Ed25519 key pair
        self._private_key = Ed25519PrivateKey.generate()
        self._public_key = self._private_key.public_key()

    @property
    def public_key_bytes(self) -> bytes:
        return self._public_key.public_bytes(Encoding.Raw, PublicFormat.Raw)

    @property
    def public_key_multibase(self) -> str:
        """Multibase-encoded public key (base58btc prefix 'z')."""
        import base58
        return "z" + base58.b58encode(self.public_key_bytes).decode()

    def did_document(self) -> dict:
        """Generate the DID Document served at /.well-known/did.json"""
        key_id = f"{self.did}#key-1"
        return {
            "@context": [
                "https://www.w3.org/ns/did/v1",
                "https://w3id.org/security/suites/ed25519-2020/v1",
            ],
            "id": self.did,
            "verificationMethod": [{
                "id": key_id,
                "type": "Ed25519VerificationKey2020",
                "controller": self.did,
                "publicKeyMultibase": self.public_key_multibase,
            }],
            "authentication": [key_id],
            "service": [{
                "id": f"{self.did}#anp-service",
                "type": "ANPService",
                "serviceEndpoint": f"https://{self.domain}/anp",
            }],
        }

    def sign_message(self, message: dict) -> dict:
        """Add a proof to a message using Ed25519 signature."""
        message_bytes = json.dumps(message, sort_keys=True).encode()
        signature = self._private_key.sign(message_bytes)
        signature_b64 = base64.urlsafe_b64encode(signature).decode()

        return {
            **message,
            "proof": {
                "type": "Ed25519Signature2020",
                "created": datetime.now(timezone.utc).isoformat(),
                "verificationMethod": f"{self.did}#key-1",
                "proofPurpose": "authentication",
                "proofValue": signature_b64,
            }
        }

    async def send_message(self, target_did: str, content: str) -> dict:
        """Send a signed ANP message to another agent."""
        # Resolve target DID to get endpoint
        target_domain = target_did.replace("did:web:", "")
        async with httpx.AsyncClient() as client:
            did_doc = (await client.get(f"https://{target_domain}/.well-known/did.json")).json()

        endpoint = next(
            s["serviceEndpoint"] for s in did_doc["service"]
            if s["type"] == "ANPService"
        )

        message = {
            "from": self.did,
            "to": target_did,
            "type": "https://anp.example.com/message",
            "body": {"content": content},
            "timestamp": datetime.now(timezone.utc).isoformat(),
        }
        signed = self.sign_message(message)

        async with httpx.AsyncClient() as client:
            resp = await client.post(f"{endpoint}/messages", json=signed)
            resp.raise_for_status()
            return resp.json()

# Usage
async def anp_example():
    alice = ANPAgent("alice.example.com")
    result = await alice.send_message(
        target_did="did:web:bob.example.com",
        content="Request: analyze this dataset and return anomalies",
    )
    print(result)

Part 4: Protocol Gateway — Bridging A2A, ACP, and ANP

In practice, you need a protocol gateway that routes messages between agents regardless of which protocol they speak. The gateway:

  1. Accepts messages in any protocol format
  2. Translates to the target agent's protocol
  3. Routes based on the target agent's /.well-known/ descriptor
Multi-Protocol Agent Gatewaypython
from enum import Enum
from dataclasses import dataclass
import httpx

class AgentProtocol(Enum):
    A2A = "a2a"
    ACP = "acp"
    ANP = "anp"
    UNKNOWN = "unknown"

@dataclass
class NormalizedMessage:
    """Protocol-agnostic internal message format."""
    sender_id: str
    content: str
    metadata: dict

async def detect_protocol(agent_url: str) -> AgentProtocol:
    """Probe an agent URL to detect which protocol it speaks."""
    async with httpx.AsyncClient(timeout=5) as client:
        try:
            resp = await client.get(f"{agent_url}/.well-known/agent.json")
            if resp.status_code == 200 and "skills" in resp.json():
                return AgentProtocol.A2A
        except Exception:
            pass
        try:
            resp = await client.get(f"{agent_url}/.well-known/did.json")
            if resp.status_code == 200 and "@context" in resp.json():
                return AgentProtocol.ANP
        except Exception:
            pass
        try:
            resp = await client.get(f"{agent_url}/agents")
            if resp.status_code == 200:
                return AgentProtocol.ACP
        except Exception:
            pass
    return AgentProtocol.UNKNOWN

async def route_message(
    target_url: str,
    message: NormalizedMessage,
    protocol: AgentProtocol | None = None,
) -> str:
    """Route a normalized message to a target agent, auto-detecting protocol."""
    if protocol is None:
        protocol = await detect_protocol(target_url)

    if protocol == AgentProtocol.A2A:
        return await send_via_a2a(target_url, message)
    elif protocol == AgentProtocol.ACP:
        return await send_via_acp(target_url, message)
    elif protocol == AgentProtocol.ANP:
        return await send_via_anp(target_url, message)
    else:
        raise ValueError(f"Could not detect protocol for {target_url}")

async def send_via_a2a(url: str, msg: NormalizedMessage) -> str:
    async with httpx.AsyncClient(timeout=60) as client:
        resp = await client.post(f"{url}/tasks/send", json={
            "message": {"role": "user", "parts": [{"type": "text", "text": msg.content}]}
        })
        result = resp.json()
        return result["artifacts"][0]["parts"][0]["text"]

async def send_via_acp(url: str, msg: NormalizedMessage) -> str:
    async with httpx.AsyncClient(timeout=60) as client:
        resp = await client.post(f"{url}/agents/default/runs", json={
            "input": [{"parts": [{"content_type": "text/plain", "content": msg.content}]}]
        })
        run = resp.json()
        return run["output"][0]["parts"][0]["content"]

async def send_via_anp(url: str, msg: NormalizedMessage) -> str:
    domain = url.replace("https://", "").split("/")[0]
    async with httpx.AsyncClient(timeout=60) as client:
        resp = await client.post(f"{url}/anp/messages", json={
            "from": msg.sender_id,
            "to": f"did:web:{domain}",
            "body": {"content": msg.content},
        })
        return resp.json().get("response", "")

# Example: send to three different agents without caring about their protocol
async def orchestrate():
    msg = NormalizedMessage(
        sender_id="did:web:orchestrator.example.com",
        content="Analyze Q1 sales data and identify top 3 anomalies",
        metadata={},
    )
    results = {}
    for name, url in [
        ("analyst", "https://analyst-agent.example.com"),    # speaks A2A
        ("auditor", "https://auditor-agent.enterprise.com"), # speaks ACP
        ("verifier", "https://verifier.defi.example.com"),   # speaks ANP
    ]:
        results[name] = await route_message(url, msg)
    return results

Knowledge check

What is the primary advantage of ANP's DID-based identity over A2A's URL-based identity?

Summary

  1. A2A is best for cloud-native agent ecosystems with HTTP task delegation — Agent Cards drive discovery, SSE enables streaming
  2. ACP is best for enterprise pipelines with structured JSON-LD semantics and explicit run lifecycle management
  3. ANP adds cryptographic identity via DIDs, enabling verifiable cross-organizational agent communication without central registries
  4. In production, a protocol gateway auto-detects the target agent's protocol and translates normalized internal messages accordingly
  5. All three protocols converge on the same pattern: discovery → authentication → message exchange — the differences are in how each step is implemented
  6. Start with A2A if you need immediate ecosystem compatibility; layer ANP when cross-org trust boundaries become a requirement

You now have the full Agentic AI toolkit — from individual agent architectures through multi-framework systems to cross-protocol interoperability.

Agentic AI