Your tool calls an endpoint that takes thirty seconds and streams as it works, while the user watches a spinner for all of it. The model cannot show progress, because it is waiting for the tool result like everyone else. When you do send the chunks to the screen, the model’s next turn has no record that they happened, and it answers as if the user saw nothing.
What you get
You will end up with one tool whose progress reaches the user over three channels, and a test that the model sees only the final result on every one of them. This is for you if your tools run long enough for a user to wonder.
Short answer
Stream progress to the user from inside the tool, through a channel the model never sees. Use
a data- part on a Vercel AI SDK message stream, or an event: progress line on your own
server-sent event response. Return only the final result as the tool result, because that is
all the model keeps. Anthropic’s eager_input_streaming streams the model’s own tool arguments
as it writes them, not your tool’s output.
You will need
Node 22 or later and an API endpoint that streams its response. Verified 2026-10-21 against Node 22.22.0, ai 7.0.143, @anthropic-ai/sdk 0.135.0, and Zod 4.6.7. The endpoint is a stand-in on 127.0.0.1 that writes one JSON line per step with a pause between them. The two models are stand-ins too: a scripted mock for the AI SDK, and a server on 127.0.0.1 that answers the Anthropic SDK with the documented event sequence. No request leaves the machine, and no key is set.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| Anthropic fine-grained tool streaming | The slow part is the model writing a large tool input, such as a file body | Fragments arrive unvalidated, so the client parses defensively, and nothing your tool produces is in the stream | The slow part is the tool’s own work after the input is complete |
| Hand-written server-sent events | Any framework or none, and a browser or a terminal on the other end | A wire format you write and parse yourself, ids and replay you store yourself, and no message object to hang parts on | You already render a UI message stream and want the parts typed |
| Vercel AI SDK data stream parts | The front end already consumes an AI SDK message stream | A writer the tool must be able to reach, and part types that are the SDK’s vocabulary rather than yours | The client is not an AI SDK client, or the agent loop is not the SDK’s |
Server-sent events cost the most code and bind you to nothing. The AI SDK’s data parts cost a closure over the stream writer and give you typed, reconciled parts on a message the client already holds. Anthropic’s switch costs one field and solves a different problem: it shortens the wait for the model’s tool input, which is the part that arrives before your tool has run at all.
Stream progress as server-sent events
The tool takes a sink for progress and returns the result. Everything handed to the sink goes to the user; only the return value goes to the model.
let result: ReportResult | undefined
for await (const line of lines(res.body)) {
const parsed = JSON.parse(line) as Progress | (ReportResult & { done: true })
if ('done' in parsed) {
result = { reportId: parsed.reportId, sections: parsed.sections, rows: parsed.rows }
} else {
onProgress(parsed)
}
}
if (!result) throw new Error('the stream ended without a result line')
return result
The agent runs behind POST /chat, and the sink writes each progress line as an event on the
response. The format is the
specification’s:
an id line, an event line, a data line, and a blank line to dispatch. The conversation the
model sees is a separate list, and only the tool result is appended to it. A tool that fails
still answers its tool_use, marked is_error, and the browser gets an error event.
emit('tool_call', { name: 'buildReport', input })
const result = await buildReport(apiUrl, input.topic, (p) => emit('progress', p)).catch((err: Error) => err)
if (result instanceof Error) {
// A failed tool still answers its tool_use, marked is_error, so the model can read what failed.
session.conversation.push({ role: 'user', content: [{ type: 'tool_result', tool_use_id: 'call_1', is_error: true, content: result.message }] })
return emit('error', { message: result.message })
}
// The only thing from the tool that the model will ever see.
session.conversation.push({ role: 'user', content: [{ type: 'tool_result', tool_use_id: 'call_1', content: JSON.stringify(result) }] })
The demo posts a message and reads the response with fetch and a parser written to the same
section of the specification. It then prints the conversation the model would be sent, and
reconnects with a Last-Event-ID header to see what a dropped connection gets back.1
node --experimental-strip-types sse.ts
content-type: text/event-stream
events as the browser receives them
id=1 tool_call {"name":"buildReport","input":{"topic":"q3"}}
id=2 progress {"step":1,"of":4,"note":"loaded 120 rows"}
id=3 progress {"step":2,"of":4,"note":"grouped by region"}
id=4 progress {"step":3,"of":4,"note":"wrote 3 sections"}
id=5 progress {"step":4,"of":4,"note":"rendered charts"}
id=6 answer {"text":"The Q3 report rpt_2026_q3 has 3 sections over 120 rows."}
the conversation the model is sent next turn
user: Build the q3 report
assistant: tool_use
user: tool_result
assistant: The Q3 report rpt_2026_q3 has 3 sections over 120 rows.
progress events sent to the browser: 4, kept in the conversation: 0
the browser reconnects with Last-Event-ID: 3
id=4 progress {"step":3,"of":4,"note":"wrote 3 sections"}
id=5 progress {"step":4,"of":4,"note":"rendered charts"}
id=6 answer {"text":"The Q3 report rpt_2026_q3 has 3 sections over 120 rows."}
Four progress events went to the browser and the conversation holds four messages, with the progress in none of them. The replay works because the server kept every emitted event per session, numbered across its turns so that an id names one event. Without that store, a reconnect gets a blank screen and the result when it arrives.
Write data parts on an AI SDK message stream
The AI SDK’s createUIMessageStream hands
its execute callback a writer, and a tool created inside that callback can write to it while
the model waits. Two kinds of part do two jobs: a part with an id is reconciled on the client
as it changes, and a transient part is delivered once and never added to the message.
const build = tool({
description: 'Build the quarterly report.',
inputSchema: z.object({ topic: z.string() }),
execute: ({ topic }) =>
buildReport(apiUrl, topic, (p) => {
// Same id every time, so the client holds one progress part that updates in place.
writer.write({ type: 'data-progress', id: 'report', data: p })
// Transient: delivered to the onData callback, never added to the message.
writer.write({ type: 'data-log', data: { line: `${p.step}/${p.of} ${p.note}` }, transient: true })
}),
})
The model is the SDK’s mock, scripted to call the
tool once and then answer. The demo prints the chunks as a browser would receive them, the parts
left on the assistant message, and the model messages that convertToModelMessages builds for
the next turn.
node --experimental-strip-types vercel.ts
chunks as the browser receives them
start
start-step
tool-input-available buildReport {"topic":"q3"}
data-progress #report {"step":1,"of":4,"note":"loaded 120 rows"}
data-log (transient) {"line":"1/4 loaded 120 rows"}
data-progress #report {"step":2,"of":4,"note":"grouped by region"}
data-log (transient) {"line":"2/4 grouped by region"}
data-progress #report {"step":3,"of":4,"note":"wrote 3 sections"}
data-log (transient) {"line":"3/4 wrote 3 sections"}
data-progress #report {"step":4,"of":4,"note":"rendered charts"}
data-log (transient) {"line":"4/4 rendered charts"}
tool-output-available {"reportId":"rpt_2026_q3","sections":3,"rows":120}
finish-step
start-step
text-start
text-delta "The Q3 report rpt_2026_q3 has 3 sections over 120 rows."
text-end
finish-step
finish
parts kept in the assistant message
step-start
tool-buildReport
data-progress #report {"step":4,"of":4,"note":"rendered charts"}
step-start
text
what the model is sent on the next turn
user: text "Build the q3 report"
assistant: tool-call buildReport {"topic":"q3"}
tool: tool-result buildReport {"reportId":"rpt_2026_q3","sections":3,"rows":120}
assistant: text "The Q3 report rpt_2026_q3 has 3 sections over 120 rows."
user: text "Which region grew most?"
progress lines in the model's view: 0
Eight data parts crossed the wire between the tool call and its output. One survives on the
message, the data-progress part with its last value, because every write carried the same id.
The four transient parts were shown and discarded.2 The model messages for the next turn carry
the tool call and the tool result only, because the SDK leaves data parts out of the model’s view
without being asked.
Switch on fine-grained tool streaming
Anthropic’s switch streams something else. A large tool input the model writes, a file body for
example, arrives as
input_json_delta
fragments, and by default the API buffers and validates each parameter before sending any of it.
Setting eager_input_streaming: true on the tool definition sends the fragments as they are
written. It is a field on the tool, on the regular messages.stream call, with no beta header.3
export const BUILD_REPORT: Anthropic.Tool = {
name: 'buildReport',
description: 'Build the quarterly report from an outline.',
// The one switch: per tool, on the regular messages.stream call, with no beta header.
eager_input_streaming: true,
input_schema: {
type: 'object',
properties: { topic: { type: 'string' }, outline: { type: 'string' } },
required: ['topic', 'outline'],
},
}
The SDK’s stream helper raises an inputJson event per fragment with the parsed snapshot so far.
The stand-in answers the first turn with a tool call cut into three fragments, mid-string and
mid-key, and records the request it received.
node --experimental-strip-types anthropic.ts
turn 1: the tool input, as the model writes it
fragment "{\"topic\": \"q3" -> {}
fragment "\", \"outline\": \"1. Revenue by region" -> {"topic":"q3"}
fragment "\\n2. Costs\\n3. Outlook\"}" -> {"topic":"q3","outline":"1. Revenue by region\n2. Costs\n3. Outlook"}
stop_reason tool_use, input {"topic":"q3","outline":"1. Revenue by region\n2. Costs\n3. Outlook"}
request sent: anthropic-beta header absent, tools[0].eager_input_streaming true, stream true
the tool runs: progress goes to the user, the result goes to the model
progress 1/4 loaded 120 rows
progress 2/4 grouped by region
progress 3/4 wrote 3 sections
progress 4/4 rendered charts
turn 2: what the model is sent
user: Build the q3 report
assistant: tool_use
user: tool_result
progress lines in the request: 0
answer: The Q3 report rpt_2026_q3 has 3 sections over 120 rows.
the same turn with max_tokens 16, below what the input needs
stop_reason max_tokens, the SDK's snapshot {"topic":"q3"}, progress lines shown: 0
tool_result sent: is_error true, content {"INVALID_JSON":"{\"topic\": \"q3\", \"outline\": \"1. Revenue by region"}
answer: The outline was cut off, so the report was not built.
The fragments are the model’s output, and the snapshot grows a key at a time as the SDK’s
tolerant parser keeps up. The progress lines come later, from the tool, and go to the console
only. The second request carries the tool_result block and nothing else from the tool, which is
the only place a Messages API conversation has for what a tool did. The fragments are not
validated, so the documentation
tells you to guard the parse and check for a max_tokens stop. The last run shows why: the cut
snapshot is valid JSON without the required outline, so the sample parses the raw fragments
and returns them in the documented INVALID_JSON wrapper.
Check it worked
Seven tests, with the Node test runner. Four of them, one per
route and one for the tool, count the progress lines that reached the user and those that reached
the model, which is 0 every time.
node --experimental-strip-types --test stream.test.ts
1..7
# tests 7
# suites 0
# pass 7
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 1162.28131
The other three cover the failures. A reconnect after a second turn must replay that turn alone,
and a failing tool must end its turn with an error event without taking the server down. Input
cut at max_tokens must never reach the tool.
When it goes wrong
The user asks about a step they watched, and the model answers that it has no record of it. The
progress went to the screen and the conversation kept only the tool result, as all three outputs
show with their 0. Put what the model needs to know into the tool result, as a short summary
beside the data, and leave the screen’s copy on the screen.
A refresh mid-run shows a blank panel until the answer lands. The events were written to one
response and stored nowhere, so a reconnect has nothing to replay. Number the events with id
across the whole session, keep what you emitted, and answer a Last-Event-ID with what came after
it, as the SSE route does.
The accumulated tool input fails to parse. With eager_input_streaming the API no longer
validates the parameter before streaming it, and a response that stops at max_tokens can cut a
parameter in half. Guard the parse, check the stop reason, and send the raw text back as an
is_error tool result so the model can try again.
When not to do this
Do not stream progress into the conversation as text to make the model remember it. Every turn then carries every progress line, which costs tokens for the life of the session and teaches the model nothing a one-line summary in the tool result would not. Keep the two channels apart.
Do not switch on eager_input_streaming to show your tool’s progress. It streams the model’s
tool input, so it helps when the model writes a large parameter and does nothing for a tool
that runs for thirty seconds after the input is complete. It also costs the client a defensive
parse it did not need before.
Do not keep transient parts or unnumbered events for anything a user might need after a refresh. Transient parts are delivered once by design, and an SSE stream with no ids has no way to say what was missed. A reconnect rebuilds the screen from what the server stored, so store it, or accept that the screen starts blank.
Do not put secrets or raw API payloads on the progress channel. It reaches the browser, where the tool result does not, and the user can read every chunk the API sent.
Related how-tos
Last verified
Verified 2026-10-21 against Node 22.22.0, ai 7.0.143, @anthropic-ai/sdk 0.135.0, and Zod 4.6.7.
Every output block is what the command preceding it printed. The streaming endpoint, the AI SDK
model, and the Messages API are stand-ins on 127.0.0.1, so the fragments and events reproduce
exactly. The Anthropic SDK’s request, headers, and parsing are real, and the event sequence the
stand-in sends is the one the streaming documentation describes. The max_tokens cut is the
stand-in’s, placed mid-string.
Footnotes
-
The specification defines the
idfield and theLast-Event-IDheader a browser’sEventSourcesends when it reconnects, and says nothing about what the server should do with it. Replay is the server’s problem, which is why the route here keeps every event it emitted. ↩︎ Back to text -
The AI SDK’s streaming data page states the two rules in one line each. A regular data part is added to the message history and appears in
message.parts; a transient part is sent to the client and never added. A progress bar wants the first rule and a log line wants the second, and the only difference on the wire is a boolean. ↩︎ Back to text -
The feature spent eight months behind the beta header
fine-grained-tool-streaming-2025-05-14, by the release notes. The documentation says the per-tool field replaces that header. A request still sending the header turns the feature on for every tool that leaves the field unset, and an explicitfalsekeeps buffered streaming for a tool even then. A header meant to mark an experiment has become a default setting for anyone who forgot to remove it. ↩︎ Back to text