Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

A2A: agents talking to agents

Here is the situation that produces the need.

Support built an agent that answers product questions. Analytics built one that forecasts demand. Risk built a fraud detector. Three teams, three frameworks, three clouds, three deploy cycles.

Then someone asks the fraud agent to explain a flagged transaction, which requires context only the transaction-analysis agent has. Today a human bridges that gap by opening two tools and copying between them. Every team that wants a capability another team already built rebuilds it, badly, because integrating was harder than reimplementing.

You can solve this the way you have solved it before — import their library, or call their HTTP API with a bespoke client. Both work, and both stop working at organisational boundaries. A library means shared runtime and shared release cadence. A bespoke API means every pair of agents needs a custom integration, so \( n \) agents cost you \( O(n^2) \) integrations.

A protocol turns that into \( O(n) \). That is the entire argument for standardisation, and it is the same argument that produced MCP one layer down.


The distinction, stated crisply

Hold this and everything else follows.

MCP connects an agent to tools. A tool is a capability with a defined input and output. You call it, it returns, you are done. Transactional, usually fast, stateless from the caller’s point of view. “Do this specific thing.”

A2A connects an agent to another agent. An agent reasons, plans, uses its own tools, maintains its own state, and may take minutes or hours. You do not call it, you delegate to it, and you may get intermediate updates before you get a result. “Achieve this goal.”

The Google whitepaper’s phrasing is the one to remember: MCP lets you say do this specific thing; A2A lets you say achieve this complex goal.

They are not competitors and they compose in layers. Your orchestrator delegates to a specialist agent over A2A; that specialist uses MCP internally to reach its own tools; it may then delegate onward over A2A to a third agent.

The auto-repair-shop analogy in the whitepaper makes it stick. A customer describes a rattling noise to a Shop Manager agent — A2A. The manager diagnoses, then delegates to a Mechanic agent — A2A. The mechanic runs scan_vehicle_for_error_codes() and get_repair_procedure() — MCP. Needing a part, the mechanic contacts a Parts Supplier agent — A2A.

Conversational, goal-shaped, potentially long-running interactions go over A2A. Structured, transactional capability invocations go over MCP.

The practical test: if the thing on the other end has its own system prompt, it is an agent.

Saying it out loud. MCP connects an agent to tools, A2A connects an agent to another agent, and the cleanest way to say it is that MCP lets you say “do this specific thing” while A2A lets you say “achieve this complex goal.” A tool has a defined input and output — you call it, it returns, you’re done. An agent reasons, plans, uses its own tools, keeps its own state, and may take minutes or hours, so you don’t call it, you delegate to it, and you might get intermediate updates before a result. They aren’t competitors, they compose: your orchestrator delegates to a specialist over A2A, that specialist uses MCP internally to reach its own tools, and it may delegate onward over A2A again. The practical test I’d give is one line — if the thing on the other end has its own system prompt, it’s an agent.


What A2A actually is

A2A began at Google in 2025 and was donated to the Linux Foundation the same year, which is the relevant governance fact — it is a multi-vendor open standard rather than one company’s SDK. The specification reached version 1.0.0, and it defines three transport bindings: JSON-RPC 2.0, gRPC, and HTTP+JSON/REST.

Four concepts, and that is genuinely all of it.

Saying it out loud. A2A started at Google in 2025 and was donated to the Linux Foundation the same year, which is the governance fact that matters — it’s a multi-vendor standard rather than one company’s SDK. The spec is at 1.0 and defines three transport bindings: JSON-RPC, gRPC, and HTTP plus JSON. And it’s genuinely only four concepts: the Agent Card that advertises capability and auth, the Task as a stateful long-lived unit of work, Messages and Parts and Artifacts as the content model, and about eight methods. The economic argument underneath all of it is the same one that produced MCP a layer down — n agents with bespoke pairwise integrations cost you order n-squared connectors, and a protocol turns that into order n.

1. The Agent Card

A JSON document advertising what an agent is, what it can do, where to reach it, and how to authenticate. The business card of the ecosystem, and the basis of discovery.

Served, for publicly discoverable agents, at a well-known path following RFC 8615:

https://{domain}/.well-known/agent-card.json
{
  "protocolVersion": "1.0.0",
  "name": "solaris-kb-agent",
  "description": "Answers questions about Solaris Audio products from the support knowledge base.",
  "version": "1.4.0",
  "preferredTransport": "JSONRPC",
  "url": "https://kb.solaris-audio.example/a2a",
  "capabilities": { "streaming": false, "pushNotifications": false },
  "defaultInputModes": ["text/plain"],
  "defaultOutputModes": ["text/plain"],
  "securitySchemes": {
    "bearer": { "type": "http", "scheme": "bearer", "bearerFormat": "JWT" }
  },
  "security": [{ "bearer": [] }],
  "skills": [
    {
      "id": "kb_lookup",
      "name": "Knowledge base lookup",
      "description": "Find the documented answer to a product or policy question.",
      "tags": ["support", "knowledge-base"],
      "examples": ["Do the Solaris headphones support multipoint pairing?"]
    }
  ]
}

Two things to notice.

The card is the contract, and it carries a version. A client that fetches it knows the transport, the auth scheme, the supported content types, and the skills, without a conversation between the two teams.

And the securitySchemes block reuses OpenAPI’s vocabulary rather than inventing one, which means your existing API gateway probably already knows how to enforce it. The spec also allows an authenticated extended card — a public card advertising the basics, and a richer one for callers who have proven who they are.

Saying it out loud. The Agent Card is a JSON document at a well-known URL saying what an agent is, what skills it has, where to reach it, and how to authenticate — it’s the business card of the ecosystem and the basis of discovery. The thing to emphasise is that the card is the contract, and it carries a version, so a client that fetches it knows the transport, the auth scheme, the content types, and the skills without any conversation between the two teams. The security block reuses OpenAPI’s vocabulary rather than inventing one, which means your existing API gateway probably already knows how to enforce it. And there’s an authenticated extended card, so you can advertise the basics publicly and the richer capability list only to callers who’ve proven who they are.

2. The Task

The unit of work, and the reason A2A is not just RPC.

A task has an ID, a status, a message history, and artifacts. It is stateful and long-lived: created when you send a message, progressing through states, addressable afterwards by ID.

The lifecycle states in v1.0.0 are named TASK_STATE_*, and the ones you will handle are:

  • TASK_STATE_WORKING — in progress.
  • TASK_STATE_INPUT_REQUIRED — the remote agent needs something from you and is waiting.
  • TASK_STATE_AUTH_REQUIRED — it needs credentials to continue.
  • TASK_STATE_COMPLETED, TASK_STATE_FAILED, TASK_STATE_CANCELED, TASK_STATE_REJECTED — terminal.

INPUT_REQUIRED is the state that justifies the whole protocol. A remote agent that can pause mid-task, ask a clarifying question, and resume is doing something no request-response API models. That is what “delegate a goal” means as a wire protocol.

Saying it out loud. The Task is the unit of work and the reason A2A isn’t just RPC. It has an ID, a status, a message history, and artifacts, and it’s stateful and long-lived — created when you send a message, moving through states, addressable by ID afterwards. The state that justifies the whole protocol is input-required: a remote agent that can pause mid-task, ask you a clarifying question, and then resume is doing something no request-response API models. That’s what “delegate a goal” actually means on the wire. The operational consequence is that tasks need durable storage — if your task registry is a Python dict, a restart loses every in-flight task.

3. Messages, Parts, and Artifacts

A Message has a role (user or agent) and a list of Parts. A Part is the smallest unit of content: text, a file reference, or structured data. An Artifact is a durable output the task produced — a document, a table, a generated image — also composed of Parts.

The Message/Part split is what makes A2A multimodal without special cases, and the Message/Artifact split is what separates conversation from deliverable.

Saying it out loud. A Message has a role and a list of Parts, a Part is the smallest unit of content — text, a file reference, or structured data — and an Artifact is a durable output the task produced, like a document or a table, also made of Parts. Two splits are doing the work here. The Message-Part split is what makes the protocol multimodal without special-casing anything. And the Message-Artifact split is what separates conversation from deliverable, so “here’s my progress narration” and “here’s the report you asked for” are different objects rather than something you have to parse apart later.

4. The methods

The v1.0.0 core operations are SendMessage, SendStreamingMessage, GetTask, ListTasks, CancelTask, SubscribeToTask, the push-notification configuration methods, and GetExtendedAgentCard.

One naming note that will save you an hour. The 0.x revisions used slash-style JSON-RPC method names — message/send, tasks/get, tasks/cancel — and v1.0.0 moved to the operation names above. Deployed servers and older SDKs still speak the 0.x names, so if you are writing a server, accepting both is cheap insurance. The code below does exactly that.

Saying it out loud. The core operations are the ones you’d expect — send a message, send a streaming message, get a task, list tasks, cancel, subscribe, configure push notifications, and fetch the extended card. The one naming detail that’ll save you an hour is that the 0.x revisions used slash-style JSON-RPC method names like message/send and tasks/get, and 1.0 moved to the operation names. Plenty of deployed servers and older SDKs still speak the old names, so if you’re writing a server, accepting both is cheap insurance against an integration that fails for a reason nobody can see in the logs.


Build it: expose an agent over A2A, and call it

Take the knowledge-base worker from Part 4’s multi-agent system. Right now it is a Python object a supervisor imports. Make it a network peer.

Two files, both runnable, no framework beyond FastAPI and httpx.

The server

"""Expose a Part 4 worker over an A2A-style HTTP interface.

Agent Card at /.well-known/agent-card.json, JSON-RPC at /a2a.
Shapes follow A2A v1.0.0: Task / Message / Part, TASK_STATE_* status values.
"""
from __future__ import annotations

import uuid
from typing import Any

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse

PORT = 8101
BASE = f"http://127.0.0.1:{PORT}"

AGENT_CARD: dict[str, Any] = {
    "protocolVersion": "1.0.0",
    "name": "solaris-kb-agent",
    "description": "Answers questions about Solaris Audio products from the support knowledge base.",
    "version": "1.4.0",
    "preferredTransport": "JSONRPC",
    "url": f"{BASE}/a2a",
    "capabilities": {"streaming": False, "pushNotifications": False},
    "defaultInputModes": ["text/plain"],
    "defaultOutputModes": ["text/plain"],
    "securitySchemes": {
        "bearer": {"type": "http", "scheme": "bearer", "bearerFormat": "JWT"}
    },
    "security": [{"bearer": []}],
    "skills": [
        {
            "id": "kb_lookup",
            "name": "Knowledge base lookup",
            "description": "Find the documented answer to a product or policy question.",
            "tags": ["support", "knowledge-base"],
            "examples": ["Do the Solaris headphones support multipoint pairing?"],
        }
    ],
}

KB = {
    "multipoint": "Solaris headphones support multipoint pairing with two devices "
                  "(firmware 3.2 or later).",
    "warranty": "All Solaris headphones carry a 24-month limited warranty from purchase date.",
    "water": "Solaris headphones are IPX4 splash resistant; they are not submersible.",
}

TASKS: dict[str, dict] = {}
app = FastAPI()


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


def _text(message: dict) -> str:
    return " ".join(p.get("text", "") for p in message.get("parts", []))


def _answer(question: str) -> str:
    q = question.lower()
    for key, article in KB.items():
        if key in q:
            return article
    return "No knowledge base article matched that question."


def _send_message(params: dict) -> dict:
    msg = params["message"]
    task_id = msg.get("taskId") or f"task-{uuid.uuid4().hex[:8]}"
    context_id = msg.get("contextId") or f"ctx-{uuid.uuid4().hex[:8]}"
    answer = _answer(_text(msg))
    task = {
        "kind": "task",
        "id": task_id,
        "contextId": context_id,
        "status": {"state": "TASK_STATE_COMPLETED"},
        "messages": [
            msg,
            {"role": "agent", "parts": [{"text": answer}],
             "taskId": task_id, "contextId": context_id},
        ],
        "artifacts": [
            {"artifactId": f"art-{uuid.uuid4().hex[:6]}",
             "name": "kb_answer",
             "parts": [{"text": answer}]}
        ],
    }
    TASKS[task_id] = task
    return task


def _get_task(params: dict) -> dict:
    task = TASKS.get(params["id"])
    if task is None:
        raise KeyError(params["id"])
    return task


# accept both the v1.0 operation names and the 0.x slash names
HANDLERS = {"SendMessage": _send_message, "GetTask": _get_task,
            "message/send": _send_message, "tasks/get": _get_task}


@app.post("/a2a")
async def rpc(request: Request):
    body = await request.json()
    rid = body.get("id")
    auth = request.headers.get("authorization", "")
    if not auth.startswith("Bearer "):
        return JSONResponse(
            {"jsonrpc": "2.0", "id": rid,
             "error": {"code": -32001, "message": "authentication required"}},
            status_code=401)
    handler = HANDLERS.get(body.get("method"))
    if handler is None:
        return {"jsonrpc": "2.0", "id": rid,
                "error": {"code": -32601, "message": f"unknown method {body.get('method')!r}"}}
    try:
        return {"jsonrpc": "2.0", "id": rid, "result": handler(body.get("params") or {})}
    except KeyError as exc:
        return {"jsonrpc": "2.0", "id": rid,
                "error": {"code": -32002, "message": f"task not found: {exc}"}}


if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="127.0.0.1", port=PORT, log_level="warning")

Notice that the agent’s own logic is untouched. A2A is an adapter around it, which is the point — you are not rewriting the agent, you are giving it a network contract.

Saying it out loud. Turning a local worker into an A2A peer is less code than people expect: serve the Agent Card at the well-known path, expose a JSON-RPC endpoint, keep a task registry, and map incoming messages onto the agent you already have. The things that make it real rather than a demo are the unglamorous ones — accepting both the 1.0 operation names and the older slash-style names, returning proper task states instead of just a final string, and backing the task registry with a database rather than a dict so a restart doesn’t lose in-flight work. The protocol is the easy part; the state management is where the engineering is.

The client

"""Client side: discover a remote agent by card, then delegate a task to it."""
from __future__ import annotations

import itertools
import httpx

_ids = itertools.count(1)


class RemoteA2AAgent:
    def __init__(self, card_url: str, token: str, timeout: float = 20.0) -> None:
        self.http = httpx.Client(timeout=timeout,
                                 headers={"authorization": f"Bearer {token}"})
        self.card = self.http.get(card_url).json()
        self.endpoint = self.card["url"]

    # the card is the contract: name, skills and auth scheme all come from it
    @property
    def name(self) -> str:
        return self.card["name"]

    @property
    def skills(self) -> list[str]:
        return [s["id"] for s in self.card.get("skills", [])]

    def _rpc(self, method: str, params: dict) -> dict:
        resp = self.http.post(self.endpoint, json={
            "jsonrpc": "2.0", "id": next(_ids), "method": method, "params": params})
        resp.raise_for_status()
        body = resp.json()
        if "error" in body:
            raise RuntimeError(f"{self.name}: {body['error']['message']}")
        return body["result"]

    def ask(self, question: str, context_id: str | None = None) -> tuple[str, dict]:
        msg = {"role": "user", "parts": [{"text": question}]}
        if context_id:
            msg["contextId"] = context_id
        task = self._rpc("SendMessage", {"message": msg})
        state = task["status"]["state"]
        if state != "TASK_STATE_COMPLETED":
            raise RuntimeError(f"{self.name}: task ended in {state}")
        answer = " ".join(p["text"] for p in task["messages"][-1]["parts"])
        return answer, task

    def poll(self, task_id: str) -> dict:
        return self._rpc("GetTask", {"id": task_id})

Running the two together:

$ python kb_agent_server.py &
$ python remote_agent.py
== discovery ==
   name:   solaris-kb-agent
   skills: ['kb_lookup']
   auth:   ['bearer']

== delegation ==
   task id: task-b56ad97c | state: TASK_STATE_COMPLETED
   answer:  Solaris headphones support multipoint pairing with two devices (firmware 3.2 or later).

== the task is addressable afterwards ==
   GetTask: TASK_STATE_COMPLETED | artifacts: ['kb_answer']

== unauthenticated caller ==
   HTTP 401 authentication required

Four things happened there that a function call cannot do.

The caller discovered the agent’s capabilities at runtime, from the card, without an import or a shared type. The work became an addressable task that outlives the call, so a long-running version could be polled. The result carried a durable artifact distinct from the conversational reply. And the boundary is authenticated, because it is a network boundary between two teams.

Saying it out loud. On the client side you fetch the Agent Card, read the transport and auth scheme off it, send a message, and then poll or subscribe until the task reaches a terminal state. Four things happen there that a plain function call cannot do: the caller discovers the capabilities at runtime from the card with no import and no shared type, the work becomes an addressable task that outlives the call, the result carries a durable artifact distinct from the conversational reply, and the boundary is authenticated because it is a boundary between two teams. The discipline that matters is treating every response as a task rather than a return value, because the remote agent may come back with input-required or auth-required instead of an answer. And you need a client-side deadline, because “still thinking” and “never going to answer” look identical from here.

Wiring it into a supervisor

The supervisor from Part 4 does not need to know any of this. Give the remote agent the same interface as a local worker and the orchestration code is unchanged:

class A2AWorker:
    """Adapts a remote A2A agent to the local Worker interface from Part 4."""

    def __init__(self, remote: RemoteA2AAgent, budget_s: float = 30.0) -> None:
        self.remote, self.budget_s = remote, budget_s
        self.name = remote.name

    def run(self, packet) -> "Report":
        try:
            answer, task = self.remote.ask(packet.objective)
        except Exception as exc:                       # network peer, so: expect failure
            return Report(status="failed", findings=[],
                          note=f"{self.name} unreachable: {type(exc).__name__}")
        return Report(status="ok", findings=[answer],
                      note=f"a2a task {task['id']}", steps_used=1)

The except clause is not defensive style, it is the difference in kind. A local worker fails by raising. A remote peer fails by timing out, returning a 503, being mid-deploy, having rotated its token, or answering slowly enough that your budget expires. Every A2A call is a network call to a system on someone else’s release schedule, and your handoff contract from Part 4 — objective, resolved inputs, constraints, return shape — matters more across that boundary, not less.

Two requirements the whitepaper flags, and they are not optional at scale.

Distributed tracing. Propagate a trace ID across every A2A hop. Without it, debugging a three-agent interaction is archaeology across three teams’ log systems.

Durable state. A2A tasks are stateful and can be long-lived. If your task registry is a Python dict — as in the demo above — a restart loses every in-flight task. Real deployments back it with a database.

Saying it out loud. The nice property is that your supervisor doesn’t need to know any of this — you adapt the remote agent to the same interface as a local worker and the orchestration code is unchanged. The one thing that genuinely differs in kind is the error handling. A local worker fails by raising; a remote peer fails by timing out, returning a 503, being mid-deploy, having rotated its token, or just answering slower than your budget allows. So every A2A call is a network call to a system on somebody else’s release schedule, and the handoff contract — objective, resolved inputs, constraints, return shape — matters more across that boundary, not less. Two things stop being optional at scale: propagate a trace ID across every hop, or debugging a three-agent interaction is archaeology across three teams’ log systems, and back the task store with a real database.


When not to use A2A

The failure mode is adopting the protocol because it is interesting.

Do not use A2A inside one process. If your “multi-agent system” is a supervisor calling three functions, A2A adds serialisation, HTTP, auth, and a task store to something that was a function call. Local sub-agents remain the right answer for tightly coupled work.

Do not use A2A for tools. If the thing on the other end has no system prompt and no reasoning, it is a tool. MCP is the lighter, better-fitting protocol, and dressing a database query up as an agent buys you nothing.

Do use A2A when the boundary is organisational. Different team, different repository, different deploy cadence, different framework, different company. That is where a durable service contract is worth its cost, and where the alternative — a bespoke integration per pair — actually hurts.

The rule of thumb: A2A’s cost is a distributed system; its benefit is a stable contract across an organisational boundary. If you do not have the boundary, you are paying the cost for nothing.

Saying it out loud. The failure mode is adopting the protocol because it’s interesting. Don’t use A2A inside one process — if your multi-agent system is a supervisor calling three functions, you’ve added serialisation, HTTP, auth, and a task store to something that was a function call. Don’t use it for tools either: if the thing on the other end has no system prompt and no reasoning, it’s a tool, and MCP is the lighter, better-fitting protocol. Do use it when the boundary is organisational — different team, different repo, different deploy cadence, different company. The rule of thumb is that A2A’s cost is a distributed system and its benefit is a stable contract across an organisational boundary, so if you don’t have the boundary, you’re paying the cost for nothing.


Registries: probably not yet

The pitch is a central catalogue of every agent and tool, with discovery, governance, and access control.

It is genuinely valuable at scale, and it is a common premature investment.

With fifty tools and six agents, configuration files work fine and a registry is a service to operate for no benefit. With five thousand tools across forty teams, nobody can find anything, three teams have built the same integration, and your security team cannot answer “which agents can write to production.”

The decision framework is two sentences.

Build a tool registry when tool discovery becomes a bottleneck, or when security needs centralised auditing of what can be called.

Build an agent registry when multiple teams need to find and reuse each other’s agents without coupling to them.

If you do build one, the useful architecture is thin. Catalogue entries — MCP tool descriptors and A2A agent cards — with owner, version, environment, and status. Search that a human can use, because human discovery is the primary benefit; developers finding an existing capability before building a duplicate is worth more than runtime discovery. Curated subsets rather than firehose access, so a generalist agent can take the full catalogue while a specialist gets a reviewed shortlist. And an identity per agent, so “which agent called this” has an answer.

Managed options exist — Gemini Enterprise’s Agent Registry pairs a catalogue with per-agent cryptographic identity, for instance. The advice stands regardless of vendor: start without one, and build it when the pain is specific enough to describe in a sentence.

One caution about runtime discovery. An agent that queries a registry and adopts whatever tools it finds has a tool set nobody reviewed, and every tool description is prompt injected into your context. That is Chapter 4’s supply chain risk with a discovery mechanism attached. Runtime discovery plus an approval allow-list is fine. Runtime discovery alone is a vulnerability with good ergonomics.

Saying it out loud. A central catalogue of every agent and tool is genuinely valuable at scale and it’s a very common premature investment. With fifty tools and six agents, config files work fine and a registry is a service to operate for no benefit. With five thousand tools across forty teams, nobody can find anything, three teams have built the same integration, and security can’t answer “which agents can write to production.” So: build a tool registry when discovery is a bottleneck or security needs centralised auditing, build an agent registry when multiple teams need to reuse each other’s agents without coupling to them, and otherwise wait until the pain is specific enough to describe in one sentence. One real caution — an agent that queries a registry at runtime and adopts whatever it finds has a tool set nobody reviewed, and every tool description is being injected straight into your context. Runtime discovery plus an approval allow-list is fine; runtime discovery alone is a vulnerability with good ergonomics.

What you should be able to do now

  • State the difference between MCP and A2A in one sentence each, and apply the test — does the thing on the other end have its own system prompt — to classify a real integration.
  • Read and write an Agent Card: protocol version, transport, URL, skills, security schemes, and the well-known discovery path.
  • Explain the A2A task lifecycle and why TASK_STATE_INPUT_REQUIRED is the state that distinguishes delegation from RPC.
  • Expose an existing agent behind an A2A-style JSON-RPC interface without changing the agent’s logic, and consume it from another agent using only its card.
  • Adapt a remote A2A agent to a local worker interface, and enumerate the failure modes that exist only across a network boundary.
  • Decide against A2A for in-process sub-agents and for tools, and state the organisational condition that justifies it.
  • Apply the registry decision framework, and explain why runtime tool discovery without an approval step is a security problem.

Further reading