A2A Protocol: Make AI Agents Talk to Each Other in Python — ContentBuffer guide

A2A Protocol: Make AI Agents Talk to Each Other in Python

K
Kodetra Technologies··5 min read Intermediate

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, and httpx
  • 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 used agent.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: true but you have no streaming endpoint, compliant clients will call it and get errors. Only advertise what you implement.

Quick reference

PieceWhere it livesPurpose
Agent Card/.well-known/agent-card.jsonDiscovery: identity, skills, endpoint URL
message/sendJSON-RPC method at card urlSend a message to start a task
TaskServer responseTracks state; holds artifacts + history
ArtifactInside the TaskCarries the actual output back to the client
A2A-VersionRequest headerNegotiates 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

Subscribe to join the conversation...

Be the first to comment