Tools as an interface
Lesson 2 of 6
2Tools as an interface
The big pictureThe model never sees your tool's code. It sees only its name, description and parameter schema, and from those it decides when to call it and with what. The description is the interface. A tool named tool1 described as "gets data" breaks an agent worse than a weaker model, and a tool with clear error messages lets it correct itself.
What the picture shows: The request passes first through the tool's name and description, because they are all the model chooses by. That is why the delivery-date question went to tool2 with vague descriptions, and to get_order_status with clear ones.
Map of this part
The concepts in order, click any one to jump to it:
Two ways into the same idea
To the model a tool is a button labelled with a name and a short description. It does not see behind the button; it only reads the label. Label it "do" with no explanation and it will press it at the wrong time. A clear label matters more than the model's own intelligence.
A tool definition is a name, a description and a JSON Schema of parameters. The description says when to use it and when not, what it returns, and its possible errors. A few precise tools beat many overlapping ones, and errors go back to the model as clear text it can correct from.
Before You Start
🤔When an agent is worth it
You know every extra tool enlarges the context at every step, and that fewer usually suffice.
🧾JSON schema
You have seen structured output with a JSON schema that sets the fields, their types and which are required.
The Core Idea
The model never sees your tool's code, only its name, description and parameter schema. In this lesson you build those three well, then write error messages the model understands.
1The description is the interface
The model picks a tool from its words alone. A name like tool1 and a description like "gets data" tell it nothing, so it picks almost at random.
In the example, a simple "model" picks the tool that shares the most words with the request. It is a simplification, but real models lean on the description in the same way.
def pick_tool(request, tools):
"""Stand-in for the model: picks the tool whose name and description share most words with the request."""
words = set(request.lower().replace("?", "").split())
def overlap(t):
return len(words & set((t["name"].replace("_", " ") + " " + t["description"]).lower().split()))
return max(tools, key=overlap)["name"]
vague = [
{"name": "tool1", "description": "Gets data."},
{"name": "tool2", "description": "Handles records from the system."},
]
clear = [
{"name": "get_order_status", "description": "Look up the shipping status and delivery date of a customer order by its order number."},
{"name": "search_help_articles", "description": "Search help centre articles for how-to questions, like resetting a password or changing an address."},
]
requests = ["what is the delivery date of my order?", "how do I reset my password?"]
for r in requests:
print(f"{r}\n vague tools -> {pick_tool(r, vague)} clear tools -> {pick_tool(r, clear)}")
With vague tools the choice is effectively random: tool2 was picked for the delivery question only because its description contains "the". With clear names and descriptions every request went to its tool. A good description says what the tool does, when to use it, and gives an example of a request it fits.
search_docs and find_documents. The model does not know the difference because you never wrote it, so it picks at random. Merge them, or write in each description when to use it instead of the other.2The schema and its defaults
A tool definition has three parts: a name with a verb and an object, a description saying what, when and when not, and a JSON schema of its parameters. The model fills the schema, and your code validates it before running.
get_order_status = {
"name": "get_order_status",
"description": (
"Look up the shipping status and expected delivery date of one customer order. "
"Use it when the user asks where an order is or when it will arrive. "
"Do not use it for refunds; use get_refund_policy for that. "
"Example: 'where is my order 48213?' -> order_id='48213'."
),
"input_schema": {
"type": "object",
"properties": {
"order_id": {"type": "string", "description": "The 5-digit number on the receipt, digits only."},
"include_history": {"type": "boolean", "default": False,
"description": "Also return every tracking event. Rarely needed."},
},
"required": ["order_id"],
},
}
The description says what, when and when not, points to the alternative tool, and gives an example. One parameter is required, the second optional with a default and a description saying it is rarely needed. Field names differ slightly between providers (input_schema or parameters); the content is the same.
Look at the picture: the description is the widest part, because it is the most important line you write for an agent. And every optional parameter with a sensible default reduces what the model must decide, and its errors.
3Error messages the model understands
The model reads the tool's result, errors included. A raw exception leaves it flailing, and an error that says what happened and what to do next lets it correct itself.
Look at the picture: the tool returns a clear error, so the model asks the user for the right number, then calls the tool again.
Two versions of the same tool. The first crashes on any unexpected input; the second cleans the input and explains the error.
import json
ORDERS = {"48213": "shipped"}
def get_order_status_bad(order_id):
return ORDERS[order_id] # raises KeyError: the model sees a stack trace
def get_order_status(order_id, include_history=False):
order_id = str(order_id).strip().lstrip("#")
if not order_id.isdigit() or len(order_id) != 5:
return {"error": f"order_id must be 5 digits, got {order_id!r}. Ask the user for the number on their receipt."}
if order_id not in ORDERS:
return {"error": f"No order {order_id}. Check the number with the user; do not guess another one."}
return {"order_id": order_id, "status": ORDERS[order_id]}
for arg in ["#48213", "4821", "99999"]:
try:
print("bad: ", get_order_status_bad(arg))
except Exception as e:
print("bad: crashed with", type(e).__name__, e)
print("good:", json.dumps(get_order_status(arg)))
The bad version crashed even on #48213, a valid number with a # that people often type. The good version cleaned it and succeeded; when the number was short it told the model what to ask the user, and when it did not exist it said "do not guess another one". That last sentence prevents real behaviour: models trying nearby numbers until something turns up.
How It Works, Step by Step: how the model calls your tool
The model receives each tool's name, description and parameter schema, not its code.
It picks the tool whose description fits the request, and fills its parameters according to the schema.
Your code cleans and validates the arguments before running, such as stripping # from an order number.
The result returns, or an error saying what went wrong and what to do next, and the model reads it and decides.
Going deeper
And return only what the model needs. A fifty-field reply straight from the database fills the context with internal numbers and meaningless identifiers, repeated in every later step. Pick the fields that answer, and add an optional detail parameter for whoever needs more.
Worked example
Let us see it directly. The "model" here picks the tool whose words overlap most with the request; a simplification, but real models rely on the description in essentially the same way.
Try it without code
Myth vs Reality
| Myth | Reality |
|---|---|
| The model understands what a tool does from its code. | It never sees the code. The name, description and schema are the whole interface. |
| Returning every field is better, the model picks what it needs. | Everything a tool returns stays in the history and is resent at every step, adding cost and distraction. |
| Error messages are only for the developer. | The model reads the tool's result, so the error message is instructions for the next step. A clear error lets it correct itself. |
Try it yourself
Your tool returns 50 database fields on every call. What is the problem?
Everything a tool returns becomes part of the history. Return what answers the common question, and offer a parameter for whoever needs detail.
What is the best tool reply when the requested order does not exist?
The error message is instructions for the next step. The nearest existing order is the most dangerous option: it may give the user someone else's order details.
Quick review cards
Check Your Understanding
The agent calls search_orders with the whole question text instead of the order number. Where is the problem most likely?
In the tool, not the model: the name suggests free-text search, and the parameter description does not say "the number only" or give an example. Rename it to get_order_status(order_id), describe the required format with an example, and make the tool reject free text with a message explaining the right format.
You have three tools: get_customer, get_orders and get_shipments, and most questions are "where is my order?". What do you change?
Design one tool around the user's task, such as get_order_status(order_id), that returns what the common question needs directly, saving the agent two steps and two chances to go wrong.
Resources for this lesson
Grouped by type. Start with the official docs for precision, or an article or video for the explanation.
Done with this lesson?
Sign in to save your progress.