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 orphantool_result. - B. Retry-implementation bug: a transient API error triggered a retry that re-sent the tail of
messagesstarting with thetool_result. - C. Concurrent-request ordering: two async calls were in flight and the
tool_resultgot submitted before thetool_usewas 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:
- Every
tool_resultblock must have atool_useimmediately before it in conversation order. - The
tool_use_idon thetool_resultmust match anidon that precedingtool_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.