Back to guides
Troubleshooting·August 28, 2026·6 min read

Claude API 400 “tool_use ids were found without tool_result blocks” — the ordering rule

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.

Last reviewed on .

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

response (HTTP 400)
{
  "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

CauseFix
Your tool threw, so nothing was appendedStill send a tool_result, with is_error: true and the error text.
The tool_result landed in a later messageIt must be the very next message — no assistant or user turn in between.
tool_use_id does not matchEcho the exact id from the tool_use block; do not regenerate it.
History trimmed mid-round-tripTrim 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.

tool_loop.py
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.

validate.py
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

More error semantics live in the error reference; getting a key takes a minute via sign up and the authentication docs.