Prompt Recipes
Sam Q.4 min read129 views

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

Streaming code fragments assembling into one complete tool-call block on a white background
Streaming code fragments assembling into one complete tool-call block on a white background
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.

Anthropic OpenAI 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

EventCarriesYour job
message_startempty Messagenote the id
content_block_startblock type + indexif tool_use, open a buffer for that index
content_block_delta (text_delta)a text chunkappend to your text
content_block_delta (input_json_delta)a partial_json fragmentappend to that index buffer
content_block_stopnothingnow parse the buffer
message_stopnothingdone

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):

python
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:

python
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:

python
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:

python
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_use cannot be resumed the way a text block can. tool_use and thinking blocks 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 through thinking_delta events under the same start-delta-stop shape.

Cost to test: about $0.01

S

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.