# How to stream partial tool output back to a user

> Show a user the chunks a tool is receiving while the model waits, through AI SDK data parts, your own server-sent events, or Anthropic's input streaming.

Source: https://voxgig.com/howto/stream-tool-output-during-an-agent-turn

- Audience: agent-builder
- Level: advanced
- Languages: typescript
- Verified: 2026-10-21
- Published: 2026-10-19

## 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](https://platform.claude.com/docs/en/agents-and-tools/tool-use/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](https://html.spec.whatwg.org/multipage/server-sent-events.html) | 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](https://ai-sdk.dev/docs/ai-sdk-ui/streaming-data) | 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.

```ts title="tool.ts"
  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](https://html.spec.whatwg.org/multipage/server-sent-events.html#parsing-an-event-stream)'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.

```ts title="sse.ts"
  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]

```bash
node --experimental-strip-types sse.ts
```

```text output
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`](https://ai-sdk.dev/docs/ai-sdk-ui/streaming-data) 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.

```ts title="vercel.ts"
      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](https://ai-sdk.dev/docs/ai-sdk-core/testing), 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.

```bash
node --experimental-strip-types vercel.ts
```

```text output
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`](https://platform.claude.com/docs/en/build-with-claude/streaming#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]

```ts title="anthropic.ts"
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.

```bash
node --experimental-strip-types anthropic.ts
```

```text output
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](https://platform.claude.com/docs/en/agents-and-tools/tool-use/fine-grained-tool-streaming)
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](https://nodejs.org/api/test.html). 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.

```bash
node --experimental-strip-types --test stream.test.ts
```

```text output
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

- [Consume Server-Sent Events with fetch and custom headers](/howto/consume-sse-with-fetch-and-custom-headers)

- [Pick SSE, WebSockets, or long polling for a live endpoint](/howto/choose-sse-websockets-or-long-polling)

- [Retry a streaming response without replaying received data](/howto/retry-a-streaming-response-without-replaying-data)

- [Keep an agent informed while an MCP tool runs for minutes](/howto/report-progress-from-a-long-running-tool)

- [Wire an API client into the OpenAI Agents SDK](/howto/wire-an-api-client-into-openai-agents-sdk)

- [Build a tool-calling loop with the Claude Agent SDK](/howto/tool-calling-loop-with-the-claude-agent-sdk)

## 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.

[^1]: The specification defines the `id` field and the
[`Last-Event-ID`](https://html.spec.whatwg.org/multipage/server-sent-events.html#the-last-event-id-header)
header a browser's `EventSource` sends 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.

[^2]: The AI SDK's [streaming data](https://ai-sdk.dev/docs/ai-sdk-ui/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.

[^3]: The feature spent eight months behind the beta header
`fine-grained-tool-streaming-2025-05-14`, by the
[release notes](https://platform.claude.com/docs/en/release-notes/overview). The [documentation](https://platform.claude.com/docs/en/agents-and-tools/tool-use/fine-grained-tool-streaming)
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 explicit `false` keeps 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.