Back to Knowledge
New

Structured Outputs & Tool Calling

A chatbot that writes paragraphs is a demo. An app needs the model to hand back data your code can trust, and to actually do things: look up an order, file a ticket, search your docs. That's structured outputs

Structured Outputs

Forcing a model to answer in an exact shape, usually JSON that matches a schema you define, so your code can use the result without fragile parsing. It's generally available in the Claude API via `output_config.format` (plus `"strict": true` on tools); in the AI SDK you use `Output.object({ schema })`.

"Like handing someone a form with labeled boxes instead of a blank page. You always know where the answer goes."

, tool calling

Tool Use (Function Calling)

Giving a model a list of functions it may call, each with a name, description, and input schema. The model replies with "call getWeather with city=Paris", your code runs it and sends back the result, and the model continues. This is how chatbots check databases, send emails, or browse.

"Like a manager who can't leave the office but can phone the right department and ask for exactly what they need."

, and the agent loop

Agent Loop

The core cycle behind every AI agent: the model decides on an action, calls a tool, reads the result, and repeats until the task is done or a stop condition hits. Claude Code runs this loop for you; in the AI SDK, `ToolLoopAgent` with `stopWhen: isStepCount(10)` runs it with a safety cap.

"Like a detective's routine: follow a lead, check what it turned up, decide the next lead, until the case is closed."

that ties them together. All examples use AI SDK 7.

1Three ideas, one sentence each

Structured output

You give the model a schema

Schema

The structure of your database — what tables exist, what columns they have, and how they relate to each other.

"Like the blueprint of a building. It defines the shape before you add the furniture (data)."

; it gives you back an object that matches it, already parsed and typed.

Tool calling

You describe functions; the model asks to call one with specific arguments; your code runs it and returns the result.

Agent loop

Call the model, run any tools it asked for, feed results back, repeat until it answers or hits a stop condition.

2Mental model: a form, a phone list, and a timer

Structured output is a form instead of an essay. Asking "tell me about this email" gets you prose. Handing over a form with a dropdown for category, a 1 to 5 box for urgency, and one line for summary gets you something you can put in a database.

Tools are an approved phone list for a new intern. The intern (the model) can't touch your systems. It can only say "please call lookupOrders with this email." You decide which numbers are on the list and what each call is allowed to do.

The stop condition is a kitchen timer. Without one, a confused intern can keep dialing forever, and every call costs tokens

Tokens

The units AI uses to process text. Roughly 1 token = 4 characters. You pay per token, and context windows are measured in tokens.

"Like words on a meter. The more you write (or the AI writes), the more tokens tick by."

. Always set one.

3Structured output with a zod schema

Terminal

npm install ai zod
# Only if you also call the Claude API directly:
npm install @anthropic-ai/sdk

In AI SDK 7 you use the normal generateText call and add an output. The result lands in result.output, fully typed from your schema. (If a tutorial tells you to use generateObject, it's out of date: that's been deprecated since v6.)

lib/triage.ts

import { generateText, Output } from 'ai';
import { z } from 'zod';

const ticketSchema = z.object({
  category: z.enum(['billing', 'bug', 'feature-request', 'other']),
  urgency: z.number().int().min(1).max(5).describe('5 = on fire'),
  summary: z.string().describe('One sentence, plain English'),
  customerEmail: z.string().email().nullable(),
});

const emailBody = 'Hi, I was charged twice this month...';
const { output } = await generateText({
  model: 'anthropic/claude-haiku-4.5',
  output: Output.object({ schema: ticketSchema }),
  prompt: `Triage this support email:\n\n${emailBody}`,
});

// output is fully typed: output.category is 'billing' | 'bug' | ...
if (output.urgency >= 4) {
  console.log('Page someone:', output.summary);
}

.describe() strings are sent to the model, so use them as mini-instructions. Two other shapes save you writing wrapper objects:

lib/classify.ts

import { generateText, Output } from 'ai';
import { z } from 'zod';

const review = 'Great tacos, slow delivery.';
const { output: sentiment } = await generateText({
  model: 'anthropic/claude-haiku-4.5',
  output: Output.choice({ options: ['positive', 'neutral', 'negative'] }),
  prompt: `Classify the sentiment of this review: ${review}`,
});

const { output: tags } = await generateText({
  model: 'anthropic/claude-haiku-4.5',
  output: Output.array({ element: z.object({ tag: z.string(), confidence: z.number() }), maxItems: 5 }),
  prompt: `Suggest tags for: ${review}`,
});
console.log(sentiment, tags.length);
Streaming a big object? streamText takes the same output option and exposes partialOutputStream, so you can render fields as they fill in.

4Tools and multi-step loops

A tool is three things: a description (when to use it), an inputSchema (what arguments are valid), and an execute function (what actually happens). The model only ever sees the first two.

lib/order-bot.ts

import { generateText, isStepCount, tool } from 'ai';
import { z } from 'zod';

const lookupOrders = tool({
  description: 'Look up a customer\'s recent orders by email address.',
  inputSchema: z.object({
    email: z.string().email().describe('The customer email address'),
  }),
  execute: async ({ email }) => {
    const orders = await db.orders.findByEmail(email);
    return orders.slice(0, 5).map((o) => ({ id: o.id, status: o.status }));
  },
});

const result = await generateText({
  model: 'anthropic/claude-sonnet-5',
  instructions: 'You help customers track orders. Use tools; never guess order data.',
  tools: { lookupOrders },
  stopWhen: isStepCount(5), // at most 5 model calls, then stop no matter what
  prompt: 'Where is my order? My email is sam@example.com',
});

console.log(result.text);
console.log(`Took ${result.steps.length} steps, ${result.usage.totalTokens} tokens total`);
What happens: step 1, the model asks for lookupOrders. The SDK runs execute and sends back the result. Step 2, the model writes the answer. Two steps, two model calls.
Why the stop condition: without stopWhen, generation ends after a tool call and you never get the final text. With it, you allow up to n rounds and cap cost.

In v7, result.usage is the total across all steps, which is the number you care about for billing. Per-step detail lives in result.steps.

5Reusable agents, finish tools, and human approval

Once the same model + instructions + tools show up in three routes, wrap them in a ToolLoopAgent. Two patterns worth stealing here: a finish tool (a tool with no execute that the agent calls to say "I'm done," paired with hasToolCall), and approval for anything with side effects.

lib/agents/support.ts

import { hasToolCall, isStepCount, ToolLoopAgent, tool } from 'ai';
import { z } from 'zod';

export const supportAgent = new ToolLoopAgent({
  model: 'anthropic/claude-sonnet-5',
  instructions: 'You are Taco Tracker support. Search the docs before answering.',
  tools: {
    searchDocs: tool({
      description: 'Search the help center',
      inputSchema: z.object({ query: z.string() }),
      execute: async ({ query }) => searchDocs(query),
    }),
    refundOrder: tool({
      description: 'Refund an order. Only when the customer explicitly asks.',
      inputSchema: z.object({ orderId: z.string() }),
      execute: async ({ orderId }) => refundOrder(orderId),
    }),
    finalAnswer: tool({
      description: 'Call this with your final reply to the customer.',
      inputSchema: z.object({ reply: z.string() }),
    }),
  },
  // Pause for a human before any refund runs.
  toolApproval: { refundOrder: 'user-approval' },
  // Stop when the agent calls finalAnswer, or after 10 steps as a safety net.
  stopWhen: [hasToolCall('finalAnswer'), isStepCount(10)],
});

const result = await supportAgent.generate({ prompt: 'How do I change my delivery address?' });
console.log(result.steps.length);

With toolApproval set, the refund doesn't run. The result contains a tool approval request instead, and in a chat UI you answer it from the client. This is human-in-the-loop

Human-in-the-Loop

Designing an AI workflow so a person approves, corrects, or chooses at key moments, especially before risky or irreversible actions like sending money, emailing customers, or deleting data. Claude Code's permission prompts are a built-in example.

"Like a pilot on autopilot who still has to confirm before landing."

in about four lines:

app/chat/page.tsx (excerpt)

// In your useChat component: approval requests arrive as tool parts.
const { messages, addToolApprovalResponse } = useChat({ /* transport... */ });

// When the user clicks a button next to the pending refund:
addToolApprovalResponse({ id: approvalId, approved: true });
// or: addToolApprovalResponse({ id: approvalId, approved: false, reason: 'Not eligible' });

Need the approval to wait for hours, or survive a deploy? That's a job for a durable workflow, covered in Durable Agents.

6Doing it natively with the Claude API

If you use Anthropic's own SDK, structured outputs are generally available with no beta header. The parameter is output_config.format (the older output_format is deprecated). The TypeScript SDK's messages.parse helper plus zodOutputFormat validates the reply against your zod schema for you.

lib/extract-invoice.ts

import Anthropic from '@anthropic-ai/sdk';
import { zodOutputFormat } from '@anthropic-ai/sdk/helpers/zod';
import { z } from 'zod';

const Invoice = z.object({
  vendor: z.string(),
  total: z.number(),
  currency: z.string(),
  dueDate: z.string().describe('ISO date, YYYY-MM-DD'),
});

const client = new Anthropic(); // reads ANTHROPIC_API_KEY
const invoiceText = '...';

const response = await client.messages.parse({
  model: 'claude-sonnet-5',
  max_tokens: 2000,
  messages: [{ role: 'user', content: `Extract the invoice fields:\n\n${invoiceText}` }],
  output_config: { format: zodOutputFormat(Invoice) },
});

// parsed_output is null if parsing failed, so guard it
const invoice = response.parsed_output;
if (invoice) console.log(invoice.vendor, invoice.total);

For tools, add "strict": true to the tool definition and Claude's arguments are guaranteed to match the schema. Strict schemas need required and additionalProperties: false:

Tool definition (Claude API)

{
  "name": "book_table",
  "description": "Book a restaurant table",
  "strict": true,
  "input_schema": {
    "type": "object",
    "properties": {
      "date": { "type": "string", "format": "date" },
      "partySize": { "type": "integer", "enum": [1, 2, 3, 4, 5, 6] }
    },
    "required": ["date", "partySize"],
    "additionalProperties": false
  }
}

Reference: Claude structured outputs docs

7Common traps

Trusting tool arguments

The model picks the arguments, and a crafty user can steer it. Inside execute, check the signed-in user owns that order ID. Never let the model choose whose data to read.

Vague descriptions

“gets data” is useless. Say what the tool does, when to use it, and when not to. Descriptions are prompts.

No stop condition

Tools without stopWhen end after one call. With a huge limit, a confused model burns tokens in circles. Pick a small number and raise it only with evidence.

Giant schemas

Twenty nested optional fields make the model guess. Split into two calls or simplify. Prefer .nullable() with a clear describe() over lots of optional fields.

Output + tools step budget

When you combine tools and output, producing the final object counts as a step. Leave room in isStepCount for it.

Copying v4/v5 tutorials

Old names are deprecated, removed, or behave differently now. See the rename table below.

If a tutorial saysAI SDK 7 usesWhy
generateObject / streamObjectgenerateText / streamText with output: Output.object({ schema })deprecated since v6
stepCountIs(n)isStepCount(n)v7 rename
system: '...' or a system message in messagesinstructions: '...'system messages in messages are rejected by default
needsApproval on the tooltoolApproval: { toolName: 'user-approval' } on the call or agentv7 moves approval out of the tool
onFinish + totalUsageonEnd; result.usage is already the all-steps totalv7 usage change

8Which one do I need?

Turn messy input into clean data (extract, classify, tag)

Structured output only. One call, no tools.

Answer questions that need your live data

Tools + a small isStepCount (3 to 5).

Open-ended tasks where the model plans its own steps

ToolLoopAgent with a finish tool and a step cap.

Anything that sends, buys, deletes, or refunds

Tools + toolApproval, so a human clicks yes first.

Runs longer than a request, waits on people, or must survive crashes

A durable workflow wrapping the agent.

Before you ship any of these, write a few test cases. The evals and guardrails guide shows how to check that the schema output is actually right, not just valid.

Your model can act now. Keep it safe and alive.

Tools are where prompt injection starts to hurt, and multi-step agents are where serverless timeouts start to bite.