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.
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.
import jsonimport loggingfrom typing import Literalimport anthropicfrom pydantic import BaseModel, ValidationError, Fieldlogging.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_use input before validation, so a rejected call still leaves evidence for debugging.
Using pydantic to validate block.input against the same shape declared in input_schema, catching missing fields, wrong types, and invalid enum values in one place.
Returning a structured tool_result with is_error: True on 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.
The anthropic SDK parses the JSON the model produces for tool arguments into a Python dict automatically, so you rarely see a raw JSONDecodeError from 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_result with is_error: True closes 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.
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.
# 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_input
Assuming block.input always matches your schema. Nothing structurally guarantees the model's tool call satisfies your input_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_error tool 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: True on 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 set is_error: True on the tool_result block when the call failed.
Treating every unknown tool name as a validation failure. An unrecognized block.name is 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.
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?
pydantic schemas mirror the input_schema you 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 hoc if checks.
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.
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 anthropic Python 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.
Reviewed by Chris St. John·Last updated Jul 19, 2026