·8 min read·AlgoMindset Team

Tool Schemas Models Actually Call: The Description Is the Prompt

Your tool definitions are in the context window

Here is the reframe that fixes most tool-use problems. Tool schemas are not configuration that sits beside the prompt — they are serialised into the context window on every single turn and read by the model exactly like prompt text. A vague description is a vague instruction. An ambiguous parameter name is an ambiguous instruction. You are writing prompt copy inside a JSON object.

Once you accept that, the debugging method changes. When an agent calls the wrong tool, the first question is not 'how do I improve the reasoning' but 'if I had only these descriptions and nothing else, would I have known which one to use?' Usually the honest answer is no.

Name for the job, not for the endpoint

Tool names leak your internal architecture constantly, because engineers name them after the thing they wrap. get_data, call_api, fetch_v2, do_lookup. The model has no idea what any of those do relative to each other.

Name the tool after the user-visible job it performs, in verb_noun form, and make the nouns distinct across your whole tool set. If two tools share a noun, a reader — human or model — has to consult the descriptions to tell them apart, and that is exactly the moment selection errors creep in.

json
// Before — three tools that all sound like the same thing
{ "name": "get_user" }          // fetches the account record
{ "name": "get_user_data" }     // fetches usage metrics
{ "name": "lookup_user" }       // searches by email

// After — distinct jobs, distinct nouns
{ "name": "get_account_profile" }
{ "name": "get_account_usage" }
{ "name": "search_accounts_by_email" }

Write the description for someone deciding, not someone calling

The model reads a description to answer one question: is this the tool for the situation in front of me? So the description should be about applicability first and mechanics second. A good shape is three sentences — what it does, when to use it, and when not to.

That third sentence is the one almost everybody omits and the one that buys the most accuracy. Negative guidance disambiguates in a way positive description cannot, because it draws the boundary against the neighbouring tool.

json
{
  "name": "search_accounts_by_email",
  "description":
    "Find accounts whose email matches a full or partial address. "
    "Use when the user identifies someone by email but you do not have "
    "an account ID yet. Do not use to fetch details for an ID you already "
    "have \u2014 call get_account_profile instead.",
  "input_schema": {
    "type": "object",
    "properties": {
      "email": {
        "type": "string",
        "description": "Full or partial email, e.g. 'ana@' or 'ana@acme.com'."
      },
      "limit": {
        "type": "integer",
        "description": "Max results to return. Default 10, maximum 50.",
        "default": 10
      }
    },
    "required": ["email"]
  }
}

Parameters: describe the value, not the type

The type field already says it is a string. The description's job is to constrain the shape of the value — format, units, allowed range, and above all an example. Models are dramatically more reliable at producing a well-formed argument when a concrete example sits right next to the parameter.

Three rules cover most of it. Give every parameter an example unless it is genuinely free text. State units explicitly — days versus hours, cents versus dollars, and every timestamp gets a named format. And use enums for anything with a closed set of values, because an enum is enforced by the API while a description saying 'one of active, churned, trial' is merely a suggestion.

The mismatch to watch for is a parameter marked required that also carries a default. It reads as contradictory, and models will sometimes omit it on the assumption the default applies. Pick one.

json
"properties": {
  "status": {
    "type": "string",
    "enum": ["active", "churned", "trial"],
    "description": "Account lifecycle state to filter on."
  },
  "since": {
    "type": "string",
    "description": "Inclusive start date, ISO-8601 date only, e.g. '2026-08-01'."
  },
  "window_days": {
    "type": "integer",
    "description": "Look-back window in DAYS (not hours). e.g. 30 for last month."
  }
}

Fewer tools, better chosen

Selection accuracy degrades as the tool count rises, and it degrades fastest when the tools overlap. An agent with fifty near-identical CRUD wrappers is worse than one with eight well-drawn capabilities, even though the fifty technically expose more surface.

Two consolidation moves help. First, collapse variants into one tool with a parameter: search_by_email, search_by_name and search_by_phone become search_accounts with a field enum. Second, raise the level of abstraction — instead of exposing get_order, get_customer and get_shipment separately so the model can join them itself, expose get_order_summary that does the join server-side and returns one object. You spend a little flexibility and buy a lot of reliability.

If a single agent genuinely needs a large tool set, that is usually the signal to split it into several agents with focused tool sets, or to add a retrieval step that selects a relevant subset of tools per turn. Both are real patterns. Cramming everything into one prompt and hoping is not.

Design the return value too

The schema gets all the attention, but the return value goes into the context window as well and it is usually much bigger. Two failure modes dominate. The first is dumping a raw API response with forty fields the model does not need, which crowds the window and buries the three that matter. The second is returning something enormous — an unpaginated list, a full document — which can blow the window in a single call.

Return a shaped, bounded object. Include a count when you truncate, so the model knows there was more and can narrow its query rather than silently reasoning over a partial list. That one field prevents a whole class of confidently-wrong answers.

python
def search_accounts(email: str, limit: int = 10) -> dict:
    rows = db.search(email, limit=min(limit, 50))
    total = db.count(email)
    return {
        "ok": True,
        "returned": len(rows),
        "total_matches": total,           # model can see it is truncated
        "truncated": total > len(rows),
        "accounts": [
            {"id": r.id, "name": r.name, "email": r.email, "status": r.status}
            for r in rows                 # four fields, not forty
        ],
    }

How to actually test this

Tool selection is measurable, and measuring it is much cheaper than debating it. Build a set of thirty to fifty user inputs paired with the tool you expect to be called first. Run them, and record only which tool the model reached for. You are not evaluating the final answer here — just the routing decision.

The confusion matrix that falls out points straight at the fix. If get_account_usage keeps getting picked when get_account_profile was correct, those two descriptions do not draw a clear enough line, and you add the negative clause. If a tool is never selected, either the description does not match how users phrase the need, or the tool should not exist.

Re-run it whenever you add a tool. A new tool changes selection for the existing ones — this is the part teams forget, and it is why an agent that worked fine in March starts mis-routing in June. The tool set is a system, not a list.