How to Fix Claude API 400 Errors in JSON Tool Calls (2026)
Claude API returning 400 errors during tool_use or tool_result calls? This guide covers all 5 root causes — orphaned tool blocks, context compression, concurrent execution, beta header mismatch, and schema key violations — with Python and TypeScript fixes.
Have broken JSON right now? Fix it free in under 1 second — no signup.
Fix My JSON →Claude's tool use is one of the most powerful features in the Anthropic API — and one of the most common sources of 400 errors. If your production pipeline is throwing 400 Bad Request during tool calls, this guide covers every root cause with reproducible examples and exact fixes.
The errors fall into five distinct categories. Most developers hit two or three of them before diagnosing the real source.
Why Tool Call 400 Errors Are Different
Regular Claude API 400 errors are usually prompt or schema issues — missing type, invalid required, unsupported keywords. These are caught early in development.
Tool call 400 errors are nastier. They often appear after your pipeline has been running fine for days or weeks. The conversation history is the problem, not the schema. Claude's API validates the entire message array on every request — one malformed turn anywhere in the conversation fails the whole request.
Error Pattern 1: Orphaned tool_result Block
This is the most common production failure and the hardest to debug.
What it looks like:400 Bad Request
{
"type": "error",
"error": {
"type": "invalid_request_error",
"message": "messages: tool_result block references a tool_use id that does not exist"
}
}
What causes it:
When Claude's context compression runs, it summarizes old messages to stay within the token limit. If compression deletes an assistant message containing a tool_use block but keeps the corresponding user message with the tool_result block, the result has no matching request. Every subsequent API call fails until the orphaned message is removed.
This also happens when you manually slice the message history without checking for tool_use/tool_result pairs.
Python fix — scan and clean orphans before each request:def remove_orphaned_tool_results(messages: list[dict]) -> list[dict]:
"""Remove tool_result blocks whose tool_use_id has no matching tool_use."""
# Collect all tool_use ids in assistant turns
tool_use_ids = set()
for msg in messages:
if msg["role"] == "assistant":
content = msg["content"] if isinstance(msg["content"], list) else [msg["content"]]
for block in content:
if isinstance(block, dict) and block.get("type") == "tool_use":
tool_use_ids.add(block["id"])
# Filter out tool_result blocks with no matching tool_use
cleaned = []
for msg in messages:
if msg["role"] == "user":
content = msg["content"] if isinstance(msg["content"], list) else [{"type": "text", "text": msg["content"]}]
filtered_content = []
for block in content:
if isinstance(block, dict) and block.get("type") == "tool_result":
if block.get("tool_use_id") in tool_use_ids:
filtered_content.append(block)
# else: skip orphaned result
else:
filtered_content.append(block)
if filtered_content:
cleaned.append({**msg, "content": filtered_content})
else:
cleaned.append(msg)
return cleaned
Usage — call before every API request
messages = remove_orphaned_tool_results(conversation_history)
response = client.messages.create(model="claude-sonnet-4-6", messages=messages, ...)
TypeScript fix:
function removeOrphanedToolResults(messages: MessageParam[]): MessageParam[] {
const toolUseIds = new Set<string>();
for (const msg of messages) {
if (msg.role === "assistant") {
const content = Array.isArray(msg.content) ? msg.content : [msg.content];
for (const block of content) {
if (typeof block === "object" && block.type === "tool_use") {
toolUseIds.add(block.id);
}
}
}
}
return messages
.map((msg) => {
if (msg.role !== "user") return msg;
const content = Array.isArray(msg.content)
? msg.content
: [{ type: "text" as const, text: msg.content as string }];
const filtered = content.filter(
(block) =>
typeof block !== "object" ||
block.type !== "tool_result" ||
toolUseIds.has((block as ToolResultBlockParam).tool_use_id)
);
return filtered.length > 0 ? { ...msg, content: filtered } : null;
})
.filter(Boolean) as MessageParam[];
}
Error Pattern 2: Concurrent Tool Execution Race Condition
What it looks like:400 Bad Request
"messages: tool_use block does not have a corresponding tool_result"
Or the inverse: duplicate tool_result for the same tool_use_id.
What causes it:When you run multiple tool calls in parallel — Claude returns multiple tool_use blocks in one response and you execute them concurrently — a race condition can produce duplicate results, out-of-order appends, or a missing result if one thread throws and the exception is swallowed.
Claude's API requires exactly one tool_result for every tool_use in the preceding assistant turn. Missing or duplicate results fail the entire request.
import asyncio
async def execute_tools_parallel(tool_calls: list[dict]) -> list[dict]:
"""Execute all tool calls concurrently, return results in original order."""
async def run_one(tool_call: dict) -> dict:
result = await your_tool_dispatcher(
name=tool_call["name"],
input=tool_call["input"]
)
return {
"type": "tool_result",
"tool_use_id": tool_call["id"],
"content": str(result),
}
# Run all concurrently, preserve order via gather
results = await asyncio.gather(*[run_one(tc) for tc in tool_calls], return_exceptions=True)
# Replace exceptions with error results — never let one failure break the set
safe_results = []
for i, (tool_call, result) in enumerate(zip(tool_calls, results)):
if isinstance(result, Exception):
safe_results.append({
"type": "tool_result",
"tool_use_id": tool_call["id"],
"content": f"Error: {str(result)}",
"is_error": True,
})
else:
safe_results.append(result)
return safe_results
After parallel execution — append all results in one atomic step
tool_use_blocks = [b for b in assistant_response.content if b.type == "tool_use"]
tool_results = await execute_tools_parallel([b.model_dump() for b in tool_use_blocks])
messages.append({"role": "user", "content": tool_results})
The key rule: never append tool results one at a time. Build the full tool_result list and append the entire user turn in one operation.
Error Pattern 3: Beta Header Mismatch (Bedrock / Vertex / Proxy)
What it looks like:400 Bad Request
"Unknown parameter: betas"
Or:
400 Bad Request
"The request body contains an unknown field: 'anthropic-beta'"
What causes it:
If you're using Claude via AWS Bedrock, Google Vertex AI, or an API proxy, experimental beta headers that work fine against api.anthropic.com are not supported. Bedrock and Vertex run their own API surface — betas like computer-use-2024-10-22 or max-tokens-3-5-sonnet-2024-07-15 get rejected.
import os
from anthropic import Anthropic, AnthropicBedrock
def get_client_and_kwargs(use_bedrock: bool = False):
if use_bedrock:
client = AnthropicBedrock()
# No betas on Bedrock — omit the header entirely
extra_kwargs = {}
else:
client = Anthropic()
extra_kwargs = {"betas": ["your-beta-flag"]}
return client, extra_kwargs
client, extra_kwargs = get_client_and_kwargs(
use_bedrock=os.getenv("USE_BEDROCK", "false").lower() == "true"
)
response = client.messages.create(
model="claude-sonnet-4-6",
messages=messages,
tools=tools,
**extra_kwargs,
)
Error Pattern 4: $schema Key in Tool Input Schema
What it looks like:400 Bad Request
{
"error": {
"type": "invalid_request_error",
"message": "tools.0.input_schema: Additional property '$schema' is not allowed"
}
}
What causes it:
JSON Schema validators and schema generation libraries (TypeBox, Zod-to-JSONSchema, json-schema-library) often include a $schema declaration in their output:
{
"$schema": "https://json-schema.org/draft/2020-12",
"type": "object",
"properties": { ... }
}
The Anthropic API rejects tool input_schema objects that contain $schema, $id, or other JSON Schema meta-keywords. These are valid JSON Schema but violate Anthropic's strict key pattern.
Note: this constraint is expected to loosen with the MCP RC on July 28, 2026 (full JSON Schema 2020-12 adoption) — but until then, strip these keys.
Python fix:FORBIDDEN_SCHEMA_KEYS = {"$schema", "$id", "$comment", "$vocabulary"}
def clean_tool_schema(schema: dict) -> dict:
"""Recursively remove JSON Schema meta-keywords from a tool input_schema."""
if not isinstance(schema, dict):
return schema
return {
k: clean_tool_schema(v)
for k, v in schema.items()
if k not in FORBIDDEN_SCHEMA_KEYS
}
Apply before registering tools
tools = [
{
"name": tool["name"],
"description": tool["description"],
"input_schema": clean_tool_schema(tool["input_schema"]),
}
for tool in raw_tools
]
TypeScript fix:
const FORBIDDEN_KEYS = new Set(["$schema", "$id", "$comment", "$vocabulary"]);
function cleanToolSchema(schema: Record<string, unknown>): Record<string, unknown> {
if (typeof schema !== "object" || schema === null) return schema;
return Object.fromEntries(
Object.entries(schema)
.filter(([k]) => !FORBIDDEN_KEYS.has(k))
.map(([k, v]) =>
typeof v === "object" && v !== null
? [k, cleanToolSchema(v as Record<string, unknown>)]
: [k, v]
)
);
}
Error Pattern 5: String Content in Multi-Role Turns
What it looks like:400 Bad Request
"messages: roles must alternate between 'user' and 'assistant'"
Or the request succeeds, then breaks on the next context compaction.
What causes it:When tool_use and tool_result blocks mix with plain text in the same conversation, some serialization paths write content as a plain string instead of an array. For example:
{"role": "user", "content": "What is the weather?"}
Versus the correct form when tool results are also present:
{"role": "user", "content": [{"type": "text", "text": "What is the weather?"}]}
Mixing these formats in the same conversation works until context compaction runs and the reconstruction logic can't merge them.
Fix — always normalize content to array format:def normalize_message_content(messages: list[dict]) -> list[dict]:
"""Ensure all message content is in array form, not plain string."""
result = []
for msg in messages:
content = msg["content"]
if isinstance(content, str):
content = [{"type": "text", "text": content}]
result.append({**msg, "content": content})
return result
messages = normalize_message_content(messages)
Repair Malformed Tool Call JSON
When Claude returns a tool call with malformed JSON in the input field — truncated, missing closing braces, or with control characters — you need to repair before dispatching.
Use AI JSONMedic's repair API or the open-source jsonrepair library:
import httpx
from jsonrepair import repair_json # pip install jsonrepair
def safe_parse_tool_input(tool_use_block) -> dict:
raw = tool_use_block.get("input", {})
if isinstance(raw, dict):
return raw # Already parsed by SDK
# String form (edge case with some proxy layers)
try:
import json
return json.loads(raw)
except json.JSONDecodeError:
repaired = repair_json(raw)
return json.loads(repaired)
For systematic repair in a pipeline, AI JSONMedic handles all five error classes above — truncation, wrong quotes, control characters, unescaped strings, and Python boolean leakage.
Summary: Which Error You Have
| Symptom | Pattern | Fix |
|---|---|---|
tool_result references a tool_use id that does not exist | Orphaned tool_result | Scan and remove orphaned blocks before each request |
tool_use block does not have a corresponding tool_result | Concurrent race | Gather all results before appending user turn |
Unknown parameter: betas | Beta header mismatch | Detect runtime, skip betas on Bedrock/Vertex |
Additional property '$schema' is not allowed | Schema key violation | Strip meta-keywords before registering tools |
roles must alternate | String content mixed with arrays | Normalize all content to array format |
Frequently Asked Questions
Why did my tool calls work fine for weeks then suddenly fail?Context compression is the most common delayed trigger. Once your conversation history grows long enough to hit the token limit, Claude's compression runs and can delete tool_use blocks while leaving orphaned tool_result blocks. The fix is to proactively scan for orphans before each API call.
Does this affect Claude Sonnet 4.6 specifically?All Claude models enforce the same tool call message format. The errors became more common after the Claude 4 upgrade (June 15, 2026) because many production pipelines hadn't been tested at conversation lengths that trigger compression.
Can I use oneOf/anyOf/allOf in tool input schemas?Currently, the Anthropic API rejects oneOf, anyOf, and allOf at the top level of tool input_schema. This changes with the MCP July 28 RC — after that, full JSON Schema 2020-12 composition is valid. Until then, flatten union types manually.
toolu_01.... How do I find which tool it is?
Search your conversation history for the id field in tool_use blocks. If you can't find it, the block was already compressed out — that's the orphaned tool_result pattern.
Disabling compression is not the right fix — it causes you to hit token limits instead. The correct approach is the orphan-cleaning function above, which runs in O(n) and adds negligible latency.
Will AI JSONMedic repair tool_use input JSON automatically?Yes. If Claude returns a tool call where the input value contains malformed JSON — truncated objects, trailing commas, Python booleans — paste it into AI JSONMedic's repair tool or use the API to repair programmatically before dispatch.
Related Guides
- Anthropic Tool Schema Validation Errors: 7 Patterns That Return 400 — schema structure issues (oneOf, $schema, invalid $ref)
- Getting Reliable JSON from Claude API — structured output fundamentals
- MCP outputSchema Validation Failures — MCP-specific schema errors
- Fix Invalid JSON Online — repair truncated or malformed tool input JSON
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