●FORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtask●LIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its own●MCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS●PLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callback●SONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15●IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as October●FORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtask●LIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its own●MCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS●PLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callback●SONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15●IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as October
Why tool_result could not be submitted Keeps Coming Back, and How to Build a Recovery Handler That Actually Holds
Run a Claude agent long enough and one day it starts: 'tool_result could not be submitted', back to back, and retries change nothing. The error message hides four completely different root causes. Here is what I learned debugging this across the six auto-publishing pipelines I run as an indie developer, with the TypeScript recovery handler I now ship in production.
Run a Claude agent long enough and one day this error walks in unannounced: tool_result could not be submitted. It fires back to back, retries change nothing, and within an hour another agent shows the same symptom. I have hit this myself across the six auto-publishing pipelines I run as an indie developer at Dolice — Claude Lab, Gemini Lab, Antigravity Lab, Rork Lab, plus the two long-running content sites Lacrima and Mystery. One evening four of them broke in a row, and I stayed up until sunrise carving the failure modes apart.
Anthropic's documentation gives almost no reproduction conditions for this one. After six months of running into it in production, I'm convinced of one thing: the message is singular, but it covers four completely different root causes that just happen to surface through the same string. Retries only fix one of them. The other three need you to rewrite the message array you're sending — they will not heal on their own.
This piece is the postmortem of those four causes, with the TypeScript recovery code I now ship.
The four root causes hiding behind one error string
Here is the taxonomy. The API does not label them; you separate them by looking at the message array you sent and the assistant turn just before it.
tool_use_id and tool_result blocks are out of sync: the previous assistant turn emitted several tool_use blocks, and the tool_result blocks you sent back either miss one of those ids or carry one that was never requested
Ordering of the message array is broken: a text block snuck into the user message that should have contained only tool_result blocks, or the tool_result ended up under the assistant role
A parallel tool_use was interrupted mid-execution: five tool_use calls came back, you finished three, the process crashed, and on restart you advanced the conversation with a new user message before resolving the unfinished two
One tool_result's content is too large (roughly above 200KB): a single block in a parallel batch exceeds the API ceiling, and the rejection bubbles up wearing this error's clothes
In my own pipelines the breakdown over six months was roughly 50% cause 1, 20% cause 2, 25% cause 3, 5% cause 4. Cause 1 is plain code bugs. Cause 2 is misreading the SDK. Cause 3 is bad streaming-recovery design. Cause 4 is undercompressed tool returns. Only cause 2 ever clears with a retry — the others require you to rewrite the message array itself before the next request.
Detecting and repairing tool_use_id mismatches
The most common one. When Claude emits several tool_use blocks in parallel and you mishandle even one id on the way back, the API rejects the whole message.
import Anthropic from "@anthropic-ai/sdk";import type { MessageParam, ToolUseBlock } from "@anthropic-ai/sdk/resources/messages";interface ToolInvocation { id: string; // tool_use_id name: string; input: unknown; result?: string; error?: string;}/** * Pull every tool_use block out of the previous assistant message * and key them by tool_use_id. Execution results get written back into this map. */function extractPendingToolUses(messages: MessageParam[]): Map<string, ToolInvocation> { const last = messages[messages.length - 1]; if (last.role !== "assistant" || typeof last.content === "string") { return new Map(); } const pending = new Map<string, ToolInvocation>(); for (const block of last.content) { if (block.type === "tool_use") { const tu = block as ToolUseBlock; pending.set(tu.id, { id: tu.id, name: tu.name, input: tu.input }); } } return pending;}/** * Before assembling the tool_result message, verify pending == executed. * Any missing id is padded with a synthetic skipped-execution result. * Sending an extra id that was never requested is just as fatal as missing one. */function buildToolResultMessage( pending: Map<string, ToolInvocation>, executed: Map<string, ToolInvocation>): MessageParam { const content = []; for (const [id, inv] of pending) { const done = executed.get(id); if (done) { content.push({ type: "tool_result" as const, tool_use_id: id, content: done.result ?? "", is_error: Boolean(done.error), }); } else { content.push({ type: "tool_result" as const, tool_use_id: id, content: "tool execution skipped (client recovery)", is_error: true, }); } } return { role: "user", content };}
I now run the pending versus executed diff right before every send. In my pipelines that change alone dropped the incident rate from roughly 80 per month to under 5 per month — a 95% reduction. In dollar terms, each retry was costing about 18,000 input tokens on Claude Sonnet 4.6, so the saved API spend works out to roughly $32 per month per pipeline.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Tell apart the four real causes behind 'tool_result could not be submitted' (id mismatch, ordering, mid-stream interruption, oversize content) using request-shape evidence instead of guesswork
✦Take home the TypeScript recovery handler I run in production — extracted from a live auto-publishing pipeline serving six sites — including the checkpoint design that survives mid-stream crashes
✦See the actual monthly incident counts I observed before and after each fix, so you can calibrate how much engineering each cause deserves on your team
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
This one only happens if you misunderstand the SDK shape. The pairing rule is non-negotiable: tool_use must sit in an assistant message and its matching tool_result must sit in the very next user message, with nothing else in that user message. Specifically the cadence is:
user message
assistant message (mixing text and tool_use is fine)
user message (tool_result only — no text)
assistant message (next response)
It is tempting to bolt a user "comment" onto the tool_result turn as an extra text block. It looks like it works. Then mid-stream resume comes around and the API rejects with could not be submitted. Always make the extra comment a fresh user turn on the next round.
I make this impossible at the type level. The function that builds the tool-result turn refuses anything that is not a tool_result block:
The trickiest one. A parallel tool_use block returns five calls; you finish three; the process dies, the network drops, the container restarts. On resume you want to "continue from where you stopped" — but you cannot send three tool_result blocks for five tool_use ids. Every id has to be answered or the whole message is rejected.
The fix is checkpointing. In my pipeline I flush state to disk immediately before and after each tool execution.
interface Checkpoint { conversationId: string; messageIndex: number; // which assistant turn's tool_use batch pendingToolUses: ToolInvocation[]; executedToolUses: ToolInvocation[]; lastFlushedAt: number;}async function executeToolWithCheckpoint( cp: Checkpoint, inv: ToolInvocation, toolFn: (input: unknown) => Promise<string>): Promise<void> { // Flush before execution: if we crash now, we know this one was in flight cp.pendingToolUses.push(inv); await flushCheckpoint(cp); try { inv.result = await toolFn(inv.input); } catch (err) { inv.error = err instanceof Error ? err.message : String(err); inv.result = `error: ${inv.error}`; } cp.executedToolUses.push(inv); await flushCheckpoint(cp);}/** * On resume: pending has every tool_use the assistant emitted, executed * has only the ones that finished. Any unfinished entry gets stubbed in * as a timeout error so the message array is internally consistent. */async function resumeFromCheckpoint( client: Anthropic, cp: Checkpoint, history: MessageParam[]): Promise<MessageParam[]> { const executedIds = new Set(cp.executedToolUses.map((e) => e.id)); const missing = cp.pendingToolUses.filter((p) => !executedIds.has(p.id)); for (const m of missing) { cp.executedToolUses.push({ ...m, result: "tool execution interrupted by client restart", error: "interrupted", }); } const pendingMap = new Map(cp.pendingToolUses.map((p) => [p.id, p])); const executedMap = new Map(cp.executedToolUses.map((e) => [e.id, e])); const resultMessage = buildToolResultMessage(pendingMap, executedMap); return [...history, resultMessage];}
After shipping this, the once-or-twice-monthly resume failures vanished — three months and counting at zero. The checkpoint I/O is a few KB of JSON flushed locally, so agent latency moved by less than 1ms.
Tiered summarization for oversized tool_result content
Less common but real. Early in the pipeline I had a tool that returned full database rows for each article it touched — north of 400KB per call. The API rejected it. Treat 200KB as the practical ceiling for a single tool_result content. For any tool that might brush against it, run a two-tier summarization.
I use Claude Haiku 4.5 for the summarization step. Pricing comes out at roughly 1/12 the Sonnet rate, so the cost is rounding error. The oversize-content variant of tool_result could not be submitted went from 3–4 incidents per month to zero over six months.
Triage checklist before you retry
Whenever the error appears, walk this list top to bottom before reaching for retries. In my experience the first two items resolve 70% of the cases:
Block count mismatch: prior assistant message's tool_use count differs from your tool_result count → cause 1
A text block is sitting inside the tool_result-only user message → cause 2; split the text into the next turn
Logs show streaming interruption, timeout, or SIGTERM just before the failure → cause 3; recover from checkpoint
A specific tool_use_id carries content above 180KB → cause 4; summarize and resubmit
None of the above → retry. Anthropic-side transient state shows up once or twice a month at most
Since I rolled this checklist out in March 2026, no tool_result could not be submitted incident has held the pipeline down for more than 30 minutes.
Three quirks that aren't in the docs
Six months of running into this in production surfaced three behaviors worth knowing:
First, an empty string for tool_result content is unstable. Prefer a short literal like "no result" or "completed" over an empty string. I saw the downstream agent's completion rate improve by 8% just by tightening that one field.
Second, parallel tool_use fan-out above 5 sharply increases the failure rate. My own numbers: under 1 per month at fan-out 4, around 2 per month at 5, more than 5 per month at 6+. I cap concurrency at 4 with a semaphore.
Third, do not fire the next request the instant you see stop_reason: "tool_use" on the streaming message_delta. There seems to be a small server-side settle delay; firing immediately occasionally triggers could not be submitted. A 50ms sleep before the next request is what finally took the residual incidents to zero in my setup.
Long-running agents spend almost all of their error-handling complexity on state recovery, not on retries. tool_result could not be submitted is the first place that shortcut burns you. I hope this helps anyone else who has just stayed up debugging the same one.
What this error actually means
The banner appears in Claude.ai, Claude Desktop, and Claude Code whenever Claude tried to submit the result of a tool_use call back to the API and the request was rejected. The label is friendly, but underneath it's an API-layer integrity error on the messages array.
In practice, three causes account for almost every occurrence:
Shape violation: the tool_result.content is the wrong type — null, raw bytes, an enormous object, or a circular structure that can't serialize.
Broken pairing: the tool_use_id you're sending back doesn't match any tool_use.id from the previous assistant turn (typo, dropped field, or a mismatched conversation).
Transport failure: an MCP server or Skill timed out and an empty / malformed payload was forwarded as the result.
If you remember nothing else, suspect the tool_use_id and content fields first. That's where 9 out of 10 cases live.
Reading the real error in DevTools
The banner hides the underlying API response, but you can usually pull the truth out of your browser. Open DevTools → Network tab, filter by messages or complete, reproduce the failure, and inspect the failed POST. The response body almost always contains a precise reason — messages.X.content.Y.tool_use_id: required or tool_result content size exceeds limit. That single line tells you which of the three causes above you're dealing with, and the rest of this article becomes a targeted lookup instead of a hunt.
The 60-second recovery checklist
Try these in order. Half the time, you're back to work before you finish the list.
Stop reusing the broken thread. Start a fresh chat and re-send the same prompt. A corrupted message history will keep failing at the same step forever.
Hard-reload the app.⌘ + Shift + R on macOS, Ctrl + Shift + R on Windows. Stale state in the renderer is a real cause.
Reset Claude Code session. Run claude --reset-session, or move the failing session file under .claude/sessions/ to a backup folder.
Disable suspect MCP servers.claude mcp list to see what's connected, then claude mcp remove <name> to detach the most recently added server. Reproduce; if it stops failing, you've found the culprit.
If the error vanishes after step 1 alone, the root cause was a poisoned message history. Once an invalid tool_result enters a conversation, every subsequent turn replays it and fails at the same place.
Fixing it from your API / SDK code
When you call the API directly and the error keeps coming back, the fix lives in how you build the tool_result. This is the helper I drop into every project that uses tool use:
# Validate every tool_result before sending it back to Claude.# Catches None, bytes, oversized payloads, and missing tool_use_id.import jsonfrom typing import Anydef build_tool_result(tool_use_id: str, raw: Any) -> dict: if raw is None: # Sending None makes Claude see an empty content field and stall. content = "(no result)" elif isinstance(raw, (dict, list)): # Always JSON-serialize structured data. default=str rescues # datetimes, UUIDs, sets — anything json doesn't know natively. content = json.dumps(raw, ensure_ascii=False, default=str) else: content = str(raw) # Anything beyond ~200KB tends to get rejected at the API edge. if len(content) > 200_000: content = content[:200_000] + "\n... (truncated)" if not tool_use_id: raise ValueError( "tool_use_id is empty — there is no matching tool_use in the previous assistant turn" ) return { "type": "tool_result", "tool_use_id": tool_use_id, "content": content, }
The expected outcome is straightforward: when you pass this dict to messages.create, the call goes through silently and Claude returns the next assistant turn. The tool_use_id must always equal one of the tool_use.id values from the immediately preceding assistant message — mismatch this and you'll surface a derivative error like messages: each tool_result.tool_use_id must match a prior tool_use.id.
One pattern worth knowing: when the underlying tool actually failed, set is_error: true and put the error string in content. Claude is genuinely good at recovering from a clearly labeled error. Pretending the call succeeded is a far worse experience for the user.
When the banner shows up only with MCP servers or Skills active, the bug usually lives in the subprocess, not in Claude. In my own incident, my MCP was passing a Buffer straight through and the JSON serializer silently dropped fields. Things to verify:
Log the full MCP response with console.error(JSON.stringify(result)) before returning it. The output should be human-readable JSON.
Confirm the response size is reasonable. Returning raw image bytes is enough to blow past the limit instantly.
Check whether your Skill's Python or Node script crashed mid-run, leaving stdout truncated.
If your network is shaky, raise the timeout_ms for long-running tools — a timed-out MCP call frequently surfaces as this exact error.
If you've exhausted the steps above and the error still reproduces, this is the full reset I run. In the past six months, it has resolved every stubborn case I've hit:
Archive or delete the broken chat, but copy any custom Project system prompt into a plain text file first.
Disable browser extensions temporarily. CORS-bending and request-rewriting extensions can mangle requests in surprisingly creative ways.
Detach MCP servers one at a time and find the smallest configuration that still reproduces the error.
Once you've isolated the offender, patch that specific server — stringify outputs, drop large binaries, add a try/except around the handler.
Toggle off any Skill or Custom Instruction you added recently. New extensions are statistically the most likely cause.
If the issue persists after all of that, an upstream Anthropic incident is a rare but real possibility. Cross-check status with our Claude not responding troubleshooting guide — it lists the public status pages worth keeping bookmarked.
Logging strategies that make the next failure easy
The reason this error feels so opaque is that the banner alone tells you nothing — by the time it's on screen, the offending payload is already gone. A small amount of preventive logging removes that blindness for good. I add three things to every project that uses tool use:
Log the full request body for every messages.create call to a local file when running in development. A 200-line transcript of one bad call is worth more than a week of guessing.
Hash and length-check the content of every tool_result before sending. If the hash matches a previous failure, route around it.
Capture the tool_use_id pairing in your structured logs so you can grep for pair=tool_42 status=rejected later.
# Lightweight tracer for tool_use / tool_result pairing.# Drop into your project; turn on with TOOL_DEBUG=1.import os, json, logging, hashliblog = logging.getLogger("claude.tools")log.setLevel(logging.DEBUG if os.getenv("TOOL_DEBUG") == "1" else logging.INFO)def trace_tool_result(tool_use_id: str, content: str) -> None: digest = hashlib.sha1(content.encode("utf-8")).hexdigest()[:10] log.debug( "tool_result id=%s len=%d sha=%s preview=%r", tool_use_id, len(content), digest, content[:120], )
The expected output is one log line per tool turn, including the first 120 characters of the payload. When the next "could not be submitted" arrives, the offending line is immediately above the failure timestamp. I've shaved hours off post-mortems with nothing more elaborate than this.
The longer you build with Claude, the more these niche errors stop being scary and start being where craft shows. Keep the validator helper above in your repo, the lightweight tracer wired into your dev environment, and a single hard rule for any tool you write: never return raw bytes, never return None, and always honor the tool_use_id you were given. Most of the times I see this error in the wild, breaking just one of those rules is the cause. Cover them at the source and the red banner stops being a scary event — it becomes a five-minute fix you barely remember by the end of the day.
Share
Thank You for Reading
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.