When we wrote the Agent Card deep dive, we treated the card as the contract a client reads before a session exists. This companion piece covers what happens after that: the unit of work itself. In the v1.0.0 release of the Agent2Agent specification, every stateful interaction is a Task moving through an explicit lifecycle, and the objects you exchange with an agent split cleanly into inputs (Messages made of Parts) and outputs (Artifacts). Get these three structures and the state machine right, and streaming, polling, and push notifications all become delivery choices rather than redesigns.
We build every field description here against the a2aproject/A2A repository — both the published v1.0.0 spec and its canonical schema, a2a.proto — and we call out the v0.3.0 spellings where your integration will still meet them in the wild, because agents built against the older spec remain reachable under the A2A-Version: 0.3 fallback semantics.
Task Anatomy and the Status Container
A Task is the protocol’s stateful unit of work. Its JSON shape (v1.0.0, camelCase per the spec’s ProtoJSON naming rules) carries six fields: id (server-generated, required), contextId (groups related tasks and messages), status (required), artifacts (output objects), history (exchanged messages), and metadata. The status field is itself a container — a TaskStatus — holding the state enum, an optional message from the agent, and an optional ISO 8601 UTC timestamp.
Two identifier rules cause most integration bugs we review. First, task IDs are server-generated, full stop: the spec states that client-provided taskId values for creating new tasks are not supported, and an unknown taskId in a message MUST produce TaskNotFoundError. Second, contextId and taskId must agree: if a client sends a message carrying both and the contextId differs from that of the referenced task, the agent MUST reject it. If only taskId is set, the agent MUST infer the contextId from the task.
Reading TaskState: Terminal Versus Interrupted
The v1.0.0 enum serializes to SCREAMING_SNAKE_CASE values in JSON — for example "TASK_STATE_WORKING" — because the bindings follow the ProtoJSON convention of emitting the protocol buffer enum names verbatim. Here is what each state means and what it forbids:
TASK_STATE_SUBMITTED— accepted and acknowledged, execution not yet started.TASK_STATE_WORKING— the agent is actively processing.TASK_STATE_INPUT_REQUIRED— paused; the agent needs additional input to proceed. This is an interrupted state, not a terminal one.TASK_STATE_AUTH_REQUIRED— paused pending authentication; also an interrupted state. The in-task authorization flow in section 7.6 says the agent SHOULD flip to this state when it needs secondary credentials, and MAY resume immediately once it has them — no new message is even required.TASK_STATE_COMPLETED— finished successfully. Terminal.TASK_STATE_FAILED— finished with an error. Terminal.TASK_STATE_CANCELED— canceled before completion. Terminal.TASK_STATE_REJECTED— the agent decided not to perform the task, either at creation or later when it realizes it can’t or won’t proceed. Terminal.TASK_STATE_UNSPECIFIED— unknown or indeterminate state.
The distinction that drives your client logic: terminal states (COMPLETED, FAILED, CANCELED, REJECTED) end the lifecycle — a message sent to such a task MUST fail with UnsupportedOperationError, streams MUST close, and subscribing to them is an error. Interrupted states (INPUT_REQUIRED, AUTH_REQUIRED) hold the task open: a blocking SendMessage call returns when the task reaches either group, so your synchronous client wakes up on “needs input” just like it wakes up on “done.” The response is typically a status message from the agent explaining what’s missing:
HTTP/1.1 200 OK
Content-Type: application/a2a+json
{
"task": {
"id": "task-uuid",
"status": {
"state": "TASK_STATE_INPUT_REQUIRED",
"message": {
"role": "ROLE_AGENT",
"parts": [{"text": "Where would you like to fly from and to?"}]
}
}
}
}
To resume, the client sends a new Message referencing the same taskId — the follow-up does not need the terminal-state dance. In v0.3.0 these values were lowercase kebab-case strings ("input-required", "auth-required", "unknown"), and the request field that controls blocking versus fire-and-forget was blocking; v1.0.0 renamed it to returnImmediately with inverted polarity, defaulting to blocking. If you are porting a v0.3 client, that inversion is the quiet breaker.
Messages: Conversation Turns, Not Deliverables
A Message is one unit of communication between client and server. Required fields: messageId (created by the message’s creator — so for your outbound turns, that’s you), role, and parts. Optional: contextId, taskId, metadata, extensions, and referenceTaskIds, the last being an explicit list of other task IDs the message draws on for context. Roles are exactly two: ROLE_USER for client-to-server, ROLE_AGENT for server-to-client — the same Message object shape flows in both directions.
The spec’s section 3.7 draws the line that matters most for architecture reviews: Messages initiate tasks, carry clarifications, and attach to status updates — but they SHOULD NOT be used to deliver task outputs, and results SHOULD BE returned as Artifacts. We treat this as a hard rule when advising teams: anything your business logic consumes downstream belongs in an artifact, because message delivery is explicitly not guaranteed. The agent decides which messages are persisted into Task.history; transient informational messages may never be stored, pre-creation messages are not there, and a client that dropped off a stream and reconnected MAY have missed status messages entirely. The spec is blunt: messages MUST NOT be considered a reliable delivery mechanism for critical information.
Parts: One Container, Four Kinds of Content
In v1.0.0, Part is a single flat object with a four-way oneof: text (string content), raw (file bytes, base64-encoded in JSON), url (a pointer to file content), or data (arbitrary structured JSON — object, array, string, number, boolean, or null). A Part MUST contain exactly one of those four; a parser that accepts two is non-conformant. Three fields apply to whichever content you chose: metadata, filename (optional, for files), and mediaType (the MIME type — notably available on every part type, not just files).
The legacy spelling differs. In v0.3.0, parts were three named types discriminated by kind: TextPart (kind: "text"), FilePart (kind: "file", wrapping a file object whose FileWithBytes variant carried base64 in bytes while FileWithUri carried uri, plus name and mimeType), and DataPart (kind: "data" with a data object). If your agent answers 0.3-semantics requests while speaking 1.0 internally, that shape mapping — file.bytes to raw, file.uri to url, mimeType to mediaType — is exactly where null-pointer bugs breed, so unit-test both directions.
One pragmatic note from our structured-output work: data is free-form by design and the spec does not attach a schema field to DataPart in either version, so if you need validated JSON between agents, negotiate the shape out-of-band or via a declared A2A extension rather than assuming senders and receivers agree on a blob.
Artifacts: The Output Channel of a Task
An Artifact is a task output object with required artifactId (unique within the task) and parts (at least one), plus optional name, description, metadata, and extensions. Artifacts arrive two ways: baked into the Task object you fetch via GetTask, or streamed/pushed as they are produced.
Chunking lives on the event, not the artifact. TaskArtifactUpdateEvent carries taskId, contextId, the artifact itself, metadata, and two flags: append (merge this artifact’s parts into the previously sent artifact with the same artifactId) and lastChunk (final chunk for that artifact ID). There is no SSE-style cursor or event-ID mechanism in A2A — resumption is by re-reading task state, not by asking the stream to replay from a position.
HTTP/1.1 200 OK
Content-Type: text/event-stream
data: {"task": {"id": "task-uuid", "contextId": "context-uuid", "status": {"state": "TASK_STATE_WORKING"}}}
data: {"artifactUpdate": {"taskId": "task-uuid", "contextId": "context-uuid", "artifact": {"parts": [{"text": "# Climate Change Report\n\n"}]}}}
data: {"statusUpdate": {"taskId": "task-uuid", "contextId": "context-uuid", "status": {"state": "TASK_STATE_COMPLETED"}}}
That is the streaming contract in miniature (adapted from the spec’s section 6.2): the stream opens with the Task object, continues with zero or more status/artifact events in strict generation order — implementations MUST NOT reorder them — and MUST close when the task hits a terminal state.
Polling, Streaming, or Push: Three Rails for the Same Train
The spec defines three complementary update mechanisms, and understanding them in one frame saves you writing three event handlers. Under the SSE streaming model, both the SendStreamingMessage operation and the SubscribeToTask operation deliver every update as a StreamResponse — a wrapper whose oneof payload is task, message, statusUpdate, or artifactUpdate. Polling via GetTask returns the same Task object those events describe. And push webhooks, regardless of which binding initiated the task, POST a StreamResponse JSON payload to your registered URL. So the event vocabulary — status transitions with attached messages, artifact deltas with append/lastChunk — is identical in all three modes; only the transport differs.
Our selection heuristic for enterprise rollouts:
- Polling (
GetTaskon an interval) is the universal fallback: it works against every binding, survives restrictive firewalls where you cannot receive callbacks, and needs no persistent connection. The costs are latency and wasted requests. Best for simple integrations and low update frequency — see the spec’s task update delivery section. - Streaming (SSE via
SendStreamingMessage/SubscribeToTask) wins for interactive UX and long tasks with frequent intermediate output — token streams, progress deltas. It requiresAgentCard.capabilities.streaming: true, a persistent connection, and an operational plan for disconnects. - Push notifications invert the connection: the agent POSTs to your webhook, so it fits server-to-server automation and very long tasks where neither side should hold a socket open. It demands your endpoint be reachable, secured, and idempotent, and
AgentCard.capabilities.pushNotifications: true.
A subtlety we verified in section 3.5.2: an agent MAY serve multiple concurrent streams per task, MUST broadcast the same events in the same order to each, and closing one stream MUST NOT affect the others. Task lifecycle is independent of any individual stream’s lifecycle — the task keeps running when your dashboard window closes.
What Became of tasks/resubscribe
The method named in our title’s second half is a v0.3.0 citizen: tasks/resubscribe existed to let a client reconnect to an SSE stream for an ongoing task after a message/stream connection (or an earlier resubscribe) was interrupted. Two facts made it treacherous. Backfill of events missed during the outage was explicitly implementation-dependent — “not strictly defined by this specification” was its wording — so a reconnected client could find holes. And the stream itself, being SSE, carried no position marker A2A agreed to honor.
In v1.0.0 the method mapping table no longer lists a resubscribe operation; the semantics were absorbed by SubscribeToTask (REST POST /tasks/{id}:subscribe). The spec strengthened the reconnect story where it counts: the operation is valid for any task not in a terminal state, attempting it on a terminal task MUST return UnsupportedOperationError, and — crucially — the response MUST begin with a fresh Task object representing current state at subscription time. The spec’s stated rationale is exactly the race we used to hand-roll: replaying current state first “prevents a potential loss of information between a call to GetTask and calling SubscribeToTask.”
{
"jsonrpc": "2.0",
"id": 5,
"method": "SubscribeToTask",
"params": { "id": "task-uuid" }
}
So the modern reconnect pattern is: re-establish a stream with SubscribeToTask, read the opening Task snapshot for truth (it is the atomic baseline; no gap check needed), then fold in the status/artifact events that follow. Transient messages missed during the outage may be gone for good — which returns us to the rule from section 3.7: put anything that matters into artifacts or task status, not into stream chatter.
Push Notifications: Config, Payload, Authentication, Trust
Push setup is a per-task CRUD resource. You register a webhook with CreateTaskPushNotificationConfig (REST POST /tasks/{id}/pushNotificationConfigs, or inline in SendMessage via configuration.taskPushNotificationConfig), and manage it with GetTaskPushNotificationConfig, ListTaskPushNotificationConfigs, and DeleteTaskPushNotificationConfig. If you call any of them against an agent whose card says pushNotifications: false, you MUST get PushNotificationNotSupportedError — the capability gate from the Agent Card article in action. The config persists until task completion or explicit deletion.
The stored config object (TaskPushNotificationConfig) has fields tenant, id, taskId, url (required), token, and authentication. The payload hitting your webhook is the same StreamResponse oneof you handle in a stream — typically {"statusUpdate": {...}} — POSTed to your URL:
POST /webhook/a2a-notifications HTTP/1.1
Host: client.example.com
Authorization: Bearer secure-client-token-for-task-aaa
Content-Type: application/json
{
"statusUpdate": {
"taskId": "43667960-d455-4453-b0cf-1bae4955270d",
"contextId": "c295ea44-7543-4f78-b524-7a38915ad6e4",
"status": {"state": "TASK_STATE_COMPLETED", "timestamp": "2024-03-15T18:30:00Z"}
}
}
Authentication has two separable layers, and conflating them causes webhook security reviews to stall. First, authentication: an AuthenticationInfo with a required scheme string drawn from the IANA HTTP authentication scheme registry (Bearer, Basic, Digest; scheme names are case-insensitive per RFC 9110 §11.1) and optional credentials whose format depends on the scheme. The agent MUST present those credentials in the request headers — the example above is a Bearer credential echoed into Authorization. Second, token: a unique-for-this-task-or-session string whose job is receiver-side verification. In v0.3.0 the documented wire behavior was the X-A2A-Notification-Token header, which the webhook validated before trusting anything else; in v1.0.0 the field survives in the schema with that purpose intact, and we still recommend matching it per-config exactly as the older spec showed.
The delivery contract is at-least-once, and the obligations split by side. Agents MUST attempt delivery at least once per configured webhook, MAY retry with exponential backoff, SHOULD use 10–30 second timeouts, and MAY give up after a configured run of consecutive failures. Clients MUST respond with HTTP 2xx to acknowledge, MUST validate that the payload’s taskId matches a task they actually created, and SHOULD process notifications idempotently because duplicate deliveries are expected. Section 13.2 adds the SSRF guardrail we like seeing called out: the agent side SHOULD validate webhook URLs against private IP ranges and link-local addresses so a malicious registration can’t turn your agent into an internal-network port scanner. Design your webhook handler as an idempotent state-merger keyed by taskId plus status timestamp, return 2xx fast, and reconcile from GetTask asynchronously when you want certainty.
Idempotency: Which Retries Are Safe
The v1.0.0 idempotency rules (section 3.3.1) are short enough to quote in a code-review checklist: Get operations (GetTask, ListTasks, GetExtendedAgentCard) are naturally idempotent. SendMessage operations MAY be idempotent — the spec points at the client-generated messageId as the dedup handle agents can use to recognize a retried turn. And CancelTask is idempotent: repeated cancellation requests have the same effect, with one wrinkle — a duplicate cancel MAY return TaskNotFoundError if the task was already canceled and purged, so treat not-found-on-cancel as success-with-questions rather than a failure. Because JSON-RPC request id values are per-request, never per-conversation, retry safety ultimately rests on messageId, stable taskId references, and your webhook handler tolerating the same statusUpdate twice. ListTasks pagination follows the same defensive spirit: nextPageToken MUST always be present and MUST be an empty string on the final page — key your loop on that, not on result-array length.
What This Guide Does Not Cover
Deliberately out of scope: the Agent Card itself — skill declarations, supportedInterfaces, and signed cards live in our card deep dive; the A2A extensions mechanism beyond its field hooks; gRPC and custom bindings; polling interval tuning and SDK-specific clients (for the end-to-end wiring picture see our SAP A2A integration walkthrough); the Copilot/Joule-side orchestration we cover in SAP Joule, M365 Copilot, and Azure; and error-handling UX. A2A’s position relative to MCP is another post. Browse the full set in /tutorials/.
FAQ
Can I send another message to a task that already reached COMPLETED?
No. COMPLETED, FAILED, CANCELED, and REJECTED are terminal, and the spec requires agents to reject further messages with UnsupportedOperationError. Continuation belongs to a new Task in the same contextId — optionally cross-referencing the old one via referenceTaskIds — not to a reopened one.
The docs show "input-required" but the spec examples say "TASK_STATE_INPUT_REQUIRED". Which does the wire use?
Version decides. A v0.3.0 agent exchanged lowercase kebab-case strings; a v1.0.0 exchange follows ProtoJSON and serializes the protocol buffer enum names, i.e. TASK_STATE_INPUT_REQUIRED. Since an empty A2A-Version header is interpreted as 0.3, send the A2A-Version: 1.0 header explicitly when you want enum-style values; agents MUST answer with VersionNotSupportedError if they cannot honor it.
My SSE client dropped mid-task. Do I lose the events that happened while I was gone?
Status transitions and artifact state are never lost — but stream-position replay is not a concept A2A provides. Reconnect with SubscribeToTask; the response MUST open with the current Task object, which is an atomic snapshot covering everything up to that instant, then continues with live events. Transient status messages missed in the gap may never appear again, which is precisely why task results belong in artifacts.
Is the push-notification token the same thing as authentication.credentials?
No — different layers, different directions. authentication (scheme plus credentials) tells the agent how to prove itself when POSTing to your webhook, e.g. Bearer plus a token echoed into Authorization. The token field is a task- or session-unique string you set for your webhook to validate — v0.3.0 documented it as arriving in the X-A2A-Notification-Token header — so a receiver can drop anything that isn’t about a task it started, even if the transport credentials are valid.
References
- A2A Protocol Specification v1.0.0
- A2A Specification v0.3.0
- A2A latest specification index
- a2aproject/A2A repository
- A2A releases
- a2a.proto canonical schema
- ProtoJSON JSON format
- Server-Sent Events — WHATWG HTML specification
- RFC 9110 — HTTP Semantics
- RFC 7807 — Problem Details for HTTP APIs
- IANA HTTP Authentication Schemes registry
- A2A streaming and asynchronous operations topic