How-to › Use AI to do the integration

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.

Audience
Agent builder
Level
advanced
Topic
Build an agent that calls APIs
Languages
TypeScript
Verified

Read first: Build a tool-calling loop with the Claude Agent SDK

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
Anthropic fine-grained tool streamingThe slow part is the model writing a large tool input, such as a file bodyFragments arrive unvalidated, so the client parses defensively, and nothing your tool produces is in the streamThe slow part is the tool’s own work after the input is complete
Hand-written server-sent eventsAny framework or none, and a browser or a terminal on the other endA wire format you write and parse yourself, ids and replay you store yourself, and no message object to hang parts onYou already render a UI message stream and want the parts typed
Vercel AI SDK data stream partsThe front end already consumes an AI SDK message streamA writer the tool must be able to reach, and part types that are the SDK’s vocabulary rather than yoursThe 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.

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

  1. The specification defines the id field and the Last-Event-ID 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. ↩︎ Back to text

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

  3. 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 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. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.