A2A Protocol¶
Agent-to-agent communication over the A2A protocol (the Google-backed emerging standard). One selectools agent exposes itself over HTTP; other agents — selectools or not — discover it and send it tasks.
Stability:
@beta— first release, API may evolve.
Serving an agent¶
from selectools.serve import A2AServer
server = A2AServer(agent=my_agent, auth_token="sk-...")
server.serve(port=8000)
A2AServer is itself an ASGI app, so you can also run it with any ASGI server directly:
Endpoints¶
| Route | Method | Auth | Purpose |
|---|---|---|---|
/.well-known/agent.json | GET | never | Agent Card discovery |
/a2a | POST | bearer (optional) | JSON-RPC 2.0 message handler |
Trust model (v1)¶
- Single shared tenant. One bearer token grants full access: any authenticated caller can read every stored task — including other callers' inputs and outputs — via
tasks/get. Do not share one server between mutually untrusting parties. - Do not run unauthenticated on a public interface. Without
auth_token, anyone who can reach the port can run the agent and read all stored tasks.serve()logs a warning when started without a token on a non-loopback host. - Per-request agent isolation. Each
message/sendruns on an isolated clone of the agent (fresh history and usage, memory dropped), so callers never see each other's conversation context. A2A v1 has no session model: every request starts from a clean slate, with no cross-request memory. - Bounded resources. The in-memory task store keeps at most
max_taskstasks (default 2000, FIFO eviction — an evicted id returns-32001fromtasks/get), and bodies larger thanmax_body_bytes(default 1 MiB) are rejected with-32600before parsing. - Sanitized errors. When the agent raises, remote callers only see the exception type (
Agent execution failed (RuntimeError)); the full detail goes to the server log at warning level.
Agent Card¶
The card is auto-generated from the agent's metadata: config.name, the server description, and one A2A skill per tool (name + description). The agent's system prompt is intentionally not used as the default description, since the card is served unauthenticated.
{
"protocolVersion": "0.2.6",
"name": "researcher",
"description": "Research agent that summarizes topics",
"url": "",
"version": "0.21.0",
"capabilities": {
"streaming": false,
"pushNotifications": false,
"stateTransitionHistory": false
},
"defaultInputModes": ["text/plain"],
"defaultOutputModes": ["text/plain"],
"skills": [
{"id": "summarize", "name": "summarize", "description": "Summarize a text", "tags": ["tool"]}
]
}
JSON-RPC methods¶
| Method | Description |
|---|---|
message/send | Run a task through the agent, return the final Task |
tasks/send | Legacy alias for message/send |
tasks/get | Fetch a previously created task by id |
tasks/cancel | Cancel a task (always -32002 in v1 — tasks finish within the request) |
Request:
{
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": {
"message": {
"role": "user",
"parts": [{"kind": "text", "text": "Research quantum computing trends"}],
"messageId": "..."
}
}
}
Response (result is an A2A Task object):
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"id": "9f1c...",
"contextId": "ab42...",
"kind": "task",
"status": {"state": "completed", "timestamp": "2026-06-10T12:00:00+00:00"},
"artifacts": [
{"artifactId": "...", "name": "response", "parts": [{"kind": "text", "text": "..."}]}
],
"history": [ ... ]
}
}
Task lifecycle¶
submitted → working → input-required → completed / failed / canceled.
This synchronous v1 runs the agent inside the request, so a task moves submitted→working→completed (or failed) before the response is sent. The status.state field is modeled anyway so async backends can slot in later without changing the wire format.
Error mapping¶
| Condition | Result |
|---|---|
| Invalid bearer token | HTTP 401 (before any JSON-RPC handling) |
| Body is not valid JSON | JSON-RPC error -32700 |
Body larger than max_body_bytes | JSON-RPC error -32600 (checked before parsing) |
| Not a valid JSON-RPC 2.0 request | JSON-RPC error -32600 |
| Unknown method | JSON-RPC error -32601 |
Missing/invalid message or no text part | JSON-RPC error -32602 |
Unknown (or evicted) task id (tasks/get/tasks/cancel) | JSON-RPC error -32001 |
| Task not cancelable | JSON-RPC error -32002 |
| Agent raises during the run | Task with status.state = "failed" and the exception type (not its message) in status.message — not a transport-level error |
Text-first v1¶
file and data parts are accepted and never rejected: data parts are appended to the prompt as JSON, file parts are acknowledged but their content is not forwarded to the agent. Full multimodal pass-through is a follow-up.
Consuming a remote agent¶
from selectools.a2a import A2AClient
client = A2AClient("https://other-agent.example.com", auth_token="sk-...")
card = await client.discover() # reads /.well-known/agent.json
print(card.name, [s.id for s in card.skills])
result = await client.send_task("Research quantum computing trends")
print(result.state) # "completed"
print(result.text) # the agent's answer
send_task returns an A2ATask (id, context_id, state, text, error, raw). Agent failures come back as state == "failed" with the detail in .error; protocol and transport failures raise A2AError.
Sync code can use the wrappers:
In-process testing¶
Pass an httpx.ASGITransport to talk to an A2AServer without a socket:
import httpx
from selectools.a2a import A2AClient
transport = httpx.ASGITransport(app=server)
client = A2AClient("http://testserver", transport=transport)
See examples/103_a2a_protocol.py for a complete offline round-trip.