# Agent Series (11): A2A Protocol — How Agents Collaborate with Each Other

> Source: <https://dev.to/wonderlab/agent-series-11-a2a-protocol-how-agents-collaborate-with-each-other-59o1>
> Published: 2026-06-03 05:35:47+00:00

The previous article covered MCP: an Agent connects to tool services via a standard protocol. Tools are passive — they wait to be called, execute, return a result.

But some scenarios require delegating to another Agent with autonomous decision-making, not just a tool:

When these three Agents need to collaborate, how do they communicate? Who knows who exists? How do they hand off work?

This is the problem **A2A (Agent-to-Agent) Protocol** solves.

```
MCP:  Agent  ←→  Tools/Data (vertical integration, Agent invokes tools)
A2A:  Agent  ←→  Agent      (horizontal collaboration, Agent delegates to Agent)
```

Each Agent publishes an `AgentCard`

describing its capabilities:

``` python
from a2a.types import AgentCard, AgentSkill

research_card = AgentCard()
research_card.name = "research-agent"
research_card.description = "Gathers factual background on technical topics"

skill = AgentSkill()
skill.id = "research"
skill.name = "Research"
skill.description = "Collect key facts on a topic"
skill.tags.extend(["research", "facts"])
research_card.skills.append(skill)
```

Key AgentCard fields: `name`

, `description`

, `skills`

(each skill has `tags`

for discovery).

Think of it as the Agent equivalent of OpenAPI Spec — a machine-readable capability declaration that humans can also read.

Agents don't pass function calls to each other — they pass `Task`

objects:

``` python
from a2a.types import Task, TaskState, TaskStatus, Message, Part, Role

task = Task()
task.id = str(uuid.uuid4())
task.status.state = TaskState.TASK_STATE_SUBMITTED

# Input: a User-role Message
msg = Message()
msg.role = Role.ROLE_USER
part = Part(); part.text = "Should I use Python or Go?"
msg.parts.append(part)
task.history.append(msg)
```

`Task`

has a lifecycle: `SUBMITTED → WORKING → COMPLETED / FAILED`

. On completion, the Agent appends the result as a `ROLE_AGENT`

Message.

`AgentRegistry`

stores all registered AgentCards and supports discovery by tag:

``` python
class AgentRegistry:
    def register(self, card: AgentCard, handler: Callable[[Task], Task]) -> None:
        self._agents[card.name] = AgentEntry(card=card, handler=handler)

    def discover(self, tag: str) -> list[AgentCard]:
        """Return all agents whose skills include the given tag."""
        ...

    def delegate(self, agent_name: str, input_text: str) -> Task:
        """Create a Task and execute it via the registered handler."""
        ...
```

Without a protocol, the orchestrator calls three Python functions directly:

``` php
def direct_orchestrator(question: str) -> str:
    research = research_agent_fn(question)    # hard dependency
    analysis = analysis_agent_fn(research)   # hard dependency
    answer   = writing_agent_fn(analysis)    # hard dependency
    return answer
```

Real execution output:

```
→ calling research_agent (direct)
→ calling analysis_agent (direct)
→ calling writing_agent (direct)

Answer: Choose Python if you need rapid development with broad libraries.
        Select Go if performance and concurrency are critical...
```

Works perfectly. The problem is structural: the orchestrator has three hardcoded function references. Replace any one Agent, and you must edit the orchestrator. If the orchestrator lives in a different service, that means a cross-service code change.

Register three Agents, each with distinct skill tags:

```
[registry] registered: research-agent
[registry] registered: analysis-agent
[registry] registered: writing-agent
```

Discovery test:

```
researchers = registry.discover("research")
# → Found: research-agent — Gathers factual background on technical topics

writers = registry.discover("writing")
# → Found: writing-agent — Composes clear technical prose from analysis output
```

The orchestrator never writes an agent name — it discovers by tag:

``` php
def a2a_orchestrator(question: str) -> str:
    researchers = registry.discover("research")
    t1 = registry.delegate(researchers[0].name, question)

    analysts = registry.discover("analysis")
    t2 = registry.delegate(analysts[0].name, task_output(t1))

    writers = registry.discover("writing")
    t3 = registry.delegate(writers[0].name, task_output(t2))
    return task_output(t3)
```

Real execution output:

```
→ delegating to research-agent (discovered via tag)
→ delegating to analysis-agent (discovered via tag)
→ delegating to writing-agent (discovered via tag)

Answer: Choose Python for rapid development; Go for high-throughput performance...
```

**The critical difference: the orchestrator code contains no agent names.** Register a `writing-agent-v2`

with the same `writing`

tag, and the orchestrator discovers and uses it immediately — zero code changes.

A2A's most powerful use case: **the LLM reads the AgentCard catalog and decides which Agents to call and in what order**.

Show the LLM the agent catalog:

```
Available agents:
  research-agent: Gathers factual background [skills: Research(research, facts)]
  analysis-agent: Analyzes research notes    [skills: Analysis(analysis, tradeoffs)]
  writing-agent:  Composes technical prose   [skills: Writing(writing, prose)]
```

LLM outputs an execution plan:

```
["research-agent", "analysis-agent", "writing-agent"]
```

Execute the plan:

```
Executing 3 agents:
  → delegating to research-agent
  → delegating to analysis-agent
  → delegating to writing-agent

Final answer: Choose Python for rapid development; Go for high-throughput...
```

This is A2A's end state: no pre-configured orchestrator pipeline. The LLM reads the task requirements and the AgentCard descriptions, then **plans the collaboration chain at runtime**.

```
Dimension         MCP                          A2A
──────────────────────────────────────────────────────────────────────
Problem solved    Agent ↔ Tool/Data            Agent ↔ Agent
Discovery         list_tools() (tool catalog)  discover() (Agent registry)
Unit of work      Tool call (sync)             Task (async-ready)
Coupling          Agent invokes tool directly  Orchestrator delegates task
Other end         Passive tool service         Autonomous Agent with logic
Cross-service     Tool is an independent proc  Agent is an independent service
Who decides       Agent (tool use)             Orchestrator (delegation)
```

Complete four-way selection guide:

```
Scenario                                  Recommended
──────────────────────────────────────────────────────────────────────
Same codebase, call target known          Direct function call
Agent needs external tools                MCP (tools as service)
Agent delegates to specialist Agents      A2A (agents as service)
Cross-org large-scale Agent network       ANP (decentralized discovery)
```

**AgentCard Design**

`description`

: one sentence about what the Agent is good at — the LLM reads it for routing decisions`skill.tags`

: use semantically clear tags (`research`

, `analysis`

, `writing`

), not version numbers or internal IDs`AgentCard`

should be machine-readable and human-readable (think OpenAPI style)**Task Design**

`Task.id`

: use UUID for tracking and idempotent retry`history`

(Message chain) for context passing — don't concatenate everything into `part.text`

`ROLE_USER`

(input) from `ROLE_AGENT`

(output) messages**Registry and Discovery**

**LLM-Driven Routing**

Five core takeaways:

Up next: **Agent Evaluation Framework** — how to systematically test Agents, which metrics matter, and how to use DeepEval.

*Find more useful knowledge and interesting products on my Homepage*
