Command Palette

Search for a command to run...

Hectal
PHASE 8Advanced ~23 min· topic 2 of 9

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. execute does 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 execute function. It runs on your server inside streamText.

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.

route.ts (the tool part)whole filets
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.

app/api/chat/route.ts (real app)whole filets
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. 1

    Remove the describe

    In the first example delete .describe("The order id, like ORD-1042") and run it again.

    The description line inside orderId disappears from the JSON Schema. A real model then has only the field name to guess from.

  2. 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-1042 line appears: now the input passed the schema, so execute ran.

Code & diagrams

What a tool looks like to the model

This is the server side again, without a screen. The Zod schema became JSON Schema, and your .describe text came along.

Preview

Press Run to see the page.

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.
Bad input and a tool that does not exist

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

Press Run to see the page.

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.

terminal
$ Console
── what you'll see ──
(server) lookupOrder runs with 1042
(server) lookupOrder runs with my last order
model sees: refundEverything -> {"type":"error-text","value":"AI_NoSuchToolError: Model tried to call unavailable tool 'refundEverything'. Available tools: lookupOrder."}
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?

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 tools object 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 as string for free.

Remember this

  1. 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. 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 the tools object.

  3. 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 a description inside the schema. Small notes next to each field are the cheapest way to get correct input.

  4. 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, execute is 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. 5

    Unknown tools. A model can ask for a tool you never defined (refundEverything). The SDK does not run anything. It reports NoSuchToolError: Model tried to call unavailable tool and 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. 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 let execute throw only for the unexpected.

  7. 7

    Server tools versus browser tools. A tool with an execute runs on the server. A tool *without* execute is only a declaration: the SDK stops and hands the call to the browser (Topic 8.5). The same tool() helper builds both.

Explain it without notes

01

What are the four parts of a tool, and which of them does the model actually see?

02

A model sends { orderId: 42 } to a tool that expects text. What happens, and why does it matter that execute does not run?

03

Why is passing the Zod schema check not enough to make a tool safe?

04

What happens when the model asks for a tool that is not defined?

Practice

01

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?

02

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 type field 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 execute did not run.

  • I can explain what happens when a model names a tool that does not exist.

  • I know a schema checks shape, and execute must still check meaning.