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

Debugging Claude API Tool Use Schema Errors: 3 Patterns I've Hit and How to Fix Them

A practical guide to diagnosing Claude API Tool Use errors—from schema definition mistakes to invalid_tool_use blocks and Claude ignoring your tools entirely. Based on real implementation experience.

tool-use24api39troubleshooting92python22error-handling11

When I first integrated Tool Use into Beautiful HD Wallpapers—an app I've been developing since 2014 that now has over 50 million cumulative downloads—the error I hit wasn't what I expected. The schema looked fine on the surface. Claude wasn't throwing an obvious error. But the tool_result I was returning had a subtle structural mismatch with what the API expected.

The frustrating part: it all came back as a generic invalid_request_error. No line number, no field name, just a cryptic message that could mean a dozen different things. I spent more time on that one issue than I care to admit.

If you're implementing Tool Use and something's not working, here's the diagnostic flow I now use every time.

Understanding the 3-Phase Structure First

Tool Use involves three distinct phases, and knowing which phase your error occurs in narrows down the cause immediately.

Phase 1: Define tools — You pass a tools array to the API with your tool schemas. Errors here mean the schema structure is malformed. Phase 2: Claude calls a tool — The response contains a tool_use block with Claude's chosen arguments. Errors here are rare unless you've constructed unusual inputs. Phase 3: Return the result — You add a user turn containing a tool_result block. Errors here usually mean a structural mismatch or a missing tool_use_id.

When an invalid_request_error comes back immediately after your first call, that's Phase 1. If it happens after you've sent the tool result, that's Phase 3. If there's no error but Claude keeps responding with text instead of calling a tool, that's a description or prompt design issue.

import anthropic
 
client = anthropic.Anthropic()
 
# Phase 1: define tools with correct schema
tools = [
    {
        "name": "classify_image",
        "description": "Classify an image into one of the given categories. Call this when the user asks to categorize or classify an image.",
        "input_schema": {
            "type": "object",
            "properties": {
                "image_url": {
                    "type": "string",
                    "description": "URL of the image to classify"
                },
                "categories": {
                    "type": "array",
                    "items": {"type": "string"},
                    "description": "List of candidate categories"
                }
            },
            "required": ["image_url", "categories"]
        }
    }
]
 
response = client.messages.create(
    model="claude-sonnet-4-6",
    max_tokens=1024,
    tools=tools,
    messages=[{"role": "user", "content": "Please classify this image: https://example.com/photo.jpg"}]
)

Pattern 1: Malformed input_schema

The most common cause is a required field placed inside a property definition instead of at the schema's top level. It's an easy mistake because some validation libraries accept it, but the Claude API follows JSON Schema Draft 7 strictly.

The required field must be a top-level array of property name strings, sibling to properties. Writing "required": true inside a property definition either silently fails or causes an invalid_request_error. Another common issue is omitting type from property definitions entirely. Every property needs an explicit type, even if you're only providing a description.

# ❌ Wrong: required inside the property definition
"input_schema": {
    "type": "object",
    "properties": {
        "query": {
            "type": "string",
            "required": True  # this field is ignored or causes an error
        }
    }
}
 
# ✅ Correct: required as a top-level array of field name strings
"input_schema": {
    "type": "object",
    "properties": {
        "query": {
            "type": "string",
            "description": "The search query"
        }
    },
    "required": ["query"]
}

A quick validation step before sending to the API can catch this immediately. The jsonschema package's Draft7Validator.check_schema() validates the schema structure locally without making any API calls.

Pattern 2: Incorrect tool_result structure

After Claude returns a tool_use block, the result must come back in a specific format as part of a user message. A plain text string won't work — it needs to be a tool_result block inside a content array.

The tool_use_id must exactly match the ID from Claude's tool_use block in the previous response. If you're handling parallel tool calls — where Claude calls multiple tools in one turn — each result needs its own tool_result block with the correct matching ID. Using the wrong ID, or omitting it, causes an invalid_request_error at Phase 3.

# ❌ Wrong: returning a plain text message instead of tool_result
messages.append({
    "role": "user",
    "content": "The classification result is: landscape"
})
 
# ✅ Correct: tool_result block inside a content array
if response.stop_reason == "tool_use":
    tool_use_block = next(b for b in response.content if b.type == "tool_use")
    result = execute_my_tool(tool_use_block.input)
 
    messages.append({
        "role": "user",
        "content": [
            {
                "type": "tool_result",
                "tool_use_id": tool_use_block.id,  # copy exactly from Claude's response
                "content": str(result)
            }
        ]
    })

For parallel tool calls, you need to collect all results and send them together in a single user message, not as separate messages.

Pattern 3: Claude ignores the tool entirely

If there's no error but Claude keeps responding with text, the problem is almost always in the description field or the user's message.

The description is the primary signal Claude uses to decide whether to call a tool. Vague descriptions like "processes data" or "handles requests" leave Claude uncertain, and it defaults to a text response when the task seems answerable without a tool.

Write descriptions that specify the trigger condition explicitly. "Call this tool when the user asks to X" or "Use this when you need Y" gives Claude a clear decision rule. If you want to guarantee tool use regardless of the prompt, tool_choice lets you force it.

# Force at least one tool to be called
response = client.messages.create(
    model="claude-sonnet-4-6",
    max_tokens=1024,
    tools=tools,
    tool_choice={"type": "any"},  # Claude must call at least one tool
    messages=[{"role": "user", "content": "Classify this image"}]
)
 
# Force a specific tool
response = client.messages.create(
    model="claude-sonnet-4-6",
    max_tokens=1024,
    tools=tools,
    tool_choice={"type": "tool", "name": "classify_image"},
    messages=[{"role": "user", "content": "Analyze this image"}]
)

Using type: "any" is a good diagnostic step: if it works with forced tool use but not with type: "auto", the issue is in how Claude is interpreting the trigger conditions in your description and prompt.

Additional Schema Design Notes

A few smaller issues that tend to surface in production after the initial implementation works.

enum casing — When you use "enum" to restrict string values, Claude will pick from your list, but the case of the output string can sometimes differ from your definitions. Normalizing to lowercase on your side before processing is safer than assuming exact case matches.

Nested objects — Every level of nested objects needs its own "type": "object" declaration along with its own properties. It's repetitive, but the structure can't be abbreviated. Missing type at any nesting level causes validation failures.

Description length — Descriptions that are too long increase token usage. A two-to-three sentence description covering "what the tool does" and "when to call it" is the practical sweet spot. With multiple tools defined, long descriptions across all of them add up quickly in context costs.

additionalProperties: false — Adding this to your schema prevents Claude from including fields you didn't define. It's useful for strict output validation, but it also means if Claude tries to add any extra field, the tool call itself fails. Start without it and add it once your tool definitions are stable.

Putting It Together

The pattern I follow now when something breaks: check which phase the error is in, then go down the checklist for that phase. Phase 1 errors → look at schema structure. Phase 3 errors → look at tool_result format and ID matching. No error but no tool call → fix the description or use tool_choice to diagnose.

Once you've built this mental model, most Tool Use errors resolve in under ten minutes. The implementation itself is straightforward once you get past the initial schema learning curve.

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-22
Designing a Model-Selection Fallback That Survives this model is currently unavailable on Claude API
When Claude API answers 'this model is currently unavailable', treating it like a 529 Overloaded or a rate limit sends you the long way round. Here is how I tell the rejections apart by SDK exception class, switch model IDs per request, and the assumptions I had to fix along the way.
⬡ API & SDK2026-05-22
Why tool_result could not be submitted Keeps Coming Back, and How to Build a Recovery Handler That Actually Holds
Run a Claude agent long enough and one day it starts: 'tool_result could not be submitted', back to back, and retries change nothing. The error message hides four completely different root causes. Here is what I learned debugging it on the always-on agent jobs I run as an indie developer, with the TypeScript recovery handler I now ship in production.
⬡ API & SDK2026-05-16
Automating Multilingual App Review Replies with Claude API — Living with the App Store's Undocumented Limits
An indie developer running a portfolio of wallpaper and healing apps shares the full implementation of Claude API-powered multilingual review reply automation — including App Store's undocumented 8-second rule, session limits, and the three traps that can get you banned.