The A2A side
Specification: https://a2a-protocol.org/v1.0.0/specification/. The SDK is @a2a-js/sdk 1.2.0, which implements v1.0.0. When the two disagree, trust the spec — dist also ships a v0.3 compatibility layer whose constants are easy to mistake for current ones.
Discovery
Everything starts from the Agent Card at .well-known/agent-card.json: a declaration of skills, input and output modes, security schemes, and the transports the agent answers on.
{
"name": "Revisor",
"supportedInterfaces": [
{ "url": "http://127.0.0.1:41241/", "protocolBinding": "JSONRPC", "protocolVersion": "1.0" }
],
"capabilities": { "streaming": true, "pushNotifications": false },
"skills": [ { "id": "text_stats", "inputModes": ["text"], "outputModes": ["text", "data"] } ]
}name is the only field in there that identifies this agent rather than what it can do, so it is what a caller has to go on with two of them in front of it. It comes from AGENT_NAME; blank leaves the built-in name — Revisor above, Claude (via ACP) or Codex (via ACP) on the bridge.
The client hardcodes neither a URL nor a protocol: ClientFactory.createFromUrl() fetches the card and picks a transport out of supportedInterfaces. It dials what the card says, not the address it fetched the card from, and PUBLIC_URL is the knob over that field: it routes a client through the wire-tap instead of letting it connect directly, and it is what has to move when HOST does, or the card advertises an address the caller cannot reach.
The task lifecycle
SUBMITTED ──► WORKING ──► COMPLETED
│ │
│ ├──► FAILED
│ └──► CANCELED
└──► INPUT_REQUIRED ──► (client replies into the same taskId) ──► WORKING ──► …INPUT_REQUIRED is the state that separates A2A from an ordinary POST /do. It is not terminal: the task stays alive, the client sends another message carrying the same taskId, and execute runs again — this time with requestContext.task populated. The call has state, history and an identity.
Streaming
SendStreamingMessage answers with Server-Sent Events. Each frame is a complete JSON-RPC response carrying the request's id, not a bare payload:
data: {"jsonrpc":"2.0","id":3,"result":{"statusUpdate":{"taskId":"…","status":{"state":"TASK_STATE_WORKING", …}}}}The result of the work arrives as an artifact, not as a message: a named object with parts, which the client can store and refer to later. GetTask returns it long after the stream closed.
Invariants that bite
All of these were verified against live traffic. Each one breaks the code either silently or with a misleading error.
The first event must be task or message. A stream that opens with statusUpdate or artifactUpdate is rejected — including when continuing a task that already exists. The bridge publishes the task snapshot before anything else for exactly this reason.
Wire method names are PascalCase: SendMessage, SendStreamingMessage, GetTask, CancelTask, ListTasks. The v0.3 slash names (message/send, tasks/get) survive in the SDK only inside compat/v0_3; do not copy them from 0.2.x/0.3.x guides.
The A2A-Version: 1.0 header is mandatory for raw HTTP calls. Without it the server treats the caller as v0.3 and answers -32009 VERSION_NOT_SUPPORTED. This is the single most common reason a hand-written curl call fails, and src/raw.sh demonstrates it on purpose.
Part has two representations. In TypeScript it is a discriminated union, { content: { $case: 'text', value } }; on the wire it is flat, {"text": "…", "mediaType": "text/plain"}. The schema is generated from a protobuf oneof, so parsing the JSON by hand yields no union.
TaskState is a numeric enum, which is why the reverse lookup TaskState[state] works in logs. Errors arrive in the google.rpc.ErrorInfo shape: data[].@type, reason, domain.
The CancelTask contract
Stricter than it looks. DefaultRequestHandler.cancelTask:
- loads the task and rejects outright if it is already terminal;
- calls
executor.cancelTask; - drains the event bus until the task reaches a terminal state;
- requires the stored state to be
CANCELED.
Anything else — a perfectly ordinary COMPLETED included — reaches the caller as -32002 TASK_NOT_CANCELABLE. An executor that merely forwards the cancellation and lets the turn finish therefore looks broken from outside. How the bridge satisfies this is in acp-bridge.md.