Classic painting used as the article cover
← Back to blog

TOOL DESIGN

Designing Tools Agents Can Actually Use

Tool schema design is the highest-leverage reliability work available to an agent team, and almost nobody does it deliberately. Naming, parameters, responses, errors, and the tool-count problem.

Paulina XuAug 17, 202619 min
Tool DesignReliabilityEvaluation

TL;DR

A tool's schema is the highest-leverage reliability lever an agent team has, and most teams still spend their first hour of debugging on the prompt instead. Anthropic reports that adding worked examples to a tool's schema, without changing a word of the prompt, raised tool-call accuracy from 72% to 90% on their complex-parameter benchmark.

That gap makes sense once you notice how often each one gets read. A system prompt is interpreted loosely, once, at the start of a run. A tool schema is read on every call, by every model a team ever puts behind it.

We don't think prompting stops mattering. This is an argument about where the first fix belongs, not whether prompting has a place at all.

Concretely: name tools for what they do, write descriptions for someone who's never seen the codebase, keep parameters flat, return small responses, write errors as instructions the model can act on, and gate writes at the tool rather than folding them into a mega-tool's parameters. Past thirty or so tools, do that deliberately, because the model starts guessing well before then.

Overview

When an agent behaves badly, the instinct is to fix the prompt. Add a constraint, add an example, add a "CRITICAL: you MUST" line, run it again. Sometimes it helps. Often the real defect sits one layer down: the model made a reasonable decision given a tool whose name didn't say what it did, a description that assumed knowledge of a codebase the model has never seen, and parameters that required it to guess a value it had no way to know.

A capable model paired with badly specified tools looks incompetent, and swapping in well-specified tools, without touching the prompt, is often enough to make the same model look careful. Anthropic reports that supplying worked input examples in tool definitions, a schema-side change that touches no prompt at all, moved accuracy on complex parameter handling from 72% to 90%. Their documentation is unusually blunt about where the leverage sits, calling detailed descriptions "by far the most important factor in tool performance." A 2026 paper on rewriting tool descriptions puts it more sharply: agent-side effort "increasingly plateaus due to the quality of the tool interfaces these agents consume." We think that's the right place to spend the first hour, not the prompt.

Take a revenue-operations agent at a mid-sized software company, the kind that answers questions from sales leadership by reading the CRM and a data warehouse, and can change a handful of fields on an opportunity record. A version built around four loosely specified tools and a 2,000-word system prompt explaining how to use them is the shape most teams reach for first. Splitting those into six precisely scoped tools and cutting the prompt to 300 words is the fix this post is going to argue for, and the rest of it is about why that trade tends to win.

Fix the tool before the prompt. If a model is using a tool wrongly, the schema is the cheaper and more durable place to intervene. It's read on every call, and it generalizes across every prompt and every model run behind it.

Naming

A tool name is the shortest description anyone will ever write for it, and the model reads it first. Names that describe a mechanism force the model to infer the purpose; names that describe the purpose skip that step entirely.

The agent's first version had a tool called query, wrapping a search endpoint that could hit accounts, contacts, or opportunities depending on an entity parameter. The model used it constantly and wrongly. It passed SQL sometimes, a natural-language question other times, and more than once searched accounts when it meant to search opportunities. None of that was unreasonable behavior. query is a word that means six things.

json
{ "name": "query", "description": "Query the CRM." }

Compare:

json
{ "name": "crm_search_opportunities" }
{ "name": "crm_search_accounts" }
{ "name": "crm_get_opportunity" }

The namespace prefix carries most of the weight here. It says which system is being touched, which barely matters until a second system shows up, and then crm_search_accounts sitting next to warehouse_search_accounts is an easy call while search_accounts next to lookup_customers is a coin flip. The verb separates a search from a single-record fetch, two operations with different cost profiles that a shared name papers over. And the object is plural, because a list, not a single record, is what comes back.

Namespacing isn't merely tidy. Anthropic's write-up on tool authoring reports that even the shape of the namespace is measurable: "we have found selecting between prefix- and suffix-based namespacing to have non-trivial effects on our tool-use evaluations." Service-based (asana_search) and resource-based (asana_projects_search) are both defensible. Picking one and sticking with it is what matters.

Watch for near-synonyms too. get_account, fetch_account, and lookup_account living in the same tool set will get confused with each other, because they're genuinely confusable, and no description rescues them. Names that encode internal vocabulary are a separate problem: get_sfdc_oppty_v2 names a schema migration, not the model's task. The same discipline applies inside the schema, and Anthropic's guidance is specific about it: "instead of a parameter named user, try a parameter named user_id."

Descriptions

Treat the description as the entire briefing for a reader who can see only the schema, not as documentation for a developer who already knows the implementation. Anthropic frames the test well: "think of how you would describe your tool to a new hire on your team." That means spelling out the specialized query formats, the niche terminology, and the relationships between resources a person would otherwise have to infer from your codebase, and making all of it explicit instead of assumed. The published guidance gives a floor of three or four sentences: what the tool does, when it should be used and when it should not, what each parameter means, and what the tool does not return.

Before:

json
{ "name": "crm_search_opportunities", "description": "Searches opportunities." }

After:

json
{
  "name": "crm_search_opportunities",
  "description": "Search open and closed sales opportunities by account name, owner, stage, or close-date range. Returns up to 50 summary records (id, name, account, stage, amount, close date) ordered by close date descending. Use this to find opportunities when you do not already have an opportunity ID; use crm_get_opportunity instead when you do, because it returns the full record including line items and activity history. Amounts are in USD. This tool does not return contact details or forecast categories."
}

Notice what the second version tells the model that the first doesn't: what it can search by, and what comes back and in what order. More important is the detail most descriptions skip entirely, the line that says when to reach for a different tool instead, and that's the one that does the most to cut down wrong-tool selection. It also states a unit convention the model would otherwise have to guess. "Amounts are in USD" belongs here rather than in the system prompt, because it's read exactly where it's relevant, costs nothing on turns that never touch this tool, and stays correct when someone adds a second tool with a different convention.

One consequence of descriptions carrying this much behavioral weight is that they become an instruction channel, and an unsigned, mutable one. Microsoft's incident-response team documented an attack in June 2026 that hides instructions in MCP tool metadata, noting that "the MCP blends instructions (tool descriptions) with data, so a change to a tool's metadata can redirect the agent's behavior." Review descriptions from sources you don't control with the same rigor as system prompts, and pin versions.

Parameters

Parameter design comes down to one rule: the model should never have to guess. Every parameter it can't derive from the conversation is a coin flip, and every coin flip is a bug with a probability attached.

Flat and explicit beats nested and clever. A schema mirroring your internal request object is optimized for your convenience, not the model's accuracy.

json
{
  "filter": {
    "type": "object",
    "properties": {
      "criteria": {
        "type": "array",
        "items": {"type": "object", "properties": {
          "field": {"type": "string"}, "op": {"type": "string"}, "value": {}
        }}
      }
    }
  }
}

Expressive, and nearly unusable. It requires the model to know your field names, your operator vocabulary, and your coercion rules, none of which are written anywhere it can see. The flat version encodes the same common cases and removes all three unknowns.

json
{
  "type": "object",
  "properties": {
    "account_name": {"type": "string", "description": "Exact or partial account name."},
    "owner_email":  {"type": "string", "description": "Opportunity owner's work email."},
    "stage": {
      "type": "string",
      "enum": ["prospecting", "qualification", "proposal",
               "negotiation", "closed_won", "closed_lost"],
      "description": "Restrict to one pipeline stage. Omit for all stages."
    },
    "closes_after":  {"type": "string", "description": "ISO date, e.g. 2026-09-01."},
    "closes_before": {"type": "string", "description": "ISO date, e.g. 2026-12-31."},
    "limit": {"type": "integer", "description": "Max records, 1-50. Default 20."}
  },
  "required": [],
  "additionalProperties": false
}

Enums over free strings, wherever the set is closed. The stage enum removes a whole failure class: the model can no longer pass "Proposal Sent" and get an empty result it can't diagnose. OpenAI's guidance frames the goal well: use types and structure to "make invalid states unrepresentable." A long required list is usually a sign the tool is really two tools; marking something optional that the tool can't actually work without just moves the failure downstream to a 422.

Do not ask for values the model cannot know. The worst parameters are internal identifiers with no discovery path, things like a tenant_id or a pricebook_id. If the value is a property of the calling context, inject it at the boundary instead of asking for it. A parameter the model must invent is a parameter it will invent.

Turn on strict validation where the platform offers it. With strict: true and additionalProperties: false, arguments are guaranteed to validate before your handler sees them, implemented by constraining token sampling to schema-valid outputs, which is why it's a guarantee rather than a retry. A tool needing passengers: 2 can no longer receive passengers: "two". Anthropic's implementation, unlike OpenAI's, doesn't require every property to be listed in required, so optional parameters survive.

Well specified: crm_search_opportunities

Task: Q4 EMEA pipeline

Enum stage, ISO dates

Region injected at boundary

20 records + next_cursor

Answer

Poorly specified: query(entity, q)

Task: Q4 EMEA pipeline

Guess entity value

Guess q syntax

Empty result, no reason

Retry with new guess

Figure 1 — The same task against two tools. The poorly specified one fails in the way a model cannot recover from: silently, with no signal about which guess was wrong.

Responses

Response design gets less attention than input design and causes at least as much trouble, because its failures are quiet. A tool that returns too much rarely errors outright; it just fills the context window with noise, and the agent gets worse three steps later, for reasons nobody thinks to blame on the tool.

Return only high-signal information. A CRM opportunity record may have 180 fields; the model needs six. Anthropic's guidance goes further than "trim it" and says to strip low-level technical identifiers (uuid, 256px_image_url, mime_type) in favor of semantically meaningful ones, with a striking claim attached: "merely resolving arbitrary alphanumeric UUIDs to more semantically meaningful and interpretable language (or even a 0-indexed ID scheme) significantly improves Claude's precision in retrieval tasks by reducing hallucinations." Opaque identifiers are tokens the model cannot reason about and will sometimes reconstruct incorrectly.

Paginate, and make continuing obvious. The recommended levers are pagination, range selection, filtering, and truncation, each with sensible defaults.

python
def search_opportunities(args, ctx, crm) -> dict:
    rows, cursor = crm.search(**args, page_size=min(args.get("limit", 20), 50))
    return {
        "results": [summarize(r) for r in rows],
        "returned": len(rows),
        "next_cursor": cursor,                     # null when exhausted
        "note": None if cursor is None else
                "More results exist. Pass next_cursor to continue, or narrow "
                "the filters instead if you already have enough.",
    }

That hint matters more than it looks. Given a cursor and no guidance, models paginate exhaustively, which is how one question becomes forty tool calls. Telling the caller that narrowing is an option changes the behavior.

Cap the size, and say when you truncated. Silent truncation is worse than an error, because the model reasons confidently over a partial answer without knowing it's partial. Two Anthropic products converge on the same ceiling: Claude Code restricts tool responses to 25,000 tokens by default, and on the managed-agent runtime any output over 100,000 characters (about 25,000 tokens) is written to a file in the sandbox, with the model receiving a preview plus the path. Where it earns the added complexity, a response-format enum with concise and detailed lets the model pay for the expensive version only when it needs it. The published worked example renders the same result at 206 tokens detailed versus 72 concise. Default to concise, and resist adding the knob everywhere.

Errors as Instructions

An error is a message to a reader that can change what it does next.

A stack trace tells the model something failed and nothing about what to do next, so it retries or gives up vaguely. An error written as an instruction routinely produces a clean recovery on the next step. Anthropic's documentation is direct: "instead of generic errors like failed, include what went wrong and what Claude should try next." The MCP specification builds the same distinction into the protocol, separating protocol errors (malformed requests, unknown tools), described as "issues with the request structure itself that models are less likely to be able to fix," from tool execution errors, which should "contain actionable feedback that language models can use to self-correct and retry with adjusted parameters."

Before:

crm.client.CRMAPIError: (400, '{"errorCode":"MALFORMED_QUERY","message":"unexpected token: EMEA"}')

After:

No results: 'region' is not a searchable field on this tool. Regional scope is
applied automatically from your access. To narrow by geography, filter on
account_name, or use warehouse_revenue_by_region. Retry once with corrected
arguments.

The rewritten version says what was wrong, why the model couldn't have known it, and what to do instead. It also says whether retrying is appropriate at all, which is the detail most error messages skip and the one that costs the most to skip: a model can't tell a transient failure from a permanent one, so it guesses, and it guesses "retry" far more often than it should. Anthropic notes Claude will typically retry an invalid tool call two or three times with corrections before apologizing, which is exactly the budget a good error message converts into a fix.

ClassThe model shouldExample wording
Bad argumentsFix and retry once"stage must be one of: … Retry with a valid value."
Not foundTry a different identifier, or stop"No opportunity with that ID. Search for it first."
Not permittedNever retry; proceed without it"Not permitted for this user. Do not retry this tool."
Transient upstreamDo not retry in-loop; report"The CRM is unavailable. Stop and report the outage."
Already appliedTreat as success"Already recorded (idempotent replay). Continue."
Needs a humanPark and report"Awaiting approval. Do not retry; report that it is pending."

Alongside the table: never put a raw internal exception message in a tool result. It's uninformative to the model, and a small disclosure to anyone who can influence the transcript. And use the API's own error flag (is_error: true, or isError in MCP) rather than returning an error-shaped success, so the platform and your telemetry both know what happened.

Consequential Actions

Tools that change the world need properties read-only tools don't, and the schema is the right place for them because it's the part of the contract the model reads on every call.

There's a real tension to resolve first. Anthropic's documentation recommends consolidating related operations, grouping create_pr, review_pr, and merge_pr behind one tool with an action parameter, because fewer, more capable tools reduce selection ambiguity. That's right for selection, and it creates a real problem for governance: the tool is the unit of gating.

On Anthropic's own managed-agent runtime, permission policies are configured per toolset and overridden per tool name. always_allow executes automatically; always_ask pauses for human approval. If merge_pr is just an action value inside a mega-tool, there's no name left to attach a policy to. The workable rule is to consolidate freely among operations that share a consequence class, and never across one: reads consolidate, writes don't merge into reads, and irreversible writes don't merge into reversible ones. That runtime's defaults encode the same instinct. The first-party toolset defaults to always_allow, while MCP toolsets default to always_ask, explicitly so that "new tools added to an MCP server do not execute in your application without approval."

MCP offers a vocabulary for declaring these properties: ToolAnnotations carries readOnlyHint, destructiveHint, idempotentHint, and openWorldHint. The defaults are pessimistic, destructiveHint and openWorldHint are both true unless a tool says otherwise, so an unannotated tool is assumed dangerous. Use them, but read the caveat in the same specification: clients "MUST consider tool annotations to be untrusted unless they come from trusted servers." A design that treats a server's self-declared readOnlyHint as an authorization decision has misplaced its trust boundary.

Enforcement belongs in your own schema and handler, offered here as design guidance rather than vendor doctrine. Idempotency keys on every write. Required, with a description saying how to derive them deterministically, and with the boundary recomputing the canonical key rather than trusting what arrived, so a model that regenerates it on retry can't cause a duplicate. A dry-run mode where the action is expensive to reverse works well too, and it doubles as the first step of getting a human to actually look at the change before it happens: the dry-run output is what they review. Ask for a stated reason as well, one sentence, which costs the model nothing and attaches a human-readable justification to every audit record.

json
{
  "name": "crm_update_opportunity_stage",
  "description": "Change the pipeline stage of one opportunity. This is visible to the account team and triggers forecast recalculation. Call with dry_run=true first to see the effect, then again with dry_run=false to apply. Derive idempotency_key as \"<opportunity_id>:<new_stage>\".",
  "input_schema": {
    "type": "object",
    "properties": {
      "opportunity_id": {"type": "string"},
      "new_stage": {
        "type": "string",
        "enum": ["prospecting", "qualification", "proposal",
                 "negotiation", "closed_won", "closed_lost"]
      },
      "reason":  {"type": "string", "description": "One sentence, stored in the audit record."},
      "dry_run": {"type": "boolean", "description": "If true, report the effect without applying it."},
      "idempotency_key": {"type": "string"}
    },
    "required": ["opportunity_id", "new_stage", "reason", "dry_run", "idempotency_key"],
    "additionalProperties": false
  },
  "strict": true
}

Making dry_run required rather than defaulted is deliberate. A required boolean forces the model to state intent explicitly, and explicit intent is something you can audit and gate. A defaulted one just gets omitted.

One thing not to do: implement confirmation by having the tool return "are you sure?" and waiting for the model to call it again with confirm: true. The model will simply call it again with confirm: true. Confirmation only means something when the confirming party sits outside the loop entirely: a human, or a permission check the model has no way to talk its way past. The MCP specification says the same normatively: there "SHOULD always be a human in the loop with the ability to deny tool invocations."

Consolidate within a consequence class, never across one. Selection ambiguity is a reliability problem you can fix with better descriptions. A write hidden inside a read's schema is a governance problem you cannot fix at all.

The Tool-Count Problem

Everything above assumes a handful of tools. The failure mode that scales worst is the one nobody designs for: what happens at fifty tools, or three hundred.

Two costs grow together. The first is mechanical: Anthropic measured a five-server setup (GitHub, Slack, Sentry, Grafana, Splunk) at 58 tools consuming roughly 55,000 tokens of definitions before the conversation starts, with GitHub alone accounting for about 26,000. That's a fixed tax on every request, paid whether or not any of those tools is relevant. The second is behavioral, and the vendors have converged on a remarkably narrow band for where it bites.

Anthropic states plainly that "Claude's ability to pick the right tool degrades once you exceed 30–50 available tools," and recommends adopting tool search at ten tools or 10,000 tokens of definitions. OpenAI recommends keeping fewer than 20 functions available at the start of a turn, described as a soft suggestion. Google's Gemini guidance says to keep the active set to 10–20 tools maximum. Three vendors, three independently derived numbers, all inside one order of magnitude.

The academic picture is more interesting than "more tools, worse accuracy," and getting it right changes which fix you reach for. A 2026 study introduced a chance-corrected metric for the question across registries from 20 to 3,251 tools and found that adaptive shortlisting holds coverage while presenting far fewer candidates: on BFCL, 370 tools, it reached 90.3% against the 90.8% of always showing fifty, while presenting seven on average, and its Claude Sonnet 4.6 validation showed 93.1% selection accuracy against 87.1% for a fixed five-tool list, widening to 76.8% against 60.9% on medium-difficulty queries where the right tool is present but not ranked first. A too-small fixed list is also a failure mode, and it runs the opposite direction from the one everyone worries about. On ToolBench's 3,251 tools a fixed shortlist of five wins on aggregate coverage, 64.7% against 61.9%, but finds nothing on hard queries, the ones where the correct tool ranks sixth to twentieth; searching deeper recovers 16.7% of them.

A second 2026 paper complicates that story. Examining where selection actually fails, it found the model attended to the correct tool 80% of the time against a 21% chance baseline, and the correct tool was the under-attended one in only about 10% of failures, a result the authors say "refutes the 'lost-in-the-middle' hypothesis about crowded tool lists." Reordering tools recovered at most 23% of failures; interventions at the decision step recovered 59–91%. The model is usually finding your tool and then failing to pick it. Our read of both papers is that this is a discrimination problem, not a ranking one, and discrimination gets fixed with better names and descriptions, not a shorter list.

Namespacing is the cheapest mitigation, and it compounds with tool search because one query then matches a whole related group. Splitting agents by domain is the next lever: a tool set spanning finance, HR, and engineering is probably three agents rather than one with ninety tools. Each gets a smaller candidate set and a far simpler authorization story, and where a task genuinely spans domains, a coordinator delegating to domain agents keeps every candidate set small.

Progressive disclosure. Present a small set and let the agent discover the rest on demand. Anthropic ships this as a first-class feature: tools marked for deferred loading are known to the request but kept out of context until a search surfaces them, and because schemas are appended as references rather than swapped in, the prompt cache survives, which is what makes it practical rather than merely clever. The effect is large in both dimensions: roughly 85% reduction in definition tokens (one worked example goes from about 77,000 to 8,700), and accuracy improvements on their MCP evaluations from 49% to 74% for Opus 4 and 79.5% to 88.1% for Opus 4.5. A related approach exposing tools as files an agent reads on demand illustrates the same effect with a worked example going from 150,000 tokens to 2,000. The token saving is the obvious win; the accuracy gain is what proves the tool count was hurting in the first place.

Let scope intersection do some of the work too, which is free if there's already an authorization boundary in place. The presented list should be the intersection of what the agent may do and what the current user may do, not the union of everything the platform can reach. The MCP specification explicitly permits this: the advertised tool set "MAY vary by the authorization presented on the request — for example, returning only the tools the caller's granted scopes permit." It improves accuracy and security at the same time, which is rare.

One caution about the fashionable fix. Retrieval over a tool catalogue moves the problem rather than dissolving it, and the retrieval layer is itself attackable: a 2026 paper demonstrated injected tools whose metadata is positioned to "semantically span many user queries, dominate the top-k results, and push all benign tools out of the agent's context," reporting up to 95% attack success at a 1% injection rate. If tool search stands between your agent and three hundred tools, catalogue integrity is a security control.

Description quality and tool count aren't independent, either. The description-rewriting work cited earlier reports cutting accuracy degradation by about 29% as catalogues scale past 150 candidates. Thirty well-differentiated tools beat twelve ambiguous ones. Anthropic's context-engineering guidance gives the cleanest test for whether you have a differentiation problem at all: "if a human engineer can't definitively say which tool should be used in a given situation, an AI agent can't be expected to do better."

yes

no

yes

no

no

yes

no

yes

Candidate capability

Different consequence class?
read / reversible / irreversible

Separate tools

Different required params?

Would one description
cover both clearly?

Could a human engineer
always say which to use?

One tool, optional params

separate different required params? -- yes --> separate one clear description covers both? -- no --> separate could a human engineer always say which to use? -- no --> separate -- yes --> one tool, optional params -->

Figure 2 — Splitting heuristic. Consequence class always splits, because it is the unit of gating. Everything else is a differentiation question.

Built for Evaluation

The last property separates a tool set you can improve from one you can only argue about: tools you can test.

Agent behavior is measurable only if the environment is reproducible, and tools are the environment. τ-bench is the clearest exemplar: a deterministic database plus a simulated user, evaluated by "compar[ing] the database state at the end of a conversation with the annotated goal state." That design is what let it produce the field's most sobering reliability result, frontier function-calling agents succeeded on under half its tasks and were inconsistent across repeated trials, with pass^8 under 25% in retail. A tool layer you cannot hold constant cannot produce that number at all.

Make tools deterministic given fixed backing state. A tool returning "the last 20 opportunities" returns something different every day, so a test against it fails for reasons unrelated to the agent. Either pin the backing store for evaluation runs, or make the time window an explicit parameter a test can fix. Anthropic's evaluation guidance adds the operational half: each trial should start from a clean environment, because "unnecessary shared state between runs (leftover files, cached data, resource exhaustion) can cause correlated failures."

Tools should also be mockable at the schema boundary, not inside the client. If the handler receives a client rather than constructing one, a recorded fixture can substitute for it without touching the tool definition at all.

python
HANDLERS: dict[str, tuple[dict, callable]] = {}

def tool(schema: dict):
    def register(fn):
        HANDLERS[schema["name"]] = (schema, fn)
        return fn
    return register

@tool(SEARCH_OPPORTUNITIES)
def search_opportunities(args: dict, ctx, crm) -> dict:   # crm is injected
    ...

Assert on state, not on prose. Anthropic recommends deterministic graders wherever possible and LLM graders only where necessary, with an illustration that lands the point: "a flight-booking agent might say 'Your flight has been booked' at the end of the transcript, but the outcome is whether a reservation exists in the environment's SQL database." The same guidance warns against the other tempting shortcut too, checking that the agent made a specific sequence of tool calls, calling that "too rigid" and something that produces "overly brittle tests." Grade what the agent produced, not the path it took. A sprawling, under-specified tool set produces a test suite that's flaky for reasons nobody can isolate, which in practice is no suite at all.

Conclusion

Tool design isn't documentation and it isn't plumbing. It's the interface through which a probabilistic system acts on a deterministic one, and nearly every property you want from that interaction (correct selection, correct arguments, bounded context, graceful recovery, safe writes, testability) gets decided in the schema rather than the prompt.

Name tools for purpose, with a consistent system prefix. Write descriptions for someone who can't see your code, and say when not to use the tool. Keep parameters flat, use enums, and never require a value the model can't know. Return high-signal fields, paginate explicitly, and say when you truncated. Write errors as instructions that state whether to retry. Put idempotency keys and dry-run modes on anything consequential, keep the confirming party outside the loop, and never merge a write into a read; the tool is the unit of gating. Then watch the tool count, because past roughly thirty to fifty tools, it's the axis everything else quietly degrades on. We'd bet most of the reliability problems teams blame on the model are, on close inspection, schema problems wearing a different name.

The schema is read on every call; the prompt is written once. That asymmetry is the argument. The published evidence points the same way from three directions: worked examples moving accuracy on complex parameter handling from 72% to 90%, deferred loading moving Opus 4 from 49% to 74%, and rewritten descriptions cutting scaling degradation by about 29%. All three are schema changes. None is a better prompt.

Sources