Handling Malformed Tool-Use JSON and Schema Validation Failures
Tool use is the contract between Claude and your code: the model produces structured arguments, your code executes a function against them. Most of the time that contract holds. Occasionally it doesn't, arguments that don't match the declared schema, a required field missing, a type mismatch, and if your code assumes the contract always holds, that one bad tool call can crash an agent loop or corrupt its state several turns later.
Summary
Claude's tool use returns tool_use content blocks where input is already parsed into a Python object by the SDK, matching the JSON shape the model produced.
The failure isn't usually invalid JSON syntax, the SDK's parsing handles that, it's a mismatch between what the model produced and what your tool's schema actually requires: a missing required field, a wrong type, an enum value that isn't one of the allowed options.
Left unhandled, this kind of mismatch either throws deep inside your tool-execution code with a confusing stack trace, or worse, it doesn't throw at all and your tool silently receives a value it wasn't built for.
The fix is a validation layer between "the model produced tool input" and "your tool code runs," one that catches the mismatch, logs it with enough detail to diagnose, and either repairs the input or fails the tool call cleanly with a structured error message the model can see and react to.
This article builds that layer using pydantic for schema validation, since it composes well with the JSON Schema style tool definitions already require.
Recipe
Quick-reference recipe card - copy-paste ready.
from pydantic import BaseModel, ValidationError
class SearchArgs(BaseModel):
query: str
max_results: int = 10
def validate_tool_input(raw_input: dict) -> SearchArgs | None:
try:
return SearchArgs.model_validate(raw_input)
except ValidationError as e:
print("invalid tool input:", e)
return NoneWhen to reach for this:
- Any tool whose arguments come directly from a Claude
tool_useblock, before you pass them into your actual function. - Multi-turn agent loops, where a bad tool call today can silently corrupt state used several turns later.
- Tools with non-trivial schemas: enums, nested objects, optional fields with defaults, anything beyond a single string argument.
- Any system where a tool failure needs to be reported back to the model in a way it can act on, rather than crashing the whole turn.
Working Example
import json
import logging
from typing import Literal
import anthropic
from pydantic import BaseModel, ValidationError, Field
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("tool_use_validation")
client = anthropic.Anthropic()
class CreateTicketArgs(BaseModel):
title: str
priority: Literal["low", "medium", "high"]
assignee: str | None = Field(default=None)
TOOLS = [
{
"name": "create_ticket",
"description": "Create a support ticket.",
"input_schema": {
"type": "object",
"properties": {
"title": {"type": "string"},
"priority": {"type": "string", "enum": ["low", "medium", "high"]},
"assignee": {"type": "string"},
},
"required": ["title", "priority"],
},
}
]
def create_ticket(args: CreateTicketArgs) -> str:
return f"Created ticket '{args.title}' (priority={args.priority})"
def handle_tool_use(block: anthropic.types.ToolUseBlock) -> dict:
"""Validate a tool_use block's input before executing the tool.
Returns a tool_result content block, success or structured error,
ready to send back to the model."""
logger.info("tool_use.raw", extra={"tool": block.name, "raw_input": json.dumps(block.input)})
if block.name != "create_ticket":
return {
"type": "tool_result",
"tool_use_id": block.id,
"content": f"Unknown tool: {block.name}",
"is_error": True,
}
try:
args = CreateTicketArgs.model_validate(block.input)
except ValidationError as e:
logger.warning("tool_use.invalid", extra={"tool": block.name, "errors": e.errors()})
error_summary = "; ".join(
f"{err['loc']}: {err['msg']}" for err in e.errors()
)
return {
"type": "tool_result",
"tool_use_id": block.id,
"content": f"Invalid arguments for create_ticket: {error_summary}",
"is_error": True,
}
result = create_ticket(args)
logger.info("tool_use.success", extra={"tool": block.name})
return {
"type": "tool_result",
"tool_use_id": block.id,
"content": result,
}
if __name__ == "__main__":
response = client.messages.create(
model="claude-sonnet-5",
max_tokens=512,
tools=TOOLS,
messages=[{"role": "user", "content": "Create a high priority ticket titled 'Payments down'."}],
)
for block in response.content:
if block.type == "tool_use":
result = handle_tool_use(block)
print(result)What this demonstrates:
- Logging the raw
tool_useinput before validation, so a rejected call still leaves evidence for debugging. - Using
pydanticto validateblock.inputagainst the same shape declared ininput_schema, catching missing fields, wrong types, and invalid enum values in one place. - Returning a structured
tool_resultwithis_error: Trueon failure, so the model sees exactly what was wrong and can retry with corrected arguments in the same turn. - Keeping tool execution (
create_ticket) completely separate from validation, so the function itself never has to defend against malformed input.
Deep Dive
How It Works
- The
anthropicSDK parses the JSON the model produces for tool arguments into a Pythondictautomatically, so you rarely see a rawJSONDecodeErrorfrom a tool-use block itself. - What you do see is a schema mismatch: the parsed dict doesn't satisfy the constraints your tool actually needs, a missing required key, a string where you expected an integer, an enum value outside the allowed set.
- Feeding validation errors back to the model as a
tool_resultwithis_error: Truecloses the loop, the model can see exactly what was wrong and often self-corrects in its next turn, without any code-level retry logic. - Silently swallowing a validation failure (catching the exception and doing nothing) is the most dangerous option, because the agent's conversation continues as if the tool call succeeded, and the actual failure surfaces later as an unrelated-looking bug.
Failure Shapes You'll Actually See
| Failure | Example | Detection |
|---|---|---|
| Missing required field | Model omits priority | pydantic raises ValidationError with a missing error type |
| Wrong type | Model sends "priority": 1 instead of a string | ValidationError with a type_error |
| Invalid enum value | Model sends "priority": "urgent" (not in the allowed set) | ValidationError with a literal_error |
| Extra/unexpected fields | Model sends an undeclared field | Ignored by default in pydantic; use model_config = {"extra": "forbid"} to reject instead |
| Nested structure mismatch | A list expected, a single object sent | ValidationError pointing at the nested loc path |
Repair vs Reject
- Repair when the mismatch is small and unambiguous, e.g. the model sent
"priority": "High"instead of"high", a case-normalization repair is safe and saves a round trip. - Reject when the mismatch changes meaning, e.g. a missing required field, there's no safe default to invent, and guessing risks executing the tool with wrong data.
- Never repair by silently dropping a validation error and proceeding with partial or default data for something that changes the tool's real-world effect (an assignee change, a payment amount). Reject those explicitly instead.
Python Notes
# A repair pass for the safe, unambiguous case: case-insensitive enum matching.
# Only attempt repairs where the fix is unambiguous; anything else should be rejected.
def repair_enum_case(raw_input: dict, field: str, allowed: list[str]) -> dict:
value = raw_input.get(field)
if isinstance(value, str):
lowered = value.lower()
if lowered in allowed:
raw_input[field] = lowered
return raw_inputGotchas
-
Assuming
block.inputalways matches your schema. Nothing structurally guarantees the model's tool call satisfies yourinput_schema, it's a strong prompt-level convention, not an enforced contract. Fix: always validate before executing, even for tools that "always" get simple arguments in practice. -
Catching the validation error and doing nothing. A silently swallowed error looks like a successful turn to the rest of the agent loop, and the real failure surfaces later, disconnected from its cause. Fix: always either repair explicitly and log it, or return a structured
is_errortool result the model can see. -
Logging only that validation failed, not what specifically failed. "Tool input invalid" without the field-level errors makes repeated failures hard to diagnose across many calls. Fix: log
e.errors()(or equivalent field-level detail) on every validation failure, not just a boolean. -
Repairing ambiguous mismatches instead of rejecting them. Silently defaulting a missing required field to a guessed value can execute a tool with meaningfully wrong data. Fix: repair only unambiguous, low-risk mismatches (case normalization, whitespace); reject everything else.
-
Not returning
is_error: Trueon a failed tool result. Sending a tool result back to the model without marking it as an error can cause the model to treat a failure as a success and continue as if the tool ran correctly. Fix: always setis_error: Trueon thetool_resultblock when the call failed. -
Treating every unknown tool name as a validation failure. An unrecognized
block.nameis a different problem, a routing bug or a stale tool definition, not a schema mismatch. Fix: handle unknown tool names as a distinct branch with its own logging, separate from schema validation.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
pydantic validation (this article) | You want typed, composable schemas that mirror your input_schema definitions | You need zero dependencies and only have one or two trivial string fields |
Manual dict key/type checks | A single tool with very simple, stable arguments | Schemas grow past a couple of fields; manual checks get unreadable fast |
jsonschema library directly against input_schema | You want to validate against the exact schema you already send to the API, with no duplication | You want Python-native types and IDE autocomplete on validated data, pydantic gives you that, raw jsonschema doesn't |
| Strict JSON mode / structured outputs at the API level | Your workflow doesn't require a tool_use content-block wrapper and a simpler structured response would do | You genuinely need the tool_use loop (multi-tool agents, tool results feeding back into the conversation) |
FAQs
Does the anthropic SDK ever fail to parse tool-use JSON at all?
Rarely at the syntax level, the SDK handles well-formed JSON parsing for you. The failures you'll actually encounter are schema mismatches: valid JSON that doesn't satisfy your tool's required shape.
Why use pydantic instead of just checking dict keys manually?
pydanticschemas mirror theinput_schemayou already declare for the tool, reducing duplication.- Field-level error messages (
e.errors()) are structured and consistent, which makes logging and debugging much easier than ad hocifchecks. - Type coercion and validation are handled in one call instead of scattered manual checks.
Should I always return a tool_result with is_error, or can I just raise an exception?
Return a structured tool_result with is_error: True when you want the model to see the failure and potentially self-correct within the same conversation. Raise an exception only when the failure should halt the whole turn, not continue the agent loop.
What's the difference between repairing and rejecting a malformed tool call?
Repair means fixing an unambiguous, low-risk mismatch automatically (like case normalization) and proceeding. Reject means returning a structured error instead of guessing, used whenever the fix isn't obviously safe, such as a missing required field.
Can a malformed tool call corrupt state even if my code doesn't crash?
Yes. If a validation failure is silently caught and ignored, the conversation continues as though the tool succeeded, but any state that depended on the tool's real effect (a created record, an updated value) never actually happened, and that gap can surface as a confusing bug much later.
How do I handle a tool call for a tool name that doesn't exist?
Treat it as a distinct failure branch from schema validation, log it separately, and return a tool_result with is_error: True describing the unknown tool name so the model can see the routing failure explicitly.
Is it safe to just add `extra: forbid` to reject unexpected fields?
It's a reasonable default for most tools, since unexpected fields usually indicate model drift or a stale schema. Just be aware it will reject calls if you ever loosen a tool's schema without updating the corresponding pydantic model.
What should the error message in a rejected tool_result actually contain?
Enough for the model to self-correct: the specific field(s) that failed and why, not just "invalid input." Passing through pydantic's e.errors() in a readable form usually gives the model what it needs to retry correctly.
Should validation logic live inside the tool function or separate from it?
Separate. Keeping validation as its own layer means the tool function can assume its input is already correct, which keeps the function itself simple and makes the validation logic reusable and independently testable.
Does this pattern change for tools that call external APIs versus local functions?
The validation layer itself doesn't change, you still validate before executing. What changes is what "reject" should mean downstream, an external API call might also need its own error handling for failures the schema validation can't catch, like the external service itself rejecting a technically valid value.
Related
- Understanding Why Claude Agents Fail in Production - where contract failures fit in the broader failure taxonomy.
- Troubleshooting & Reliability Basics - logging tool-use blocks before you add validation on top.
- Retry and Exponential Backoff Strategies for Transient Claude API Failures - resilience patterns for the transport-level failures that sit alongside contract failures.
- Root-Cause-Analysis Template for Agent Failures - documenting a tool-use incident once it's resolved.
- Troubleshooting & Reliability Best Practices - where tool-use validation fits into the broader checklist.
Stack versions: Written against the Claude model lineup current as of ~June 2026 - Claude Fable 5, Claude Opus 4.8, Claude Sonnet 5 (the default), and Claude Haiku 4.5 - and the official
anthropicPython SDK (latest 0.x release). Model names, pricing, and SDK versions move quickly - verify current specifics at platform.claude.com/docs before relying on them.