
A2A Protocol: Make AI Agents Talk to Each Other in Python
Summary
Build a minimal A2A server with an Agent Card so other agents can discover and message it.
AI agents used to live on islands. Your LangGraph research agent could not call a teammate's CrewAI booking agent, because each framework speaks its own dialect. The Agent2Agent (A2A) protocol fixes that: it is an open standard, now at version 1.0, that lets any agent discover and message any other agent over plain HTTP and JSON-RPC — no shared code, no shared framework.
In this guide you will build the smallest possible A2A setup from scratch in Python: a server agent that publishes an Agent Card and answers messages, plus a client agent that discovers it and asks a question. Once you see the three moving parts — discovery, message, result — you can wrap any existing agent in the same envelope.
What you'll build
A "Weather Agent" that exposes two endpoints: a discovery document at /.well-known/agent-card.json, and a JSON-RPC endpoint that handles the message/send method. A separate client reads the card, then sends a message and prints the reply.
Prerequisites
- Python 3.10+ and
pip - Comfort with HTTP and JSON — A2A is just JSON over POST
- Three packages:
starlette,uvicorn, andhttpx - No A2A SDK required for this demo (we show the SDK shortcut at the end)
Step 1: Design the Agent Card
The Agent Card is a JSON document that describes who the agent is and what it can do. Clients fetch it from the well-known URI before sending anything. The key fields are url (where to send messages), capabilities (streaming, push notifications), and skills (a machine-readable menu of what the agent offers).
{
"protocolVersion": "1.0",
"name": "Weather Agent",
"description": "Answers questions about current weather for a city.",
"url": "http://localhost:9000/",
"preferredTransport": "JSONRPC",
"version": "1.0.0",
"capabilities": {
"streaming": false,
"pushNotifications": false
},
"defaultInputModes": [
"text/plain"
],
"defaultOutputModes": [
"text/plain"
],
"skills": [
{
"id": "current-weather",
"name": "Current weather",
"description": "Returns the current weather for a named city.",
"tags": [
"weather",
"lookup"
],
"examples": [
"What's the weather in Tokyo?"
]
}
]
}
Notice that the card never reveals how the agent works internally — A2A calls this "opaque execution." A client only needs the declared skills and the endpoint URL.
Step 2: Serve the card and a message handler
Now the server. The discovery route returns the card. The root route handles JSON-RPC. When a message/send arrives, we pull the text out of the message parts, compute an answer, and return it inside a Task whose result is carried by an artifact. In A2A, messages start a task and artifacts carry the output — keep those two ideas separate.
# server.py -- a minimal A2A server (no SDK, stdlib + Starlette + uvicorn)
import json
from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.routing import Route
AGENT_CARD = {
"protocolVersion": "1.0",
"name": "Weather Agent",
"description": "Answers questions about current weather for a city.",
"url": "http://localhost:9000/",
"preferredTransport": "JSONRPC",
"version": "1.0.0",
"capabilities": {"streaming": False, "pushNotifications": False},
"defaultInputModes": ["text/plain"],
"defaultOutputModes": ["text/plain"],
"skills": [{
"id": "current-weather",
"name": "Current weather",
"description": "Returns the current weather for a named city.",
"tags": ["weather", "lookup"],
"examples": ["What's the weather in Tokyo?"],
}],
}
# 1) Discovery endpoint: any A2A client looks here first.
async def agent_card(request):
return JSONResponse(AGENT_CARD)
def lookup_weather(text: str) -> str:
fake = {"tokyo": "18C and clear", "london": "11C and raining"}
for city, report in fake.items():
if city in text.lower():
return f"{city.title()}: {report}"
return "I only know Tokyo and London in this demo."
# 2) JSON-RPC endpoint: handles the "message/send" method.
async def rpc(request):
body = await request.json()
if body.get("method") != "message/send":
return JSONResponse({"jsonrpc": "2.0", "id": body.get("id"),
"error": {"code": -32601, "message": "Method not found"}})
parts = body["params"]["message"]["parts"]
user_text = " ".join(p.get("text", "") for p in parts)
answer = lookup_weather(user_text)
# A task carries the result back as an artifact.
result = {
"id": "task-1", "contextId": "ctx-1",
"status": {"state": "completed"},
"artifacts": [{"artifactId": "a1",
"parts": [{"kind": "text", "text": answer}]}],
}
return JSONResponse({"jsonrpc": "2.0", "id": body.get("id"), "result": result})
app = Starlette(routes=[
Route("/.well-known/agent-card.json", agent_card),
Route("/", rpc, methods=["POST"]),
])
# run with: uvicorn server:app --port 9000
Step 3: Discover, then message (the client)
The client does three things in order: fetch the card, build a JSON-RPC request for message/send, and POST it to the URL listed in the card. Note the A2A-Version: 1.0 header — from v1.0 the server assumes 0.3 if you omit it, which can silently change behavior.
# client.py -- discover the agent, then send it a message
import httpx, uuid
BASE = "http://localhost:9000"
# Step A: discovery -- read the Agent Card from the well-known URI.
card = httpx.get(f"{BASE}/.well-known/agent-card.json").json()
print("Discovered:", card["name"], "->", [s["id"] for s in card["skills"]])
# Step B: build a JSON-RPC "message/send" request.
req = {
"jsonrpc": "2.0",
"id": str(uuid.uuid4()),
"method": "message/send",
"params": {
"message": {
"role": "user",
"messageId": str(uuid.uuid4()),
"parts": [{"kind": "text", "text": "What's the weather in Tokyo?"}],
}
},
}
# Step C: call the agent. The A2A-Version header is required from v1.0.
resp = httpx.post(card["url"], json=req,
headers={"A2A-Version": "1.0"}).json()
artifact = resp["result"]["artifacts"][0]
print("Answer:", artifact["parts"][0]["text"])
Step 4: Run it and watch the exchange
# Terminal 1 -- start the agent
pip install starlette uvicorn httpx
uvicorn server:app --port 9000
# Terminal 2 -- run the client
python client.py
Expected output from the client:
Discovered: Weather Agent -> ['current-weather']
Answer: Tokyo: 18C and clear
You can also hit the discovery endpoint directly to confirm the card is live:
curl http://localhost:9000/.well-known/agent-card.json | jq .name
# "Weather Agent"
Common pitfalls
- Wrong well-known path. v1.0 uses
/.well-known/agent-card.json. Older drafts usedagent.json— clients built to spec will not find an agent published at the old path. - Putting results in messages. The spec says results belong in artifacts, not messages. Messages are for initiating tasks and status updates; return data as artifacts so clients can reliably read it.
- Forgetting the A2A-Version header. Omitting it makes the server assume protocol 0.3, so v1.0 features may be silently disabled.
- Declaring capabilities you don't support. If your card says
streaming: truebut you have no streaming endpoint, compliant clients will call it and get errors. Only advertise what you implement.
Quick reference
| Piece | Where it lives | Purpose |
|---|---|---|
| Agent Card | /.well-known/agent-card.json | Discovery: identity, skills, endpoint URL |
| message/send | JSON-RPC method at card url | Send a message to start a task |
| Task | Server response | Tracks state; holds artifacts + history |
| Artifact | Inside the Task | Carries the actual output back to the client |
| A2A-Version | Request header | Negotiates protocol version (use 1.0) |
Next steps
This hand-rolled version teaches the wire format, but in production reach for the official a2a-sdk (pip install a2a-sdk): it gives you an A2AStarletteApplication, request validation, streaming via Server-Sent Events, and push notifications out of the box. From here, try adding a second agent and have your first agent act as an A2A client to it — that is exactly how multi-agent systems compose across frameworks.
Comments
Be the first to comment