A2A protocol
A2A (Agent-to-Agent) is a JSON-RPC 2.0 protocol with SSE streaming for agent-to-agent communication. The full spec lives at a2a-protocol.org. This page covers what the template handles and where naive implementations tend to go wrong.
The happy path
Consumer POSTs to /a2a:
{"jsonrpc": "2.0", "id": "1", "method": "message/stream", "params": {...}}Server responds with an SSE stream. Each event is a JSON frame:
event: task
data: {"jsonrpc": "2.0", "id": "1", "result": {"kind": "task", ...}}
event: status-update
data: {"jsonrpc": "2.0", "id": "1", "result": {"kind": "status-update", ...}}
...That's it. Everything else — skills, extensions, push notifications — layers onto this.
The kind discriminator is not optional
Every SSE frame must carry one of:
"kind": "task"(first frame — full Task object)"kind": "status-update"(state transitions, tool progress)"kind": "artifact-update"(streaming artifacts)
@a2a-js/sdk's for await loop routes frames by kind. Without the field, the loop silently skips every frame and consumers never attach. The template's regression test test_message_stream_events_have_kind_discriminator locks this in — inline dict construction is the path of least resistance and also the easiest way to forget this field.
Camel-case vs snake-case
Wire fields are camelCase: taskId, contextId, durationMs. Python code is snake_case: task_id, context_id, duration_ms. The A2A handler is the translation boundary. Don't leak snake_case into wire responses.
Push notification tokens — two shapes
The A2A spec permits two equivalent ways to carry the shared-secret token:
Shape 1 — top-level token (what @a2a-js/sdk serializes by default):
{"url": "https://consumer/callback/abc", "token": "shared-secret"}Shape 2 — structured authentication.credentials (RFC-8821 AuthenticationInfo):
{
"url": "https://consumer/callback/abc",
"authentication": {"schemes": ["Bearer"], "credentials": "shared-secret"}
}Both are active spec — neither is deprecated. Different consumers use different shapes. If your handler only reads one, half of real-world consumers will register a webhook, receive HTTP 401s on every delivery, and silently fall back to polling. The template's _extract_push_token accepts both; when both are present, top-level wins.
SSRF is a real risk
A webhook URL is an outbound HTTP call this agent makes with a shared secret attached. If a malicious (or careless) consumer registers:
http://169.254.169.254/...— cloud metadata endpointhttp://10.0.0.1/...— LAN routerhttp://localhost/...— sibling services on the hosthttp://internal-db:5432/...— adjacent services on the docker network
...the agent would happily POST task payloads (potentially with Authorization: Bearer <secret>) to any of them.
_is_safe_webhook_url in a2a_handler.py resolves the URL's hostname once and rejects anything that lands in a private range. It's not a full DNS-rebinding defence, but it closes the "just use an RFC1918 literal" vector. Operator allowlists (PUSH_NOTIFICATION_ALLOWED_HOSTS) bypass the check for trusted docker-network targets that would otherwise fail.
Task lifecycle
SUBMITTED → WORKING → COMPLETED
↘ FAILED
↘ CANCELEDAll three terminal states fire push notifications (if configured). Terminal tasks stay in memory until a background sweeper eventually discards them — in the meantime, tasks/get + tasks/resubscribe both work.
tasks/resubscribe is the reconnect mechanism. If a streaming consumer's connection drops mid-run, they POST tasks/resubscribe with the task ID and get the remaining frames. The template keeps a buffer of emitted frames per task to serve resubscriptions reliably.
Trace propagation — not in the spec
The template reads params.metadata["a2a.trace"] on incoming requests:
{
"metadata": {
"a2a.trace": {
"traceId": "abc123",
"spanId": "def456"
}
}
}This is a protoLabs convention, not part of the A2A spec. It's how the fleet ties multi-agent Langfuse traces together. Consumers that don't know about it just don't stamp the field — the agent's trace becomes a standalone root instead of a child. No breakage.
What the template doesn't do
- Long-lived tasks: the template doesn't persist tasks across restarts. If you need durable task state, swap
_storefor a Redis/SQLite-backed impl. - Multi-tenancy: every task sees the same auth context. If you need per-caller isolation, extend the API-key middleware.
- OAuth: only API-key auth ships. A2A security schemes allow OAuth2; wire it up in
_build_agent_card.securitySchemes+ middleware if needed.
Related
- A2A endpoints reference — every method + path
- Extensions reference — protocol extensions shipped
- Cost & trace — how cost-v1 and
a2a.traceplug in