Claude API tool use is the cleanest way I have found to force a language model to return structured data instead of prose. The idea is simple: you define a tool with a JSON Schema, set tool_choice to force a call, and Claude returns a structured input block instead of free text. But "structured" does not mean "valid." There are at least three categories of failure that slip past the schema enforcement, and two of them are silent. This post walks through the full protocol, what strict mode actually catches, what it does not, and the retry loop I use in production to close the gap.

How Claude API Tool Use Works: The Two-Round-Trip Protocol

The basic flow has two mandatory round trips. On the first call, you send your messages plus a tools array. Claude responds with stop_reason: "tool_use" and a tool_use content block containing a name and an input object. You execute the tool logic on your side, then send a second request that includes the original assistant message plus a new user message containing a tool_result block. Claude then produces its final response.

Skipping the second round trip is the most common beginner mistake. If you do not return a tool_result, Claude will refuse to continue or will re-attempt the tool call in a loop. The stop_reason field is your authoritative exit signal. Do not poll; check the reason.

response = client.messages.create(
    model="claude-opus-4-5",
    max_tokens=1024,
    tools=[
        {
            "name": "extract_loan_fields",
            "description": "Extract structured loan application fields from borrower text.",
            "input_schema": {
                "type": "object",
                "properties": {
                    "loan_amount": {"type": "integer", "minimum": 50000, "maximum": 2000000},
                    "loan_purpose": {"type": "string", "enum": ["purchase", "refinance", "cash_out"]},
                    "property_state": {"type": "string", "maxLength": 2}
                },
                "required": ["loan_amount", "loan_purpose", "property_state"]
            }
        }
    ],
    tool_choice={"type": "tool", "name": "extract_loan_fields"},
    messages=[{"role": "user", "content": "I want to refinance my condo in MA for about $400,000."}]
)

The tool_choice options matter. auto lets Claude decide whether to call a tool at all. any forces a tool call but lets Claude pick which tool from your list. {"type": "tool", "name": "..."}} forces a specific tool every time. For structured output purposes, you almost always want the third option. Otherwise Claude can and will return prose when it is uncertain, and your downstream code crashes.

Using Tool Use as a Structured Output Hack

The structured-output hack is real and I use it. You define a tool that represents your desired output schema, give it a descriptive name like format_response or extract_fields, and force Claude to call it. Claude never "executes" this tool in any meaningful sense. You just read the input block and treat it as your structured output. No second round trip needed if you do not care about Claude's follow-up text.

This works reliably for extraction tasks: pulling fields from documents, classifying intent, normalizing borrower-supplied text into database-ready records. I use it in the mortgage pipeline at NewFed to parse unstructured borrower messages into typed fields before writing to the CRM. The alternative is regex plus a prayer, and the tool use approach wins on every metric.

What strict: true Actually Enforces (and What It Does Not)

Anthropic added strict: true at the tool level. When enabled, Claude is supposed to return inputs that conform exactly to your schema. In practice, this catches the most obvious deviations: missing required fields, wrong types on primitive values, strings where integers are expected. For maybe 80 to 85 percent of calls on well-described schemas, strict: true is enough.

But there are three failure modes it does not reliably catch.

Enum Fuzzing

Claude will sometimes return a value that is close to but not in your enum. If your enum is ["purchase", "refinance", "cash_out"] and the borrower says "I want to do a cash-out refi," Claude might return "cash_out_refinance" or "cashout". Strict mode is supposed to block this, but I have seen it slip through, particularly when the model is confident and the value is plausible. The fix is runtime validation, not trust.

Numerical Constraint Stripping

This one is worse because it is silent. The Anthropic Python SDK, as of the versions I have tested, strips unrecognized or advisory JSON Schema keywords before sending the request. Keywords like minimum, maximum, multipleOf, and custom extensions are not always forwarded to the model. Claude never sees them. It cannot enforce constraints it was never given. You can set minimum: 50000 in your schema and Claude will happily return 0 or -1 because the constraint was dropped in transit. I caught this when a loan amount field came back as 100 on a test with deliberately vague input.

Max Tokens Truncation

If your max_tokens is too low relative to the complexity of the tool input, the response truncates mid-JSON. You get a stop_reason of "max_tokens" instead of "tool_use", and the input block is incomplete or missing. Set your token budget deliberately. For complex extraction schemas, I budget at least 512 tokens for the tool input alone and add headroom for reasoning tokens if you are using extended thinking. Speaking of which: extended thinking and forced tool use do not compose cleanly right now. If you need structured output and extended thinking in the same call, you have to choose, or you have to strip the thinking tokens before processing the tool result.

What I'd Actually Do: A Pydantic Retry Loop

Runtime validation with Pydantic is the layer strict mode cannot replace. The pattern I use is a retry loop that feeds the exact validation error back to Claude as a tool_result with is_error: true, then asks Claude to try again. This matters more than it sounds. If you just retry without the error, Claude repeats the same mistake at roughly the same rate. If you include the Pydantic error string, Claude corrects itself on the first retry more than 90 percent of the time in my runs, because the error tells it exactly which field failed and why.

import anthropic
from pydantic import BaseModel, Field, ValidationError
from typing import Literal

client = anthropic.Anthropic()

class LoanFields(BaseModel):
    loan_amount: int = Field(ge=50000, le=2000000)
    loan_purpose: Literal["purchase", "refinance", "cash_out"]
    property_state: str = Field(max_length=2)

TOOL_DEF = {
    "name": "extract_loan_fields",
    "description": "Extract structured loan application fields from borrower text.",
    "input_schema": {
        "type": "object",
        "properties": {
            "loan_amount": {"type": "integer"},
            "loan_purpose": {"type": "string", "enum": ["purchase", "refinance", "cash_out"]},
            "property_state": {"type": "string"}
        },
        "required": ["loan_amount", "loan_purpose", "property_state"]
    }
}

def extract_with_retry(user_text: str, max_retries: int = 2) -> LoanFields:
    messages = [{"role": "user", "content": user_text}]

    for attempt in range(max_retries + 1):
        response = client.messages.create(
            model="claude-opus-4-5",
            max_tokens=512,
            tools=[TOOL_DEF],
            tool_choice={"type": "tool", "name": "extract_loan_fields"},
            messages=messages
        )

        if response.stop_reason != "tool_use":
            raise RuntimeError(f"Unexpected stop_reason: {response.stop_reason}")

        tool_block = next(b for b in response.content if b.type == "tool_use")
        raw_input = tool_block.input

        try:
            return LoanFields(**raw_input)
        except ValidationError as e:
            if attempt == max_retries:
                raise
            # Feed the error back to Claude
            messages.append({"role": "assistant", "content": response.content})
            messages.append({
                "role": "user",
                "content": [
                    {
                        "type": "tool_result",
                        "tool_use_id": tool_block.id,
                        "is_error": True,
                        "content": f"Validation failed. Fix these errors and call the tool again:\n{str(e)}"
                    }
                ]
            })

    raise RuntimeError("Retry loop exhausted without valid output")

A few things worth noting in this implementation. First, I deliberately leave the numerical constraints out of the JSON Schema sent to Claude and enforce them only in Pydantic. This is because the SDK may strip them anyway, and including them creates a false sense of security. Better to be explicit about where enforcement actually lives. Second, the error message I pass back in content is the raw Pydantic error string. It is verbose, but Claude responds better to a specific field path and constraint violation than to a generic "invalid input" message. Third, 2 retries is my default. In production I have never needed more than 1 on well-described schemas. If you are hitting 2 retries regularly, the schema description needs work, not the retry count.

Parallel Tool Calls and When to Disable Them

By default, Claude can call multiple tools in a single response when you provide more than one tool definition. This is useful for fan-out work: hitting multiple APIs in parallel, extracting several independent record types at once. The performance gain can be significant when each tool execution takes real time.

But parallel calls complicate validation. If Claude returns 3 tool calls and one of them fails Pydantic validation, you have to decide: retry the whole batch, or retry only the failed call. Retrying the whole batch is simpler but wasteful. Retrying only the failed call means managing partial state across messages, which adds complexity fast.

For structured output use cases where I care more about correctness than throughput, I set disable_parallel_tool_use: true. This forces Claude to call one tool at a time, which makes the retry loop above straightforward. For agentic loops where tools represent real side effects (writing to a database, sending an email), parallel calls are dangerous anyway and should be disabled by default.

Schema Description Quality Is the Actual Bottleneck

Everything above assumes your tool descriptions are doing real work. They are not documentation. They are prompt engineering. Claude reads the description field on both the tool and each property and uses it to decide what to extract, how to normalize, and what to do when the input is ambiguous.

Vague descriptions produce vague outputs that strict mode and Pydantic cannot fix. If your loan_purpose enum has three values and your description says "the purpose of the loan," Claude will guess based on context and get it wrong on edge cases. If your description says "classify the borrower's intent as one of: purchase (buying a new property), refinance (replacing an existing loan at a better rate), or cash_out (borrowing against existing equity)," Claude almost never misclassifies.

I treat description quality as the first thing to fix when validation errors exceed 5 percent of calls. The retry loop is a safety net, not a substitute for a well-specified schema.

If you want to see how this tool use pattern fits into a larger autonomous pipeline, the post on how I built an autonomous blog agent with Claude and Supabase shows the full agent loop, including how I handle tool results across multiple turns without losing state. And if you want to know what this kind of infrastructure actually costs to run, AI agent cost for small business has the numbers.

If you are building a system that needs this kind of validated AI output and you would rather have someone who has already shipped it design and wire it for you, reach out at Elev8 Growth Solutions. I scope and build these pipelines for small business clients.