Streaming Responses
For long-running tasks or when you want real-time progress, use stream_message to receive SSE events as the agent works.
Basic Streaming
#![allow(unused)] fn main() { use a2a_protocol_sdk::prelude::*; async fn f(client: A2aClient, params: MessageSendParams) { let mut stream = client .stream_message(params) .await .expect("connect to stream"); while let Some(event) = stream.next().await { match event { Ok(StreamResponse::StatusUpdate(ev)) => { println!("Status: {:?}", ev.status.state); } Ok(StreamResponse::ArtifactUpdate(ev)) => { for part in &ev.artifact.parts { if let a2a_protocol_types::message::PartContent::Text(text) = &part.content { print!("{text}"); } } if ev.last_chunk == Some(true) { println!(); // Newline after final chunk } } Ok(StreamResponse::Task(task)) => { println!("Final: {:?}", task.status.state); } Ok(StreamResponse::Message(msg)) => { println!("Message: {:?}", msg); } Ok(_) => { // Future event types — handle gracefully } Err(e) => { eprintln!("Error: {e}"); break; } } } } }
Event Ordering
A typical stream delivers events in this order:
Tasksnapshot (first on every stream from this repository's server; the spec requires it forSubscribeToTaskand allows a stream fromSendStreamingMessageto be a singleMessageinstead)StatusUpdate→WorkingArtifactUpdate(one or more, potentially chunked)StatusUpdate→Completed(orFailed)- Optionally, a final
Tasksnapshot with accumulated artifacts
Note: This repository's server always emits a
Tasksnapshot as the first event in any streaming response. Forsubscribe_to_task(), this allows reconnecting clients to recover the current state. Forstream_message(), it provides the initial task state before execution events begin.
Chunked Artifacts
Artifacts can be delivered in multiple chunks:
#![allow(unused)] fn main() { use a2a_protocol_sdk::prelude::*; fn extract_text(a: &Artifact) -> String { a.parts.iter().filter_map(Part::text_content).collect() } async fn f(mut stream: EventStream) { let mut buffer = String::new(); while let Some(event) = stream.next().await { match event { Ok(StreamResponse::ArtifactUpdate(ev)) => { let is_append = ev.append.unwrap_or(false); let is_last = ev.last_chunk.unwrap_or(false); if is_append { // Append parts to existing artifact. The server also // merges the new event's metadata into the existing artifact's, // key by key at the top level (new keys override existing). buffer.push_str(&extract_text(&ev.artifact)); } else { // New artifact or first chunk buffer = extract_text(&ev.artifact); } if is_last { println!("Complete artifact: {buffer}"); } } _ => {} } } } }
Re-subscribing
If a stream disconnects, re-subscribe to get the latest state:
#![allow(unused)] fn main() { use a2a_protocol_sdk::prelude::*; async fn f(client: A2aClient) -> Result<(), ClientError> { let mut stream = client .subscribe_to_task("task-abc") .await?; // Continue processing events... while let Some(event) = stream.next().await { // ... } Ok(()) } }
How a Stream Ends
next() returns None only when the stream has finished: after a Message,
or a task or status update in a terminal or interrupted state. A body that
ends anywhere else — the server went away, a proxy cut the connection — yields
ClientError::IncompleteStream first, carrying the last SSE id: so you can
resume without losing events:
#![allow(unused)] fn main() { use a2a_protocol_client::{A2aClient, ClientError}; use a2a_protocol_types::events::StreamResponse; fn handle(_event: StreamResponse) {} async fn follow(client: &A2aClient, task_id: &str) -> Result<(), ClientError> { let mut stream = client.subscribe_to_task(task_id).await?; loop { match stream.next().await { Some(Ok(event)) => handle(event), Some(Err(ClientError::IncompleteStream { last_event_id: Some(id), .. })) => { stream = client.subscribe_to_task_from(task_id, id).await?; } Some(Err(e)) => return Err(e), None => break, } } Ok(()) } }
EventStream::last_event_id() exposes the same id at any point. gRPC and
WebSocket streams carry no ids, so there it is always None and a resubscribe
starts from the Task snapshot.
A reader that falls too far behind is cut off: over SSE when it lags the
server's broadcast queue, and over WebSocket once 64 frames are waiting unread.
The error answers e.is_stream_lagged() (with e.dropped_event_count()). The
task is unaffected; resubscribe (subscribe_to_task_from with
stream.last_event_id() over SSE, or subscribe_to_task over WebSocket) to
continue.
Stream Timeouts
A stream has three bounds, one per phase:
use a2a_protocol_sdk::prelude::*; fn main() -> Result<(), ClientError> { let url = "http://agent.example.com"; use std::time::Duration; let client = ClientBuilder::new(url) .with_stream_connect_timeout(Duration::from_secs(15)) // headers .with_stream_first_event_timeout(Duration::from_secs(120)) // first data .with_stream_idle_timeout(Some(Duration::from_secs(300))) // between data .build()?; Ok(()) }
The connect timeout (default 30 seconds) bounds establishing the stream: until the response headers arrive (for gRPC, until the call is accepted), and reading the error body when the answer is not a stream.
The first-event timeout (default 5 minutes) bounds the wait for the stream's first data once it is established; a keep-alive comment counts. The specification asks a server to open with its Task or Message at once, and this repository's server does, but a2a-go writes nothing until its agent emits an event, so an agent that makes a slow model call first is silent until the call returns.
Migrating from 0.13 or earlier: the connect timeout used to bound the first event too, so an agent that flushed its headers and thought for longer than 30 seconds was cut off. If you shortened
with_stream_connect_timeoutin order to fail fast on a silent agent, setwith_stream_first_event_timeoutto the same value to keep that behaviour.GrpcTransport::with_stream_connect_timeoutlikewise now bounds opening the call rather than the first event.
After the first frame, the idle timeout (with_stream_idle_timeout, default 5 minutes) bounds the silence between chunks. Any bytes reset it, including the : keep-alive comments this repository's server writes every 30 seconds, so a healthy stream from it runs for as long as the task does. A stream that receives nothing for the whole bound ends with ClientError::Timeout ("stream idle timeout: …"); the task on the server is not cancelled, so resubscribe with subscribe_to_task to continue. Set None to disable it.
The default is five minutes because a healthy peer is never that quiet on the wire: this repository's server heartbeats every 30 seconds; a2a-go v2.5.0 sends no keep-alives unless the server opts in, but its own client gives a whole request 3 minutes; and common proxies close a connection that is silent for about 60 seconds. gRPC and WebSocket streams carry no heartbeat the stream can see, so on those bindings the bound is on the gap between events — raise it for agents that think silently for longer.
Safety Limits
The SSE parser protects against resource exhaustion:
| Limit | Value | Purpose |
|---|---|---|
| Event size | 16 MiB (with_max_event_size) | Refuses an oversized event with an error and skips it; a line with no end is refused once it outgrows the limit, so memory stays bounded |
| Stream connect timeout | 30s (default) | Bounds waiting for the stream's response headers (TCP connect itself is with_connection_timeout, 10s) |
| Idle timeout | 5 min (default) | Ends a stream whose server stopped sending, keep-alives included |
Next Steps
- Task Management — Querying tasks after streaming
- Error Handling — Handling stream failures