I have been shipping iOS apps as an indie developer since 2014, and the first thing that bit me when wiring Claude API into a production chatbot was streaming responses that contained tool_use. If you call messages.create({ stream: true }) and try to JSON.parse each event as it arrives, you will hit SyntaxError: Unexpected end of JSON input almost every time. The sample code looks fine, the docs look fine, and yet nothing works. Here is what is actually happening and what to do about it.
Symptom: parsing input_json_delta throws SyntaxError on every other call
If you log the raw events, the stream looks like this:
{ "type": "message_start", "message": { ... } }
{ "type": "content_block_start", "index": 0, "content_block": { "type": "tool_use", "id": "toolu_01...", "name": "get_weather", "input": {} } }
{ "type": "content_block_delta", "index": 0, "delta": { "type": "input_json_delta", "partial_json": "{\"loc" } }
{ "type": "content_block_delta", "index": 0, "delta": { "type": "input_json_delta", "partial_json": "ation\": \"" } }
{ "type": "content_block_delta", "index": 0, "delta": { "type": "input_json_delta", "partial_json": "Tokyo\"}" } }
{ "type": "content_block_stop", "index": 0 }
{ "type": "message_delta", "delta": { "stop_reason": "tool_use" } }
{ "type": "message_stop" }Try to parse {"loc and the parser throws. Each partial_json chunk is not a JSON fragment — it is an opaque slice of characters that becomes valid JSON only after every chunk inside the same index is concatenated and the block has stopped. The model may split a chunk mid-key, mid-value, even mid-escape.
The implementation that "parses each delta as it arrives" appears to work in small examples because everything happens to fit in one event. Push the input past a few hundred tokens, or enable parallel tool use, and it falls over.
Root cause: the API contract is per-block, not per-delta
The Anthropic messaging docs spell this out under input_json_delta: concatenate every partial_json value, keyed by content_block_delta.index, and parse the whole buffer once you see content_block_stop for that index. The temptation to treat partial_json like text_delta (which you can stream one character at a time into the UI) is what causes the bug.
The three failure modes I have hit on real apps:
- A travel booking flow received a date range across three deltas, and the code parsed the first chunk
"2026-06-and crashed - Parallel tool calls (default behaviour on Sonnet 4.6 and newer) interleaved
index: 0andindex: 1deltas, and a single shared buffer mixed the bytes - A multi-turn conversation persisted half-built tool arguments to a cookie, because the writer fired on every delta event instead of on
content_block_stop
The fix: one buffer per index, parse only on stop
Here is the minimum implementation that works in production. It uses the official @anthropic-ai/sdk (Node.js), but the same logic applies if you parse raw SSE yourself.
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
type ToolBuffer = {
id: string;
name: string;
json: string; // accumulate partial_json in index order
};
async function callWithStreamingTools() {
const stream = client.messages.stream({
model: "claude-sonnet-4-6",
max_tokens: 1024,
tools: [/* ... */],
messages: [{ role: "user", content: "Weather in Tokyo and tomorrow's headlines?" }],
});
const buffers = new Map<number, ToolBuffer>();
const completedToolUses: Array<{ id: string; name: string; input: unknown }> = [];
for await (const event of stream) {
if (event.type === "content_block_start" && event.content_block.type === "tool_use") {
buffers.set(event.index, {
id: event.content_block.id,
name: event.content_block.name,
json: "",
});
} else if (event.type === "content_block_delta" && event.delta.type === "input_json_delta") {
const buf = buffers.get(event.index);
if (buf) buf.json += event.delta.partial_json;
} else if (event.type === "content_block_stop") {
const buf = buffers.get(event.index);
if (buf) {
// Parse here, never earlier
const input = buf.json.length === 0 ? {} : JSON.parse(buf.json);
completedToolUses.push({ id: buf.id, name: buf.name, input });
buffers.delete(event.index);
}
}
}
return completedToolUses;
}Three things matter:
- Allocate a buffer only when
content_block_startreportstool_use. Do not share a buffer with text blocks - Use
+=onpartial_json. Never callJSON.parseuntil the block is complete - On
content_block_stop, fall back to{}if the buffer is empty — tools that take no arguments still arrive as an emptyinputfield
After fixing only this, the 500-level errors that appeared roughly once a week on my automated reply pipeline disappeared.
Handling disconnects mid-block
Streaming is also a fight with flaky networks. While instrumenting AdMob revenue summaries for one of my wallpaper apps, I saw SSE connections drop the moment the device came back from airplane mode, and content_block_stop never arrived. The right defence is to treat unfinished buffers as toxic and discard them at the boundary.
try {
for await (const event of stream) { /* logic above */ }
} catch (err) {
// Connection lost. Treat anything still in the buffer as incomplete
for (const [index, buf] of buffers) {
console.warn(`incomplete tool_use index=${index} name=${buf.name} bytes=${buf.json.length}`);
}
throw err;
}
// On normal completion, always inspect the final stop_reason
const final = await stream.finalMessage();
if (final.stop_reason !== "tool_use" && final.stop_reason !== "end_turn") {
// max_tokens may have truncated the response. Consider retry or continuation
}If you hand a half-built tool_use to the rest of the application, downstream schema validation throws confusing errors like "required field location missing". Refusing to forward incomplete tool calls saves a lot of debugging time.
SDK convenience events vs. raw SSE
The official SDK exposes higher-level events on messages.stream(...), including on("inputJson", (delta, snapshot) => ...) which hands you the fully concatenated string on every update. If your UI does not need to render the partial arguments live, lean on those events instead of writing the buffer logic by hand.
For environments such as Cloudflare Workers where you parse fetch ReadableStream output directly, watch for these traps:
- SSE frames are delimited by
\n\n— make sure you split on that, not on\n - A single frame can contain several events when small deltas arrive back to back
- Strip the
data:prefix before callingJSON.parseon the event line partial_jsonvalues can contain escaped newlines; treat them as plain string data and concatenate without modification
When I last ran this on Workers I tried the SDK's BetaMessageStream before reaching for a hand-rolled SSE parser. The bundle size cost was worth the easier production debugging.
A debugging order that converges fast
When something is broken end to end, this is the order I work through:
// 1) Dump raw events untouched
for await (const event of stream) {
console.log(JSON.stringify(event));
}Look at the log and verify, in order:
- Does each
content_block_startshowcontent_block.type === "tool_use"? Mixing in atextblock silently corrupts the buffer - If you string-concat every
partial_jsonfor a givenindex, do you get valid JSON? Pipe tojqto confirm - Does a
message_delta.stop_reasonarrive after the lastcontent_block_stop? - For parallel tool calls, are the
indexvalues contiguous (0, 1, 2 ...) ?
If those four boxes are ticked, the remaining bugs are buffer-keying mistakes. Trust the raw event stream before you rewrite the parser; that is the shortest path to a fix in my experience.
One more pitfall: parallel tool use and partial continuations
Sonnet 4.6 and newer return parallel tool_use blocks by default. Setting disable_parallel_tool_use: true forces serialisation and your buffer code keeps working unchanged — only index 0 ever appears. The other direction is the dangerous one: if you keep parallel calls enabled and try to optimise by ignoring later blocks once the first one finishes, you will miss content_block_stop for blocks that arrive later in the stream. The safest pattern is to receive every tool_use, then submit every tool_result in one follow-up message.
The shape of the API does shift across SDK versions, so I make a habit of dumping fresh raw events before each production release. I hope this saves someone else an evening of squinting at logs.