◉CLAUDE LABJP
●2.1.301 — Adds the vim % motion, LaTeX math rendering in the VS Code panel, and a retry-watchdog limit for usage-limit waits (10/15)●2.1.299 — Conversations over 200K tokens left idle about 54 minutes get compacted; MCP allow rules now match only the server they name●SONNET 4.5 — Retires on the Claude API on 11/30, 45 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST MODE — Opus 5.5 fast mode is out of research preview and no longer needs the beta header (10/15)●NEW — I Put a Ten-Line Promise File at the To…●2.1.301 — Adds the vim % motion, LaTeX math rendering in the VS Code panel, and a retry-watchdog limit for usage-limit waits (10/15)●2.1.299 — Conversations over 200K tokens left idle about 54 minutes get compacted; MCP allow rules now match only the server they name●SONNET 4.5 — Retires on the Claude API on 11/30, 45 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST MODE — Opus 5.5 fast mode is out of research preview and no longer needs the beta header (10/15)●NEW — I Put a Ten-Line Promise File at the To…
Articles/API & SDK
⬡ API & SDK/2026-05-22Advanced

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 it on the always-on agent jobs I run as an indie developer, with the TypeScript recovery handler I now ship in production.

claude-api83tool-use24error-handling11agent-recoveryproduction111

✦ Premium Article

I opened the overnight logs one morning and found the same line repeating down the whole file: tool_result could not be submitted. It fires back to back, retries change nothing, and within an hour a different job shows the same symptom. This happened on the background agent jobs I run as an indie developer — four of them broke in a row one evening, 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.

  1. 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
  2. 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
  3. 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
  4. 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 environment 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. That change alone dropped the incident rate from roughly 80 per month to under 5 per month.

The dollar figure is worth breaking apart, because you can recompute it for your own setup. Each failure was retried about 8 times before the exponential backoff budget ran out, and each retry carried roughly 18,000 input tokens. With 75 fewer failures per month, that's 75 × 8 × 18,000 ≈ 10.8M input tokens. Claude Sonnet 4.6 bills input at $3 / MTok, so the saving lands near $32 per month.

The number matters less than the habit behind it: count how many retries hide behind a single failure before you price anything. I assumed one failure meant one resend until I actually did the multiplication, which had me underestimating the saving by a factor of eight.

✦

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 agent codebase that runs unattended every night — 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.

or
Unlock all articles with Membership →
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.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

Related Articles

⬡ API & SDK2026-05-26
Stabilizing Claude API Structured Responses in Production — Notes on tool_use, JSON Schema, and Layered Validation
Getting Claude to return JSON takes a few lines. Keeping that JSON usable in production is a different problem. Here is the layered design I landed on after running a wallpaper classification pipeline through Claude API, built around tool_use, JSON Schema, and domain validation.
⬡ API & SDK2026-05-09
A Five-Layer Preflight Design for Claude API — How I Cut Hundreds of 400/422/529 Errors to Zero
A production-tested five-layer preflight design that catches Claude API failures before the network call — schema, token budget, model capability, content policy, and spend cap — with full TypeScript implementation and one month of operational numbers.
⬡ API & SDK2026-04-23
Running Claude API Parallel Tool Use in Production — Controlling Concurrency, Designing for Partial Failure, and Cutting Latency
Claude API's parallel tool use can halve perceived latency, but partial failures and duplicate writes arrive just as fast. Concurrency control with asyncio.gather and a Semaphore, three partial-failure policies, idempotency, observability, and a way to measure your real parallel degree.