Streaming (SSE)
Stream long-running agent tasks over Server-Sent Events with message/stream — task envelopes, keepalive, idle timeout, and client-disconnect cancellation.
When an agent produces results incrementally — progress updates, partial artifacts, a
final answer — A2A streams them back to the caller as Server-Sent Events (SSE). The
caller sends a JSON-RPC message/stream request and reads a sequence of typed event
frames until a terminal task.done or task.error.
Overview
Synchronous message/send returns a single reply.
message/stream instead opens a long-lived SSE connection and relays each event the
agent emits as it happens, so callers can show progress or consume artifacts before the
task finishes.
There are two ways to start a stream against the same agent:
| Trigger | Request | Pre-stream error format |
|---|---|---|
POST /a2a/{agent_id} with "method": "message/stream" | JSON-RPC body | JSON-RPC 2.0 error at HTTP 200 |
GET /a2a/{agent_id}/stream | query/headers | HTTP status code + {"is_error": true, "message": "..."} |
The POST form is the common case and what the examples below use. In both cases the
response is Content-Type: text/event-stream and the wire protocol is identical.
How it works
The gateway does not hold a socket open to your agent on the caller's behalf. Instead it uses the agent's virtual subscriber as an SSE relay: a temporary internal channel carries the agent's events to the gateway, which forwards them to the caller. This subscribe-first ordering guarantees no event is lost between the query and the agent's first emission.
The virtual subscriber relays the agent's SSE events over a temporary internal channel; the gateway forwards them to the caller until a terminal envelope closes the stream.
Event types
Each SSE frame has an event: name and a JSON data: payload. The agent's envelope
type maps to the SSE event name the caller sees:
Envelope type | SSE event name | Meaning |
|---|---|---|
status_update | task.status | Progress or status update (non-terminal) |
artifact | task.artifact | A partial or complete result artifact |
done | task.done | Terminal — the task completed successfully |
error | task.error | Terminal — the task failed |
| (other) | message | Default event name for untyped envelopes |
Each data: line is a stream envelope:
{
"stream_id": "f3c1...",
"type": "status_update",
"payload": { "status": "working", "progress": 3, "total": 5 }
}A caller reads frames until it sees task.done or task.error, then stops — both are
terminal and the gateway closes the connection after sending them.
Stream a task
Send a message/stream request with Accept: text/event-stream and read the event
frames as they arrive. Stop on the terminal task.done / task.error.
curl -N -X POST http://localhost:9090/a2a/stream-agent-01 \
-H "Content-Type: application/json" \
-H "Accept: text/event-stream" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "message/stream",
"params": {
"message": {"parts": [{"text": "Stream me some updates"}]}
}
}'using System.Text;
using System.Text.Json.Nodes;
const string KubeMqUrl = "http://localhost:9090";
const string AgentId = "stream-agent-01";
var payload = new JsonObject
{
["jsonrpc"] = "2.0",
["id"] = 1,
["method"] = "message/stream",
["params"] = new JsonObject
{
["message"] = new JsonObject
{
["parts"] = new JsonArray(new JsonObject { ["text"] = "Stream me some updates" })
}
}
};
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(60) };
var request = new HttpRequestMessage(HttpMethod.Post, $"{KubeMqUrl}/a2a/{AgentId}")
{
Content = new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json")
};
Console.WriteLine("Connecting to SSE stream...");
using var resp = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
using var stream = await resp.Content.ReadAsStreamAsync();
using var reader = new StreamReader(stream);
string? eventType = null;
int eventCount = 0;
while (!reader.EndOfStream)
{
var line = await reader.ReadLineAsync();
if (line == null) break;
if (line.StartsWith("event: "))
eventType = line[7..];
else if (line.StartsWith("data: ") && eventType != null)
{
eventCount++;
var data = line[6..];
Console.WriteLine($"[{eventType}] {data}");
if (eventType is "task.done" or "task.error")
break;
}
else if (line.Length == 0)
eventType = null;
}
Console.WriteLine($"\nReceived {eventCount} events");
Console.WriteLine("Stream completed!");package main
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
)
const (
kubemqURL = "http://localhost:9090"
agentID = "stream-agent-01"
)
func main() {
payload := map[string]interface{}{
"jsonrpc": "2.0",
"id": 1,
"method": "message/stream",
"params": map[string]interface{}{
"message": map[string]interface{}{
"parts": []map[string]interface{}{{"text": "Stream me some updates"}},
},
},
}
data, err := json.Marshal(payload)
if err != nil {
fmt.Fprintf(os.Stderr, "Marshal failed: %v\n", err)
os.Exit(1)
}
req, err := http.NewRequest(http.MethodPost, kubemqURL+"/a2a/"+agentID, bytes.NewReader(data))
if err != nil {
fmt.Fprintf(os.Stderr, "Request build failed: %v\n", err)
os.Exit(1)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
resp, err := http.DefaultClient.Do(req)
if err != nil {
fmt.Fprintf(os.Stderr, "Request failed: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
fmt.Println("Connecting to SSE stream...")
eventCount := 0
eventType := ""
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "event: ") {
eventType = strings.TrimPrefix(line, "event: ")
} else if strings.HasPrefix(line, "data: ") {
eventCount++
dataStr := strings.TrimPrefix(line, "data: ")
fmt.Printf("[%s] %s\n", eventType, dataStr)
if eventType == "task.done" || eventType == "task.error" {
break
}
}
}
fmt.Printf("\nReceived %d events\n", eventCount)
fmt.Println("Stream completed!")
}import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.List;
import java.util.Map;
public class Client {
static final String KUBEMQ_URL = "http://localhost:9090";
static final String AGENT_ID = "stream-agent-01";
static final ObjectMapper MAPPER = new ObjectMapper();
public static void main(String[] args) throws Exception {
var payload = Map.of(
"jsonrpc", "2.0",
"id", 1,
"method", "message/stream",
"params", Map.of(
"message", Map.of("parts", List.of(Map.of("text", "Stream me some updates")))
)
);
var client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(60))
.build();
var req = HttpRequest.newBuilder()
.uri(URI.create(KUBEMQ_URL + "/a2a/" + AGENT_ID))
.header("Content-Type", "application/json")
.header("Accept", "text/event-stream")
.timeout(Duration.ofSeconds(60))
.POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(payload)))
.build();
System.out.println("Connecting to SSE stream...");
var resp = client.send(req, HttpResponse.BodyHandlers.ofLines());
int eventCount = 0;
String currentEvent = null;
for (var it = resp.body().iterator(); it.hasNext(); ) {
String line = it.next();
if (line.startsWith("event: ")) {
currentEvent = line.substring(7).trim();
} else if (line.startsWith("data: ")) {
eventCount++;
String data = line.substring(6);
System.out.println("[" + currentEvent + "] " + data);
if ("task.done".equals(currentEvent) || "task.error".equals(currentEvent)) {
break;
}
}
}
System.out.println("\nReceived " + eventCount + " events");
System.out.println("Stream completed!");
}
}import asyncio
import json
import httpx
from httpx_sse import aconnect_sse
KUBEMQ_URL = "http://localhost:9090"
AGENT_ID = "stream-agent-01"
async def main() -> None:
payload = {
"jsonrpc": "2.0",
"id": 1,
"method": "message/stream",
"params": {
"message": {"parts": [{"text": "Stream me some updates"}]},
},
}
async with httpx.AsyncClient(timeout=60) as client:
print("Connecting to SSE stream...")
async with aconnect_sse(
client,
"POST",
f"{KUBEMQ_URL}/a2a/{AGENT_ID}",
json=payload,
headers={"Accept": "text/event-stream"},
) as event_source:
event_count = 0
async for event in event_source.aiter_sse():
event_count += 1
data = json.loads(event.data)
print(f"[{event.event}] {json.dumps(data)}")
if event.event in ("task.done", "task.error"):
break
print(f"\nReceived {event_count} events")
print("Stream completed!")
if __name__ == "__main__":
asyncio.run(main())const KUBEMQ_URL = "http://localhost:9090";
const AGENT_ID = "stream-agent-01";
async function main() {
const request = {
jsonrpc: "2.0",
id: 1,
method: "message/stream",
params: { message: { parts: [{ text: "Stream me some updates" }] } },
};
console.log("=== POST-based streaming ===");
const resp = await fetch(`${KUBEMQ_URL}/a2a/${AGENT_ID}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
},
body: JSON.stringify(request),
});
const reader = resp.body!.getReader();
const decoder = new TextDecoder();
let eventCount = 0;
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const frames = buffer.split("\n\n");
buffer = frames.pop() ?? "";
for (const frame of frames) {
if (!frame.trim()) continue;
let eventType = "";
let eventData = "";
for (const line of frame.split("\n")) {
if (line.startsWith("event: ")) eventType = line.slice(7).trim();
else if (line.startsWith("data: ")) eventData = line.slice(6).trim();
}
if (!eventType) continue;
eventCount++;
const payload = JSON.parse(eventData);
console.log(`[${eventType}] ${JSON.stringify(payload)}`);
if (eventType === "task.done" || eventType === "task.error") {
console.log(`\nStream complete. Total events: ${eventCount}`);
reader.cancel();
return;
}
}
}
console.log(`\nStream ended. Total events: ${eventCount}`);
}
main().catch(console.error);A typical stream is a sequence of task.status frames followed by a terminal task.done:
Connecting to SSE stream...
[task.status] {"type": "status_update", "payload": {"status": "working", "progress": 1, "total": 5}}
[task.status] {"type": "status_update", "payload": {"status": "working", "progress": 2, "total": 5}}
[task.status] {"type": "status_update", "payload": {"status": "working", "progress": 5, "total": 5}}
[task.done] {"type": "done", "payload": {"final_result": "completed", "event_count": 5}}Keepalive and idle timeout
To keep the connection alive through proxies during quiet periods, the gateway emits an
SSE comment line — : keepalive — every 30 seconds. It is a comment, not an event,
so SSE clients ignore it; it exists only to keep the socket warm.
If the agent produces no events for MaxSSEIdleSeconds (default 300 seconds), the
gateway closes the stream: it sends a terminal task.error with code -32001
("stream idle timeout") and issues a best-effort cancel to the agent. Tune the window
with MaxSSEIdleSeconds — see Configuration.
Client disconnect
If the caller closes the connection before a terminal event, the gateway detects the
disconnect and cancels the work on the agent rather than letting it run to completion.
It sends a stream_cancel query to the agent's virtual subscriber on
_AGENTS_.agents/{agent_id} (carrying a2a_stream_id, with a 10-second timeout), which
closes the relay's HTTP connection to the agent. This frees the
concurrency slot promptly instead of waiting
for the task to finish.
The SSE stream endpoint omits the per-route timeout middleware — long-lived streams are
bounded by MaxSSEIdleSeconds, not the 60-second request timeout that applies to
message/send.
Response size limit
During relay, the virtual subscriber tracks the accumulated data: bytes of each SSE
event. If a single event exceeds AgentMaxResponseBytes (default 10 MB), the relay is
aborted to protect the gateway from memory exhaustion. Keep individual artifact events
under this bound; for large results, chunk them across multiple task.artifact events.
Related
SSE behavior
The SSE wire format, keepalive, idle timeout, and reconnection in depth.
Synchronous messaging
message/send for single-reply request/reply, context IDs, and the Executed flag.
How it works
The virtual subscriber, the temporary stream channel, and stream_cancel routing.
Error handling
A2A error codes, the idle-timeout -32001 error, and retry strategy.
Was this page helpful?
Synchronous Messaging
Send JSON-RPC 2.0 message/send requests to A2A agents — context IDs, custom methods, header forwarding, and concurrent calls.
Error Handling
A2A error codes (-32001 to -32004), JSON-RPC base codes, the transport vs application error distinction, and retry strategy for AI agent calls.