A chain has a bill that a team can compute before the request arrives, because a developer fixed the number of calls in advance. Tools remove that guarantee and buy something substantial with it. A model given tools can look up a figure it was never told, act on a system it has no access to by itself, and choose which of those to do from what it reads in the moment.
The mechanism arrived as a product feature in 2023, when the main providers added a structured way for a model to request a call and for an application to return the result. Before that a team parsed an action out of free text with a regular expression and hoped the format held. What changed is the reliability of the channel and never the underlying idea, which is that a model emits a request and a program decides whether to honour it.
The sections below set out the three part contract a tool presents, show a weak tool definition beside a strong one, take the argument between many narrow tools and few broad ones, and give the guard that runs before any call executes. The last two sections cover what a tool hands back, since a result occupies the window for the rest of the conversation.
The contract a tool presents to the model
A tool reaches the model as three things and nothing else.
- A name. A short identifier the model emits when it wants the tool. The
name is read as words, so
find_invoicescarries meaning andget_datacarries none. - A description. Free text explaining what the tool does, when it applies and what it returns. This is the largest part of the contract and the part most teams write last.
- A parameter schema. A JSON Schema for the arguments, which doubles as documentation for the model and as a validator for the application.
All three are serialised into the context window on every request, alongside the system prompt and everything else. The model has no other source of knowledge about the tool. It has never seen the code, the database, the rate limits or the reason the tool was added.
What comes back from the model is a structured request naming the tool and carrying an arguments object. Nothing has executed at that point. The application receives the request, decides whether to run it, runs it, and returns a result as another message in the conversation. Every step of that sequence belongs to the application, which is the property the whole of the security argument rests on.
The description read as a prompt
The definition below is the one that appears in most first drafts.
{
"name": "get_data",
"description": "Gets data.",
"parameters": {
"type": "object",
"properties": {
"query": { "type": "string" }
}
}
}
A model holding that definition has to guess what data means, when the tool applies, what it returns and what a query looks like. It guesses fluently, and the calls arrive with arguments that look right and mean nothing.
{
"name": "find_invoices",
"description": "List the invoices on one account within a date range. Use this to answer any question about what a customer was charged and when. Returns at most 50 invoices, newest first. It cannot issue a refund, change a plan or read a different account. If the account has not been identified yet, call identify_account first and use the id it returns.",
"parameters": {
"type": "object",
"additionalProperties": false,
"required": ["account_id", "from_date", "to_date"],
"properties": {
"account_id": {
"type": "string",
"pattern": "^acct_[0-9a-f]{16}$",
"description": "The id returned by identify_account. Never construct one."
},
"from_date": {
"type": "string",
"format": "date",
"description": "Inclusive. The earliest supported value is 2019-01-01."
},
"to_date": {
"type": "string",
"format": "date",
"description": "Inclusive, and never earlier than from_date. The range may span at most 365 days."
},
"status": {
"type": "string",
"enum": ["paid", "open", "void", "uncollectible"],
"description": "Omit this to include every status."
}
}
}
}
Five things the second description does are worth copying into every tool a team writes.
- It states the question the tool answers. A model matching a customer's question against a list of tools is doing a retrieval problem, and the description is the document it retrieves against.
- It states the limit on what comes back. Fifty invoices, newest first. A model that knows the cap plans around it and stops asking for five years of history in one call.
- It states what the tool cannot do. Three sentences of that kind stop the model reaching for this tool when it needs a different one, which is the most common wrong call in a large tool set.
- It names the tool that has to run first. Ordering between tools lives nowhere else, and a model with no ordering information invents an account id so it can proceed.
- It forbids the fabrication explicitly. The line telling the model never to construct an account id addresses the single most common failure in tool use, which is a well formed identifier for a record that does not exist.
The pattern on the account id closes the same hole from the other side. An invented identifier rarely matches a sixteen character hexadecimal string behind a fixed prefix, so the validator rejects it before any query runs.
Many narrow tools set against few broad ones
Every tool definition occupies window space on every request, used or not. Forty tools averaging 120 tokens each is 4,800 tokens of overhead per call, paid on every turn of a conversation, which at the prices of the previous page runs to real money at volume. That is the argument for fewer tools, and taken on its own it points the wrong way.
The counterweight is where the decision ends up. A narrow tool puts the
decision in a schema, so find_invoices with an account pattern, a date format
and a status enum can be checked by a validator before execution and audited
afterwards by reading the arguments. A broad tool moves the decision into a
string. A run_sql tool taking a query is every operation its credential
allows, and no amount of argument validation can separate a reasonable query
from one assembled out of text that arrived in a customer's message.
The workable position is a middle one. Tools stay narrow enough that their schemas carry the constraints. The set exposed on any given request is then reduced to the ones the task could plausibly need, using the classification the chain already produces. A billing question exposes six tools out of forty, so the window cost falls and the model chooses among a shorter list.
Two tools are worth keeping out of a set almost regardless. A shell tool and a general database tool are each a single entry in a permissions review and every action they can perform in practice, which is why a tool audit that counts rows undercounts the risk.
Validating arguments before anything executes
The application stands between the request and the execution, and everything it should check fits on one screen.
def execute(call: ToolCall, ctx: Session) -> ToolResult:
tool = REGISTRY.get(call.name)
if tool is None:
# The model named a tool that does not exist in this set.
metrics.increment("tool.unknown", name=call.name)
return ToolResult.error(
code="unknown_tool",
message=f"There is no tool named {call.name}. "
f"Available tools: {', '.join(REGISTRY.names())}",
)
try:
args = tool.schema.parse(call.arguments) # types, enums, patterns
except ValidationError as err:
metrics.increment("tool.bad_arguments", name=call.name)
return ToolResult.error(code="bad_arguments", message=str(err))
# Authorisation is computed from the session. An account id inside the
# arguments is a request from the model and never a grant of access.
if not ctx.may(tool.permission, resource=args.account_id):
audit.log(ctx, call, decision="denied")
return ToolResult.error(
code="not_permitted",
message="This session cannot read that account.",
)
if tool.is_consequential and not ctx.approved(call):
return ToolResult.pending_approval(describe_for_human(call))
audit.log(ctx, call, decision="allowed")
return tool.run(args, ctx)
One line in that function carries more weight than the rest together. The authorisation check reads the session and never the arguments, so a model persuaded by something it read to ask for a different account receives a denial. Every design that passes an identifier from the model straight into a query has moved authorisation into text the model controls.
The unknown tool branch matters for a second reason. Returning the list of real
tool names turns a fabricated call into a correction the model can act on, and
counting the occurrences turns it into a measurement. A rising tool.unknown
rate usually means the tool set has grown past what the descriptions can keep
distinct.
The shape of a tool result
A tool result is a message in the conversation, so every token in it is paid for on every subsequent turn. A result that dumps the underlying record is the most expensive mistake available in an agent.
The raw result, about 4,100 tokens
[{"id":"in_9f21c04b8e17","object":"invoice","account_country":"GB",
"account_name":"Northwind Ltd","amount_due":8240,"amount_paid":8240,
"application_fee_amount":null,"attempt_count":1,"attempted":true,
... thirty eight fields, fifty rows, almost none of them ever read
The shaped result, about 180 tokens
{"count": 3,
"truncated": false,
"invoices": [
{"id": "in_9f21", "date": "2026-08-01", "total_minor": 8240, "status": "paid"},
{"id": "in_8c04", "date": "2026-07-01", "total_minor": 8240, "status": "paid"},
{"id": "in_7b55", "date": "2026-06-01", "total_minor": 4120, "status": "void"}]}
The shaped version carries a truncated flag, which is the field teams forget.
A model receiving three rows with no indication that fifty were cut will tell
the customer they have three invoices.
Errors that tell the model what to do next
A tool that fails is talking to a model, and the model will act on whatever it receives. A stack trace says nothing actionable, costs several hundred tokens and puts internal paths into a window a customer may eventually read.
Traceback (most recent call last):
File "/srv/billing/invoices.py", line 212, in find
raise QueryTimeout(cursor.stats())
billing.errors.QueryTimeout: statement 4f91 exceeded 30000 ms
{
"error": "date_range_too_wide",
"message": "from_date and to_date span 1,826 days. The maximum is 365.",
"retryable": true,
"next_step": "Call find_invoices again with to_date no more than 365 days after from_date."
}
The second version gives the model the fault, the limit it broke, whether another attempt is worth making and the exact correction. A model holding that can fix its own call in one turn. A model holding the stack trace usually tries the same call again, which is one of the ways a loop stops ending.
A name, a description, a schema and a guard describe one call. A model holding several of these and a goal it has been asked to reach will call one, read the result and decide what to call next, and at that point the application is no longer running a sequence somebody wrote. It is running something that needs a name of its own.