Step-by-Step · Wire-Level · A2A v1.0

Agent-to-Agent — A2A at the protocol level.

You build two AI agents that have never seen each other's code, plus an orchestrator that hires them one after the other. By the end you understand A2A at the protocol level — not just the SDK surface, but what actually goes over the network.

2
Agents that have never seen each other
1
Orchestrator that hires both
JSON-RPC
Transport over plain HTTP POST
v1.0
Protocol, as of 2026
AT
Aria Tan
Agent Architect · Multi-Agent Systems

"I build systems in which agents hire each other — across team, framework, and machine boundaries. A2A is the standard for that. This guide is the path I give every new engineer on the team: first the five concepts, then two agents, then the wire itself."

Verified against a2a-sdk 1.1.0 · Python 3.13 · Groq 1.4.0 · Protocol facts from the A2A v1.0 spec, May 2026.

Learning path

Three stages — from concept to wire

First the mental model, then the runnable code, then what really goes over the network. In this order, A2A becomes traceable.

1 — The Core Concepts

Five concepts carry the entire project: A2A, Agent Card, JSON-RPC, Protobuf, and the Task lifecycle. Read them once — and the code stops being magic.

To the concepts

2 — Build Two Agents

A Research Agent and a Writer Agent as A2A servers, plus an orchestrator as the client that discovers both via their Cards and hires them one after the other.

Build the agents

3 — The Wire

Strip away the SDK, and you see the raw JSON-RPC envelope, the Task response, and a clean failure trace. Plus the v1.0 path into production.

Read the wire
Concepts · 00

The Core Concepts

Five concepts carry the entire project. Read them once, and the code stops being magic.

A2A — Agent2Agent

An open protocol (Linux Foundation, contributed by Google) that lets one agent call another over HTTP — across teams, frameworks, and machines. REST for agents: both sides agree on a discovery document, a message format, and a transport.

A2A vs. MCP — sideways vs. down

MCP connects an agent downward to tools and data — the other side is passive. A2A connects an agent sideways to other agents — the other side is autonomous and can decide, decline, or ask follow-up questions.

Agent Card

A small JSON document that every agent publishes at the fixed path /.well-known/agent-card.json. It answers three questions: who are you, what can you do (skills), how do I reach you (URL + protocol). Discovery means: no hardcoded endpoints in the calling logic.

JSON-RPC 2.0

The decades-old "call a function on a remote server via JSON" standard. A request names a method, passes params, carries an id; the response mirrors the id and delivers a result or an error. A2A uses it as one transport; the method is message/send.

Protobuf

The schema, not necessarily the wire bytes. Every type — AgentCard, Message, Task, Artifact — is a Protobuf object. Over JSON-RPC it serializes to JSON, but with Protobuf fingerprints: fields in camelCase (mediaType), enums as uppercase strings (ROLE_USER).

Life of a Task

Stateful work moves through a lifecycle: submitted → working → completed (or input-required, failed, canceled, rejected). Results attach as Artifacts. The order in which you emit these events is not optional — more on that in the Research Agent.

Architecture · 01

Architecture Overview

The orchestrator drives everything. It reads each agent's Card, sends a message, and reads the artifact that comes back. The agents each call Groq to do their actual thinking.

1 — Fetch the Research Card

The orchestrator fetches /.well-known/agent-card.json from the Research Agent to learn its address and skills. No hardcoded endpoint.

2 — Send the topic

It sends the topic as a message and receives a summary back as a Task artifact.

3 — Fetch the Writer Card

The same discovery step, this time for the Writer Agent on a different port.

4 — Send the summary, receive the rewrite

The output of the first agent becomes the input of the second. That chaining is the orchestration.

The data flow in one sentence.

orchestrator → research card → research agent → summary → writer card → writer agent → rewrite. Each agent calls the Groq LLM behind the scenes; the orchestrator sees none of it except the finished artifact.

Setup · 02

Prerequisites & Setup

You need Python 3.13 and uv (never pip or venv directly), plus a free Groq API key from console.groq.com (no credit card).

terminal · scaffold
uv init a2a-agents --python 3.13
cd a2a-agents

The most important detail: the A2A server routes need Starlette, which only ships as an optional extra. Install via the [http-server] extra, or the server won't import.

terminal · dependencies
uv add "a2a-sdk[http-server]==1.1.0" "groq==1.4.0" uvicorn httpx python-dotenv

Create a .env with your key, plus a committed .env.example template. Make sure .gitignore excludes .env, .venv/, and __pycache__/.

.env
# .env
GROQ_API_KEY=gsk_your_actual_key_here

Step 1 — Verify Your Groq Key

Before you build agents, confirm the key works with a tiny async call. That also teaches the most important Windows rule: call load_dotenv() before constructing the Groq client — the client reads GROQ_API_KEY from the environment at the moment it is created, and uv run does not load .env automatically on Windows.

test_groq.py
"""Throwaway check that GROQ_API_KEY works with the async Groq client."""
import asyncio

from dotenv import load_dotenv
from groq import AsyncGroq

load_dotenv()  # must run before AsyncGroq()

async def main() -> None:
    client = AsyncGroq()
    response = await client.chat.completions.create(
        model="llama-3.3-70b-versatile",
        messages=[{"role": "user", "content": "Reply with exactly: Groq is working"}],
        max_tokens=20,
    )
    print(response.choices[0].message.content)

if __name__ == "__main__":
    asyncio.run(main())

Every run command in this project is prefixed with PYTHONPATH=. so that package imports resolve correctly.

terminal
PYTHONPATH=. uv run python test_groq.py
Failure modes.

An authentication error means the key is wrong. GROQ_API_KEY not found means your .env is missing or load_dotenv() was skipped.

Step 2 · 04

The Research Agent

An A2A agent server is three concepts wired together: the AgentCard (the business card), the AgentExecutor (your logic — two async methods, execute and cancel), and the route factories that turn Card + handler into the GET discovery route and the POST work route.

Imports, Prompt, Card

The string "JSONRPC" in the interface is exact — it must match the SDK's transport name, or the client won't find the endpoint.

agents/research_agent.py · top
"""Research Agent — an A2A server that summarizes a topic with Groq."""
from dotenv import load_dotenv
from groq import AsyncGroq
from starlette.applications import Starlette

from a2a.helpers import new_task_from_user_message, new_text_message, new_text_part
from a2a.server.agent_execution import AgentExecutor, RequestContext
from a2a.server.events import EventQueue
from a2a.server.request_handlers import DefaultRequestHandler
from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes
from a2a.server.tasks import InMemoryTaskStore, TaskUpdater
from a2a.types import AgentCapabilities, AgentCard, AgentInterface, AgentSkill

load_dotenv()

SYSTEM_PROMPT = (
    "You are a research assistant. Given a topic, write a tight, factual "
    "summary in 3-4 sentences. No preamble, no bullet points."
)

def build_agent_card() -> AgentCard:
    """Build this agent's public Agent Card (its discovery document)."""
    skill = AgentSkill(
        id="summarize_topic",
        name="Summarize Topic",
        description="Generates a concise 3-4 sentence research summary of any topic.",
        tags=["research", "summary"],
        examples=["Summarize transformers in AI"],
    )
    return AgentCard(
        name="Research Agent",
        description="Summarizes any topic into a short research brief using Groq.",
        version="1.0.0",
        default_input_modes=["text/plain"],
        default_output_modes=["text/plain"],
        capabilities=AgentCapabilities(streaming=False),
        supported_interfaces=[
            AgentInterface(url="http://localhost:8001", protocol_binding="JSONRPC"),
        ],
        skills=[skill],
    )

The Executor — and the order that is not optional

The Task object must reach the queue before any status event, or the SDK throws InvalidAgentResponseError. And the artifact must be added before complete(), because complete() marks a terminal state that locks out further updates.

1 — Enqueue Task (FIRST)

Get or create the task and enqueue the Task object first.

2 — start_work()

TaskUpdater wraps the queue so standard lifecycle events get emitted.

3 — _summarize() → Groq

The actual work: topic in, summary out.

4 — add_artifact(), then complete()

Attach the artifact first, then set the terminal state.

agents/research_agent.py · executor
class ResearchAgentExecutor(AgentExecutor):
    """Core logic: read the topic, call Groq, publish a summary artifact."""

    def __init__(self) -> None:
        self.groq = AsyncGroq()  # reads GROQ_API_KEY from the environment

    async def _summarize(self, topic: str) -> str:
        """Call Groq and return the summary text."""
        response = await self.groq.chat.completions.create(
            model="llama-3.3-70b-versatile",
            messages=[
                {"role": "system", "content": SYSTEM_PROMPT},
                {"role": "user", "content": topic},
            ],
            max_tokens=512,
        )
        return response.choices[0].message.content

    async def execute(self, context: RequestContext, event_queue: EventQueue) -> None:
        """Handle one A2A request: topic in, summary artifact out."""
        # 1. Get or create the task, and enqueue the Task object FIRST.
        task = context.current_task or new_task_from_user_message(context.message)
        if not context.current_task:
            await event_queue.enqueue_event(task)

        # 2. TaskUpdater wraps the queue so we emit standard lifecycle events.
        updater = TaskUpdater(event_queue=event_queue, task_id=task.id, context_id=task.context_id)
        await updater.start_work(message=new_text_message("Researching the topic..."))

        # 3. Do the actual work.
        summary = await self._summarize(context.get_user_input())

        # 4. Attach the artifact, THEN mark complete.
        await updater.add_artifact(parts=[new_text_part(summary, media_type="text/plain")])
        await updater.complete(message=new_text_message("Research complete."))

    async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None:
        """No long-running work to interrupt in this project."""
        return None

Wiring it into an app

DefaultRequestHandler holds the executor, an in-memory task store, and the Card. The two route factories create the GET (discovery) and POST (work) routes, and app is the object that uvicorn imports.

agents/research_agent.py · app
def build_app() -> Starlette:
    """Wire the executor and card into a Starlette app with A2A routes."""
    card = build_agent_card()
    handler = DefaultRequestHandler(
        agent_executor=ResearchAgentExecutor(),
        task_store=InMemoryTaskStore(),
        agent_card=card,
    )
    routes = [
        *create_agent_card_routes(card),
        *create_jsonrpc_routes(handler, "/"),
    ]
    return Starlette(routes=routes)

app = build_app()
Package note.

You also need an empty agents/__init__.py so that agents.research_agent imports as a package. Start the agent, then fetch its Card to confirm it is serving.

terminal · run + verify
PYTHONPATH=. uv run uvicorn agents.research_agent:app --port 8001
# in a second terminal:
curl http://localhost:8001/.well-known/agent-card.json

You should get JSON that contains "name":"Research Agent", the summarize_topic skill, and "url":"http://localhost:8001".

Step 3 · 05

The Writer Agent

The Writer Agent is structurally identical to the Research Agent. That is exactly the lesson: the plumbing (executor lifecycle, routes, Card structure) is boilerplate you copy, and the value lives entirely in the prompt and the advertised skill. Exactly four things change.

1 — SYSTEM_PROMPT

rewrite instead of summarize.

2 — AgentSkill id

rewrite_simple instead of summarize_topic.

3 — Card identity

Writer Agent instead of Research Agent.

4 — Port

8002 instead of 8001.

Copy research_agent.py to agents/writer_agent.py, rename _summarize to _rewrite, change the status strings, then swap the prompt and the Card.

agents/writer_agent.py · the four changes
SYSTEM_PROMPT = (
    "You are an explainer. Rewrite the given text as a punchy, beginner-friendly "
    "explanation a smart 15-year-old would enjoy. Keep it to 3-4 short sentences. "
    "Use plain words and one concrete analogy. No preamble."
)

skill = AgentSkill(
    id="rewrite_simple",
    name="Rewrite Simply",
    description="Rewrites any text into a punchy, beginner-friendly explanation.",
    tags=["writing", "explainer"],
    examples=["Rewrite this dense paragraph so a beginner gets it"],
)

return AgentCard(
    name="Writer Agent",
    description="Rewrites text into a punchy, beginner-friendly explanation using Groq.",
    version="1.0.0",
    default_input_modes=["text/plain"],
    default_output_modes=["text/plain"],
    capabilities=AgentCapabilities(streaming=False),
    supported_interfaces=[
        AgentInterface(url="http://localhost:8002", protocol_binding="JSONRPC"),
    ],
    skills=[skill],
)
terminal · run + verify (second terminal)
PYTHONPATH=. uv run uvicorn agents.writer_agent:app --port 8002
curl http://localhost:8002/.well-known/agent-card.json
Step 4 · 06

The Orchestrator

The orchestrator is a client, not a server. It knows nothing about the agents except their base URLs. For each agent it resolves the Card, builds a client from it, sends a message, and reads the artifact off the returned Task. The output of the first agent becomes the input of the second.

SDK surface — verify before running.

The A2A client API shifted across the 1.x line. The pattern below follows the official A2A v1.0 client tutorial (A2ACardResolver + create_client + send_message as an async iterator). On a2a-sdk==1.1.0 the equivalent may be ClientFactory(ClientConfig(...)).create(card) or .create_from_url(url). Confirm the exact import names against your installed version with uv run python -c "import a2a.client as c; print(dir(c))" before treating this as final.

orchestrator.py
"""Orchestrator — hires the Research Agent, then the Writer Agent, over A2A."""
import asyncio

import httpx
from a2a.client import A2ACardResolver, create_client
from a2a.client.client import ClientConfig
from a2a.helpers import new_text_message
from a2a.types import Role, SendMessageRequest, Task

RESEARCH_URL = "http://localhost:8001"
WRITER_URL = "http://localhost:8002"

def artifact_text(task: Task) -> str:
    """Pull the text out of the first artifact of a completed Task."""
    for artifact in task.artifacts or []:
        for part in artifact.parts:
            if getattr(part, "text", None):
                return part.text
    return ""

async def call_agent(http: httpx.AsyncClient, base_url: str, text: str) -> str:
    # 1. Resolve the card from /.well-known/agent-card.json — discovery, no hardcoding.
    resolver = A2ACardResolver(httpx_client=http, base_url=base_url)
    card = await resolver.get_agent_card()

    # 2. Build a client from the card. Transport is read off the card, not assumed.
    client = await create_client(agent=card, client_config=ClientConfig(streaming=False))

    # 3. Send one message; these agents reply with a single completed Task.
    request = SendMessageRequest(message=new_text_message(text, role=Role.ROLE_USER))
    result = None
    async for event in client.send_message(request):
        result = event  # last event is the final Task

    return artifact_text(result)

async def main() -> None:
    topic = "the transformer architecture in AI"
    async with httpx.AsyncClient(timeout=60) as http:
        summary = await call_agent(http, RESEARCH_URL, topic)
        print("RESEARCH SUMMARY:\n", summary, "\n")

        rewrite = await call_agent(http, WRITER_URL, summary)
        print("WRITER REWRITE:\n", rewrite)

if __name__ == "__main__":
    asyncio.run(main())

Step 5 — Run It End to End

Three processes. Two agent servers stay running; the orchestrator is a one-shot script. The topic flows through both agents and comes out as a rewritten explanation.

three terminals
# terminal 1
PYTHONPATH=. uv run uvicorn agents.research_agent:app --port 8001
# terminal 2
PYTHONPATH=. uv run uvicorn agents.writer_agent:app --port 8002
# terminal 3
PYTHONPATH=. uv run python orchestrator.py
The pipeline in one picture.

topic → Research Agent :8001 → summary → Writer Agent :8002 → final explanation. The orchestrator chains both without ever hardcoding an endpoint.

The Wire · 08

Understanding the Wire

Strip away the SDK — and this is what goes over the network when the orchestrator sends a topic: an ordinary HTTP POST carrying a JSON-RPC 2.0 envelope. Note the Protobuf fingerprints: camelCase fields, uppercase enum strings.

request · POST http://localhost:8001/
{
  "jsonrpc": "2.0",
  "id": "req-1",
  "method": "message/send",
  "params": {
    "message": {
      "role": "ROLE_USER",
      "parts": [{ "text": "the transformer architecture in AI" }],
      "messageId": "a1b2c3d4"
    }
  }
}
response · the completed Task
{
  "jsonrpc": "2.0",
  "id": "req-1",
  "result": {
    "id": "task-9f8e",
    "contextId": "ctx-7a6b",
    "status": { "state": "TASK_STATE_COMPLETED" },
    "artifacts": [{
      "artifactId": "art-1",
      "name": "result",
      "parts": [{ "text": "Transformers are a neural-network design that..." }]
    }]
  }
}

The id on the response mirrors the request's. The actual work lives under result; an error object would replace it on failure. Everything else is the Protobuf schema dressed as JSON.

Reading a failure (wrong port)

Point the orchestrator at a port where no agent is listening, and the failure is precise and early — it happens at discovery, before any message is sent. Nothing reaches the LLM.

traceback · abridged
httpx.ConnectError: All connection attempts failed
  ... raised during A2ACardResolver.get_agent_card()
a2a.client.AgentCardResolutionError: agent card could not be resolved
  at http://localhost:8003/.well-known/agent-card.json
How to read it.

The error names the discovery step, not the message step — proof that A2A clients fail fast on a bad address instead of sending work into the void. Fix the URL in the orchestrator and restart; no agent code changes.

v1.0 & Production · 10

What v1.0 changes for production

This tutorial runs on localhost without auth — right for learning, not for deployment. A2A v1.0 (early 2026) hardened the spec along four axes that map directly to enterprise requirements.

Signed Agent Cards

A cryptographic signature lets a receiving agent verify that the Card was issued by the domain owner — the defense against card-forgery attacks that redirect agents to a malicious endpoint. This trust model is what makes decentralized discovery viable in the first place.

Multi-Tenancy

A single endpoint can host multiple agents, so a SaaS provider can serve a different agent per tenant without spinning up new infrastructure per customer.

Multi-Protocol Bindings

The same logical agent can be exposed over JSON-RPC and gRPC — JSON-RPC for reach and debuggability, gRPC for throughput, without forking the agent.

Version Negotiation

Spec-level guarantees for backward-compatible migration (v0.3 → v1.0), so a fleet of agents can upgrade incrementally instead of in a risky big-bang cutover.

Adoption signal.

At the one-year mark (April 2026), A2A passed 150+ supporting organizations, with native integration in Azure AI Foundry / Copilot Studio and Amazon Bedrock AgentCore. The AP2 extension adds agent-driven payments. For inter-agent integration there is practically no competing standard today.

What you build next

Swap in streaming

Set streaming=True on the capabilities and the client config to receive status_update and artifact_update chunks as they arrive, instead of one final Task.

Add a third agent

A fact-checker between Research and Writer. The orchestrator change is one more call_agent line; the agents stay untouched. That is the payoff of discovery-based wiring.

Persist tasks

Replace InMemoryTaskStore with a durable store so tasks survive restarts and can be polled by id.

Harden for deploy

Sign the Agent Cards, put auth in front of the JSON-RPC route, and move off localhost behind real DNS/TLS.

Agent code verified against a2a-sdk 1.1.0 · Client pattern & v1.0 facts from a2a-protocol.org & Linux Foundation, May 2026.