Claude Streaming Tool Use: Recipes That Ship (2026)
Streamed tool arguments arrive as partial_json fragments, not JSON. Four copy-paste recipes to buffer by index, parse once, and stop corrupting tool calls, on Claude and OpenAI.
Updated on August 11, 2026
On this page
Quick Answer (2026): When you stream a Claude response that calls a tool, the tool arguments do not arrive as JSON. They arrive as input_json_delta events carrying partial_json string fragments. Concatenate every fragment for a given content-block index, then parse the JSON once, at content_block_stop. Parsing a single delta throws. OpenAI streams tool calls the same way. Below: four copy-paste recipes plus the failure mode that corrupts one call in a hundred.
Tested against the Anthropic Messages API and the OpenAI Chat Completions API as documented in 2026. Model ids below:
claude-sonnet-5 and gpt-5.
The stream, block by block
A streamed turn is a sequence of content blocks. Each block opens, deltas, then closes.
Scroll to see more
| Event | Carries | Your job |
|---|---|---|
message_start | empty Message | note the id |
content_block_start | block type + index | if tool_use, open a buffer for that index |
content_block_delta (text_delta) | a text chunk | append to your text |
content_block_delta (input_json_delta) | a partial_json fragment | append to that index buffer |
content_block_stop | nothing | now parse the buffer |
message_stop | nothing | done |
Text lives at one index, a tool call at another. Two tools means two indexes. Key everything by index or you will splice two calls into one.
Recipe 1: Accumulate the tool call, parse once
Claim: the tool input streams as string fragments, not JSON. Buffer per index, parse at stop.
Receipt (Python, raw events):
import anthropic, json
client = anthropic.Anthropic()
buffers = {} # content-block index -> accumulated partial_json string
tools = [{
"name": "get_weather",
"description": "Get current weather for a city",
"input_schema": {
"type": "object",
"properties": {"location": {"type": "string"}},
"required": ["location"],
},
}]
with client.messages.stream(
model="claude-sonnet-5",
max_tokens=1024,
tools=tools,
tool_choice={"type": "any"},
messages=[{"role": "user", "content": "Weather in San Francisco?"}],
) as stream:
for event in stream:
if event.type == "content_block_start" and event.content_block.type == "tool_use":
buffers[event.index] = ""
elif event.type == "content_block_delta" and event.delta.type == "input_json_delta":
buffers[event.index] += event.delta.partial_json
elif event.type == "content_block_stop" and event.index in buffers:
args = json.loads(buffers[event.index]) # safe here, not before
print(args) # {'location': 'San Francisco, CA'}
Why: the API emits the tool input as partial_json chunks so you can show progress. The complete object only exists once the block closes. The fragments look like {"location":, then "San Fra, then ncisco, CA"}. None of those parse alone.
Failure mode: calling json.loads inside the delta loop throws on the first fragment and kills the stream handler. Silent data loss if you swallow the exception and move on.
Ship: buffer by event.index. Parse only on content_block_stop.
Recipe 2: Let the SDK accumulate
Claim: if you do not need a per-token UI, the SDK builds the final object for you.
Receipt:
with client.messages.stream(
model="claude-sonnet-5",
max_tokens=1024,
tools=tools,
tool_choice={"type": "any"},
messages=[{"role": "user", "content": "Weather in San Francisco?"}],
) as stream:
final = stream.get_final_message()
for block in final.content:
if block.type == "tool_use":
print(block.name, block.input) # input is a real dict
Why: get_final_message() (TypeScript finalMessage(), Go message.Accumulate) does the buffer-and-parse for you and keeps the connection alive, so large max_tokens calls do not time out. The helpers ship in the official Anthropic Python SDK.
Failure mode: you lose the live progress feed. If your UI renders the tool call forming, you need Recipe 1.
Ship: default to this. Reach for raw events only when the interface needs them.
Recipe 3: Fine-grained streaming when latency matters
Claim: for big tool inputs, turn on fine-grained streaming to get fragments sooner.
Receipt:
tools = [{
"name": "write_file",
"description": "Write a file",
"input_schema": {
"type": "object",
"properties": {
"path": {"type": "string"},
"body": {"type": "string"},
},
"required": ["path", "body"],
},
"eager_input_streaming": True,
}]
Why: by default the model emits one complete key and value at a time, so the stream stalls between properties on a large input. Fine-grained tool streaming delivers the input as Claude generates it, with no server-side buffering and no JSON validation.
Failure mode: no server-side validation means malformed or truncated JSON can reach you. You own the parse. Never hand a fine-grained partial to a tool that expects a complete object.
Ship: enable per tool, only for large inputs like file bodies or long queries. The accumulate-then-parse-once rule still holds. Fine-grained changes when fragments arrive, not that they are fragments.
Recipe 4: Same discipline on OpenAI
Claim: OpenAI streams tool calls as argument fragments too. Same buffer, same single parse.
Receipt:
from openai import OpenAI
import json
client = OpenAI()
buffers = {}
stream = client.chat.completions.create(
model="gpt-5",
messages=[{"role": "user", "content": "Weather in San Francisco?"}],
tools=[{"type": "function", "function": {
"name": "get_weather",
"parameters": {
"type": "object",
"properties": {"location": {"type": "string"}},
"required": ["location"],
},
}}],
stream=True,
)
for chunk in stream:
for tc in (chunk.choices[0].delta.tool_calls or []):
buffers.setdefault(tc.index, "")
buffers[tc.index] += tc.function.arguments or ""
args = {i: json.loads(b) for i, b in buffers.items()}
print(args)
Why: OpenAI streams tool calls as delta.tool_calls[].function.arguments string fragments, mirroring input_json_delta. Both providers send tool arguments as text, never as ready JSON.
Failure mode: the function name arrives once, on the first chunk for that index. Later chunks carry only argument text. Read the name early and key everything by tc.index.
Ship: one buffer-per-index helper covers both providers. Write it once, import it everywhere.
When NOT to stream tool use
Two cases where streaming is the wrong call.
- Batch work. If you do not need real time, send the job to the Message Batches API for half price. Streaming a batch is wasted engineering.
- Fragile reconnection paths. A dropped stream mid
tool_usecannot be resumed the way a text block can.tool_useandthinkingblocks are not partially recoverable, so a network error means you re-run the turn. Keep tool turns short and idempotent. The same block model powers extended thinking, which streams reasoning throughthinking_deltaevents under the same start-delta-stop shape.
Cost to test: about $0.01
Written by
Sam Q.Sam Q. writes terse, tested prompt recipes for PromptAttic. Ships them the way you would a commit message: short, verified, no preamble.
FAQ
Why can't I parse each streaming tool delta as JSON?
Because each event carries a partial_json fragment, not a complete object. The first fragment might be an open brace and a quoted key; the next might be half a value. Calling json.loads on any single fragment throws. Concatenate every fragment for a content-block index, then parse once at content_block_stop.
What is input_json_delta in Claude's stream?
It is the delta type used for tool_use content blocks in the Anthropic Messages API. Each input_json_delta event carries a partial_json string, and the final tool_use.input is always an object you build by concatenating those strings. It is the tool-call equivalent of text_delta for plain text.
What is fine-grained tool streaming?
Fine-grained tool streaming, enabled per tool with eager_input_streaming set to true, delivers a tool's input as Claude generates it, without server-side buffering or JSON validation. It lowers latency on large inputs like file bodies, but because there is no validation, malformed or truncated JSON can reach your client, so you own the parse.
Does OpenAI stream tool calls the same way?
Yes. OpenAI sends tool arguments as delta.tool_calls[].function.arguments string fragments across streamed chunks, mirroring Claude's input_json_delta. Both providers stream tool arguments as text, never as ready JSON, so the same buffer-by-index then parse-once discipline works for both.
Can I resume a streaming tool call after a dropped connection?
No. tool_use and thinking blocks are not partially recoverable. Unlike a text block, where you can resume from the last text received, a dropped stream in the middle of a tool call forces you to re-run the turn. Keep tool turns short and idempotent so a retry is safe.
Why does the stream pause between tool arguments?
Current models emit one complete key and value from the tool input at a time, so there can be delays between streaming events while the model works out the next property. A pause is not a hang. Turning on fine-grained tool streaming makes fragments arrive sooner.
Related recipes
Claude Prompt Caching: 3 Recipes That Pay Off, 2 That Lose Money (June 2026)
Three Claude prompt-caching recipes with real cost math for Sonnet 4.6, Opus 4.7, and Haiku 4.5. Plus two patterns where caching quietly costs you 25% more than not using it.
Stop Sequences on Claude: 4 Recipes That Ship (2026)
Stop sequences in 2026: four copy-paste recipes that cut a model off at a delimiter, bound a JSON object, force one section, and stop an agent from faking its own turn, plus where they break.
Message Batches API: 4 Recipes That Ship (2026)
The Message Batches API runs LLM jobs async for 50% off in Claude and OpenAI. Four copy-paste recipes: submit, poll without hammering, match results by custom_id, and survive the 24-hour expiry.