Lesson 3 — Anatomy of a tool
You'll build: a precise understanding of the three parts of a tool —
description,inputSchema,execute— and how the model decides to call one. Concepts:tool(), Zod input schemas, tool descriptions, safeexecute· Run:yarn dev 1
The idea
A tool is a function you hand to the model so it can do things, not just talk. In the Vercel AI SDK you define one with the tool() helper, and every tool has exactly three parts:
| Part | Who reads it | Why it matters |
|---|---|---|
description | The model | It decides when to call the tool based on this text. |
inputSchema | The model and the SDK | Tells the model what arguments to produce; the SDK validates them. |
execute | Your runtime | The actual code that runs and returns a result to the loop. |
Get these three right and the model uses your tool reliably and safely. Get them wrong — a vague description, a loose schema, an unsafe execute — and you get an agent that calls the wrong tool, with bad arguments, doing dangerous things.
Refresher — what is a schema?
A schema is a machine-readable description of a data shape ("an object with a string field called city"). We use Zod to write schemas in TypeScript. The SDK turns your Zod schema into instructions for the model and validates the model's output against it before your execute runs.
Mental model
When the agent loop runs, the model weighs each tool's description against the user's request, then emits a tool call whose arguments must satisfy the inputSchema. Only after validation passes does your execute run.
Notice the validation gate: the SDK protects your execute from malformed input. Your job is to make the schema tight enough that "valid" also means "safe".
The problem
Consider a tempting but dangerous calculator:
// ❌ NEVER do this — executes arbitrary model output as code
execute: async ({ expression }) => ({ result: eval(expression) }),The model controls expression. With eval, a prompt-injection attack (or just a confused model) could run arbitrary JavaScript on your server — process.exit(), file access, anything. Never run model output as code. The fix is to accept only a tiny, well-defined grammar and parse it yourself.
Walkthrough
A data-fetching tool
The weather tool shows the simplest shape: a clear description, a one-field schema, and an execute that returns structured data.
export const getWeather = tool({
description:
"Get the current weather for a given city. Use this whenever the user asks about weather.",
inputSchema: z.object({
city: z.string().describe("The city to get the weather for, e.g. 'Paris'"),
}),
execute: async ({ city }) => {
// Deterministic pseudo-random data so the same city gives the same result.
const conditions = ["sunny", "cloudy", "rainy", "snowy", "windy"];
const seed = [...city.toLowerCase()].reduce((a, c) => a + c.charCodeAt(0), 0);
const condition = conditions[seed % conditions.length];
const temperatureC = (seed % 30) - 2; // range: -2°C .. 27°C
return { city, condition, temperatureC, unit: "°C" };
},
});Things to notice:
- The description explicitly says when to use it ("whenever the user asks about weather"). That sentence is what the model reads to make its decision.
- The schema uses
.describe(...)on thecityfield. That hint is passed to the model so it knows exactly what to put there. - The execute returns a structured object (
{ city, condition, temperatureC, unit }). Structured results are easier for the model to use than prose. (It's mock data so the lesson runs without a weather API key.)
A safe action tool
The calculator demonstrates the security mindset: it does real work on model input without ever trusting that input blindly.
export const calculator = tool({
description:
"Evaluate a basic arithmetic expression (supports + - * / parentheses and decimals). " +
"Use this for any math instead of doing it yourself.",
inputSchema: z.object({
expression: z
.string()
.describe("A math expression, e.g. '23 * 19' or '(2 + 3) / 4'"),
}),
execute: async ({ expression }) => {
const result = safeEvaluate(expression);
return { expression, result };
},
});The execute delegates to safeEvaluate, a small recursive-descent parser that accepts only digits, decimals, whitespace, + - * /, and parentheses — nothing else. Anything outside that grammar is rejected before any math happens:
export function safeEvaluate(input: string): number {
if (!/^[\d.\s+\-*/()]+$/.test(input)) {
throw new Error("Expression contains invalid characters.");
}
let pos = 0;
const peek = () => input[pos];
const skipSpaces = () => {
while (pos < input.length && input[pos] === " ") pos++;
};
// expr := term (('+' | '-') term)*
function parseExpr(): number {
let value = parseTerm();
skipSpaces();
while (peek() === "+" || peek() === "-") {
const op = input[pos++];
const rhs = parseTerm();
value = op === "+" ? value + rhs : value - rhs;
skipSpaces();
}
return value;
}
// term := factor (('*' | '/') factor)*
function parseTerm(): number {
let value = parseFactor();
skipSpaces();
while (peek() === "*" || peek() === "/") {
const op = input[pos++];
const rhs = parseFactor();
value = op === "*" ? value * rhs : value / rhs;
skipSpaces();
}
return value;
}
// factor := number | '(' expr ')'
function parseFactor(): number {
skipSpaces();
if (peek() === "(") {
pos++; // consume '('
const value = parseExpr();
skipSpaces();
if (peek() !== ")") throw new Error("Missing closing parenthesis.");
pos++; // consume ')'
return value;
}
const start = pos;
while (pos < input.length && /[\d.]/.test(input[pos]!)) pos++;
const numStr = input.slice(start, pos);
if (numStr === "") throw new Error("Expected a number.");
return Number(numStr);
}
const result = parseExpr();
skipSpaces();
if (pos !== input.length) throw new Error("Unexpected trailing characters.");
return result;
}This is the whole point: the tool is powerful (it computes arbitrary arithmetic) but bounded (it cannot execute code). Because safeEvaluate is a plain, exported function, it's also directly unit-testable — which is exactly what we do in the testing lessons.
Bundling the tools
Finally, the tools are bundled into one object whose keys are the names the model uses:
export const tools = {
getWeather,
calculator,
};When you saw called \getWeather`` in Lessons 1–2, that name came from this object's keys.
Run it
The same first agent exercises both tools:
yarn dev 1In result.steps you'll see the model choose getWeather for the weather part of the question and calculator for the math part — proof that the descriptions successfully routed each sub-task to the right tool.
Production caveats
Treat every tool input as hostile
The model's tool arguments can be influenced by the user (and by injected text in retrieved documents — Lesson 7 & 10). Validate with a tight schema and never pass tool input to eval, shell commands, raw SQL, or file paths without sanitizing. The calculator's allow-list grammar is a model for this.
Descriptions are prompt engineering
If the model calls the wrong tool or skips a tool, improve the description first. Say what the tool does, when to use it, and (sometimes) when not to. Clear, specific descriptions beat clever code here.
Exercises
Tighten a schema. Add a constraint to the weather tool's
cityfield so empty strings are rejected (hint:z.string().min(1)). What happens if the model sends an empty city?Solution
With
.min(1), the SDK rejects an empty-stringcityduring validation —executenever runs with bad input. The model receives the validation error and can retry with a real city. This is the schema acting as a safety gate.Break the calculator safely. Call
safeEvaluate("2 + 2; process.exit()")in a scratch test. What does it do, and why is that good?Solution
It throws
"Expression contains invalid characters."because;, letters, and()used as a call are outside the allowed grammar. The malicious payload is rejected before any evaluation — demonstrating why an allow-list beatseval.Add a third tool (design only). Sketch the
descriptionandinputSchemafor aconvertCurrencytool. What fields does it need, and how would you keepexecutesafe?Solution
You'd want fields like
amount: z.number().positive(),from: z.string(),to: z.string(). Keepexecutesafe by validating currency codes against a known allow-list and using a trusted rate source — never interpolating the inputs into a query string or shell command.
Key takeaways
- A tool has three parts:
description(model decides when to call),inputSchema(model fills it, SDK validates), andexecute(your safe runtime code). - The description is prompt engineering — it drives tool selection.
- The schema is a validation gate — make it tight so "valid" implies "safe".
- Never run model output as code. Use bounded grammars/allow-lists like
safeEvaluateinstead ofeval.