This is a message-ordering error, not a tools error. Claude requires every tool_use block in an assistant turn to be answered by a tool_result block in the immediately following user turn — same ids, nothing in between. Your loop dropped one, usually because the tool threw.
The error
{
"type": "error",
"error": {
"type": "invalid_request_error",
"message": "messages.1: tool_use ids were found without tool_result blocks immediately after: toolu_01A... Each tool_use block must have a corresponding tool_result block in the next message."
}
}Causes and fixes at a glance
| Cause | Fix |
|---|---|
| Your tool threw, so nothing was appended | Still send a tool_result, with is_error: true and the error text. |
| The tool_result landed in a later message | It must be the very next message — no assistant or user turn in between. |
| tool_use_id does not match | Echo the exact id from the tool_use block; do not regenerate it. |
| History trimmed mid-round-trip | Trim at whole tool round-trips, never between the two halves of one. |
The invariant, stated once
Every tool_use block in an assistant turn needs exactly one tool_result block in the immediately following user message, carrying the same tool_use_id. Several tool_use blocks in one turn need several tool_result blocks in that one next message. Nothing may come between the two turns.
Always reply, even when the tool failed
The model handles a failed tool perfectly well; it cannot handle a missing one. Returning the error as a tool_result keeps the conversation valid and usually gets you a sensible recovery instead of a 400.
results = []
for block in (b for b in resp.content if b.type == "tool_use"):
try:
out = run_tool(block.name, block.input)
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": str(out),
})
except Exception as e:
# A failed tool still owes the model an answer.
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": f"Tool failed: {e}",
"is_error": True,
})
messages.append({"role": "assistant", "content": resp.content})
messages.append({"role": "user", "content": results})Validate the last two turns before sending
A dozen lines of assertion catch this at the call site rather than as a 400 from the network — walk the assistant turn's tool_use ids and confirm the next user turn answers all of them.
def check_pairs(messages):
for i, m in enumerate(messages):
if m["role"] != "assistant" or not isinstance(m.get("content"), list):
continue
ids = {b.get("id") for b in m["content"]
if isinstance(b, dict) and b.get("type") == "tool_use"}
if not ids:
continue
nxt = messages[i + 1] if i + 1 < len(messages) else None
answered = {b.get("tool_use_id") for b in (nxt or {}).get("content", [])
if isinstance(b, dict) and b.get("type") == "tool_result"}
missing = ids - answered
assert not missing, f"message {i}: unanswered tool_use {missing}"Trim history at round-trip boundaries
Context-window trimming that cuts by message count will eventually cut between a tool_use and its tool_result. Treat the pair as one indivisible unit when deciding what to drop.
If you’re calling through Kunavo
This one is your payload, and Kunavo does not paper over it: 400 is on the not-retryable list, so a malformed tool round-trip fails once instead of spending a second upstream round-trip of latency to reach the same error, and the rejected request is recorded at zero cost. On /v1/messages you are speaking the Messages protocol directly, so tools, tool_use and tool_result blocks are forwarded rather than translated. One caveat worth coding against: the returned envelope's type field is labelled api_error even when the upstream called it invalid_request_error — branch on the HTTP status and the message text, not on that field.
FAQ
Can I just drop the tool_use turn instead of answering it?
Yes, if you drop the whole assistant turn. What is invalid is keeping the tool_use and omitting its tool_result.
Does the OpenAI-compatible endpoint have the same rule?
The same pairing is required, spelled differently — tool_calls on the assistant message, then one role: "tool" message per call carrying tool_call_id.
Does a rejected request cost anything?
On Kunavo, no. Failed requests are recorded at zero cost and never reach a billed upstream call.
Related guides
- model_not_found / 404 — model naming across Claude, Gemini and gateways
- Claude API 429 rate_limit_error — causes and the fix that holds
More error semantics live in the error reference; getting a key takes a minute via sign up and the authentication docs.