~/articles/designing-tool-schemas-llm-agents
◆◆Intermediatecovers OpenAIcovers Anthropic

Designing Tool Schemas That Models Actually Understand

How to write tool names, descriptions, and parameter schemas that prevent wrong tool selection, hallucinated arguments, and silent agent failures in production.

17 min readupdated 2026-07-02Ironclad Academy
// DEPTH
the full breakdown — requirements, capacity, evolution, trade-offs

An agent at a fintech startup started misclassifying refund requests as fraud alerts at a rate of about 3%. The model was calling flag_suspicious_transaction when it should have called initiate_refund. Both tools existed in the catalog. Both had descriptions. The descriptions were: "Flag a suspicious transaction for review" and "Initiate a refund for a transaction." At a 4% refund rate across 500k daily transactions, 3% wrong-tool calls was 600 fraudulent flags per day on legitimate refund requests — each one creating a manual review ticket. The fix was a single paragraph rewrite of two description fields. The failure was never in the code.

The description is the only signal

When a model decides which tool to call, it has access to the tool name, the description, and the parameter schema. That's it. No runtime context about your business domain, no understanding of your database relationships, no inference about what the calling agent is trying to accomplish beyond what's in the conversation so far.

This means the description field carries extraordinary weight — far more than its equivalent in any documentation system you've used before. It's not documentation a human will skim. It's an instruction the model will follow with the same literalness it applies to your system prompt. Write it accordingly.

A weak description:

"description": "Get customer information"

A description that actually works:

"description": "Retrieve profile and account status for a single customer by their numeric ID. Use this when you have a specific customer ID and need their name, email, subscription tier, or account status. Do not use this to search for customers by name or email — use customer_search for that."

The second version answers the question the model is actually asking: "Is this the right tool for what I'm trying to do right now?" The negative clause — "Do not use this to..." — is often more important than the positive one when you have multiple tools with overlapping scope.

Naming and namespacing

Tool names contribute to selection. A model reading ten tools named create, list, update, delete, search, get, fetch, find, retrieve, and query will spend meaningful probability mass resolving ambiguity on every call. A model reading calendar_create, calendar_list, calendar_update, contacts_search, contacts_get makes the selection decision mostly from the prefix.

This isn't a style preference. An empirical study on MCP tool description quality (arXiv 2602.14878, February 2026) found that poor tool naming and description quality caused measurable drops in agent task completion rates — the model wastes turns selecting the wrong tool and recovering from errors rather than making progress.

The naming pattern that works best in production:

  • {domain}_{action} for general tools: order_get, order_create, order_cancel
  • {domain}_{resource}_{action} when the domain has multiple resource types: crm_contact_search, crm_deal_create
  • Keep names to 2–3 segments maximum; beyond that, tool names start looking like module paths and models parse them inconsistently

Avoid: generic verbs as standalone names (search, get, process), names that shadow common English words (run, check, fix), and abbreviations that look unambiguous to you but are genuinely ambiguous to a model that doesn't share your team's vocabulary.

Parameter schema design

Once the model has selected the right tool, parameter generation is the next failure surface. Every ambiguous or underspecified parameter produces hallucinated values.

Types should be as specific as the contract requires. Using "type": "string" for a date parameter means the model will sometimes produce "next Tuesday", sometimes "07/02/2026", and sometimes "2026-07-02" — and your downstream code handles all three or crashes on two of them. Using "type": "string", "format": "date" with an explicit description mentioning ISO 8601 collapses this variance dramatically.

Enums turn categorical parameters from open guesses into closed choices. A status, priority, or visibility field typed as a bare string invites the model to invent a value — "urgent" when your system only knows "high", "USD dollars" when it expects "USD". Adding "enum": ["low", "medium", "high"] makes an off-list value structurally impossible under strict mode and rare without it. Two caveats. Enum values are prompt text like everything else in the schema, so make them self-describing — "awaiting_shipment" beats "STAT_03", for the same reason tool results should return labels instead of codes. And an enum with dozens of entries pays its token cost on every single request; past roughly 15–20 values, a lookup tool that returns valid options is usually cheaper than inlining them all.

Here is a schema for a calendar event creation tool that demonstrates the difference between good and poor parameter design:

# Tool definition (Anthropic SDK idiom)
tool = {
    "name": "calendar_event_create",
    "description": (
        "Create a new calendar event for a specific user. "
        "Use when you have confirmed the event details (title, time, attendees) "
        "and the user has asked you to schedule something. "
        "Do not use to update an existing event — use calendar_event_update for that. "
        "Returns the created event ID and a confirmation URL."
    ),
    "input_schema": {
        "type": "object",
        "properties": {
            "title": {
                "type": "string",
                "description": "Short event title, 5–80 characters. Do not include the date."
            },
            "start_time": {
                "type": "string",
                "format": "date-time",
                "description": "Event start time in ISO 8601 format with timezone offset, e.g. '2026-07-02T14:00:00-07:00'."
            },
            "end_time": {
                "type": "string",
                "format": "date-time",
                "description": "Event end time in ISO 8601 format. Must be after start_time."
            },
            "attendee_emails": {
                "type": "array",
                "items": {"type": "string", "format": "email"},
                "description": "List of attendee email addresses. Include the organizer only if they should receive the invite."
            },
            "calendar_id": {
                "type": "string",
                "description": "ID of the calendar to create the event in. Use the value returned by calendar_list, not a display name."
            },
            "visibility": {
                "type": "string",
                "enum": ["default", "private", "public"],
                "description": "Event visibility. Use 'default' unless the user explicitly asked for a private or public event."
            }
        },
        "required": ["title", "start_time", "end_time", "calendar_id"],
        "additionalProperties": False
    }
}

Compare this to a schema that would cause regular failures:

# The schema that causes production incidents
bad_tool = {
    "name": "create_event",
    "description": "Create a calendar event.",
    "input_schema": {
        "type": "object",
        "properties": {
            "title": {"type": "string"},
            "start": {"type": "string", "description": "Start date"},
            "end": {"type": "string", "description": "End date"},
            "attendees": {"type": "array"},
            "calendar": {"type": "string"}
        },
        "required": ["title", "start"]
    }
}

The bad version will produce: unformatted dates, attendees arrays containing names instead of emails, calendar set to a display name instead of an ID, and missing end since it's not required.

{ "type": "agent-loop", "scenario": "weather-trip", "title": "Tool selection and argument generation in the agent loop" }

Strict mode: what it enforces and what it misses

Both OpenAI and Anthropic now support strict schema enforcement, and you should use it. On OpenAI, set strict: true on the function definition. On Anthropic, this is available for custom tool definitions. Both require a specific schema shape:

  • Every property must appear in required
  • Every object must include "additionalProperties": false
  • Optional fields are expressed as nullable unions: {"type": ["string", "null"]}
# OpenAI strict-mode tool definition
from openai import OpenAI

client = OpenAI()

tools = [{
    "type": "function",
    "function": {
        "name": "order_get",
        "description": "Retrieve an order by its numeric order ID. Returns order status, line items, and shipping address. Use when the user asks about a specific order and you have the order ID.",
        "strict": True,
        "parameters": {
            "type": "object",
            "properties": {
                "order_id": {
                    "type": "integer",
                    "description": "Numeric order ID, e.g. 84729. Do not pass string representations."
                },
                "include_line_items": {
                    "type": ["boolean", "null"],
                    "description": "If true, include individual line items in the response. Defaults to false. Pass null to use default."
                }
            },
            "required": ["order_id", "include_line_items"],
            "additionalProperties": False
        }
    }
}]

What strict mode guarantees: the model cannot produce a response where order_id is a string, where extra properties appear, or where required fields are absent. The schema is compiled to a finite-state machine (or pushdown automaton in xGrammar's implementation) that masks invalid tokens at each generation step.

What strict mode does not guarantee: semantic correctness. The model can produce "order_id": 99999 for an order that doesn't exist. It can produce a start_time that is syntactically valid ISO 8601 but in the past when the user asked for a future meeting. It can produce a quantity of 0 when the user clearly wanted to add items. Schema validation and business-rule validation are different layers; you need both.

One critical incompatibility: on OpenAI, strict: true and parallel_tool_calls: true cannot both be active. The tool-use round trip article covers why. If you want parallel tool calls — which you often do for performance — you must choose between strict schema enforcement and parallel execution. Strict mode on individual tools is still possible if you disable parallel calls globally.

Tool result design

The model's next action is almost always influenced by what the previous tool returned. A tool result that's opaque or identifier-heavy forces the model to reason about values it cannot interpret.

// What you might return if you're not thinking about it
{
  "status_code": "ORD_STAT_003",
  "assignee": "USR_4829",
  "priority": 2,
  "created": 1719878400
}

// What you should return
{
  "status": "awaiting_shipment",
  "assignee_name": "Sarah Chen",
  "assignee_email": "s.chen@example.com",
  "priority": "medium",
  "created_at": "2026-06-01T12:00:00Z"
}

The second version lets the model reference assignee_name in a user-facing message without making another tool call to resolve a user ID. Anthropic's own guidance on tool design makes this explicit: return values should be semantically meaningful, not internal representations. If your tool calls an API that returns raw identifiers, translate them in the tool wrapper before returning to the model.

There's a valid exception: when a field value will be passed back to another tool call verbatim — like a cursor for pagination or a session_token — keep it opaque and say so in the description. The model's job then is to treat it as a passthrough, not to interpret it.

Token cost management

The token overhead from tool definitions is real and often underestimated. Let's work through it:

Anthropic tool-use system injection (baseline, varies by model):
  - auto tool_choice, no tools:    0 tokens
  - any tool defined:              ~290 tokens (minimum)
  - tool_choice: any or tool:      ~804 tokens

Per tool definition cost (estimated from typical schemas):
  - Simple tool (name + short desc + 2-3 params):   ~80-120 tokens
  - Medium tool (full desc + 5-8 params):            ~150-250 tokens
  - Complex tool (long desc + nested schema):        ~300-500 tokens

Practical scenario — a customer service agent with 15 tools:
  Baseline injection:                    400 tokens
  15 tools × 180 tokens avg:           2,700 tokens
  Total tool overhead per request:      3,100 tokens

At 50k requests/day × $3/M tokens:
  50,000 × 3,100 × $0.000003 = $465/day just from tool context

Three tactics to reduce this:

Trim the active tool set. If the agent is clearly in a refund flow, don't include scheduling, inventory lookup, or account creation tools. Dynamic tool loading based on conversation state is the right pattern for large tool catalogs — Anthropic's Tool Search Tool (2025) lets Claude discover and load tools on demand rather than loading all of them upfront.

Write tighter descriptions. Every word costs tokens. A 300-word description is not always better than a 60-word one. Say what needs to be said; cut what's repeated elsewhere.

Use prompt caching on the tool block. If your tool definitions are stable across requests (they usually are), the tool context block is a perfect cache target. With Anthropic's prompt caching and OpenAI's equivalent, you pay the input token cost once and cache hit cost (~10% of input cost) on subsequent requests. The prompt caching deep dive covers the mechanics.

{ "type": "prompt-cache", "scenario": "agent", "title": "Caching stable tool definitions to cut per-request cost" }

What breaks: failure modes in production

flowchart TD
    FAIL["Tool schema failure modes"] --> SEL["Wrong tool selected\n(ambiguous descriptions,\nno negative conditions)"]
    FAIL --> HALL["Hallucinated arguments\n(underspecified types,\nmissing format hints)"]
    FAIL --> MISS["No tool called\n(description doesn't match\nthe model's framing of the task)"]
    FAIL --> GHOST["Ghost tool call\n(model invents a tool\nthat doesn't exist)"]
    FAIL --> SEMVAL["Semantic validation failure\n(valid schema, wrong values —\nfake ID, bad date range)"]
    FAIL --> TOK["Token exhaustion\n(too many tools, context\nfills before task)"]

    SEL --> FIX1["Fix: negative conditions +\nnamespacing"]
    HALL --> FIX2["Fix: typed params +\nformat + examples"]
    MISS --> FIX3["Fix: rewrite desc from\nmodel's perspective"]
    GHOST --> FIX4["Fix: validate tool name\nbefore dispatch"]
    SEMVAL --> FIX5["Fix: business-rule\nvalidation layer"]
    TOK --> FIX6["Fix: dynamic tool loading,\nprompt caching"]

    style FAIL fill:#ff2e88,color:#111
    style FIX1 fill:#15803d,color:#fff
    style FIX2 fill:#15803d,color:#fff
    style FIX3 fill:#15803d,color:#fff
    style FIX4 fill:#15803d,color:#fff
    style FIX5 fill:#15803d,color:#fff
    style FIX6 fill:#15803d,color:#fff

Wrong tool selected is the most common failure and the hardest to catch without evals. It often happens when two tools have genuinely overlapping scope — customer_get and account_get that return largely similar data, or search_orders and list_orders that seem interchangeable. Resolution: make each description contain an explicit contrast with its near-siblings ("Use this, not order_list, when you have a specific order ID rather than a filter").

Hallucinated arguments appear when parameter types are underspecified. The model can't invent a format that doesn't exist in the schema — but it can fill a "type": "string" field with anything. Stricter types and format constraints narrow the hallucination surface. For any field where the value must come from a previous tool result, say so explicitly: "Pass the session_id returned by auth_start, do not generate a new one."

No tool called (the model answers in prose when it should call a tool) happens when the description doesn't match how the model internally represents the task. You wrote "Use this to retrieve order information" but the model's token-level decision was framed around "look up a purchase" — and your description never used those words. One fix: read your own description while mentally substituting the user's phrasing. If the semantic overlap is low, add synonyms.

Ghost tool calls — the model generates a tool_use block with a name that isn't in the registered list — happen with smaller models and with large tool catalogs where the model pattern-matches to something that sounds right rather than something that exists. Always validate the tool name before dispatch. Return a structured error: {"error": "unknown_tool", "message": "No tool named 'order_modify' exists. Available order tools: order_get, order_cancel, order_update."}.

Semantic validation failures are what strict mode can't save you from. You need a validation layer that applies business rules: date ranges that don't invert, quantities that are positive, foreign keys that resolve to real records, status transitions that are valid. The pattern that works: run Pydantic (Python) or Zod (TypeScript) validation on the schema shape, then run your domain validators on the semantic constraints, before executing the tool. Return errors as structured tool results that the model can reason about and retry from.

import anthropic
from pydantic import BaseModel, field_validator
from datetime import datetime

class OrderGetInput(BaseModel):
    order_id: int
    include_line_items: bool | None = None

    @field_validator('order_id')
    @classmethod
    def order_id_must_be_positive(cls, v):
        if v <= 0:
            raise ValueError(f'order_id must be positive, got {v}')
        return v

def dispatch_tool(tool_name: str, tool_input: dict) -> dict:
    """Validate and dispatch a tool call from the model."""
    validators = {
        "order_get": OrderGetInput,
        # ... other validators
    }

    if tool_name not in validators:
        return {
            "error": "unknown_tool",
            "message": f"No tool '{tool_name}'. Valid tools: {list(validators.keys())}"
        }

    try:
        validated = validators[tool_name](**tool_input)
    except Exception as e:
        # Return structured error — model can retry with corrected args
        return {"error": "validation_failed", "message": str(e)}

    # Semantic validation
    if tool_name == "order_get":
        order = db.get_order(validated.order_id)
        if not order:
            return {
                "error": "not_found",
                "message": f"No order with ID {validated.order_id}. Verify the order ID with the user."
            }
        return order.to_dict(include_line_items=validated.include_line_items)

Describing tool results back to the model

Error messages returned from tools are a distinct category of prompt engineering. The model reads them and decides whether to retry, escalate, ask the user, or give up. A vague error — "status": "error" — gives the model nothing to work with. A specific, actionable error message guides the next step:

// Unhelpful error result
{"success": false, "error": "Invalid input"}

// Error result the model can act on
{
  "success": false,
  "error_code": "date_range_invalid",
  "message": "end_date (2026-06-01) must be after start_date (2026-07-01). The dates appear reversed. Please confirm with the user.",
  "suggestion": "Swap start_date and end_date if the user intended the range July–June of the following year."
}

The suggestion field is optional but valuable in ambiguous cases. The model is more likely to take a useful corrective action when it has a concrete direction, rather than having to generate one from scratch.

The parallel calls tradeoff

When an agent needs to call multiple tools — look up a customer and check their order history simultaneously, for example — parallel tool calls cut latency significantly. The model returns multiple tool_use blocks in one response, you execute them concurrently, and return all results in one turn. The tool-use round trip article covers the mechanics in detail.

The design implication for schemas: tools that will be called in parallel must have independent inputs. A tool that requires the output of another tool as input cannot be parallelized — and if your schema design forced that dependency where it wasn't logically necessary, you've created a latency penalty. Think about data flow when designing schemas: prefer tools that take stable identifiers (order IDs, user IDs) as inputs rather than values that must come from previous calls.

sequenceDiagram
    participant M as Model
    participant A as Application
    participant T1 as customer_get
    participant T2 as order_list

    M->>A: tool_use: customer_get(id=42)
    M->>A: tool_use: order_list(customer_id=42)
    Note over A: Execute both concurrently
    A->>T1: fetch customer
    A->>T2: fetch orders
    T1-->>A: customer data
    T2-->>A: order list
    A->>M: tool_result (customer_get) + tool_result (order_list)
    M-->>A: Final response using both results

Both customer_get and order_list take customer_id as their primary parameter — an input the model can derive from context rather than needing to get from one tool before calling the other. That's the pattern to aim for.

Testing your schemas before shipping

Schema design bugs are silent until production. The test structure that catches most of them:

Adversarial selection tests. Give the model a set of similar tools and a task that should select one specific tool. Measure selection accuracy across 20–50 prompts. If accuracy is below 90%, rewrite the descriptions. This is a deterministic test you can run in CI.

Parameter generation tests. For each tool, collect 10–20 natural-language requests that should result in tool calls, and assert that the generated parameters match expectations — correct types, valid formats, no hallucinated values. Pydantic validators make the assertion layer easy to write.

Null case tests. Give the model a task that should NOT trigger a tool call (it can be answered from context or is outside the agent's scope) and verify no tool is called. Agents that call tools when they shouldn't are as broken as agents that fail to call them when they should.

The building reliable tool-using systems article covers the full testing and observability stack for tool-using agents. The structured outputs and constrained decoding article goes deeper on how strict mode actually works at the inference layer. And for understanding how the model interprets the full schema block within the context window, the context window management article is the right companion.

The judgment call: when to invest in schema quality

Schema quality investment pays off in proportion to: the number of tools, the similarity between tools, the consequences of wrong calls, and the volume of traffic.

A two-tool agent with distinct functions and low stakes doesn't need a detailed description engineering pass — the model will figure it out. A 30-tool agent making writes to customer accounts at scale, where a wrong tool call sends an unauthorized refund or deletes a record, needs every description reviewed the way you'd review a system prompt for a high-stakes deployment.

The refund-vs-fraud-flag story at the start of this article is not unusual. The fix is always the same: read your descriptions as if you're a language model with no context about your system, and ask whether the distinction is actually clear. If you can't tell from the text alone, the model can't either.

// FAQ

Frequently asked questions

Why does an LLM call the wrong tool even when the right one exists?

Tool selection is driven by the tool name, the description, and the parameter schema — with the description carrying the most weight. Ambiguous or overlapping descriptions cause the model to pick based on superficial keyword matching rather than semantic fit. Namespacing related tools with a consistent prefix (e.g., calendar_create vs calendar_list) and making each description state explicitly when NOT to use that tool both reduce wrong-tool calls significantly.

What is the difference between strict mode and non-strict mode in tool schemas?

Strict mode (OpenAI's strict: true, Anthropic's equivalent) enforces the schema at generation time via constrained decoding — the model physically cannot produce a parameter that violates the schema. Non-strict mode relies on the model following instructions. Strict mode requires all properties to be listed under required and additionalProperties: false on every object; missing either causes schema rejection at registration time.

How many tokens do tool definitions consume per request?

More than most engineers expect. On Anthropic models, the system-level injection for tool use alone is 290–804 tokens depending on the model family and tool_choice setting — before a single tool definition. Each tool definition then adds its own tokens for name, description, and schema. With 20+ tools, you can easily spend 3,000–5,000 tokens per request just on tool context, which both increases cost and shrinks the effective context window for the actual task.

Should tool results return raw IDs and codes or human-readable labels?

Human-readable labels wherever possible. A model reasoning over {"status": "CUST_ACT_3847"} is guessing at semantics; the same model reasoning over {"status": "active"} is not. Return natural-language values in tool results and reserve raw identifiers for fields the model is expected to pass back verbatim in a subsequent call — and label those fields clearly in the description.

What happens when the model hallucinates a tool that does not exist?

The model generates a tool_use block with a name that isn't in the registered tool list. Without a guard, your application crashes trying to dispatch it. Forcing a tool call from a constrained list (OpenAI's allowed_tools, Anthropic's tool_choice: any) prevents this for hosts that support it; otherwise, always validate the tool name before dispatch and return a structured error that explains the valid options.

Does strict schema mode guarantee correct outputs?

Structural correctness only. Constrained decoding ensures the output matches the JSON Schema shape — all required fields present, types match, enum values are valid. It cannot prevent semantic errors: a hallucinated order ID that validates as a string, a date range where end is before start, or a quantity field containing a plausible-but-wrong number. Semantic validation is a separate layer you must build yourself.

// RELATED

You may also like