Building Multi-Agent Systems with Google’s A2A Protocol

Research finds information. Writer saves files. They've never met — until A2A. How to wire specialists with LangGraph, MCP, and a routing host agent.

Short read

You've got a Research Agent. It finds anything you ask for.

You've got a Writer Agent. It saves files exactly where you want them. Both are brilliant at what they do.

Now you want something simple: research finds information, writer saves it. One task, two agents.

But here's the problem — they've never met.

They don't know the other exists. Two experts in separate rooms, no door between them.

This is where most people start writing glue code. Hardcoding URLs. Manually passing outputs from one agent to another. It works until you add a third agent. Then a fourth. Then it's spaghetti.

There had to be a better way.

What if agents could simply introduce themselves?

"Hey, I'm Research. I find things on the web."

"Nice to meet you. I'm Writer. Send stuff my way, I'll save it."

No hardcoding. No glue code. Just agents discovering each other and figuring out how to collaborate.

That's exactly what Google built. It's called A2A — Agent to Agent protocol.

In this article, I'll show you how to build a multi-agent system where agents introduce themselves, discover each other's capabilities, and work together cleanly.

What is A2A?

A2A stands for Agent to Agent. It's an open protocol by Google that lets AI agents talk to each other over HTTP.

Think of it like this: every agent gets a business card. The card says who they are, what they can do, and how to reach them. Any other agent can read this card and decide, "Okay, this is who I need for this task."

In A2A, that business card is an Agent Card — a simple JSON file at a well-known URL:

json
// /.well-known/agent-card.json
{
"name": "Research Agent",
"description": "I search the web and find information",
"url": "http://localhost:8001",
"capabilities": {
"streaming": false,
"pushNotifications": false
}
}

That's it. Name, description, URL, capabilities.

When agents want to work together, they don't need custom integrations. They:

  1. Fetch the agent card
  2. Read what the agent can do
  3. Send a message using a standard format

The message format is JSON-RPC. That's what makes A2A powerful: any agent, built by anyone, in any language, can talk to any other agent — as long as they speak A2A.

Your Python agent can collaborate with someone's JavaScript agent. An agent on your laptop can talk to one on AWS. They just need each other's URLs.

Discovery. Communication. That's A2A in two words.

The Architecture

So agents can introduce themselves. Great. But who talks to who?

You could let every agent talk to every other agent. Research talks to Writer. Writer talks to Research. Add a third agent, now everyone talks to everyone.

That's peer-to-peer. And it's a mess.

With three agents you get six possible connections. A fourth agent makes twelve. A fifth? Twenty.

But connections aren't the real problem. The real problem is every agent needs to know about every other agent, and every agent needs logic to decide "should I handle this or pass it on?" You're duplicating decision-making across the system. When the LLM gets confused about who to delegate to — good luck debugging that.

There's a simpler pattern. One agent is in charge. The rest just do their job.

This is the Host Agent Pattern. Google's official A2A samples use it.

Host agent architecture: CLI talks to a Routing Agent, which delegates via A2A to Research, Writer, and other specialists

The Routing Agent is the only one that knows about other agents. It fetches their agent cards, understands their capabilities, and decides who handles what.

The specialized agents don't know anyone. They don't need to. They search the web, write files, analyze data — and respond.

Want to add a new agent? Deploy it and tell the Routing Agent its URL. No changes to existing agents. No new connections to wire up.

What we're building:

  • Routing Agent (port 8000) — orchestrator. Receives all requests, delegates to specialists
  • Research Agent (port 8001) — searches the web (DuckDuckGo)
  • Writer Agent (port 8002) — reads and writes files

User talks to Routing Agent. Routing Agent talks to everyone else. Simple chain of command.

Tech Stack

Three main pieces:

  1. LangGraph — building the agents
  2. MCP — giving agents tools
  3. A2A Python SDK — agent communication

LangGraph

LangGraph is a framework for building agents with LLMs. It handles the loop:

  1. Receive input
  2. Decide what to do
  3. Use tools if needed
  4. Return a response

It also gives us memory out of the box. Each agent can remember previous messages using a simple MemorySaver.

We're using Google's Gemini as the LLM, but you could swap in OpenAI, Anthropic, or another provider.

MCP (Model Context Protocol)

MCP is how we give tools to agents. Instead of hardcoding tool functions, we connect to MCP servers that expose tools.

Examples:

  • DuckDuckGo MCP server — web_search, fetch_content
  • Filesystem MCP server — read_file, write_file, edit_file

The agent doesn't know or care how those tools work internally. It just calls them through MCP. That keeps agent code clean and tools reusable.

A2A Python SDK

Google's official SDK for A2A. It gives us:

  • A2AServer — expose an agent as an A2A endpoint
  • A2AClient — send messages to other agents
  • AgentCard — describe what an agent can do

When we start an agent, it serves its agent card at /.well-known/agent-card.json. Other agents fetch that card to discover capabilities.

Building the Agents

Three agents:

  • Research — searches the web
  • Writer — saves files
  • Routing — decides who does what

The Base Agent

Before specialists, we need a foundation. Every agent needs to:

  • Start an A2A server (so others can find and talk to it)
  • Connect to MCP tools (so it can actually do things)
  • Wire up LangGraph (so it can think)

Instead of repeating that three times, we use a base class:

python
class BaseAgent(ABC):
def __init__(
self,
name: str,
description: str,
skills: list[AgentSkill],
mcp_command: str,
system_prompt: str,
):
self.name = name
self.description = description
self.skills = skills
self.mcp_command = mcp_command
self.system_prompt = system_prompt
self._memory = MemorySaver()

What's going on here:

  • name / description — how the agent introduces itself
  • skills — what it can do (others read this to decide whether to delegate)
  • mcp_command — shell command to start the MCP server for tools
  • system_prompt — how the agent thinks
  • _memory — LangGraph memory for conversation history

On startup, the agent grabs its tools:

python
async def setup(self):
# Start the MCP server and connect to it
self._mcp_connection = await self._mcp_manager.get_connection(
self.mcp_command
)
# Ask the MCP server: "What tools do you have?"
mcp_tool_infos = await self._mcp_connection.list_tools()
# Wrap those tools so LangGraph can use them
self._tools = self._mcp_manager.create_langchain_tools(
self._mcp_connection, mcp_tool_infos
)

Think of it like walking up to a toolbox (MCP server), looking inside, and grabbing what's there. Tools are discovered at runtime — not hardwired in advance.

Then the thinking loop:

python
async def process(self, query: str, context_id: str):
# Build the brain (only once)
if self._graph is None:
model = ChatGoogleGenerativeAI(model="gemini-2.0-flash")
self._graph = create_react_agent(
model,
tools=self._tools,
checkpointer=self._memory,
prompt=self.system_prompt,
)
# Remember conversations by context_id
config = {"configurable": {"thread_id": context_id}}
# Think and respond
async for event in self._graph.astream(
{"messages": [HumanMessage(content=query)]},
config=config,
):
# Yield responses as they come
...

LangGraph's create_react_agent does the heavy lifting. It takes:

  • An LLM (Gemini)
  • Tools (from MCP)
  • Memory (conversation history)
  • A system prompt (personality / instructions)

…and returns an agent that can reason, use tools, and respond.

context_id is how the agent remembers previous messages in a conversation.

Research Agent

python
SYSTEM_PROMPT = """You are a Research Agent specialized in finding information online.
You have access to web search tools. Use them to:
- Search for current information, news, and facts
- Find answers to questions
- Research topics thoroughly
Always provide comprehensive and accurate search results."""
class ResearchAgent(BaseAgent):
def __init__(self):
super().__init__(
name="Research Agent",
description="Searches the web using DuckDuckGo",
skills=[
AgentSkill(
id="web-search",
name="Web Search",
description="Search the internet for information",
tags=["search", "web", "research"],
)
],
mcp_command="uvx ddgs-mcp",
system_prompt=SYSTEM_PROMPT,
)
  • The system prompt is the job description.
  • The skills list is the resume. When routing asks "who can search the web?", these tags (search, web, research) make matching easy.
  • uvx ddgs-mcp starts a DuckDuckGo search server. On setup, the agent connects and gets tools like search_web(query).

Writer Agent

python
SYSTEM_PROMPT = """You are a Writer Agent specialized in file system operations.
You have access to tools to:
- Read files and directories
- Write and create files
- List directory contents
Always confirm what operations you've completed."""
class WriterAgent(BaseAgent):
def __init__(self, allowed_dir: str):
super().__init__(
name="Writer Agent",
description="Reads and writes files to the filesystem",
skills=[
AgentSkill(
id="file-ops",
name="File Operations",
description="Read, write, and manage files",
tags=["files", "write", "filesystem"],
)
],
mcp_command=f"npx @modelcontextprotocol/server-filesystem {allowed_dir}",
system_prompt=SYSTEM_PROMPT,
)

allowed_dir matters. The MCP filesystem server restricts all ops to that folder. Safety first — you don't want an agent deleting your home directory.

The command npx @modelcontextprotocol/server-filesystem /tmp/workspace gives tools like read_file(), write_file(), list_directory().

Wrap Each Agent in an A2A Server

An agent sitting in memory can't talk to anyone. Expose it over HTTP:

python
from a2a.server.apps import A2AStarletteApplication
from a2a.server.request_handlers import DefaultRequestHandler
from a2a.server.tasks import InMemoryTaskStore
# Create the agent
agent = ResearchAgent()
await agent.setup()
# Wrap it in an A2A server
agent_card = agent.get_agent_card("localhost", 8001)
executor = BaseAgentExecutor(agent)
task_store = InMemoryTaskStore()
handler = DefaultRequestHandler(agent_executor=executor, task_store=task_store)
app = A2AStarletteApplication(agent_card=agent_card, http_handler=handler)
# Start the server
config = uvicorn.Config(app=app.build(), host="localhost", port=8001)
server = uvicorn.Server(config)
await server.serve()

Research is live at http://localhost:8001. Anyone can:

  • Fetch its card at /.well-known/agent-card.json
  • Send it tasks via JSON-RPC

Same pattern for Writer on port 8002:

python
agent = WriterAgent(allowed_dir="/tmp/workspace")
await agent.setup()
agent_card = agent.get_agent_card("localhost", 8002)
executor = BaseAgentExecutor(agent)
task_store = InMemoryTaskStore()
handler = DefaultRequestHandler(agent_executor=executor, task_store=task_store)
app = A2AStarletteApplication(agent_card=agent_card, http_handler=handler)
config = uvicorn.Config(app=app.build(), host="localhost", port=8002)
server = uvicorn.Server(config)
await server.serve()

Two servers running. Still no door between them — that's the routing agent's job.

The Agent Registry

The registry is the routing agent's address book: who's available, how to reach them.

python
from a2a.client import A2ACardResolver, A2AClient
class AgentRegistry:
def __init__(self):
self._agents = {}
self._http_client = httpx.AsyncClient(timeout=120.0)
async def discover_agent(self, url: str):
# Fetch the agent's card
resolver = A2ACardResolver(httpx_client=self._http_client, base_url=url)
card = await resolver.get_agent_card()
# Create a client to talk to it
client = A2AClient(httpx_client=self._http_client, agent_card=card, url=url)
# Store for later
self._agents[card.name] = {
"card": card,
"client": client,
"url": url,
}
async def send_message(self, agent_name: str, task: str) -> str:
agent = self._agents.get(agent_name)
if not agent:
return f"Agent '{agent_name}' not found"
request = SendMessageRequest(
id=str(uuid.uuid4()),
params=MessageSendParams(
message=Message(
role=Role.user,
parts=[Part(root=TextPart(text=task))],
message_id=str(uuid.uuid4()),
)
),
)
response = await agent["client"].send_message(request)
return parse_response(response)
def get_agents_summary(self) -> str:
lines = ["Available agents:"]
for name, agent in self._agents.items():
lines.append(f" - {name}: {agent['card'].description}")
return "\n".join(lines)

The registry can:

  • Discover — fetch cards and store connections
  • Send messages — route tasks to the right agent
  • Summarize — tell the LLM who is available
python
class RoutingAgent:
def __init__(self):
self.name = "Routing Agent"
self._registry = None
self._memory = MemorySaver()
async def setup(self, agent_urls: list[str]):
self._registry = AgentRegistry()
for url in agent_urls:
await self._registry.discover_agent(url)
def _create_send_message_tool(self):
registry = self._registry
async def send_message(agent_name: str, task: str) -> str:
return await registry.send_message(agent_name, task)
return StructuredTool(
name="send_message",
description="Send a task to a specialized remote agent",
coroutine=send_message,
)

The routing agent:

  1. Takes agent URLs during setup
  2. Discovers each one through the registry
  3. Exposes a send_message tool LangGraph can call

Start the Routing Agent Server

python
agent = RoutingAgent()
# Discover specialist agents
await agent.setup([
"http://localhost:8001", # Research
"http://localhost:8002", # Writer
])
agent_card = agent.get_agent_card("localhost", 8000)
executor = RoutingAgentExecutor(agent)
task_store = InMemoryTaskStore()
handler = DefaultRequestHandler(agent_executor=executor, task_store=task_store)
app = A2AStarletteApplication(agent_card=agent_card, http_handler=handler)
config = uvicorn.Config(app=app.build(), host="localhost", port=8000)
server = uvicorn.Server(config)
await server.serve()

Routing is live at http://localhost:8000. It knows both specialists and can delegate.

Create a Client

python
from a2a.client import A2ACardResolver, A2AClient
async def main():
resolver = A2ACardResolver(
httpx_client=httpx.AsyncClient(),
base_url="http://localhost:8000",
)
card = await resolver.get_agent_card()
client = A2AClient(
httpx_client=httpx.AsyncClient(),
agent_card=card,
url="http://localhost:8000",
)
request = SendMessageRequest(
id=str(uuid.uuid4()),
params=MessageSendParams(
message=Message(
role=Role.user,
parts=[Part(root=TextPart(
text="research AI news and save to notes.txt"
))],
message_id=str(uuid.uuid4()),
)
),
)
response = await client.send_message(request)
print(response)

Or an interactive CLI with a session id so the agent remembers the thread:

python
registry = AgentRegistry()
await registry.discover_agent("http://localhost:8000")
session_id = str(uuid.uuid4())
while True:
message = input("You: ")
if message.lower() in ("exit", "quit"):
break
response = await registry.send_message_with_session(
"Routing Agent",
message,
session_id,
)
print(f"Agent: {response}")

Startup Sequence

Specialists come up first. Routing discovers them. Then the client can talk.

Startup sequence: start specialized agents, start routing agent, agent discovery via agent-card.json

Multi-Agent Task Flow

A single user message becomes load history → model + tools → A2A calls to Research and Writer → save conversation → final answer.

Task flow sequence diagram from CLI through Routing Agent, MemorySaver, Gemini, Research, and Writer

See It in Action

Research, save, and follow-up across agents in one terminal session:

Terminal demo: Research on :8001, Writer on :8002, Routing on :8000, client session with a2a-send

What We Built

A multi-agent system where:

  • Agents discover each other through A2A agent cards
  • Tools come from MCP — no hardcoding search or file logic
  • LangGraph handles reasoning — each agent thinks independently
  • The routing agent orchestrates — delegates based on skills, not brittle rules

The specialists stay small. Routing is basically an LLM with one tool: send_message.

That's the point.

  • A2A handles communication
  • MCP handles tools
  • LangGraph handles thinking and memory

You wire them together.

What you can build next

  • More specialists (code writer, data analyst, email sender)
  • Different MCP servers (databases, APIs, browsers)
  • Agents across machines — A2A doesn't care where they run
  • Agent marketplaces where anyone can publish and discover agents

The future isn't one super-agent that does everything. It's many small agents that do one thing well — and talk to each other.

Thanks for reading.

Keep reading

Related notes