DEV Community

Jeff
Jeff

Posted on Originally published at powerduck.com

A2A streaming and the task lifecycle: SSE progress updates, artifacts, and push notifications

When an agent takes thirty seconds or five minutes to finish, a plain request/response call is the wrong shape: the connection idles through proxies that time out, the user sees no progress, and a retry cannot tell whether the work already happened. The Agent2Agent (A2A) protocol addresses this by making the unit of work a task with a visible state machine, and by offering three ways to observe it, a blocking call, a streaming call over Server-Sent Events, and push notifications for when the client cannot hold a connection open. The exact method names and fields are versioned in the A2A specification, so pin the release you implement against; the lifecycle and event model described here are the stable core.

The task state machine

Every method call creates or resumes a task whose status moves through a small, explicit set of states:

State Meaning What the client does
submitted Accepted, work not started Wait for updates
working Actively processing Show progress, keep listening
input-required Agent needs more information (human in the loop) Prompt the user, then send another message on the same task
completed Terminal; artifacts are ready Read the result
failed Terminal; an error explains why Surface the error, optionally retry as a new task
canceled Terminal; the client or system canceled it Stop
unknown State cannot be determined Poll or query the task

Two states make this more than a job queue. input-required turns the task into a conversation: the agent pauses, the client supplies the missing information, and the same task resumes. working is not a single hop; the agent may emit many status messages and partial artifacts while in it.

A task carries an id, a contextId that groups a multi-turn exchange, a status (with a timestamp and a message), and artifacts. Artifacts hold the actual output as ordered parts, text parts, file parts, and structured data parts, which lets an agent stream a document chunk by chunk and attach generated files.

Three invocation styles

A2A is JSON-RPC 2.0 over HTTP. The same logical send has a blocking and a streaming variant:

  • message/send submits a message and returns the task once it reaches a terminal or interaction state. Simple, but no progress for long work.
  • message/stream submits (or continues) a task and opens a text/event-stream; the server emits status and artifact updates as they happen, ending with a final event.
  • tasks/get, tasks/cancel fetch the current task and cancel it.
  • tasks/pushNotification/set / get register and read a push channel for clients that cannot keep an SSE connection open.

Streaming over SSE

message/stream responds with Content-Type: text/event-stream. Each SSE data: frame carries a JSON-RPC message, either a status update or an artifact update:

POST / HTTP/1.1
Content-Type: application/json
Accept: text/event-stream

{
  "jsonrpc": "2.0",
  "id": "req-1",
  "method": "message/stream",
  "params": {
    "message": {
      "role": "user",
      "parts": [{ "kind": "text", "text": "Draft a migration plan for our billing service." }],
      "messageId": "m-1"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The event stream communicates progress first, then output:

event: status-update
data: {"taskId":"t-9","contextId":"c-2","status":{"state":"working","timestamp":"2026-10-08T10:00:01Z","message":{"role":"agent","parts":[{"kind":"text","text":"Inventorying current endpoints..."}]}}}

event: status-update
data: {"taskId":"t-9","contextId":"c-2","status":{"state":"working","timestamp":"2026-10-08T10:00:12Z","message":{"role":"agent","parts":[{"kind":"text","text":"Drafting the phased rollout..."}]}}}

event: artifact-update
data: {"taskId":"t-9","artifact":{"artifactId":"a-1","name":"migration-plan.md","parts":[{"kind":"text","text":"# Migration plan\\n\\n## Phase 1 ..."}],"lastChunk":true},"append":true}

event: status-update
data: {"taskId":"t-9","contextId":"c-2","status":{"state":"completed","timestamp":"2026-10-08T10:00:41Z"},"final":true}
Enter fullscreen mode Exit fullscreen mode

The client renders the working messages as a progress feed, appends artifact chunks as they arrive, and tears down the stream only when it sees final: true on a terminal state. Because updates are framed as JSON-RPC messages, the same envelope works for transport errors and for the input-required pause; the client does not need a second protocol to handle clarifying questions.

SSE is the right default for streaming because it is unidirectional server-to-client, traverses ordinary HTTP infrastructure, and reconnects with standard semantics. Use it rather than WebSocket when the client only needs to receive progress and sends new input by making another request.

Push notifications for intermittent clients

A serverless function, a mobile app in the background, or a workflow engine cannot hold an SSE socket open for five minutes. Push notifications invert the delivery: the client registers a callback and the server POSTs updates to it.

{
  "jsonrpc": "2.0",
  "id": "req-2",
  "method": "tasks/pushNotification/set",
  "params": {
    "taskId": "t-9",
    "pushNotificationConfig": {
      "url": "https://client.example.com/a2a/callback",
      "token": "<signed JWT the server presents on callback>",
      "authentication": { "schemes": ["Bearer"] }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The server then calls the registered URL with task status update events, typically on terminal states and optionally on progress milestones. The token lets the client authenticate that the callback genuinely comes from the agent, which matters because the callback is an inbound request to the client's own infrastructure. This is the same webhook reliability problem as any async API: document retries, signing, and which states trigger a push, and keep tasks/get as the reconciliation path if a push is missed.

Choosing the delivery mode

Client situation Use
Fast task, simple integration message/send (blocking)
Long task, wants live progress, can hold a connection message/stream (SSE)
Background/mobile/serverless, cannot hold a socket Push notifications + tasks/get
Recovering after a disconnect tasks/get to resync, then resume streaming

A robust client treats these as composable: it streams when it can, registers a push channel when it cannot, and always knows how to poll tasks/get to reconcile state after a dropped connection. The task id and context id are the correlation keys across all three, which is why resuming does not duplicate work.

Modeling it over HTTP

Document the JSON-RPC endpoint, the SSE response media type, and the task schema explicitly so generated clients and renderers understand the dual response:

paths:
  /:
    post:
      operationId: agentMessageStream
      summary: Submit a message and stream task updates over SSE
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/MessageRequest' }
      responses:
        '200':
          description: Server-Sent Events stream of status and artifact updates.
          content:
            text/event-stream:
              schema: { $ref: '#/components/schemas/TaskUpdateEvent' }
        '202':
          description: Task accepted and queued when the stream is opened lazily.
  /tasks/{taskId}:
    get:
      operationId: getTask
      summary: Fetch the current task state and artifacts (reconciliation)
      parameters:
        - name: taskId
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Current task.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Task' }
Enter fullscreen mode Exit fullscreen mode

Model the task state as the closed enum above, keep artifacts as arrays of typed parts, and document that an input-required status is followed by another message/stream or message/send carrying the same context. The Agent Card served at /.well-known/agent.json advertises which of these capabilities (streaming, push notifications) the agent supports, so clients should discover capabilities there rather than assuming every agent streams.

What this buys agents and integrators

The lifecycle turns "call an agent" from an opaque wait into something observable and resumable: UIs show real progress, workflows persist task ids and reconcile after crashes, human-in-the-loop steps are a first-class state rather than a timeout, and partial artifacts can render before completion. It also maps cleanly onto the patterns already used for long-running REST jobs (202 plus a status resource and webhooks), so an existing HTTP API can be bridged to A2A without inventing a new execution model.

Checklist

  1. Model agent work as a task with the explicit state machine; treat input-required as a resumable pause on the same context.
  2. Offer message/send for short work and message/stream over SSE for long work; end the stream with a terminal, final event.
  3. Carry output as artifacts of typed parts, appending chunks in order.
  4. Provide tasks/get for reconciliation after disconnects and tasks/cancel for explicit cancellation.
  5. Offer push notifications with an authenticated callback for clients that cannot hold a socket; document retries and signing.
  6. Correlate every mode with task id and context id so resumes never duplicate work.
  7. Advertise streaming and push capabilities in the Agent Card; pin the A2A spec version you implement.
  8. Document the SSE media type and task schemas so generated clients handle the streaming response.

Get these right and a five-minute agent run becomes a live, resumable, human-in-the-loop task instead of a spinner and a timeout.

You can model the task lifecycle and SSE responses, generate a typed client, and simulate streaming updates against a mock in one local-first workspace, right in your browser. For wrapping an existing service in this task model, see bridging an existing HTTP API to an A2A agent.

Top comments (0)