A2A Task Runtime Guide: Run, Stream, Resume, Recover
A task has two sides: the server that runs it and the client that follows it. This guide is for both. The object pages (Task , Message , Part , Artifact ) explain the fields. This page adds what they do not have: the decisions you make when you run a task, which operation each state accepts, a validator for event streams, a map from failures back to causes, and the changes that bite when you move from v0.3.
Everything is checked against A2A v1.0.1: the a2a.proto at that tag, the specification, and the specification’s own sample exchanges. Where the specification and the proto disagree, the proto wins (specification section 1.4). Where the specification disagrees with itself, this guide says so instead of picking a side silently.
The path
- Choose the response shape and delivery mode.
- Know what each state allows.
- Emit and read events correctly, on both sides.
- Validate a trace: run the checker until it prints
OK. - Recover when a stream, a webhook, or a follow-up goes wrong.
- Test both sides with a fixed list of scenarios.
If you are coming from v0.3, read section 7 first.
1. Choose the response shape and delivery mode
SendMessage returns either a Task or a Message, and the agent decides which (section 3.1.1).
| Situation | Return | Rule and source |
|---|---|---|
| A simple exchange with nothing to track | A Message | The agent MAY return a direct Message for simple interactions (section 3.1.1) |
| Work that continues after the reply, or needs states, artifacts, or more input | A Task | When a Task is returned, processing continues in the background (section 3.3.3) |
| Delivering results | Artifacts on the task | Messages SHOULD NOT carry task outputs; results SHOULD be returned as artifacts (section 3.7) |
| Asking a question before committing to work | A Message | Clarification is one of the listed roles of a message (section 3.7) |
Then choose how the client follows the task:
| You need | Use | The Agent Card must declare | If it does not, the agent MUST return |
|---|---|---|---|
| One answer, short work | SendMessage and wait (the default) | nothing | not applicable |
| Start the work and come back later | SendMessage with returnImmediately: true, then GetTask | nothing | not applicable |
| Live progress and partial artifacts | SendStreamingMessage; reconnect with SubscribeToTask | capabilities.streaming: true | UnsupportedOperationError |
| A client that cannot hold a connection | A webhook from taskPushNotificationConfig or CreateTaskPushNotificationConfig | capabilities.pushNotifications: true | PushNotificationNotSupportedError |
Three details decide whether this works in practice (sections 3.2.2 and 3.2.4):
- Waiting is the default. With
returnImmediatelyunset orfalse,SendMessageMUSTwait until the task is terminal or interrupted before it returns. A long task behind a short HTTP timeout fails here, not in your agent. returnImmediatelyhas no effect when the agent answers with aMessage, when you stream, or on push configurations, which work independently of execution mode.historyLength: unset means the client sets no limit and the server decides how much history to return,0means no history, and a positive number means at most that many recent messages.
2. What each state allows
a2a.proto marks four states terminal and two interrupted. The other two are in progress.
| State | Class | Where the class comes from |
|---|---|---|
TASK_STATE_SUBMITTED, TASK_STATE_WORKING | In progress | Not marked terminal or interrupted in the proto |
TASK_STATE_INPUT_REQUIRED, TASK_STATE_AUTH_REQUIRED | Interrupted | The proto comments say “This is an interrupted state” |
TASK_STATE_COMPLETED, TASK_STATE_FAILED, TASK_STATE_CANCELED, TASK_STATE_REJECTED | Terminal | The proto comments say “This is a terminal state” |
What the specification fixes for each class (a blank cell means the specification sets no rule):
| Operation | In progress | Interrupted | Terminal |
|---|---|---|---|
SendMessage or SendStreamingMessage with that taskId | The agent MAY accept it (section 3.3.3) | For TASK_STATE_INPUT_REQUIRED, the expected next step is a new message with the same taskId and contextId (section 3.4.3). For TASK_STATE_AUTH_REQUIRED, agents SHOULD accept messages (section 7.6.1) | UnsupportedOperationError (section 3.1.1) |
CancelTask | Attempts to cancel; success is not guaranteed (section 3.1.5) | TaskNotCancelableError (section 3.1.5) | |
SubscribeToTask | Opens a stream; the first event is the current Task (section 3.1.6) | Same as in progress: allowed for any task that is not terminal (section 3.1.6) | UnsupportedOperationError (section 3.1.6) |
GetTask | Returns the current state (section 3.1.3) | Returns the current state (section 3.1.3) | Returns the final state; this is how clients fetch it after a stream ended or a push arrived (section 3.1.3) |
Blocking SendMessage | Keeps waiting | Returns (section 3.2.2) | Returns (section 3.2.2) |
| An open stream | Stays open | See below | MUST close (sections 3.1.2 and 3.1.6) |
What the specification leaves open
These are the places where two correct implementations can still disagree. Write your choice down in your agent’s documentation.
- Transitions. There is no transition table. The rules above are everything the specification fixes. Its own samples first show a task in
TASK_STATE_COMPLETED(section 6.1),TASK_STATE_WORKING(section 6.2),TASK_STATE_INPUT_REQUIRED(section 6.3), orTASK_STATE_SUBMITTED(section 6.6), so a client must not requireTASK_STATE_SUBMITTEDto appear. - Does a stream close at an interrupted state? Three passages answer differently. Sections 3.1.2 and 3.1.6 require a close only at a terminal state. Section 11.7 describes updates that continue “until the task reaches a terminal or interrupted state, at which point the stream closes”. Section 7.6.1 says that when the credential is received out of band, the agent
SHOULDkeep active response streams open after it setsTASK_STATE_AUTH_REQUIRED. The specification atmain(commit98279d6) still has all three. Client rule: the end of a stream is not the end of a task; read the last state. Server rule: pick one behavior and document it. - Cancelling an interrupted task.
TaskNotCancelableErrornames only terminal states as examples, and nothing excludes an interrupted task. Attempt the cancel and handle the error. - History. Not every message is stored in
history, and a client must not rely on it unless that was agreed out of band (section 3.7). - Duplicates.
SendMessageMAYbe idempotent, and an agent may usemessageIdto detect repeats (section 3.3.1).
3. Emit and read events
What must be present
a2a.proto marks these fields REQUIRED. Arrays marked required MUST hold at least one element (section 5.7).
| Event member | Required fields |
|---|---|
task | id, status (and status.state) |
message | messageId, role, parts |
statusUpdate | taskId, contextId, status (and status.state) |
artifactUpdate | taskId, contextId, artifact (and artifact.artifactId, artifact.parts) |
Each event carries exactly one of task, message, statusUpdate, or artifactUpdate. A Part sets exactly one of text, raw, url, or data.
Server checklist
- Decide
MessageorTaskbefore the first byte. A message-only stream holds exactly oneMessage, then closes (section 3.1.2). - For a task stream, send the
Taskfirst, thenstatusUpdateandartifactUpdateevents in the order they happened. EventsMUST NOTbe reordered (section 3.5.2). - Put the explanation for an interrupted state in
status.message. ForTASK_STATE_AUTH_REQUIREDthe message is required unless the details were negotiated out of band or by an extension (section 7.6.1). - Deliver results as artifacts. For a large artifact, send the first chunk without
append, later chunks withappend: true, and mark the last onelastChunk: true(the fields ofTaskArtifactUpdateEvent). - Close the stream at the terminal state. You may send one final
Tasksnapshot first (section 11.7). - With several open streams on one task, send every event to every stream in the same order; closing one stream must not affect the others (section 3.5.2).
Client checklist
- Treat
status.stateas the truth about the task. There is nofinalflag in v1.0. - When a stream ends, read the last state. Terminal: done. Interrupted: act on it (below). Anything else: the connection was cut, so reconnect.
- Reconnect with
SubscribeToTask. Its first event is the currentTask, which closes the gap between aGetTaskand the new stream (section 3.1.6). Status messages sent while you were disconnected are not replayed, and the specification says not to depend on them (section 3.7). - Join chunks by
artifactId: a chunk withappend: trueextends the artifact with that ID, andlastChunk: trueends it. - To answer an interrupted task, send a message with the same
taskId. If you also send acontextId, it must be the task’s own: a message whosecontextIdandtaskIddo not matchMUSTbe rejected (section 3.4.3). To start new work in the same conversation, send only thecontextId. Task IDs are always assigned by the server (section 3.4.2). - For a task in
TASK_STATE_AUTH_REQUIREDwhose credential arrives out of band, keep a stream open, register a webhook, or poll withGetTask; otherwise you can miss the moment the agent resumes (section 7.6.2). - A webhook receiver
MUSTanswer2xx,SHOULDprocess notifications idempotently because deliveries can repeat, andMUSTcheck that the task ID is one it expects. The sender must attempt delivery at least once andMAYstop after repeated failures (section 4.3.3). A notification can carry only a status or artifact update; callGetTaskwhen you need the full state.
4. Validate a trace
A trace is the events of one stream or response, one JSON object per line, in the order they arrived. Save this as task-trace-lint.jq. It reads only the trace and makes no network calls.
# A2A task trace lint for v1.0.1.
# Usage: jq -s -r -f task-trace-lint.jq trace.jsonl
# A trace is one StreamResponse (or SendMessageResponse) object per line, in arrival order.
# Checks come from the REQUIRED annotations in a2a.proto and specification sections 3.1.2, 3.1.6, 3.2.3, 3.4.3 and 11.7.
# ERROR lines break the specification; WARN lines are advisory.
def blank: . == null or . == "" or . == [];
def states: ["TASK_STATE_SUBMITTED", "TASK_STATE_WORKING", "TASK_STATE_COMPLETED", "TASK_STATE_FAILED",
"TASK_STATE_CANCELED", "TASK_STATE_INPUT_REQUIRED", "TASK_STATE_REJECTED", "TASK_STATE_AUTH_REQUIRED"];
def terminal: ["TASK_STATE_COMPLETED", "TASK_STATE_FAILED", "TASK_STATE_CANCELED", "TASK_STATE_REJECTED"];
def interrupted: ["TASK_STATE_INPUT_REQUIRED", "TASK_STATE_AUTH_REQUIRED"];
def v03_states: {"submitted": "TASK_STATE_SUBMITTED", "working": "TASK_STATE_WORKING",
"input-required": "TASK_STATE_INPUT_REQUIRED", "completed": "TASK_STATE_COMPLETED",
"canceled": "TASK_STATE_CANCELED", "failed": "TASK_STATE_FAILED", "rejected": "TASK_STATE_REJECTED",
"auth-required": "TASK_STATE_AUTH_REQUIRED"};
def v03_roles: {"user": "ROLE_USER", "agent": "ROLE_AGENT"};
def members: ["task", "message", "statusUpdate", "artifactUpdate"];
def part_checks($p):
. as $part
| if type != "object" then "ERROR \($p): expected a Part object"
elif has("kind") or has("file") then "ERROR \($p): v0.3 Part shape; v1.0 sets exactly one of text, raw, url, data"
else ([("text", "raw", "url", "data") as $k | select($part | has($k))] | length) as $n
| if $n != 1 then "ERROR \($p): exactly one of text, raw, url, data must be set, found \($n)" else empty end
end;
def message_checks($p):
if type != "object" then "ERROR \($p): expected a Message object"
else
(if .messageId | blank then "ERROR \($p)/messageId: required string is missing or empty" else empty end),
(.role as $r
| if $r == null then "ERROR \($p)/role: required enum is missing"
elif v03_roles[$r | tostring] then "ERROR \($p)/role: v0.3 value \($r | tojson), use \(v03_roles[$r | tostring])"
elif (["ROLE_USER", "ROLE_AGENT"] | index($r)) == null then "ERROR \($p)/role: not ROLE_USER or ROLE_AGENT: \($r | tojson)"
else empty end),
(if (.parts | type) != "array" or (.parts | length) == 0 then "ERROR \($p)/parts: required array is missing or empty"
else (.parts | to_entries[] | .key as $k | .value | part_checks("\($p)/parts/\($k)")) end)
end;
def state_checks($p):
. as $s
| if $s == null or $s == "TASK_STATE_UNSPECIFIED" then "ERROR \($p): required state is missing or TASK_STATE_UNSPECIFIED"
elif (states | index($s)) != null then empty
elif v03_states[$s | tostring] then "ERROR \($p): v0.3 state name \($s | tojson), use \(v03_states[$s | tostring])"
else "ERROR \($p): not a TaskState value: \($s | tojson)" end;
def status_checks($p):
if type != "object" then "ERROR \($p): required object is missing"
else (.state | state_checks("\($p)/state")), (if .message != null then .message | message_checks("\($p)/message") else empty end) end;
def artifact_checks($p):
if type != "object" then "ERROR \($p): required object is missing"
else
(if .artifactId | blank then "ERROR \($p)/artifactId: required string is missing or empty" else empty end),
(if (.parts | type) != "array" or (.parts | length) == 0 then "ERROR \($p)/parts: required array is missing or empty"
else (.parts | to_entries[] | .key as $k | .value | part_checks("\($p)/parts/\($k)")) end)
end;
def unwrap: if type == "object" and has("jsonrpc") and (.result | type) == "object" then .result else . end;
def kind: if type == "object" then [members[] as $m | select(has($m)) | $m] | .[0] else null end;
def state_of: if type == "object" then ((.task | objects | .status | objects | .state) // (.statusUpdate | objects | .status | objects | .state) // null) else null end;
def shaped: type == "object" and (has("kind") | not) and ([members[] as $m | select(has($m) and (.[$m] | type) == "object")] | length) == 1;
def event_checks($i):
"event[\($i)]" as $p
| if type != "object" then "ERROR \($p): expected a JSON object"
elif has("kind") or (.statusUpdate | type) == "object" and (.statusUpdate | has("final")) then
"ERROR \($p): v0.3 event shape (kind or final); v1.0 wraps the payload in exactly one of task, message, statusUpdate, artifactUpdate"
elif ([members[] as $m | select(has($m))] | length) != 1 then
"ERROR \($p): expected exactly one of task, message, statusUpdate, artifactUpdate, found \([members[] as $m | select(has($m))] | length)"
elif ([members[] as $m | select(has($m) and (.[$m] | type) != "object")] | length) > 0 then
"ERROR \($p): the payload must be a JSON object"
elif has("task") then
(if .task.id | blank then "ERROR \($p)/task/id: required string is missing or empty" else empty end),
(.task.status | status_checks("\($p)/task/status"))
elif has("message") then (.message | message_checks("\($p)/message"))
elif has("statusUpdate") then
(.statusUpdate | (if .taskId | blank then "ERROR \($p)/statusUpdate/taskId: required string is missing or empty" else empty end),
(if .contextId | blank then "ERROR \($p)/statusUpdate/contextId: required string is missing or empty" else empty end),
(.status | status_checks("\($p)/statusUpdate/status")))
else
(.artifactUpdate | (if .taskId | blank then "ERROR \($p)/artifactUpdate/taskId: required string is missing or empty" else empty end),
(if .contextId | blank then "ERROR \($p)/artifactUpdate/contextId: required string is missing or empty" else empty end),
(.artifact | artifact_checks("\($p)/artifactUpdate/artifact")))
end;
# Trace-level rules: order, identity, termination and artifact chunking. They run only when every event is well formed.
def trace_checks:
. as $e
| ($e | length) as $n
| ($e[0].task // {}) as $first
| ($e | map(state_of)) as $st
| ($e | map(select(kind == "task" or kind == "statusUpdate") | state_of) | .[-1]) as $last
| ([range(0; $n) | select($st[.] as $s | $s != null and (terminal | index($s)) != null)] | .[0]) as $end
| (if $n > 1 and ($e[0] | kind) != "task" and ($e[0] | kind) != "message" then
"ERROR event[0]: a stream starts with a task or a message, found \($e[0] | kind // "nothing")" else empty end),
(if $n > 1 and ($e[0] | kind) == "message" then "ERROR event[1]: a message-only stream holds exactly one message" else empty end),
(range(1; $n) as $i | $e[$i] as $x
| (if ($x | kind) == "message" and ($e[0] | kind) == "task" then "ERROR event[\($i)]: message inside a task stream; only status and artifact updates follow the task" else empty end),
(if ($x | kind) == "task" and ($e[0] | kind) == "task" and ($end == null or $i != $end + 1 or $x.task.status.state != $st[$end])
then "ERROR event[\($i)]: task snapshot inside a stream; only one final snapshot after the terminal state is allowed" else empty end),
(($x.statusUpdate // $x.artifactUpdate) as $u
| if $u != null and ($first.id // null) != null and $u.taskId != null and $u.taskId != $first.id
then "ERROR event[\($i)]/\($x | kind)/taskId: \($u.taskId | tojson) differs from the stream task \($first.id | tojson)" else empty end,
if $u != null and ($first.contextId // null) != null and $u.contextId != null and $u.contextId != $first.contextId
then "ERROR event[\($i)]/\($x | kind)/contextId: \($u.contextId | tojson) differs from the stream context \($first.contextId | tojson)" else empty end),
(if $end != null and $i > $end and ($x | kind) != "task" then "ERROR event[\($i)]: event after terminal state \($st[$end]) at event[\($end)]" else empty end)),
(reduce range(0; $n) as $i ({seen: {}, closed: {}, out: []};
($e[$i]) as $x
| if ($x | kind) == "task" then .seen += ([$x.task.artifacts[]? | {(.artifactId | tostring): true}] | add // {})
elif ($x | kind) == "artifactUpdate" then
($x.artifactUpdate.artifact.artifactId | tostring) as $id
| (if $x.artifactUpdate.append == true and (.seen[$id] | not) then .out += ["WARN event[\($i)]/artifactUpdate: append for artifact \($id | tojson) before any earlier chunk"]
elif $x.artifactUpdate.append == true and .closed[$id] then .out += ["WARN event[\($i)]/artifactUpdate: append for artifact \($id | tojson) after its lastChunk"] else . end)
| .seen[$id] = true
| (if $x.artifactUpdate.lastChunk == true then .closed[$id] = true else . end)
else . end) | .out[]),
(if $n > 1 and ($e[0] | kind) == "task" and (["TASK_STATE_SUBMITTED", "TASK_STATE_WORKING"] | index($last)) != null
then "WARN event[\($n - 1)]: the trace ends in \($last) before a terminal or interrupted state; the stream was cut, resubscribe" else empty end);
map(unwrap) as $e
| if ($e | length) == 0 then "ERROR trace: no events"
else
([range(0; $e | length) as $i | $e[$i] | event_checks($i)] + (if $e | all(shaped) then [$e | trace_checks] else [] end))
| if length == 0 then "OK: \($e | length) event(s), last state \($e | map(state_of) | map(select(. != null)) | .[-1] // "none")" else .[] end
endCapture a trace from a stream by keeping the data: payloads. JSON-RPC responses are unwrapped automatically:
sed -n 's/^data: //p' stream.txt > trace.jsonl
jq -s -r -f task-trace-lint.jq trace.jsonlERROR lines break the specification or the proto. WARN lines are advisory. Order and identity rules run only when every event has the right shape, so fix the ERROR lines about single events first. A file with one event is treated as a single response or a single webhook payload, so the stream-order rules apply only to files with two or more events. In CI:
if jq -s -r -f task-trace-lint.jq trace.jsonl | grep -q '^ERROR'; then exit 1; fiWhat it reports on the official sample streams
The specification’s section 6 sample exchanges are written as illustrations, and several leave out fields that a2a.proto requires. The table shows what the validator says about each sample at the v1.0.1 tag and on main (commit 98279d6, 2026-10-20). To follow along, extract the JSON from the specification and put one event per line. For the section 6.2 stream:
git show v1.0.1:docs/specification.md \
| awk '/^### 6\.2\./{f=1} /^### 6\.3\./{f=0} f' | sed -n 's/^data: //p' > sample-6.2.jsonlUse main instead of v1.0.1 for the other column.
| Sample | At v1.0.1 | On main |
|---|---|---|
| Section 6.1, completed task | OK: 1 event(s), last state TASK_STATE_COMPLETED | same |
| Section 6.2, streaming | ERROR event[1]/artifactUpdate/contextId: required string is missing or emptyERROR event[1]/artifactUpdate/artifact/artifactId: required string is missing or emptyERROR event[2]/statusUpdate/contextId: required string is missing or empty | ERROR event[1]/artifactUpdate/artifact/artifactId: required string is missing or empty |
| Section 6.3, input required | ERROR event[0]/task/status/message/messageId: required string is missing or empty | same |
| Section 6.6, task response | OK: 1 event(s), last state TASK_STATE_SUBMITTED | same |
| Section 6.6, webhook payload | OK: 1 event(s), last state TASK_STATE_COMPLETED | same |
Do not copy the samples into a server. Build from the required fields in section 3
, then run the validator. The stream below is the section 6.2 sample from main with the one missing field added, and it passes:
{"task":{"id":"task-uuid","contextId":"context-uuid","status":{"state":"TASK_STATE_WORKING"}}}
{"artifactUpdate":{"taskId":"task-uuid","contextId":"context-uuid","artifact":{"artifactId":"artifact-uuid","parts":[{"text":"# Climate Change Report\n\n"}]}}}
{"statusUpdate":{"taskId":"task-uuid","contextId":"context-uuid","status":{"state":"TASK_STATE_COMPLETED"}}}Break it on purpose
A checker is only useful if it fails when it should. Save the stream above as stream-ok.jsonl. Each row edits it in one place and shows the single finding that results:
jq -s -c '<edit> | .[]' stream-ok.jsonl | jq -s -r -f task-trace-lint.jq| Edit | Finding |
|---|---|
del(.[0]) | ERROR event[0]: a stream starts with a task or a message, found artifactUpdate |
del(.[2]) | WARN event[1]: the trace ends in TASK_STATE_WORKING before a terminal or interrupted state; the stream was cut, resubscribe |
. + [.[1]] | ERROR event[3]: event after terminal state TASK_STATE_COMPLETED at event[2] |
.[1].artifactUpdate.taskId = "other" | ERROR event[1]/artifactUpdate/taskId: "other" differs from the stream task "task-uuid" |
.[1].artifactUpdate.contextId = "other" | ERROR event[1]/artifactUpdate/contextId: "other" differs from the stream context "context-uuid" |
.[1].artifactUpdate.append = true | WARN event[1]/artifactUpdate: append for artifact "artifact-uuid" before any earlier chunk |
.[1].artifactUpdate.artifact.artifactId = "" | ERROR event[1]/artifactUpdate/artifact/artifactId: required string is missing or empty |
.[1].artifactUpdate.artifact.parts = [] | ERROR event[1]/artifactUpdate/artifact/parts: required array is missing or empty |
.[0].task.status.state = "TASK_STATE_UNSPECIFIED" | ERROR event[0]/task/status/state: required state is missing or TASK_STATE_UNSPECIFIED |
.[2].statusUpdate.status.state = "completed" | ERROR event[2]/statusUpdate/status/state: v0.3 state name "completed", use TASK_STATE_COMPLETED |
.[2].statusUpdate.status.message = {"messageId":"m1","role":"agent","parts":[{"text":"done"}]} | ERROR event[2]/statusUpdate/status/message/role: v0.3 value "agent", use ROLE_AGENT |
.[1].artifactUpdate.artifact.parts[0] = {"kind":"text","text":"x"} | ERROR event[1]/artifactUpdate/artifact/parts/0: v0.3 Part shape; v1.0 sets exactly one of text, raw, url, data |
.[2].statusUpdate.final = true | ERROR event[2]: v0.3 event shape (kind or final); v1.0 wraps the payload in exactly one of task, message, statusUpdate, artifactUpdate |
.[1] = {"message":{"messageId":"m1","role":"ROLE_AGENT","parts":[{"text":"hi"}]}} | ERROR event[1]: message inside a task stream; only status and artifact updates follow the task |
What it does not check
- That your server enforces the operation and state rules in section 2 . It reads events, not requests; section 6 lists the requests to try.
- Transitions between states. The specification defines none, so the validator accepts any order of non-terminal states.
- That
contextIdstays the same across several tasks, that artifacts are complete, or that the bytes inraware valid base64. - A set of webhook deliveries collected from several requests. Run it on one payload at a time.
5. When it breaks
| What you see | Likely cause | Fix |
|---|---|---|
UnsupportedOperationError after a follow-up message | The task is already terminal (section 3.1.1) | Start a new task in the same contextId; use referenceTaskIds to point at the earlier one |
TaskNotCancelableError | The task is already terminal (section 3.1.5) | Call GetTask. If the state is TASK_STATE_CANCELED, you are done |
TaskNotFoundError on a follow-up | The taskId was never issued, the task was purged, or the client may not access it (sections 3.4.2 and 3.3.2). The specification says servers SHOULD NOT tell these apart | Use only taskId values the server returned; start a new task if the old one is gone |
| A follow-up is rejected | The message carries a contextId that differs from the task’s (section 3.4.3) | Send only taskId, or copy both from the task |
SendMessage hangs or times out | Waiting for completion is the default in v1.0 (section 3.2.2) | Set returnImmediately: true and poll or subscribe, or use SendStreamingMessage |
| The stream ended but the task is not done | An interrupted state, or a cut connection (see section 2 ) | Read the last state. Interrupted: answer it. Otherwise call SubscribeToTask or GetTask |
UnsupportedOperationError from SendStreamingMessage | capabilities.streaming is false or absent (section 3.3.4) | Use SendMessage and poll, or fix the card |
UnsupportedOperationError from SubscribeToTask | The task is terminal, or streaming is not declared (sections 3.1.6 and 3.3.4) | Call GetTask for the final state |
PushNotificationNotSupportedError | capabilities.pushNotifications is false or absent (section 3.3.4) | Poll or stream instead |
| The webhook never fires, or fires twice | The sender stops after repeated failures, delivers at least once, and may repeat (section 4.3.3) | Answer 2xx quickly, deduplicate on task ID and state, and fall back to GetTask |
SubscribeToTask over HTTP+JSON is refused with a routing or method error | The specification’s tables (sections 5.3 and 11.3.2) say POST /tasks/{id}:subscribe; the a2a.proto HTTP annotation says GET. Both are unchanged on main, and the proto wins (section 1.4) | Serve and call GET; when a peer answers with a method error, try the other verb |
| A client rejects your events | Required fields are missing, or enum values are in the v0.3 spelling | Run the validator on a captured trace |
| Parsers fail after an upgrade | kind, lowercase states, file parts, or final are still in use | See section 7 |
| An artifact is cut short or doubled | append and lastChunk are misused, or chunks are joined by the wrong ID | Join by artifactId; the validator warns on an append with no earlier chunk and on a chunk after lastChunk |
Error names and their JSON-RPC, gRPC, and HTTP mappings are in A2A Error Codes .
6. Test scenarios
Run these against a server (S) or a client (C). The validator covers the rows marked with a trace.
| ID | Side | Scenario | Expected | Source |
|---|---|---|---|---|
| T1 | S | Send a simple message that needs no task | One message event, then the stream closes. Trace check | Section 3.1.2 |
| T2 | S | Stream a task to completion | task first, then updates in order, closed at a terminal state. Trace check | Sections 3.1.2 and 3.5.2 |
| T3 | S | Send a message to a terminal task | UnsupportedOperationError | Section 3.1.1 |
| T4 | S | Cancel a terminal task | TaskNotCancelableError | Section 3.1.5 |
| T5 | S | Subscribe to a terminal task | UnsupportedOperationError | Section 3.1.6 |
| T6 | S | Subscribe to a running task | The first event is the current Task. Trace check | Section 3.1.6 |
| T7 | S | Send a message with an unknown taskId, then one with a mismatched contextId | TaskNotFoundError, then a rejection | Sections 3.4.2 and 3.4.3 |
| T8 | S | Use streaming or push when the card declares neither | UnsupportedOperationError, PushNotificationNotSupportedError | Section 3.3.4 |
| T9 | S | Send with the default and then with returnImmediately: true | The first returns at a terminal or interrupted state; the second returns at once with the task as it stands | Section 3.2.2 |
| T10 | S | Open two streams on one task, close one | Both receive the same events in the same order; the other stays open | Section 3.5.2 |
| T11 | C | Receive a stream that ends in an interrupted state | The client answers with the same taskId; it does not report failure. Trace check | Sections 3.4.3 and 7.6.2 |
| T12 | C | Lose a stream mid-task | The client resubscribes and continues from the first event, which is the current Task. Trace check | Section 3.1.6 |
| T13 | C | Receive the same webhook twice | One effect; both answered 2xx | Section 4.3.3 |
7. Migrating runtime code from v0.3
Compared field by field between the v0.3.0 JSON schema (specification/json/a2a.json, with the v0.3.0 gRPC proto where the schema is silent) and a2a.proto at v1.0.1. The full migration guide
covers the rest of the protocol.
| Area | v0.3.0 | v1.0.1 | What breaks |
|---|---|---|---|
| States | submitted, working, input-required, completed, canceled, failed, rejected, auth-required, unknown (the v0.3.0 gRPC proto spells cancelled TASK_STATE_CANCELLED) | TASK_STATE_SUBMITTED, TASK_STATE_WORKING, TASK_STATE_INPUT_REQUIRED, TASK_STATE_COMPLETED, TASK_STATE_CANCELED, TASK_STATE_FAILED, TASK_STATE_REJECTED, TASK_STATE_AUTH_REQUIRED; TASK_STATE_UNSPECIFIED (“unknown or indeterminate”) takes the place of unknown | Comparisons and switches on the old strings |
| Roles | user, agent | ROLE_USER, ROLE_AGENT | Sending or matching the old role strings |
| Type tag | kind is required on Task, Message, every part, and both update events | No kind; the member name tells the type (task, message, statusUpdate, artifactUpdate) | Parsers and serializers keyed on kind |
| Parts | TextPart {kind, text}, FilePart {kind, file: {name, mimeType, bytes or uri}}, DataPart {kind, data} | One Part with exactly one of text, raw, url, data, plus filename and mediaType | File and data handling |
| End of stream | final is required on the status update | No final; read the state | Code that waits for final never finishes |
| Waiting | blocking: true asks the server to wait; false or unset returns the task at once (gRPC proto) | returnImmediately defaults to false, and the call waits for a terminal or interrupted state | Timeouts, see below |
historyLength of 0 | The gRPC proto says “if 0, the history will be unlimited”; the JSON schema says only “the number of most recent messages” | 0 is a request for no messages; unset means no limit | A client that sent 0 to mean “all” now gets none |
| Push config in a send | configuration.pushNotificationConfig | configuration.taskPushNotificationConfig | A webhook that is never registered |
| JSON-RPC methods | message/send, message/stream, tasks/get, tasks/cancel, tasks/resubscribe, tasks/pushNotificationConfig/set | SendMessage, SendStreamingMessage, GetTask, CancelTask, SubscribeToTask, CreateTaskPushNotificationConfig | Routing by method name |
The “Waiting” row is the one that fails quietly. By the v0.3.0 gRPC proto, a client that never set blocking asked for an immediate answer. The same code against a v1.0.1 agent waits until the task is terminal or interrupted, and a short timeout turns that into an error that looks like a network problem.
Related pages
- Task , Message , Part , and Artifact : the objects a task carries.
- Life of a Task and Streaming and Asynchronous Operations : concepts and webhook security.
- A2A Error Codes : the error names used above.
- A2A Interoperability Reference : routes, versions, and parameter changes across the bindings, including the operations beyond tasks.
- Agent Card Implementation Guide : declare the capabilities this guide relies on.
Source: A2A official specification: Core Operations (3.1), Operation Semantics (3.3), Multi-Turn Interactions (3.4), Task Update Delivery Mechanisms (3.5), Messages and Artifacts (3.7), Streaming Events and Push Notification Objects (4.2 and 4.3), Common Workflows (6), In-Task Authorization (7.6), and HTTP+JSON Streaming (11.7)
; a2a.proto at v1.0.1
; the v0.3.0 JSON schema
; sample exchanges from the specification at the v1.0.1 tag and at main commit 98279d6