Pattern 3: Function Calling / Structured Output
The LLM doesn't just produce text — it calls predefined functions or returns structured data (typically JSON).
Pattern 3: Function Calling / Structured Output
In one line: Instead of producing text, the LLM picks a function to call (with arguments) or returns a JSON object that exactly matches a schema you defined.
Function calling turns the LLM from "text generator" into "router" or "field extractor." You hand it a menu of tools (getWeather, lookupOrder, sendEmail) and it returns which one to call with what arguments. Structured output is the same idea for data: you give a JSON schema and the model returns conforming JSON. Both turn the unpredictable token stream into something your regular code can act on.
The LLM doesn't just produce text — it calls predefined functions or returns structured data (typically JSON).
Use cases
- Routing. Decide which tool/page to direct the user to.
- Data extraction. Pull structured fields from unstructured text (resumes, emails, contracts).
- Action execution. AI assistants that book meetings, send emails, query databases.
- Form filling. Convert natural language to form data.
How it works
You give the LLM a list of "tools" (function signatures); it returns calls to those tools.
import { tool } from 'ai';
import { z } from 'zod';
import { generateText } from 'ai';
const result = await generateText({
model: anthropic('claude-sonnet-4-5'),
prompt: 'What is the weather in San Francisco?',
tools: {
getWeather: tool({
description: 'Get the current weather for a location',
parameters: z.object({
location: z.string().describe('City name'),
}),
execute: async ({ location }) => {
const data = await fetchWeatherAPI(location);
return { temperature: data.temp, conditions: data.summary };
},
}),
},
});
In English: You define a
getWeathertool with a Zod schema for its arguments and anexecutefunction that actually fetches the weather. When the model sees a question it can't answer from its own knowledge, it produces a structured call likegetWeather({location: "San Francisco"}); the SDK runs yourexecute, hands the result back to the model, and the model writes a natural-language reply using it.
The flow:
- User asks a question.
- LLM decides it needs the
getWeathertool. - LLM returns:
getWeather({ location: 'San Francisco' }). - Your code executes the function.
- The result is sent back to the LLM.
- LLM uses the result to answer the user.
Structured output
When you want JSON in a specific shape:
import { generateObject } from 'ai';
import { z } from 'zod';
const result = await generateObject({
model: anthropic('claude-sonnet-4-5'),
schema: z.object({
title: z.string(),
summary: z.string(),
tags: z.array(z.string()).max(5),
sentiment: z.enum(['positive', 'neutral', 'negative']),
}),
prompt: `Analyze this article:\n\n${articleText}`,
});
// result.object is fully typed!
console.log(result.object.title);
In English: Instead of getting back free-form text, you describe the shape you want (with Zod, a TypeScript schema library), and
generateObjectmakes the model return JSON that matches it — already parsed, already validated, and typed in TypeScript. You can use the result directly withoutJSON.parseor runtime checks.
The SDK enforces the schema; you get type-safe, validated output.
Critical: always validate
Even when the LLM "should" return valid output, validate anyway:
import { z } from 'zod';
const ExpectedShape = z.object({
name: z.string(),
age: z.number().int().min(0).max(150),
});
try {
const validated = ExpectedShape.parse(llmOutput);
// Safe to use
} catch (err) {
// Log, retry, or fall back
}
LLMs occasionally violate their instructions. Validation is your safety net.
A team automates support triage. They want incoming emails turned into a structured object their existing ticket system understands.
const ticketSchema = z.object({
category: z.enum(['billing', 'bug', 'feature_request', 'other']),
urgency: z.enum(['low', 'medium', 'high']),
summary: z.string().max(120),
customerSentiment: z.enum(['frustrated', 'neutral', 'positive']),
});
const result = await generateObject({
model: anthropic('claude-haiku-4-5'), // cheap is fine here
schema: ticketSchema,
prompt: `Classify this support email:\n\n${email.body}`,
});
await ticketSystem.create({
customerId: email.from,
...result.object, // strictly typed
});
What used to be a human reading every email is now a $0.0003 model call that produces a strictly-typed object. The schema validation guarantees the downstream ticket system doesn't choke on a surprise field.
Even if you "trust" the LLM, validate. LLMs occasionally:
- Omit required fields.
- Return strings where numbers were requested.
- Generate enum values that aren't in the schema.
- Add extra fields you didn't ask for.
The Zod (or Pydantic) .parse() call is your boundary between the unpredictable model output and the rest of your application — treat it like input validation on a public API endpoint.
Common mistakes
- Vague or missing tool descriptions. A tool named
getDatawith no description leaves the model guessing when to call it. Treat the description and parameter descriptions like docstrings for a junior dev — what does it do, when should it be used, what does it return? That text is the prompt for the routing decision. - Auto-executing destructive tools. Wiring
deleteUserorsendEmaildirectly intotoolsand letting the SDK run them with no confirmation step is how prompt injection turns into real damage. Require explicit user approval — or a separate non-AI policy check — for any tool with side effects. - Skipping
.parse()because "structured output is guaranteed." Even with native structured output, models occasionally drop required fields, emit out-of-range numbers, or invent enum values. The Zod/Pydantic parse at the boundary is non-negotiable; treat it like input validation on a public API. - Picking a frontier model for a classification task. Routing into 4 enum buckets doesn't need Opus 4.7. Use Haiku 4.5 or GPT-4.1-mini for bounded extraction/classification and reserve frontier models for genuinely open-ended reasoning — easy 10-30x cost cut with no measurable quality loss.
- No retry on a schema-validation failure. When
.parse()throws, dumping a 500 to the user is worse than asking the model to try again with the validation error in the prompt. Build a single-retry path that feeds the Zod error back as a corrective message.
Page checkpoint
Did function calling stick?
RequiredWhat's next
→ Continue to Pattern 4: Agentic Workflows — let the model plan and execute multi-step tasks.