Back to Blog
EnglishTutorial

Claude tool_use_id Errors: Fix Missing and Misordered tool_result Blocks

Fix Claude tool_use_id and tool_result errors with valid message pairs, a runnable native tool loop, and a local validator for missing, duplicate, and misordered results.

C
Crazyrouter Team
October 11, 2026 / 2 views
Share:
Claude tool_use_id Errors: Fix Missing and Misordered tool_result Blocks

Fix Claude tool_use_id errors by pairing every assistant tool_use with exactly one matching tool_result in the next user message. Preserve the assistant content, put tool results before ordinary text, and do not invent replacement IDs.

Crazyrouter is an AI API gateway where this native Claude test completed 1 synthetic tool round trip from tool_use through a matching tool_result to a final answer.

The route also returned HTTP 200 for two deliberately malformed histories. That permissive behavior is not a portable protocol guarantee. A local validator should reject broken history before another provider, model, or client encounters it.

The tool_use_id and tool_result contract#

This article concerns client tools in Anthropic's native Messages API. OpenAI Responses uses function_call and function_call_output with call_id; Chat Completions has another message shape. Do not translate field names by guesswork. Start with Messages, Responses, and Chat Completions compared if your request crosses those formats.

An assistant can ask for multiple tools in one response. Preserve the whole assistant content array, including text and any other required blocks, and return results for the requested IDs. The ID identifies the invocation, not the tool definition. Two calls to lookup_ticket can have different IDs.

ItemCorrect native placementCommon mistake
tool_useAssistant contentDropping it from replayed history
idGenerated by the assistant responseReplacing it with a tool name
tool_resultNext user content arraySending it as an assistant message
tool_use_idExactly matches a preceding invocationReusing a stale ID
Ordinary user textAfter result blocks in that messageInserting text before results

The official tool-call handling guide explains these ordering requirements. The Claude Code configuration guide verifies the native endpoint configuration with a real CLI read task.

Native Claude tool invocation connects to a matching result in the next user message and then to a final assistant answer

Minimal broken and corrected histories#

The following IDs are illustrative protocol examples. In a real request, copy the IDs from the actual assistant response.

Broken: the result refers to a different invocation.

json
[
  {"role":"assistant","content":[
    {"type":"tool_use","id":"toolu_demo_a","name":"lookup_ticket","input":{"ticket_id":"DEMO-42"}}
  ]},
  {"role":"user","content":[
    {"type":"tool_result","tool_use_id":"toolu_wrong_id","content":"resolved"}
  ]}
]

Corrected: return the result using the exact invocation ID.

json
[
  {"role":"assistant","content":[
    {"type":"tool_use","id":"toolu_demo_a","name":"lookup_ticket","input":{"ticket_id":"DEMO-42"}}
  ]},
  {"role":"user","content":[
    {"type":"tool_result","tool_use_id":"toolu_demo_a","content":"resolved"},
    {"type":"text","text":"Explain this status briefly."}
  ]}
]

If tool execution fails, return a matching result with is_error: true and a useful error description rather than silently dropping the result. Do not return invented success. If the model requests two independent calls, include both results in the next user message; application execution order and response-block requirements are separate concerns.

Full runnable native tool round trip#

Install requests, supply CRAZYROUTER_API_KEY in the environment, save this as tool_roundtrip.py, and run python tool_roundtrip.py. The tool reads a synthetic in-memory ticket only; it has no external side effects.

python
"""One controlled native Messages tool round trip using synthetic data."""
import os
import json
import requests

def validate_pair(assistant, user):
    if assistant.get("role") != "assistant" or user.get("role") != "user":
        raise ValueError("expected adjacent assistant and user messages")
    expected = [b["id"] for b in assistant["content"] if b["type"] == "tool_use"]
    content = user.get("content")
    if not isinstance(content, list):
        raise ValueError("tool results must be content blocks")
    actual = [b["tool_use_id"] for b in content if b["type"] == "tool_result"]
    if len(set(expected)) != len(expected) or len(set(actual)) != len(actual) or set(actual) != set(expected):
        raise ValueError("missing, unknown, or duplicate tool result ID")
    seen_other = False
    for block in content:
        if block["type"] != "tool_result":
            seen_other = True
        elif seen_other:
            raise ValueError("tool_result must precede text blocks")

def main():
    base = os.getenv("CRAZYROUTER_BASE_URL", "https://cn.crazyrouter.com/v1")
    headers = {"Authorization": "Bearer " + os.environ["CRAZYROUTER_API_KEY"],
               "anthropic-version": "2023-06-01"}
    tool = {"name": "lookup_ticket", "description": "Look up a synthetic support ticket by ID.",
            "input_schema": {"type": "object", "properties": {"ticket_id": {"type": "string"}},
                             "required": ["ticket_id"]}}
    payload = {"model": "claude-sonnet-4-6", "max_tokens": 200, "tools": [tool],
               "tool_choice": {"type": "tool", "name": "lookup_ticket"},
               "messages": [{"role": "user", "content": "Look up synthetic ticket DEMO-42."}]}
    def send():
        r = requests.post(base.rstrip("/") + "/messages", headers=headers, json=payload, timeout=60)
        r.raise_for_status()
        return r.json()
    first = send()
    if first.get("stop_reason") != "tool_use":
        raise RuntimeError("model did not request a tool")
    assistant = {"role": "assistant", "content": first["content"]}
    results = []
    for block in first["content"]:
        if block["type"] == "tool_use":
            if block["name"] != "lookup_ticket" or block["input"].get("ticket_id") != "DEMO-42":
                raise ValueError("tool or input outside the synthetic fixture")
            results.append({"type": "tool_result", "tool_use_id": block["id"],
                            "content": '{"ticket_id":"DEMO-42","status":"resolved"}'})
    user = {"role": "user", "content": results}
    validate_pair(assistant, user)
    payload["messages"] += [assistant, user]
    payload.pop("tool_choice")
    final = send()
    if final.get("stop_reason") != "end_turn":
        raise RuntimeError("round trip did not reach a final answer")
    print(json.dumps({"first_id": first["id"], "final_id": final["id"], "content": final["content"]}))

if __name__ == "__main__":
    main()

The validator checks the paired messages, not every part of the API schema. The program additionally checks the allowed tool name and fixture input, requires an initial tool_use stop reason, and requires a final end_turn. A general agent needs a bounded loop when the model requests another tool; this example intentionally verifies one round trip.

For tools accepting user-controlled arguments, validate the schema, authorization, and application constraints before execution. Treat tool outputs as data from an external source and keep them inside the result block. Do not promote their contents into system instructions.

What the live route actually did#

Tests ran on October 11, 2026 using claude-sonnet-4-6 at https://cn.crazyrouter.com/v1/messages. The initial successful pair used first response msg_011CfvBmJzCnSsEiF63si8WY and final response msg_011CfvBmUWvdvVvyjtmFtwsF. The final answer reported the synthetic ticket as resolved.

Test caseObserved API resultLocal validator result
Matching generated result IDHTTP 200, final answerAccept
Wrong result IDHTTP 200Reject
Missing result replaced by “continue” textHTTP 200Reject
Duplicate result IDNot submitted liveReject in local test
Result after an ordinary text blockNot submitted liveReject in local test

Live malformed-history requests returned 200 while the local tool-result validator rejects invalid IDs and missing results

This test does not reproduce a live 400 for the malformed cases. It demonstrates why successful responses from a tolerant route cannot serve as a schema validator. An adapter might repair or normalize messages, or an upstream might be permissive; we did not trace which component caused the acceptance.

The standalone code also completed a subsequent successful pair with response IDs msg_011CfvH7DNk3VsP76N238j2w and msg_011CfvH7eWqV4ZuGQq8rR2zQ. Earlier attempts encountered read timeouts; the example leaves those visible rather than retrying a tool operation blindly. The bounded API retry implementation explains the replay boundary.

Recover from a broken conversation safely#

Find the last complete assistant/user tool pair. Compare the requested IDs with the returned IDs, check that history compaction did not retain half a pair, and restore results only from your actual execution records. A model-facing history repair must not claim that an unexecuted tool ran.

If a side-effecting tool completed but its result was lost, look up the operation using a persistent application ID before executing it again. A tool invocation ID inside model history is not a payment or delivery idempotency key. For a harmless lookup, you may be able to repeat the lookup and attach its verified result; for a side effect, deduplication is essential.

After compaction or session restoration, validate pairs before the next API call. Keep a bounded number of tool turns and an overall task timeout. Tool-schema changes can also alter the reusable prefix; use the prompt-cache miss investigation to investigate cache counters separately.

Model capabilities can vary, so inspect the Anthropic model catalog and verify the selected route. Do not assume that a different model name with the same schema has identical tool behavior.

FAQ#

What does “tool_use ids were found without tool_result blocks immediately after” mean?#

The replayed conversation contains tool invocations without the required matching results in the following user message. Check pairing, IDs, and ordering before resending it.

Can tool_use_id be the tool's name?#

No. It must be the invocation ID returned in the assistant's tool_use block. The name identifies which tool to execute.

What if one of several tool calls fails?#

Return a result for that ID with is_error: true, alongside results for the other invocations. Do not omit the failed invocation's result.

Can I insert ordinary text before tool results?#

The documented native format requires results before ordinary text in the next user message. The validator rejects a later result block after text.

Why did the malformed requests return HTTP 200 here?#

The precise component responsible was not identified. Treat this as observed tolerance in one route, not permission to rely on malformed history.

Can retrying the whole conversation duplicate a real action?#

Yes, if the application reruns a side-effecting tool. Persist execution state and use application-level idempotency before replaying such work.

Implementation Guides

Topics

ComparisonsTutorial

Related Articles