To build a2a agent that interoperates with the growing ecosystem of agent runtimes, you need to speak JSON-RPC 2.0 over HTTP and publish a machine-readable Agent Card. This tutorial walks through a minimal but spec-compliant echo agent in Python, then a client that drives a task to completion, so you can see the wire format before adding real logic.
Prerequisites
- Python 3.10+ (tested on 3.11)
pip install flask requestscurlfor quick checks- Basic familiarity with HTTP and JSON
- Optional: an OpenAI-compatible LLM endpoint if you want the agent to do more than echo
No external A2A SDK is required. The protocol is simple enough to implement directly, which makes the contract obvious.
The A2A surface area
A2A defines two primary HTTP resources:
- An Agent Card, typically served at
/.well-known/agent.json, describing name, capabilities, skills, and the agent’s base URL. - A task endpoint, usually
/tasks, accepting JSON-RPC 2.0 calls. The core method istasks/send, which carries amessagewithparts(text, file, or data).
The response is a task object with a status.state (submitted, working, completed, failed) and optional artifacts. That is the entire handshake for a synchronous agent.
Step 1: Publish the Agent Card
Create agent.py and start with the card. This is what another agent discovers before calling you.
from flask import Flask, request, jsonify
app = Flask(__name__)
AGENT_CARD = {
"protocolVersion": "0.1.0",
"name": "Echo Agent",
"description": "A minimal A2A-compliant echo agent",
"url": "http://localhost:5000",
"capabilities": {"streaming": False, "pushNotifications": False},
"skills": [
{
"id": "echo",
"name": "Echo",
"description": "Returns the input text prefixed with 'Echo: '",
"tags": ["echo", "demo"],
}
],
}
@app.route("/.well-known/agent.json", methods=["GET"])
def agent_card():
return jsonify(AGENT_CARD)
Run it temporarily to confirm the card is served:
python agent.py &
curl http://localhost:5000/.well-known/agent.json
Expected output (pretty-printed by curl):
{
"protocolVersion": "0.1.0",
"name": "Echo Agent",
"description": "A minimal A2A-compliant echo agent",
"url": "http://localhost:5000",
"capabilities": {"streaming": false, "pushNotifications": false},
"skills": [
{"id": "echo", "name": "Echo", "description": "Returns the input text prefixed with 'Echo: '", "tags": ["echo", "demo"]}
]
}
Step 2: Implement the task endpoint
Extend agent.py with the JSON-RPC handler. A2A reuses JSON-RPC id matching, so echo the id from the request.
@app.route("/tasks", methods=["POST"])
def tasks():
payload = request.json
if payload.get("method") != "tasks/send":
return jsonify({
"jsonrpc": "2.0",
"id": payload.get("id"),
"error": {"code": -32601, "message": "Method not found"},
})
params = payload["params"]
task_id = params["id"]
msg = params["message"]
text = ""
for part in msg.get("parts", []):
if part.get("type") == "text":
text += part.get("text", "")
return jsonify({
"jsonrpc": "2.0",
"id": payload["id"],
"result": {
"id": task_id,
"status": {"state": "completed"},
"artifacts": [
{"parts": [{"type": "text", "text": f"Echo: {text}"}]}
],
},
})
This handler extracts text parts, builds a completed task with a single artifact, and returns it. No session store, no async—just the minimal compliant shape.
Step 3: Run and verify the agent
Start the server (if not already running) and send a raw tasks/send via curl:
curl -X POST http://localhost:5000/tasks \
-H 'Content-Type: application/json' \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tasks/send",
"params": {
"id": "task-001",
"message": {
"role": "user",
"parts": [{"type": "text", "text": "hello a2a"}]
}
}
}'
Expected response:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"id": "task-001",
"status": {"state": "completed"},
"artifacts": [
{"parts": [{"type": "text", "text": "Echo: hello a2a"}]}
]
}
}
If you see that, you have a working agent. The hard part of learning to build a2a agent is mostly understanding that the protocol does not hide the message shape—it is plain JSON.
Step 4: Write a client
A client that talks to your agent is just another HTTP caller. Put this in client.py:
import requests
import json
def send_task(base_url: str, text: str, task_id: str = "task-123"):
payload = {
"jsonrpc": "2.0",
"id": 1,
"method": "tasks/send",
"params": {
"id": task_id,
"message": {
"role": "user",
"parts": [{"type": "text", "text": text}],
},
},
}
r = requests.post(f"{base_url}/tasks", json=payload)
r.raise_for_status()
return r.json()
if __name__ == "__main__":
res = send_task("http://localhost:5000", "ping from client")
print(json.dumps(res, indent=2))
Run it:
python client.py
You should get the same structured task result, now prefixed with Echo: ping from client.
Step 5: Replace echo with real inference
Echo is a fine smoke test, but most agents exist to call a model. Swap the artifact generation for an OpenAI-compatible chat completion. Install the SDK:
pip install openai
Modify the handler to call the model instead of string concatenation:
from openai import OpenAI
client = OpenAI(base_url="https://api.openai.com/v1", api_key="YOUR_KEY")
# Inside tasks():
completion = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": text}],
)
reply = completion.choices[0].message.content
# Then in result artifacts:
"artifacts": [{"parts": [{"type": "text", "text": reply}]}]
If you want resilience across providers, an OpenRouter-class gateway such as n4n.ai exposes one OpenAI-compatible endpoint covering 240+ models with automatic fallback when a provider is degraded, so the base_url swap is the only change needed.
The Agent Card should then reflect the real skill:
{
"id": "summarize",
"name": "Summarizer",
"description": "Summarizes input text using an LLM",
"tags": ["nlp", "summarization"]
}
Common pitfalls when you build a2a agent integrations
Mismatched IDs. JSON-RPC requires the response id to match the request id. Some HTTP clients auto-generate it; keep it explicit.
Assuming streaming. The base spec supports streaming: false. If you set it true, clients expect Server-Sent Events on the same endpoint. Don’t claim a capability you don’t implement.
Losing the parts array. A2A messages are arrays of typed parts, not a single text field. Even if you only use text, wrap it: {"type": "text", "text": "..."}.
Agent Card URL drift. The url in the card must match where tasks are actually served. Discovery tools will call {url}/tasks; a mismatch yields 404s that are painful to debug.
Wrapping up
You now have a runnable server, a client, and the knowledge to swap in a model. The next step is to add tasks/get for async flows and push notifications for long-running jobs. But the core lesson stands: to build a2a agent systems, get the Agent Card and the tasks/send JSON-RPC shape right first, then layer intelligence behind the artifact.