Topic 8.2
Defining Tools
In one line
A tool is a name, a description, an input schema and an execute function. The description and schema are what the model sees, the schema also checks what the model sends back, and bad input or an unknown tool name becomes an error the model is told about, not a crash.
Think of it like this
A form at the post office. The form has labelled boxes ("name", "pincode") and rules ("six digits"). The clerk does not accept a form with the wrong boxes filled. A tool's input schema is that form: it tells the model what to fill in, and it lets your code reject a form filled in wrongly.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Zod
- A library for describing the shape of data in code and checking real data against it.
z.string()means text,z.object({…})means an object with those fields. - Input schema
- The Zod (or JSON Schema) description of what a tool accepts. The model sees it, and the SDK validates the model's input against it.
- JSON Schema
- A standard way to describe JSON data. The SDK converts your Zod schema to it before sending it to the model.
- Description
- A sentence for the model: what the tool does and when to use it. Not shown to users.
- Invalid tool call
- A tool call whose input did not match the schema, or whose name does not exist.
executedoes not run. - NoSuchToolError
- The error for a tool name you did not define. It lists the tools that are available.
- Server tool
- A tool with an
executefunction. It runs on your server insidestreamText.
Step by step
01The smallest tool
Everything a tool needs fits in a few lines. The helper is tool from ai. The first argument is the whole definition. Put the tool in a tools object, and the key is the name the model uses.
import { tool } from "ai";
import { z } from "zod";
const lookupOrder = tool({
description: "Look up one order by its id, for example ORD-1042. Returns the item and the status.",
inputSchema: z.object({
orderId: z.string().describe("The order id, like ORD-1042"),
}),
execute: async ({ orderId }) => {
// here you call your own database or API
return await db.orders.find(orderId);
},
});02See what the model is given
The first live example hands two tools to the SDK and prints the tool list that reaches the model. It is JSON Schema, with your .describe(...) text inside. additionalProperties: false means "no extra fields": the SDK adds it for you.
Then the model asks for both tools in one go and the console shows the inputs and the results. A tool with no input (returnPolicy) uses z.object({}), an empty form.
03Bad input and unknown tools
The second example makes the stand-in model misbehave three times in a row: a number where text is required, text in the wrong shape, then a tool that does not exist. It also prints (server) lookupOrder runs with … inside execute. That line never appears: the function never ran.
Each failure is a tool-error in that step. The model is told what was wrong, tries again, and in the end writes an honest answer asking for the order number.
04Write descriptions like instructions to a new colleague
"Gets order" is a bad description. "Look up one order by its id, for example ORD-1042. Returns the item and the status." is a good one: it says what it does, what it needs, and gives an example. When two tools could both fit a question, say in each description when *not* to use it.
05The route in a real app
In a real app this lives in app/api/chat/route.ts, and the only line that changes from the live examples is the model.
import { convertToModelMessages, streamText, stepCountIs } from "ai";
import { openai } from "@ai-sdk/openai"; // or anthropic, google, …
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: openai("your-model-name"), // your provider's current model
messages: await convertToModelMessages(messages),
tools: { lookupOrder, returnPolicy },
stopWhen: stepCountIs(4),
});
return result.toUIMessageStreamResponse();
}Try it yourself
- 1
Remove the describe
In the first example delete
.describe("The order id, like ORD-1042")and run it again.The
descriptionline insideorderIddisappears from the JSON Schema. A real model then has only the field name to guess from. - 2
Make the schema stricter
In the second example change the first bad call to
{ orderId: "ORD-1042" }(a valid id).The
(server) lookupOrder runs with ORD-1042line appears: now the input passed the schema, soexecuteran.
Code & diagrams
This is the server side again, without a screen. The Zod schema became JSON Schema, and your .describe text came along.
Preview
Console
Nothing logged yet.Expected console output
the model is given:
[
{
"name": "lookupOrder",
"description": "Look up one order by its id, for example ORD-1042. Returns the item and the status.",
"inputSchema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"orderId": {
"type": "string",
"description": "The order id, like ORD-1042"
}
},
"required": [
"orderId"
],
"additionalProperties": false
}
},
{
"name": "returnPolicy",
"description": "How many days a customer has to return an item.",
"inputSchema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {},
"additionalProperties": false
}
}
]
tool calls: lookupOrder {"orderId":"ORD-1043"} ; returnPolicy {}
tool results: lookupOrder -> {"item":"Ginger biscuits","status":"processing"} ; returnPolicy -> {"days":7}
answer: ORD-1043 is being prepared. Returns are accepted for 7 days.The (server) line from execute never prints: the function did not run once. Each failure went back to the model as a tool result, and the loop kept going.
Preview
Console
Nothing logged yet.Expected console output
model sees: refundEverything -> {"type":"error-text","value":"AI_NoSuchToolError: Model tried to call unavailable tool 'refundEverything'. Available tools: lookupOrder."}
step 1: lookupOrder failed: AI_InvalidToolInputError: Invalid input for tool lookupOrder: AI_TypeValidationError: Type validation failed: Value: {"orderId":1042}.
step 2: lookupOrder failed: AI_InvalidToolInputError: Invalid input for tool lookupOrder: AI_TypeValidationError: Type validation failed: Value: {"orderId":"my last order"}.
step 3: refundEverything failed: AI_NoSuchToolError: Model tried to call unavailable tool 'refundEverything'. Available tools: lookupOrder.
answer: I could not find that order. Could you send the order number, like ORD-1042?Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Trust the model's input
In the second example replace z.string().regex(/^ORD-[0-9]{4}$/, "must look like ORD-1042") with z.any() and press Run.
Myth vs fact
Myth
If the schema passes, the input is safe.
Fact
A schema checks shape, not truth. ORD-9999 has a valid shape and may not exist, or may belong to someone else. Check that inside execute.
Myth
A good description is optional because the schema already says what to send.
Fact
The schema says what the input looks like. The description is how the model decides *whether* to call the tool at all.
Myth
An unknown tool name might still run something.
Fact
The SDK only runs tools in the tools object of this request. An unknown name is reported as NoSuchToolError and nothing executes.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Validation errors are fed back to the model, which usually fixes its call on the next step. That costs a whole extra model call. Good descriptions and
.describe(...)notes on fields reduce how often it happens. - ▸
Prefer
z.enum(["processing", "shipped"])over free text when only a few values make sense. The model is shown the allowed values and is far less likely to invent one. - ▸
Build the
toolsobject per request (Topic 8.8): who is signed in decides which tools exist at all. A tool that is not in the object cannot be called, even by a tricked model. - ▸
The input schema is also the TypeScript type of
execute's argument, so({ orderId })is typed asstringfor free.
Remember this
- 1
The problem this solves: The model decides *by itself* when to call a tool and what to put in it. It reads your words and guesses. If your description is vague it calls the wrong tool. If it sends
{ orderId: 42 }where you wanted text, your code could crash or, worse, do the wrong thing. You need a clear description for the model and a strict check on what comes back. - 2
Four parts.
description: one or two plain sentences saying what the tool does and when to use it.inputSchema: the shape of the input, written with Zod, a library for describing and checking data (z.object({ orderId: z.string() })).execute: your async function that receives the checked input and returns a result. And the name, which is the key in thetoolsobject. - 3
What the model really receives. Not your code. The SDK converts the Zod schema into JSON Schema, a standard way of describing JSON, and sends the name, description and schema. The first live example prints exactly that. Notice that
.describe("The order id, like ORD-1042")on a field ends up as adescriptioninside the schema. Small notes next to each field are the cheapest way to get correct input. - 4
The SDK checks the input before running anything. When the model's tool call arrives, the SDK parses the JSON and validates it against your schema. Only if it is valid does it call
execute. If it is not,executeis never called. The tool call is marked invalid, the error is sent back to the model as the tool's result, and the model gets another chance on the next step. - 5
Unknown tools. A model can ask for a tool you never defined (
refundEverything). The SDK does not run anything. It reportsNoSuchToolError: Model tried to call unavailable tooland lists the tools that exist. This is also a safety property: a model can only ever use the tools you handed to *this* request. - 6
Where the check is not enough. A schema says the id is text of the right shape. It does not say the id exists, belongs to this customer, or is in a state where the action is allowed. Those checks are your job inside
execute(Topic 8.8). Return clear results for expected problems ({ error: "No order ORD-9999" }) and letexecutethrow only for the unexpected. - 7
Server tools versus browser tools. A tool with an
executeruns on the server. A tool *without*executeis only a declaration: the SDK stops and hands the call to the browser (Topic 8.5). The sametool()helper builds both.
Explain it without notes
What are the four parts of a tool, and which of them does the model actually see?
A model sends { orderId: 42 } to a tool that expects text. What happens, and why does it matter that execute does not run?
Why is passing the Zod schema check not enough to make a tool safe?
What happens when the model asks for a tool that is not defined?
Practice
Add a status field to lookupOrder that must be one of "processing", "shipped" or "cancelled" and is optional, and describe it. What does the JSON Schema look like?
Write a cancelOrder tool definition (no approval yet) whose description says clearly when it must not be used.
Trade-offs
- ↔
Strict schemas reject more bad calls and cost more retries, loose schemas let bad data through. Strict is the right default for anything that changes data.
- ↔
Many small tools are easier for a model to choose from; one big tool with a
typefield is easier to maintain. As the tool list grows, models tend to pick the right one less reliably (measure this with your own questions), so give each request only the tools it needs.
Done when you can
I can write a tool with a description, a Zod schema and an execute function.
I can explain what the model receives (JSON Schema) and what it does not (my code).
I can show a bad-input call and say why
executedid not run.I can explain what happens when a model names a tool that does not exist.
I know a schema checks shape, and
executemust still check meaning.