◉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-23Intermediate

Absorbing the Claude API "Tool Result Submitted" Error in a Retry Layer: A Small Conversation-History Repair

How I absorbed the Claude API "Tool result could not be submitted because the previous turn was not a tool use" error inside a small retry layer, with the diagnosis order I followed after it hit a production batch.

claude-api83anthropic-sdk3retry7indie-dev14

Early in May my AdMob review-summary batch went down with Tool result could not be submitted because the previous turn was not a tool use. I was processing roughly 8,000 reviews per day for the wallpaper app portfolio, so a production halt was not gentle. The error message has multiple possible entry points, which makes the first hour of triage harder than it has to be. These are the notes from working through it.

I am Masaki Hirokawa, an indie developer running wallpaper apps since 2014 (over 50 million downloads across the portfolio). I use the Claude API for article generation and AdMob review-response automation, and this story comes straight from that AdMob batch.

The error always means one thing, with three entry points

The error always means the same thing: a tool_result block landed somewhere that wasn't immediately after a matching tool_use. Where it gets confusing is that there are three different paths into that state. Mixing them up means the bug refuses to go away no matter how many times you "fix" it.

  • A. Manual history edits: you trimmed an older turn and lost the tool_use, leaving an orphan tool_result.
  • B. Retry-implementation bug: a transient API error triggered a retry that re-sent the tail of messages starting with the tool_result.
  • C. Concurrent-request ordering: two async calls were in flight and the tool_result got submitted before the tool_use was written back.

I hit B. My retry path naively re-sent only the tail of messages whenever a 429 came back, which is fine until the tail starts with a tool_result.

Triage order

When the error fires, log the entire messages array you were about to send. Dropping a json.dumps() immediately before messages.create() is enough. Then walk the tail in reverse and check just two things:

  1. Every tool_result block must have a tool_use immediately before it in conversation order.
  2. The tool_use_id on the tool_result must match an id on that preceding tool_use.

If both hold, the error cannot fire. If they fail, the failure shape tells you exactly which of A, B, or C you are looking at.

A small repair layer that lives inside retry

What I ended up shipping is a retry layer that re-normalizes the conversation history before each send. The minimal Python version is roughly this much code:

from anthropic import Anthropic, APIError
import time
 
client = Anthropic()
 
def ensure_tool_pairing(messages):
    """Drop tool_result blocks that lost their preceding tool_use."""
    safe = []
    seen_tool_use_ids = set()
    for msg in messages:
        content = msg.get("content", [])
        if not isinstance(content, list):
            safe.append(msg)
            continue
        new_blocks = []
        for b in content:
            t = b.get("type")
            if t == "tool_use":
                seen_tool_use_ids.add(b["id"])
                new_blocks.append(b)
            elif t == "tool_result":
                if b.get("tool_use_id") in seen_tool_use_ids:
                    new_blocks.append(b)
            else:
                new_blocks.append(b)
        if new_blocks:
            safe.append({**msg, "content": new_blocks})
    return safe
 
def call_with_repair(messages, max_retry=3):
    for attempt in range(max_retry):
        try:
            return client.messages.create(
                model="claude-sonnet-4-6",
                max_tokens=2048,
                messages=ensure_tool_pairing(messages),
                tools=[...],
            )
        except APIError as e:
            if "tool_result could not be submitted" in str(e):
                messages = ensure_tool_pairing(messages)
                time.sleep(0.5 * (2 ** attempt))
                continue
            raise
    raise RuntimeError("repair retry exhausted")

The detail that matters is calling ensure_tool_pairing both on the first send and inside the retry. My first implementation only called it inside retry, which missed the case A failures that hit on the very first send.

A note on concurrent requests

A friend of mine ran into case C the harder way. He had two asyncio.gather legs in flight; one finished first and submitted a tool_result before the other leg had written its tool_use back to shared state. The SDK is blameless here - it is a state-management bug that only becomes visible when two coroutines share the same messages array.

My personal recommendation: keep any conversation that uses tools inside a single coroutine. If you want parallelism, run independent conversations with independent messages arrays. Debugging is much faster, and the throughput ends up similar.

What to try first

If you are looking at this error in your own code right now, drop a print statement just before messages.create() that emits each tool_use_id and each tool_result.tool_use_id in order. Five minutes of that output and you will know whether you are in A, B, or C.

Even on the scale of a 50-million-download wallpaper portfolio, API integration bugs tend to be the last category of issues to settle. I hope this is useful if you are looking at the same stack trace.

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 →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

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-04-24
Running the Claude API in Python Production — Rate Limits, Retries, and Timeouts
If you put Claude API into a real backend service, how you handle 429, 503, and read timeouts decides your reliability ceiling. This is the design I settled on after operating it in production.
⬡ API & SDK2026-09-22
Character counts won't predict PDF input tokens — bracket the cost per page before you send
How to estimate the input tokens of a PDF before sending it to the Claude API, by bracketing from page count rather than character count. Includes a measured 6.6x gap on a nine-page A4 document, the two separate increases that come with current models, and the count_tokens limitation that bites when you use file_id.