Fix Claude Haiku 4.5 invalid_tool_call_format Errors (2026 Guide)
Claude Haiku 4.5 throwing invalid_tool_call_format or 400 errors in tool_use calls? This guide covers all 5 root causes — schema violations, orphan tool_result blocks, context compaction, concurrent calls, and control characters — with Python and TypeScript fixes.
Have broken JSON right now? Fix it free in under 1 second — no signup.
Fix My JSON →Claude Haiku 4.5 is fast and cheap — but it's more sensitive to tool call format errors than Sonnet or Opus. If your pipeline runs cleanly on a frontier model and breaks on Haiku 4.5, you're not alone. The invalid_tool_call_format error and its variants account for a large share of production failures when switching to smaller models.
This guide covers every root cause with reproducible examples and fixes in Python and TypeScript.
What is invalid_tool_call_format?
The error appears in several forms depending on which part of the tool call chain fails:
400 Bad Request: invalid_tool_call_format
400 Bad Request: Each tool_use block must have a corresponding tool_result block
400 Bad Request: Invalid JSON format in tool call arguments
400 Bad Request: JSON schema is invalid for tool input_schema
All of these originate from the same source: the Anthropic API validates the entire messages array and all tools definitions on every request. One malformed element — anywhere in the conversation — fails the whole call.
Haiku 4.5 surfaces these errors more often than larger models for two reasons:
- Stricter schema enforcement — Haiku 4.5 validates against JSON Schema draft-07 more aggressively. Schemas that Sonnet accepts silently will cause Haiku 4.5 to reject the request.
- Lower tolerance for complex nested schemas — Haiku 4.5 underperforms on tool schemas with deep nesting or large
anyOf/oneOfunions. The model's smaller context window makes it more likely to misformat arguments when the schema is ambiguous.
Root Cause 1: Missing required Field in input_schema
This is the most common schema violation and the easiest to fix.
What it looks like:tools = [
{
"name": "get_weather",
"description": "Get current weather for a city",
"input_schema": {
"type": "object",
"properties": {
"city": {"type": "string"},
"units": {"type": "string", "enum": ["celsius", "fahrenheit"]}
}
# MISSING: "required" field
}
}
]
The error:
400 Bad Request: JSON schema is invalid for tool 'get_weather': missing required field
Claude's API enforces that required must be present when properties exist, even if it's an empty array. This is per JSON Schema draft-07 — Anthropic's tool input_schema uses this draft.
tools = [
{
"name": "get_weather",
"description": "Get current weather for a city",
"input_schema": {
"type": "object",
"properties": {
"city": {"type": "string"},
"units": {"type": "string", "enum": ["celsius", "fahrenheit"]}
},
"required": ["city"] # Always include required, even if empty: []
}
}
]
TypeScript fix:
const tools: Tool[] = [
{
name: "get_weather",
description: "Get current weather for a city",
input_schema: {
type: "object" as const,
properties: {
city: { type: "string" },
units: { type: "string", enum: ["celsius", "fahrenheit"] }
},
required: ["city"] // Required is non-optional
}
}
];
Unsupported keywords to remove: Haiku 4.5 rejects schemas containing $schema, $defs, $dynamicRef, unevaluatedProperties, and if/then/else. Strip these before passing to the API.
Root Cause 2: Orphan tool_result Blocks After Context Compaction
This is the nastiest failure mode because it appears after your pipeline has been running correctly for hours or days.
What happens:During long conversations, context compaction removes assistant messages that contain tool_use blocks. But the corresponding user messages with tool_result blocks are kept. The Anthropic API now sees tool_result entries with no matching tool_use — and rejects the request.
400 Bad Request: tool_result block with tool_use_id 'toolu_01XY...' has no matching tool_use block
Why Haiku 4.5 is especially affected: Because Haiku has a smaller context window, context compaction triggers more often, increasing the probability of orphaning a tool_result.
Detection — Python:
def find_orphan_tool_results(messages: list[dict]) -> list[str]:
"""Returns tool_use_ids that have tool_result but no tool_use."""
tool_use_ids = set()
tool_result_ids = set()
for msg in messages:
content = msg.get("content", [])
if isinstance(content, list):
for block in content:
if block.get("type") == "tool_use":
tool_use_ids.add(block["id"])
elif block.get("type") == "tool_result":
tool_result_ids.add(block["tool_use_id"])
return list(tool_result_ids - tool_use_ids)
# Before every API call:
orphans = find_orphan_tool_results(conversation_history)
if orphans:
raise ValueError(f"Orphan tool_results detected: {orphans}")
Fix — strip orphaned pairs atomically:
def strip_orphaned_tool_pairs(messages: list[dict]) -> list[dict]:
"""Remove tool_use/tool_result pairs that are missing their counterpart."""
# Collect all tool_use IDs
tool_use_ids = set()
for msg in messages:
content = msg.get("content", [])
if isinstance(content, list):
for block in content:
if block.get("type") == "tool_use":
tool_use_ids.add(block["id"])
# Filter messages, removing any tool_result that has no matching tool_use
clean_messages = []
for msg in messages:
content = msg.get("content", [])
if isinstance(content, list):
clean_content = [
block for block in content
if not (
block.get("type") == "tool_result" and
block.get("tool_use_id") not in tool_use_ids
)
]
if clean_content:
clean_messages.append({**msg, "content": clean_content})
else:
clean_messages.append(msg)
return clean_messages
TypeScript equivalent:
function stripOrphanedToolPairs(messages: MessageParam[]): MessageParam[] {
const toolUseIds = new Set<string>();
for (const msg of messages) {
if (Array.isArray(msg.content)) {
for (const block of msg.content) {
if (block.type === "tool_use") {
toolUseIds.add(block.id);
}
}
}
}
return messages
.map(msg => {
if (!Array.isArray(msg.content)) return msg;
const cleanContent = msg.content.filter(block => {
if (block.type === "tool_result") {
return toolUseIds.has(block.tool_use_id as string);
}
return true;
});
return cleanContent.length > 0 ? { ...msg, content: cleanContent } : null;
})
.filter(Boolean) as MessageParam[];
}
Root Cause 3: XML Format Bleed-Through on Long Contexts
This is specific to Haiku 4.5 on longer conversations. Claude's smaller models occasionally revert to the legacy XML tool format on message arrays exceeding ~40,000 tokens.
What it looks like:400 Bad Request: Invalid JSON format in tool call arguments
The tool call input field contains <parameters><city>London</city></parameters> instead of {"city": "London"}.
import json
def validate_tool_call_args(tool_use_block: dict) -> bool:
"""Returns True if tool call arguments are valid JSON."""
args = tool_use_block.get("input", {})
if isinstance(args, str):
try:
json.loads(args)
except json.JSONDecodeError:
return False
return True
Fix: Trim conversation history before it exceeds 30,000 tokens when using Haiku 4.5. Keep the most recent messages and summarize older context in a system prompt addition:
MAX_HAIKU_TOKENS = 30_000 # Conservative limit for Haiku 4.5
def estimate_tokens(messages: list[dict]) -> int:
"""Rough estimate: 1 token ≈ 4 characters."""
return sum(
len(str(msg.get("content", "")))
for msg in messages
) // 4
def trim_history_for_haiku(messages: list[dict]) -> list[dict]:
while estimate_tokens(messages) > MAX_HAIKU_TOKENS and len(messages) > 2:
# Remove oldest non-system message
messages = [messages[0]] + messages[2:] # Keep system + recent
return messages
Root Cause 4: Concurrent tool_use Without Matching tool_result
When your application sends multiple tool calls in parallel and one fails mid-flight, you end up with a tool_use block that has no tool_result. The next request will fail with:
400 Bad Request: Each tool_use block must have a corresponding tool_result block in the following turn
Pattern that causes this:
# BROKEN — if tool_b fails, tool_a's result is orphaned
async def run_tools_concurrently(tool_uses):
results = await asyncio.gather(
call_tool_a(tool_uses[0]),
call_tool_b(tool_uses[1]), # If this raises, tool_a result is lost
)
return results
Fix — always return a result for every tool_use, even on failure:
async def run_tools_safely(tool_uses: list[dict]) -> list[dict]:
"""Returns a tool_result for every tool_use, including failures."""
async def safe_call(tool_use: dict) -> dict:
try:
result = await execute_tool(tool_use["name"], tool_use["input"])
return {
"type": "tool_result",
"tool_use_id": tool_use["id"],
"content": str(result)
}
except Exception as e:
return {
"type": "tool_result",
"tool_use_id": tool_use["id"],
"content": f"Error: {str(e)}",
"is_error": True
}
return await asyncio.gather(*[safe_call(tu) for tu in tool_uses])
Root Cause 5: Control Characters in Tool Arguments
Haiku 4.5 occasionally includes raw control characters (\x00–\x1f) in string arguments, especially when processing user-supplied text. The Anthropic API rejects these as invalid JSON.
import re
import json
def sanitize_tool_input(tool_input: dict) -> dict:
"""Strip control characters from all string values in tool input."""
def clean_value(v):
if isinstance(v, str):
# Remove control chars except \t, \n, \r
return re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]', '', v)
if isinstance(v, dict):
return {k: clean_value(val) for k, val in v.items()}
if isinstance(v, list):
return [clean_value(item) for item in v]
return v
return clean_value(tool_input)
# Validate before sending
def validate_and_clean_tool_call(tool_use_block: dict) -> dict:
clean_input = sanitize_tool_input(tool_use_block.get("input", {}))
# Verify it serializes cleanly
json.loads(json.dumps(clean_input)) # Raises if invalid
return {**tool_use_block, "input": clean_input}
Prevention: Schema Validator for Haiku 4.5
Combine all checks into one validation function to run before every API call:
def validate_haiku_tool_payload(messages: list[dict], tools: list[dict]) -> None:
"""
Validate tool payload for Haiku 4.5 compatibility.
Raises ValueError with specific error description on failure.
"""
# 1. Schema check
for tool in tools:
schema = tool.get("input_schema", {})
if schema.get("type") == "object" and "properties" in schema:
if "required" not in schema:
raise ValueError(
f"Tool '{tool['name']}': input_schema missing 'required' field"
)
banned = {"$schema", "$defs", "$dynamicRef", "unevaluatedProperties"}
found = banned & set(schema.keys())
if found:
raise ValueError(
f"Tool '{tool['name']}': unsupported schema keywords: {found}"
)
# 2. Orphan check
orphans = find_orphan_tool_results(messages)
if orphans:
raise ValueError(f"Orphan tool_result blocks: {orphans}")
# 3. Token estimate
if estimate_tokens(messages) > MAX_HAIKU_TOKENS:
raise ValueError(
f"Message history too long for Haiku 4.5: trim to <{MAX_HAIKU_TOKENS} tokens"
)
Use AI JSONMedic to validate the raw JSON payloads you're sending if you want to inspect them manually.
When to Upgrade from Haiku 4.5
Haiku 4.5 is the right choice for simple, flat tool schemas with 1–3 parameters. If you're seeing persistent invalid_tool_call_format errors after applying the fixes above, consider:
- Complex nested schemas (more than 3 levels deep): switch to Sonnet 4.6
- anyOf/oneOf with 5+ variants: switch to Sonnet 4.6 or simplify the union
- Parallel tool calls with error recovery: test on Sonnet first, backport to Haiku with the safety wrappers above
- Conversations over 40,000 tokens: use Haiku with aggressive history trimming or switch to a model with a larger context window
For schema validation issues specifically, see our guide on Claude API 400 errors in JSON tool calls and the MCP tool schema validation errors post for anyOf/oneOf patterns.
Quick Reference: Error → Fix
| Error message | Root cause | Fix |
|---|---|---|
JSON schema is invalid | Missing required, unsupported keywords | Add required, strip $schema/$defs |
tool_result has no matching tool_use | Orphan blocks after compaction | Run strip_orphaned_tool_pairs() before send |
Invalid JSON format in tool call arguments | XML bleed-through or control chars | Trim history; run sanitize_tool_input() |
Each tool_use must have tool_result | Concurrent call failure dropped a result | Use safe_call() wrapper; never drop results |
Requests must define tools when including tool_use | Tools missing from follow-up request | Always pass tools= on every request in the conversation |
Frequently Asked Questions
Why does my pipeline work on Sonnet 4.6 but fail on Haiku 4.5?Haiku 4.5 applies stricter JSON Schema validation and has less tolerance for complex schemas. Schemas that Sonnet accepts silently — like missing required or unsupported $schema keys — cause Haiku 4.5 to reject with a 400 error. Apply the schema fixes in Root Cause 1 first; they resolve ~60% of cases.
You're almost certainly hitting the orphan tool_result bug (Root Cause 2). Context compaction removes tool_use blocks while keeping their corresponding tool_result blocks. Add the find_orphan_tool_results() check before every API call to catch it before it causes a 400.
No. Claude's input_schema uses JSON Schema draft-07. Do not include $schema, $defs, unevaluatedProperties, or if/then/else — these are draft-2019-09+ keywords that the API rejects. Stick to draft-07: properties, required, type, enum, allOf, anyOf, oneOf, $ref (simple references only).
claude-haiku-4-5-20251001 fix the issue?
If you're on an older Haiku model ID, upgrading to the latest Haiku 4.5 model string can help — but it doesn't fix schema violations or orphan blocks. Apply the validation fixes regardless of which model version you're on.
How do I find out which tool call is failing?Enable streaming and inspect the input_json_delta events. The partial JSON that Claude is generating will appear before the 400 is thrown. If it contains XML tags or control characters, you've identified Root Cause 3 or 5. If the error is a schema rejection, it appears before generation starts.
Still dealing with broken JSON?
Paste it in and get it fixed in under 1 second — free, no signup, no install. Works with ChatGPT, Claude, n8n, and any AI output.
Fix My JSON Free →Related Articles