Short version: Every tool you register is injected into the model's context on every call, so the single biggest lever is trimming each description to its load-bearing job — what the tool does and when to call it — and deleting the worked examples and tutorials. That is precisely the change behind the headline result that LangChain cut a turn's input tokens 65%: trimmed tool descriptions plus a lean base prompt, no accuracy loss.

Why tool defs are a per-call tax#

The Messages/Chat APIs are stateless. Your tools array renders into the request prefix — Anthropic orders it toolssystemmessages; OpenAI injects function definitions into the system message — and it is re-sent and re-billed on every turn. A five-turn agent run pays for its tool schemas five times. A long ReAct loop pays dozens of times.

So a bloated schema is not a one-time cost. It is a fixed tax on every step of every session, and it scales with exactly the workloads (agents, tool-heavy loops) where you least want it.

Measure it before you touch it#

Do not eyeball this. Count the real tools block with the provider's token-count endpoint — not tiktoken, which is OpenAI's tokenizer and undercounts Claude by ~15–20% while missing tool-serialization overhead on both.

Diff a request with tools against the same request without them to isolate the tools block:

import anthropic

client = anthropic.Anthropic()

def tool_tokens(tools):
    base = client.messages.count_tokens(
        model="claude-opus-5",
        messages=[{"role": "user", "content": "."}],
    ).input_tokens
    with_tools = client.messages.count_tokens(
        model="claude-opus-5",
        tools=tools,
        messages=[{"role": "user", "content": "."}],
    ).input_tokens
    return with_tools - base

print("fat: ", tool_tokens(FAT_TOOLS))
print("lean:", tool_tokens(LEAN_TOOLS))

For OpenAI, the Responses API can count input tokens for a full request including tools without local estimation — use it instead of tiktoken when your request carries a tools array. Multiply whatever delta you find by your average turns-per-session to get the real bill.

The rewrites, in priority order#

1. Cut the description to its decision-relevant sentence

The description exists so the model can choose and call the tool. It is not a manual, a changelog, or a place for You MUST ALWAYS use this tool. Keep the one-line purpose and the trigger condition; delete worked examples, fake dialogue, and step-by-step tutorials. In deepagents PR 5009 the built-in task tool went from 1,664 → 389 tokens (−77%) by dropping exactly that material while preserving "the one-line purpose and load-bearing constraints."

2. Collapse deep and optional JSON-schema params

Every nested object and optional field is tokens on every call. Prune params the tool rarely needs; you can re-add one if calls degrade.

3. Drop redundant enums and examples

If the field is named sort_order, the model does not need an enum spelling out asc/desc and a sentence describing them. Keep enums where the value set is non-obvious or safety-relevant; drop them where the name already carries the meaning.

4. Prefer one well-named tool over five near-duplicates

Five search_orders_by_id / search_orders_by_date / search_orders_by_status tools ship five schemas the model reads and disambiguates every turn. One search_customer_orders with a few optional filters is smaller and routes better. For genuinely large libraries, use tool search / deferred loading so only relevant schemas enter context.

5. Put stable defs where caching amortizes them

Because tools render at the front of the prefix, a frozen, deterministically-ordered tool list sits perfectly for prompt caching. On Anthropic, cache reads bill at ~0.1× input price (writes at 1.25× for the 5-minute TTL, paid once); OpenAI caches long prefixes automatically. The catch: adding, removing, or reordering any tool invalidates the whole prefix. So trim first, freeze the set, sort it, and cache what remains — don't let a per-request timestamp or an unsorted json.dumps sneak in ahead of it.

Before / after: one tool#

Fat — verbose prose, restated defaults, deep optional params, redundant enums:

FAT_TOOLS = [{
    "name": "search_customer_orders",
    "description": (
        "This tool searches the customer orders database. It is very "
        "important that you ALWAYS use this tool whenever the user asks "
        "anything about orders. For example, if the user says 'where is my "
        "order' you should call this tool; if the user says 'did my order "
        "ship' you should also call this tool. It returns matching orders "
        "with all of their details."
    ),
    "input_schema": {
        "type": "object",
        "properties": {
            "customer_id": {"type": "string", "description": "The unique identifier of the customer whose orders you want to search for."},
            "status": {"type": "string", "enum": ["pending", "processing", "shipped", "delivered", "cancelled", "returned", "refunded"], "description": "Optional. The status to filter by: one of pending, processing, shipped, delivered, cancelled, returned, refunded."},
            "date_from": {"type": "string", "description": "Optional ISO 8601 start date, e.g. 2026-01-01, to filter orders placed on or after this date."},
            "date_to": {"type": "string", "description": "Optional ISO 8601 end date to filter orders placed on or before this date."},
            "sort_by": {"type": "string", "enum": ["date", "total", "status"], "description": "Optional field to sort the results by."},
            "sort_order": {"type": "string", "enum": ["asc", "desc"], "description": "Optional direction to sort in, ascending or descending."},
            "include_line_items": {"type": "boolean", "description": "Optional. Whether to include full line items in each order in the response."},
        },
        "required": ["customer_id"],
    },
}]

Lean — purpose, trigger, and only the non-obvious argument meaning:

LEAN_TOOLS = [{
    "name": "search_customer_orders",
    "description": "Look up a customer's orders. Call this for any question about order status, shipping, or history. Set `status` when the user names one.",
    "input_schema": {
        "type": "object",
        "properties": {
            "customer_id": {"type": "string", "description": "Customer whose orders to return."},
            "status": {"type": "string", "enum": ["pending", "shipped", "delivered", "cancelled"]},
            "since": {"type": "string", "description": "ISO date; return only orders on or after it."},
        },
        "required": ["customer_id"],
    },
}]

Run tool_tokens() on both and read the delta. On this shape the collapse is typically well over half; LangChain measured −43% across its whole default toolset and −77% on its heaviest tool.

The guardrail: don't over-trim#

The description is the model's only signal for which tool and how to call it — and under-description is a real, common failure mode, not just a theoretical risk. Compression that erases the trigger condition, or an argument's non-obvious semantics, buys you tokens and pays in wrong or missed tool calls no downstream prompt can fix.

So treat every trim as a hypothesis and A/B it on real tasks. Keep a small eval set of representative requests; compare tool-selection accuracy and correct-argument rate before and after. What to keep, always:

Drop the rest: restated defaults, tutorials, worked examples, "CRITICAL" scaffolding, and enums the field name already implies. For the deeper craft of what belongs in a description versus a system prompt, see prompt engineering for agents: tool descriptions.

Trim to the decision-relevant sentence, measure the drop with the token endpoint, cache the frozen set, and gate every cut behind an accuracy A/B. That is the whole method — and on a real default agent it was worth about two-thirds of the input bill.