3.8. Streaming
In one glance
- You will: Run the same turn whole and then token by token, measure what each shows a waiting human, and cancel a turn while the model is still thinking.
- You need: 3.6. A2A finished, with
mise run a2astarting and a model configured. - Time: about 35 minutes, hands-on.
Task-event streaming versus per-token model output
Streaming names two mechanisms, and keeping them apart is what this page is for. One is the A2A task-event stream: the server emits a frame whenever the task changes, with submitted, working, completed and canceled arriving as status updates and text arriving as artifact updates. It runs whatever you configure. The other is per-token model output, which splits the answer into chunks as the model writes them, and it is off by default here because chunks cannot be checked as a whole.
The task-event stream exists because a spinner carries no information. It looks the same whether the model is writing a careful answer, waiting on a tool that will never reply, or dead, so a human cannot tell a slow turn from a stuck one. Reloading does not help: the turn keeps running on the server, still holding the model and still recording tokens against the session, while the client has forgotten the task id that could have stopped it. Progress and a stop button both come out of that stream.
Per-token delivery costs a guarantee. The policy plane — the callbacks that inspect and rewrite every model response on its way out — sees one response at a time, so what it can promise is that a whole message was checked as a whole. Chunks break that.
ADK ships a third mechanism this course does not reach: bidirectional live streaming, where audio and video flow both ways over one open connection through the runtime’s RuntimeAPIController (its examples/bidi sample is the reference). It needs a Live-API model, which no open-weight local model provides, so it sits outside the account-free path this course requires. The redaction argument above applies to it with more force rather than less: a spoken answer is emitted as it is generated, and a window in which unredacted content has already left the process is a window you cannot close afterwards.
This page measures the same turn twice, sends an IP address across a chunk boundary to see what the redactor can no longer mask, and cancels a running turn from the id in its first frame.
Measure the same turn unstreamed and streamed
Start the server the way 3.6. A2A left it, then ask it something in another terminal:
cd agents/go
mise run a2acurl -sN -X POST http://127.0.0.1:8080/ -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"message/stream","params":{"message":{"kind":"message","messageId":"m-1","role":"user","parts":[{"kind":"text","text":"In two sentences, explain what the get_service_status tool reports."}]}}}'curl -sN disables buffering, so each server-sent event lands on your terminal the moment it is written. On the laptop these numbers come from — a CPU-only machine with AGENT_MODEL_TIMEOUT_S raised to 600 so a slow local turn could finish — the whole answer took a little over three minutes and produced four frames: the submitted task, a working status, one artifact carrying the finished text, and a completed status. Nothing arrived between the second frame and the last one.
0.04s task submitted
0.07s status-update working
191.89s artifact-update "The `get_service_status` tool reports the current status of a specified service..."
191.91s status-update completedPredict what the switch changes, total time or only the shape of delivery, then restart the server with it on:
cd agents/go
AGENT_A2A_STREAMING=true AGENT_MODEL_TIMEOUT_S=600 mise run a2aSet the value on the command line rather than in .env. A mise task applies its own dotenv file over your shell environment, so a .env that sets AGENT_A2A_STREAMING wins; the shipped .env.example leaves the line commented out, which is why the command-line form works.
The same request now produces sixty frames instead of four — the same submitted, working, and completed frames as before, plus fifty-six chunks and the whole-message artifact — and the first words of the answer arrive at ten seconds instead of at a hundred and ninety-two:
0.03s task submitted
0.06s status-update working
10.17s artifact-update "The"
13.63s artifact-update " `"
17.17s artifact-update "get"
... fifty-six chunks in all
222.00s artifact-update "."
225.50s artifact-update "The `get_service_status` tool reports the current status of a specified service..."
225.54s status-update completedThe turn did not get faster: this run took longer than the unstreamed one, and total time varied by tens of seconds between runs on the same prompt. What changed is that a human had something true to look at after ten seconds instead of after three minutes. Streaming buys perceived latency, which is what a person waiting on a turn actually experiences, and nothing else.
You have now measured the difference between a spinner and a stream on your own hardware, with no account and no hosted service involved.
What AGENT_A2A_STREAMING switches, and what it leaves alone
The card advertises "streaming": true whatever you do, and message/stream always gives a client server-sent events. What AGENT_A2A_STREAMING decides is narrower than its name suggests:
func (s *Server) runConfig() agent.RunConfig {
if s.options.Streaming {
return agent.RunConfig{StreamingMode: agent.StreamingModeSSE}
}
// The default leaves model streaming off: message/stream clients still get
// server-sent events of whole task updates, which is the trade-off Chapter
// 3.6 documents.
return agent.RunConfig{StreamingMode: agent.StreamingModeNone}
}That is the whole switch. Off, ADK runs the model to completion and the server publishes whole task events — submitted, working, one artifact, terminal. On, ADK asks the model adapter to stream, and every chunk the adapter yields becomes its own artifact update.
Each chunk is a delta, not a growing snapshot. ADK’s OpenAI-compatible adapter — the path local Ollama takes — emits one response per token delta and then one aggregated response carrying the concatenation, so what the wire actually carries is this:
10.17s "The"
13.63s " `"
17.17s "get"
20.74s "_service"
24.43s "_status"
28.44s "`"Deltas move work to the client: render each frame as its own message and one answer becomes fifty-six one-word bubbles. Nothing in the text says which frames are provisional, so ADK marks them:
{
"kind": "artifact-update",
"artifact": { "artifactId": "019fef6c...", "parts": [{ "kind": "text", "text": "The" }] },
"metadata": { "adk_partial": true }
}adk_partial is the flag that separates a provisional chunk from the finished answer, and clients/web/index.html uses it to accumulate chunks into one dashed-border bubble that the final, non-partial artifact then replaces in place. That replacement can change the text, not only the styling.
Why per-chunk redaction cannot mask an entity split across chunks
The policy plane redacts each response on its own, because one response is all a callback can see. A whole message is one response. A stream of the same answer is fifty-six of them.
So ask what happens to an address the model writes across a chunk boundary. Neither 10. nor 1.2.3 is an IP address; 10.1.2.3 is. One test puts that question to the shipped policy plane over the real wire:
cd agents/go
go test ./a2aserver -run TestStreamedChunksCrossTheRedactionBoundary -count=1 -v=== NAME TestStreamedChunksCrossTheRedactionBoundary/off
protocol_test.go:744: chunks the client rendered: ""
protocol_test.go:745: whole message after redaction: "The failing host is <IP_ADDRESS>, so page the on-call."
=== NAME TestStreamedChunksCrossTheRedactionBoundary/on
protocol_test.go:744: chunks the client rendered: "The failing host is 10.1.2.3, so page the on-call."
protocol_test.go:745: whole message after redaction: "The failing host is <IP_ADDRESS>, so page the on-call."
--- PASS: TestStreamedChunksCrossTheRedactionBoundary (0.00s)The last line of each pair is identical: the finished message is masked either way, and streaming never weakens that. The difference is the line above it. With the switch on, the client had already rendered — and possibly scrolled past, or copied — the unmasked address before the mask existed, and there is no protocol frame that means “forget what I said”. A masked answer that arrives after the unmasked one has been read is a record of what leaked, not a control.
The streamed transcript says how wide that window is: the last chunk landed at 222.00 seconds and the whole-message artifact at 225.50, so for three and a half seconds every word on screen had been checked only against its own chunk. A faster machine narrows the window without closing it.
That is why AGENT_A2A_STREAMING defaults to false here and stays false on the taught path. Not performance, not maturity: this agent handles incident text, incident text carries hosts and addresses and the occasional pasted email, and per-token delivery cannot keep the whole-message guarantee the policy plane makes.
Token accounting survives, whatever the folklore says. No chunk carries usage metadata, but the aggregated final response does, and the two runs above reported 2,748 tokens streamed against 2,747 unstreamed — a one-token difference in the answer itself, not in the accounting. The per-session budget from 2.4. Sessions therefore still counts a streamed turn; a budget that stopped counting whenever a client asked for chunks would let a session spend past its cap with nothing failing. Whether agentgateway attributes tokens the same way for a streamed upstream response is a question this page did not measure — 0.2. Evidence is the rule for what a page may claim.
If your own agent never handles personal data, or you redact somewhere the chunks cannot bypass, turn the switch on. Make that decision from the measurement above rather than from fashion.
Stop a running turn, and why closing the tab does not
The Cancel button in the browser client works with the switch off. The task id travels on the event stream rather than with the model’s tokens, so it arrives before the model has written a word.
Fire a turn, let it run for eight seconds, then cancel it by the id from the very first frame:
curl -s -X POST http://127.0.0.1:8080/ -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":9,"method":"tasks/cancel","params":{"id":"<your-task-id>"}}' | jq '.result.status.state'"canceled"The same cancellation from the streaming terminal, with the arrival time of every frame:
0.04s task submitted <- the id arrives here, before the model has written a word
0.07s status-update working
8.05s tasks/cancel sent
8.10s status-update canceled <- final: true, the stream endsFifty milliseconds from the cancel request to the stream ending, on a turn whose first word was still minutes away. The task ends canceled, and it ends that way durably — ask for it afterwards with no model running and tasks/get still says canceled. The offline version of this measurement is cd agents/go && go test ./a2aserver -run TestACancelableTaskIDArrivesBeforeTheModelAnswers, which asserts both halves: the client holds a task id while the model is still blocked, and the answer the model eventually produced never reaches the client.
Closing the tab, by contrast, is not cancellation:
cd agents/go
go test ./a2aserver -run TestClosingTheConnectionDoesNotCancelTheTask -count=1That test disconnects a client mid-turn and then finds the task completed anyway, because the A2A SDK runs an execution on a context deliberately detached from the request’s. This is the right design — a task that died whenever a laptop lid closed would not be a durable task — and it is exactly why the client has a Cancel button rather than a reload instruction. Reloading abandons a running turn; tasks/cancel stops it.
A canceled turn cannot leave half a change behind either: a guarded write commits in one transaction with its audit row, so a turn cut at any point either did the write and recorded it or did neither — the guarantee 3.1. Tools builds and this page inherits.
Your turn: watch the mask arrive late, then stop a turn yourself
A string in a test log is easy to nod at; the same two texts seconds apart on your own terminal are not.
Predict first: when the whole-message artifact arrives, are the earlier chunks retracted, corrected, or simply followed?
- Mode:
inspect— every step reads; the switch lives on one command line and nothing on disk changes. - Goal: see a provisional chunk and its whole-message replacement as two separate frames, then cancel a running turn and watch it stop.
- Files to touch: none.
- Preflight: stop any A2A server already on
:8080; havecurlandjqon your path; leaveAGENT_A2A_STREAMINGcommented out in.envso the command line decides it. - Steps: run
cd agents/go && go test ./a2aserver -run TestStreamedChunksCrossTheRedactionBoundary -count=1 -vand read both logged strings. Start the server withAGENT_A2A_STREAMING=true AGENT_MODEL_TIMEOUT_S=600 mise run a2a, replay thecurl -sNrequest from this page, and find the frames whereadk_partialgoes fromtruetofalse. Then send a longer question —Triage the open incidents and explain your reasoning at length.— read the task id from the first frame, and in a third terminal cancel it withtasks/cancel. Watch the streaming terminal stop, then asktasks/getfor the same id. - Gate that proves completion: you can quote the two strings the test logged and say which one a client had already rendered; you can point at the first frame carrying
"adk_partial": falseand say why it is the only text the redactor saw whole; andtasks/getanswers"canceled"for a turn you stopped by hand. - Final state: stop the server with Ctrl-C and restart it without the variable, so your machine is back on the taught default. No files changed.
The browser client is worth a look too, with agentgateway in front of it as 3.6. A2A set up: it accumulates chunks into one dashed bubble, replaces that bubble with the finished answer, and labels the bubble provisional when a cancel lands first. Whether your gateway forwards each frame as it arrives or buffers a few is a property of the proxy, not of the agent, and this page did not measure it — 0.2. Evidence is why the distinction is drawn rather than glossed.
What you can do now
- You can say what streaming buys on the same turn — first words in ten seconds instead of three minutes — and what it does not, which is a faster turn.
- You can explain, with the test output in front of you, why
AGENT_A2A_STREAMINGstays off here: chunks are redacted one at a time, and an entity split across two of them reaches the client before the mask exists. - You can stop a running turn with
tasks/canceland the id from its first frame, and say why closing the tab leaves itcompletedinstead. cd agents/go && go test ./a2aserverpasses, streaming and cancellation included.
Streaming is not one switch. You can say what it puts on the wire, which guarantee it costs, and how to give a waiting human progress and a stop button without paying that cost: task events on, model tokens off.
Continue to 3.9. Incident Run, which runs the default composition against one incident and reads the turn back call by call, naming which calls the model chose and which the code forced.