Skip to content
AI Product Engineer Wiki

    ↑ ↓ to move between results, Enter to open, Esc to close

    Menu

    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.

    Level: Intermediate 10 min read Updated
    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:

    Which output shape?. Choose by what consumes the result: a person, your code, or an action.
    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.

    1. Will a person read the result as text? If yes: Prose. Stream it, and keep it editable.
    2. Will your code use the result as data? If yes: Structured output with a schema.
    3. 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:

    lib/extract-actions.ts
    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 items array 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 null and 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:

    lib/order-assistant.ts
    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

    1. Pick a feature whose output your code currently parses or that users copy into forms.
    2. Define a zod schema for the data, with nullable fields for anything that may be missing.
    3. Use messages.parse() with zodOutputFormat, and add at least two business-rule checks in code.
    4. 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: orderId must 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

    Question 1 of 4: Your code needs a category and a priority for each ticket. What should the model return?

    Show answer

    B. Structured output with a schema

    Your code consumes the result as data, so constrain the response to a schema.

    Question 2 of 4: What does a JSON schema NOT guarantee?

    Show answer

    C. That the values are correct for your product, such as a real owner or a valid order

    Schemas check shape and types. Business rules and permissions must be checked in your code.

    Question 3 of 4: Why make the owner and due fields nullable?

    Show answer

    B. To give the model a way to say "not stated" instead of inventing a value

    Required fields push the model to fill them with something. Nullable fields allow honest gaps.

    Question 4 of 4: Which tools are safe to run automatically in a prototype?

    Show answer

    B. Read-only tools that cannot change data, spend money or contact people

    Start with read-only tools. Anything with side effects needs validation, limits and approval.

    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.