Structured outputs and tool calls in prototypes
Get reliable JSON and actions from a model in prototypes, with schema-based structured outputs, simple tool calls and validation of what the model returns.
On this page
Why this matters
Most product features don’t want prose. They want data to put in a form, a category to route a ticket, a list of action items to create as tasks, or an action to take in your system. If your prototype parses free text with regular expressions to get that data, it will break on the first unusual response.
Two API features solve this. Structured outputs make the model return JSON that matches a schema you define. Tool use lets the model ask your code to do something, with arguments in a shape you define. This article shows when to use each, with runnable examples, and why you still validate everything the model returns.
Prose, JSON or tools?
Pick the output shape from what happens next with the result:
Show text descriptionHide text description
A sequence of yes-or-no questions, asked in order. A "yes" answer stops with the outcome shown; a "no" moves to the next question.
- Will a person read the result as text? If yes: Prose. Stream it, and keep it editable.
- Will your code use the result as data? If yes: Structured output with a schema.
- Does the model need information or actions from your system to finish? If yes: Tool use, with validated inputs and approvals for risky actions.
If the answer to every question is no: Rethink the task: the result needs a clear consumer before it needs a format.
Features often combine them: a model calls a tool to look up an order, then returns a structured classification, then your interface shows a prose explanation.
Structured outputs with a schema
With structured outputs, you give the API a JSON schema and the response is constrained to match it. In TypeScript, the easiest way is to define the schema with zod (opens in a new tab) and use the SDK’s messages.parse() helper, which also gives you a typed result.
This example turns meeting notes into action items your app could create as tasks:
import Anthropic from '@anthropic-ai/sdk';
import { zodOutputFormat } from '@anthropic-ai/sdk/helpers/zod';
import { z } from 'zod';
const client = new Anthropic();
const ActionItems = z.object({
items: z.array(
z.object({
task: z.string().describe('What needs to be done, starting with a verb'),
owner: z.string().nullable().describe('Who is responsible, exactly as named in the notes, or null if unclear'),
due: z.string().nullable().describe('Due date as YYYY-MM-DD if stated, otherwise null'),
}),
),
});
export type ActionItem = z.infer<typeof ActionItems>['items'][number];
export async function extractActions(notes: string, attendees: string[]): Promise<ActionItem[] | null> {
const response = await client.messages.parse({
model: 'claude-opus-5-5',
max_tokens: 4096,
output_config: { effort: 'low', format: zodOutputFormat(ActionItems) },
system: 'Extract action items from the meeting notes. Only include tasks someone committed to. Never invent owners or dates.',
messages: [{ role: 'user', content: notes }],
});
// A truncated response could be missing items; a refusal has no output. Either way, don't create tasks.
if (response.stop_reason !== 'end_turn' || !response.parsed_output) return null;
// The schema guarantees the shape, not that the values make sense for your product. Check those in code.
return response.parsed_output.items.map((item) => ({
...item,
owner: item.owner && attendees.includes(item.owner) ? item.owner : null,
due: item.due && !Number.isNaN(Date.parse(item.due)) ? item.due : null,
}));
} Two layers of protection are at work here:
- The schema guarantees the response has the right shape: an
itemsarray of objects with the right fields and types. No more parsing free text. - Your code checks what a schema can’t: that the owner is actually one of the attendees, and that the date really is a date. An owner the model misheard becomes
nulland lands in a “needs an owner” state instead of assigning a task to a stranger.
Notice the nullable fields. Giving the model an explicit way to say “not stated” is one of the most effective ways to stop it inventing values. A schema with only required strings pushes it to fill every field with something.
A first tool call
Tools let the model reach into your system. You describe each tool with a name, a description and an input schema; the model decides when to call it and with what arguments; your code runs it and sends back the result; the model continues.
The SDK’s tool runner handles that loop for you. With betaZodTool, each tool’s input is checked against its zod schema before your function runs:
import Anthropic from '@anthropic-ai/sdk';
import { betaZodTool } from '@anthropic-ai/sdk/helpers/beta/zod';
import { z } from 'zod';
const client = new Anthropic();
// Stand-in for your real order service.
async function fetchOrder(orderId: string) {
return orderId === 'A1042' ? { orderId, status: 'shipped', carrier: 'DHL', eta: '2026-10-21' } : null;
}
// A read-only tool: safe to run without approval. Anything that changes data needs an approval step.
const getOrderStatus = betaZodTool({
name: 'get_order_status',
description: 'Look up the current status of an order by its ID, such as A1042. Read-only.',
inputSchema: z.object({ orderId: z.string().regex(/^[A-Z]\d{4}$/) }),
run: async ({ orderId }) => {
const order = await fetchOrder(orderId);
return order ? JSON.stringify(order) : `No order found with ID ${orderId}.`;
},
});
export async function answerOrderQuestion(question: string): Promise<string> {
// The runner calls tools and feeds results back until the model stops asking for them.
const final = await client.beta.messages.toolRunner({
model: 'claude-opus-5-5',
max_tokens: 4096,
output_config: { effort: 'low' },
tools: [getOrderStatus],
messages: [{ role: 'user', content: question }],
});
// The runner doesn't apply stop-reason rules for you: check how the final turn ended.
const text = final.content.flatMap((b) => (b.type === 'text' ? [b.text] : [])).join('');
if (final.stop_reason === 'end_turn') return text;
if (final.stop_reason === 'refusal') return "Sorry, I can't help with that request. Please contact support.";
return `Sorry, I couldn't finish answering (${final.stop_reason}). Please try again or contact support.`;
}
console.log(await answerOrderQuestion('Where is my order A1042?')); A few things worth noticing:
- The tool is read-only, so it’s safe to run automatically. Tools that change data, spend money or contact people need an approval step, as in human-in-the-loop and approval flows.
- The input schema is strict about format. An order ID that doesn’t match the pattern is rejected before your code runs.
- “Not found” is a normal result, returned as text the model can explain, not an exception.
- The final stop reason is still checked. The runner automates the loop, not your product decisions.
- You don’t force the tool. On current models, forcing a specific tool call isn’t supported; describe the tool well and the model calls it when it’s relevant.
Validating model output
Schemas and strict tools make malformed output rare. They don’t make wrong output impossible. Validate in layers:
| Layer | What it catches | Example |
|---|---|---|
| Schema | Wrong shape or types | due must be a string or null |
| Format rules | Values in the wrong format | Order IDs match A1234; dates parse |
| Business rules | Values that are well-formed but wrong | Owner must be an attendee; refund under the limit |
| Permissions | Actions the user isn’t allowed to take | Can’t look up another customer’s order |
| Human review | Judgments code can’t make | Approving a refund, sending an email |
The rule of thumb: treat model output like user input from the internet. Well-meaning, usually fine, and never trusted without checks, especially when it can trigger actions. Guardrails and prompt injection go deeper on why.
Common mistakes
- Parsing prose with regular expressions. Use structured outputs when your code needs data.
- Trusting the schema to check values. A schema guarantees shape, not truth. Check formats, business rules and permissions in code.
- No way to say “unknown”. Make fields nullable where information may be missing, or the model will invent values.
- Write tools without approvals. Start with read-only tools; put anything that changes data behind validation and approval.
- Ignoring the final stop reason. Even with the tool runner, check how the last turn ended before showing or acting on it.
Try it
Exercise
About 45 min
Convert a prose feature to structured output
- Pick a feature whose output your code currently parses or that users copy into forms.
- Define a zod schema for the data, with nullable fields for anything that may be missing.
- Use
messages.parse()withzodOutputFormat, and add at least two business-rule checks in code. - Run it on ten real inputs and count how many values your checks corrected or rejected.
Show a hint
Look at what happens downstream: which values would cause real problems if wrong? Those are the ones that need business-rule checks, not just types.
Show a worked solution
Example: extracting order details from support emails.
- Schema:
orderId(string or null),issueType(enum: damaged, late, wrong item, billing, other),refundRequested(boolean). - Checks:
orderIdmust match the order format and exist for this customer;issueType“billing” routes to finance only if an invoice is mentioned. - Result on ten emails: two order IDs were rejected (one belonged to a different customer, one was a misread number). Both went to a “needs review” queue instead of being acted on.
Check your understanding
Key takeaways
- Choose the output shape by its consumer: prose for people, structured output for code, tools for system access.
- Use schemas with nullable fields, and validate formats, business rules and permissions in code.
- Start with read-only tools; add write tools only with validation, limits and approvals.
- Treat model output like untrusted input, and always check the final stop reason.
Progress is saved in this browser and lights up the roadmap.
Related articles
- Human-in-the-loop and approval flows Design approval flows for AI actions, deciding which actions need a human, how to present a proposed action clearly, and how to keep approvals fast. Level: Intermediate
- Guardrails for inputs, outputs and actions Add guardrails around AI features with input checks, output validation, action limits and refusal handling, and keep them from blocking real users. Level: Expert
- Prompt-first prototyping Prototype an AI feature with nothing but prompts and real inputs before writing app code, and learn whether the core idea works in an afternoon. Level: Intermediate