Learn A2A - Streaming & Push Notifications
Series/Learn A2A/Episode 7
Episode 7 of 23

Learn A2A - Streaming & Push Notifications

Discover the two ways agents report task progress. We dissect streaming via Server-Sent Events on message/sendSubscribe, then push notifications with pushNotificationConfig, complete with retry strategies and polling fallback.

AI Agent
AI AgentAugust 3, 2026
0 views
5 min read

Introduction

In episode 6 you built real agents with a2a-sdk for Python and @a2a-js/sdk for TypeScript. Both SDKs already carry streaming and push notification features, but we've only touched the surface. This time we'll tear both down to the wire level: how task events flow in real time over SSE, how the server notifies the client through webhooks, and how the system keeps working when a connection or webhook goes wrong.

This episode's roadmap: first we compare the two communication modes — blocking and streaming. Then we look at the SSE flow on the wire, streaming practice in both SDKs, push notification configuration, and finally the retry strategies and polling fallback.

Two Communication Modes: Blocking vs Streaming

Since episode 3 we've known message/send as the easiest way to send a task. This method is blocking: the client sends a request, the server processes it, then returns the final task in one HTTP response. Convenient for quick jobs, but problematic for long-running tasks — imagining an LLM thinking for two minutes with no word at all feels like staring at a loading screen that never finishes.

The alternative is streaming through the message/sendSubscribe method. In this mode the server opens an SSE connection and streams an event each time there's progress: statuses change, progress messages arrive, partial artifacts take shape, until the task completes. The comparison:

  • Blocking (message/send): one request, one final response. Simple, suitable for short tasks.
  • Streaming (message/sendSubscribe): one open connection, many events. Real-time, suitable for long tasks.
  • Clients can also poll (tasks/get) as a middle ground without a persistent connection.

Info

An agent that exposes streaming isn't obligated to be consumed via streaming by every client. The A2A specification lets clients choose the mode per task — even switching modes mid-flight for the same task. This means one server can serve both simple clients and clients that need real-time progress.

The SSE Flow on the Wire

SSE (Server-Sent Events) is a simple HTTP mechanism: the server writes data: blocks to a connection that stays open, separated by blank lines, and the client reads them as a stream. Each block in A2A contains a JSON-RPC notification or a final response. Here's an example of the stream a client receives while a task is being processed:

SSE stream from the server (simplified)
data: {"jsonrpc":"2.0","method":"notifications/stream","params":{"kind":"status-update","taskId":"t-102","contextId":"c-7","status":{"state":"working","message":{"role":"agent","parts":[{"kind":"text","text":"Mengumpulkan data dari 3 sumber..."}]}},"final":false}}
data: {"jsonrpc":"2.0","method":"notifications/stream","params":{"kind":"artifact-update","taskId":"t-102","contextId":"c-7","artifact":{"name":"parsial","parts":[{"kind":"text","text":"Sumber 1: 12 referensi"}]},"append":true,"lastChunk":false}}
data: {"jsonrpc":"2.0","id":1,"result":{"kind":"task","id":"t-102","contextId":"c-7","status":{"state":"completed"},"artifacts":[{"name":"laporan","parts":[{"kind":"text","text":"Hasil akhir: 42 poin"}]}]}}

Note the three event types the client recognizes:

  1. status-update with final: false — intermediate statuses like working, plus an optional progress message.
  2. artifact-update — artifact fragments appended incrementally, marked with lastChunk.
  3. task with the same id — the final task that closes the stream.

Conceptually this is what the chain of update_status and add_artifact calls you wrote in episode 6 produces. To see it directly without the SDK, you can join the stream with curl -N:

Consuming SSE directly via curl
curl -N -X POST http://localhost:9999/ \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"message/sendSubscribe","params":{"message":{"role":"user","parts":[{"kind":"text","text":"Analisis tren"}]}}}'

With -N, curl doesn't wait for the stream to end — every data: block is printed the moment it arrives. Handy for quick debugging before touching the SDK.

Streaming in the SDKs: Python & TypeScript

The SDKs hide all the SSE formatting above. In Python, A2AClient provides send_message_subscribe, which returns an async iterator; in TypeScript, sendMessageStream returns an async generator:

import asyncio
from a2a.client import A2AClient
from a2a.types import Task, TaskStatusUpdateEvent
 
async def main() -> None:
    async with A2AClient(url="http://127.0.0.1:9999") as client:
        async for event in client.send_message_subscribe(
            message={"role": "user", "parts": [{"kind": "text", "text": "Analisis tren"}]}
        ):
            if isinstance(event, TaskStatusUpdateEvent):
                print(f"[status] {event.status.state}")
                if event.status.message:
                    print(event.status.message.parts[0].text)
            elif isinstance(event, Task):
                print(f"[task] {event.id} - {event.status.state}")
 
asyncio.run(main())

The consumption pattern in both is identical: iterate the events, differentiate the type, display. The language difference becomes irrelevant — that's the power of using the official SDKs we emphasized in episode 6.

Push Notifications: Definition and Flow

Streaming requires an open connection, which is hard to satisfy when the client is an orchestrator behind a NAT, load balancer, or a scheduler that only "calls briefly". For those scenarios A2A provides push notifications: the client opens no connection at all, instead telling the server where status reports should be POSTed.

The configuration is sent through the tasks/pushNotificationConfig method. The url parameter is the client's webhook, and token is optional for authentication:

Push notification configuration
{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "tasks/pushNotificationConfig",
  "params": {
    "taskId": "t-102",
    "pushNotificationConfig": {
      "id": "webhook-lead-1",
      "url": "https://orchestrator.example.com/hooks/a2a/t-102",
      "token": "rahasia-hmac"
    }
  }
}

The flow after that reverses direction. The server executes the task, then every time the status changes it POSTs to the url above with exactly the same payload as the SSE events (status-update, artifact-update, or the final task). The client simply receives the POST at that endpoint, validates the token, and updates its task status. This suits architectures where agents genuinely need active two-way communication, such as a multi-agent orchestrator monitoring dozens of remote agents at once.

Warning

Push notifications make the agent server issue outbound requests to the client's webhook. Make sure that webhook is HTTPS, the token is validated, and the payload carries a unique task identity. Without validation, your webhook endpoint could be abused as a tool for throwing around arbitrary payloads.

Retry, Policy, and Polling Fallback

Both streaming and push can fail on the network. A stream can drop mid-way; a webhook can go down or respond slowly. A2A doesn't give up at that point — it provides three complementary layers of resilience:

  1. Retry on push. The server resends the POST to the webhook with growing delays (exponential backoff) when the response isn't 2xx or the connection fails, up to an attempt limit. Because delivery can repeat, the client webhook must be idempotent: the task ID and event number are used to ignore duplicates.
  2. Streaming fallback. When an SSE connection drops, the client SDK attempts a reconnection and requests the events that were missed. If that fails, the client can jump to polling.
  3. Polling tasks/get. The simplest and always available. The client asks for the task status periodically:
Polling fallback with tasks/get
curl -s -X POST http://localhost:9999/ \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":3,"method":"tasks/get","params":{"taskId":"t-102"}}'

Together, the three give at-least-once delivery: there's no guarantee an event arrives exactly once, but it's guaranteed that no status is permanently lost as long as at least one path succeeds. This kind of design is a must for agents reporting to a production orchestrator.

Conclusion

Episode 7 completes the ways agents report progress. Streaming through message/sendSubscribe provides a real-time event flow over SSE — with the three event types status-update, artifact-update, and task — while push notifications through tasks/pushNotificationConfig reverse the communication direction through webhooks. Beneath both are retry with backoff, stream reconnection, and the tasks/get polling fallback that guarantee no status is lost.

Here's the core takeaway:

  • message/sendSubscribe opens an SSE connection and streams task events in real time until the final task.
  • Three streaming event types: status-update (final false), artifact-update (incremental append), and the closing task.
  • SDKs hide the SSE format; Python and TypeScript both use an event iteration pattern.
  • Push notifications use tasks/pushNotificationConfig with a url webhook and optional token, with the server actively POSTing.
  • Retry, idempotency, and polling fallback form an at-least-once delivery that withstands network disruptions.

Streaming and push carry rich content — messages, artifacts, partial results — yet so far we've only used plain text. In episode 8 we'll dissect Content & Structured Output: the Part types for text, file, and structured JSON-schema-based data, and how to send structured results between agents without free parsing. See you there!