Free preview lesson ยท From the full paid path

Structured Outputs and Tool Calling

28 min read

Solutions sit behind the flaps. Read the pattern, think, then open one step. If you already tried the problem, open everything.

The GSTIN Field That Was Sometimes A Sentence

A merchant onboarding team at a payments startup in Pune built a flow where a shopkeeper uploads a photo of their GST certificate, an OCR step reads the text, and a model extracts business name, GSTIN, PAN and bank account number into a form. Ninety five percent of the time it returned clean JSON. The team shipped it and moved to the next feature.

The other five percent looked like this.

{
  "businessName": "Sharma General Store",
  "gstin": "The GSTIN is 27AAAPL1234C1Z5, which follows the standard format.",
  "pan": "AAAPL1234C",
  "bankAccount": "123456789012"
}

Valid JSON. Every key present. The gstin field held a full sentence instead of a bare value. Their downstream code ran a regex check on that field expecting exactly 15 characters, and it failed silently, dropping the merchant into a manual review queue with no explanation. Over three weeks, around 400 genuine merchants sat stuck in that queue because a field that was supposed to hold data sometimes held a paragraph.

Nobody had written code to check for this, because nobody had asked the question that matters here. Getting valid JSON back is not the same as getting the JSON you designed for.

Structured output (getting a model's response to conform to a schema you define, instead of free running prose) is the fix, and it is deeper than adding the words "respond in JSON" to your prompt. By the end of this lesson you will know why that phrase alone does not work, what actually enforces the schema, and how to design a client interface for it that survives a change of model provider.

If you came here from the earlier foundation lessons, you already know that retries burn tokens, that partial wrong fields can look schema-valid, and that prompts are contracts in git. This lesson is where those ideas become a ModelClient you can implement in Java, .NET, Node, or Python.

Why Should You Care?

  1. Every real integration needs code to read the answer, not a person. A chatbot that produces nice prose is a demo. A system that extracts a GSTIN, decides a refund amount, or picks which tool to call needs output your code can parse without guessing. This is where an AI feature becomes a system instead of a toy.
  2. It is the single most asked practical question in AI engineering interviews right now. Not "what is a transformer". The question is closer to "the model sometimes returns malformed JSON in production, walk me through your fix." A weak answer says "add a retry." A strong answer explains schema enforcement, validation, and a bounded retry with a fail closed path.
  3. Everything in Phase 3 depends on this working. An agent is a loop that reads a structured decision from the model and acts on it. If you cannot trust the shape of that decision, you cannot safely build an agent on top of it. This lesson is the floor the rest of the path stands on.

๐ŸŸข The Simple Version (Start Here If You're New)

A Form, Not A Letter

If you ask someone to describe their address in a letter, you get a paragraph. Readable, but your computer cannot pull the pin code out of it reliably, because the person is free to phrase it however they like.

If you hand them a form with labelled boxes, city, state, pin code, you get data. The same information, but now it is something a machine can process without guessing.

In plain words: asking a model for structured output means giving it the form instead of asking for the letter. You describe the exact shape you want, field names and types, and the model fills in that shape rather than writing you a paragraph.

Why This Is Different From "Please Reply In JSON"

Typing "respond only in valid JSON" into your prompt feels like the same thing. It is not, and the difference is the whole lesson.

A plain instruction is a request the model tries to honour, the same way it tries to honour every other instruction in your prompt. It can still add a sentence before the JSON. It can still wrap the JSON in a markdown code fence. It can still put a full sentence inside a field that was supposed to hold a 15 character code, exactly like the GSTIN example above.

In plain words: structured output done properly is not a polite request. Most providers today offer a mode where you hand over an actual schema, and the model's response is constrained to match it at generation time, not checked afterwards. The model is not being asked to behave. It is being limited to only the shapes that are valid.

Tool Calling Is The Same Idea, Aimed At Actions

Tool Calling: Propose, Then You Decide Modelproposes tool call Your ToolExecutorauth + validate + run Result backto next model step The model never "calls" your API by itself.Your code reads the structured proposal and chooses. That boundary is what keeps a support bot from blocking a card because it misread a sentence.

Scroll sideways to read, or tap to zoom

Model proposes a tool call; your code authorises and executes
100%
Tool Calling: Propose, Then You Decide Modelproposes tool call Your ToolExecutorauth + validate + run Result backto next model step The model never "calls" your API by itself.Your code reads the structured proposal and chooses. That boundary is what keeps a support bot from blocking a card because it misread a sentence.

Tool calling (also called function calling) is structured output where the shape describes an action instead of a data record. You describe a tool, for example blockCard(cardLastFour: string), and instead of writing prose, the model can respond with a structured request to call that tool with specific arguments.

Here is the part beginners get wrong. The model never calls the tool. It produces a structured payload that says it wants to. Your code reads that payload and decides whether to actually run blockCard. The model proposes. Your code disposes. That boundary is what keeps a support bot from actually blocking a customer's card because it misread a sentence.

Both ideas, structured data and tool calls, are solved by the same mechanism, and that mechanism is what the rest of this lesson explains.

๐ŸŸก Going Deeper

The Three Ways Teams Try To Get JSON, And Why Only One Works Reliably

Prompt-Only JSON Versus Constrained Decode Prompt: "reply in JSON" May add proseMay wrap in code fencesField may hold a sentenceParse retries cost tokens Schema-constrained mode Provider limits tokensto valid schema shapesShape failures dropSemantics still need checks Schema-valid is not correct. GSTIN format can still be invented. Shape first, then business validation.

Scroll sideways to read, or tap to zoom

Prompt-only JSON versus schema-constrained decoding
100%
Prompt-Only JSON Versus Constrained Decode Prompt: "reply in JSON" May add proseMay wrap in code fencesField may hold a sentenceParse retries cost tokens Schema-constrained mode Provider limits tokensto valid schema shapesShape failures dropSemantics still need checks Schema-valid is not correct. GSTIN format can still be invented. Shape first, then business validation.

Prompting alone. "Respond only with JSON matching this shape." Cheapest to try, least reliable. The model can still add commentary, wrap output in code fences, or drift on an edge case it was not trained to handle. This is what the Pune team was doing when the GSTIN field turned into a sentence.

Parse and retry. Ask normally, try to parse the response, and if parsing fails, ask again with the error included. Better than nothing, but every retry is a paid model call, and a badly designed schema can make every attempt fail the same way, which we come back to in the war story below.

Constrained decoding through a schema or tool definition. You give the provider an actual JSON schema. The provider restricts token generation so that only valid completions of that schema are possible. This is the real fix, and it is what "structured output" and "tool calling" mean in a modern provider API.

The third approach does not eliminate every failure mode. A field can still be schema valid and semantically wrong, which is the next section. But it eliminates the entire class of failure the GSTIN bug fell into, because the model is no longer free to put a sentence where a string field was declared.

Why Schema Valid Is Not The Same As Correct

This is the trap that catches teams who think structured output solved everything.

{
  "gstin": "27AAAAA0000A1Z5",
  "pan": "AAAAA0000A",
  "bankAccount": "000000000000"
}

Every field here is the right type, the right length, the right format. It could still be entirely invented. Schema constraints check shape. They say nothing about whether the GSTIN actually belongs to this merchant, or exists at all.

Shape validation happens for free once you use a schema. Correctness needs a second, separate check: a GSTIN checksum, a lookup against a government registry, a human review for anything that touches money. This is the same "model drafts, code verifies" pattern from Lesson 1, applied specifically to the fields inside a structured response rather than to the response as a whole.

The Interface Worth Building Once

ModelClient: One Contract, Many Providers generateStructured(prompt, schema) โ†’ Ok(value) | Failed(error, raw, attempts) JavaSpring AI .NETExtensions.AI NodeVercel AI SDK PythonLangChain When a provider API churns, only the adapter changes. Callers keep handling Ok and Failed explicitly.

Scroll sideways to read, or tap to zoom

ModelClient contract: schema in, Ok or Failed out across four stacks
100%
ModelClient: One Contract, Many Providers generateStructured(prompt, schema) โ†’ Ok(value) | Failed(error, raw, attempts) JavaSpring AI .NETExtensions.AI NodeVercel AI SDK PythonLangChain When a provider API churns, only the adapter changes. Callers keep handling Ok and Failed explicitly.

Every provider names this feature differently, and the exact method calls will keep changing. What should not change is the shape of your own abstraction. Define one interface, in whichever language you use, and build every provider integration behind it.

interface ModelClient {
  // Ask the model to produce a value matching the given schema.
  // Implementations should prefer the provider's native structured output
  // or tool calling mode. If that mode is unavailable, fall back to prompting
  // for JSON and validating strictly on the way out, never trust unchecked.
  generateStructured<T>(
    prompt: string,
    schema: JsonSchema,
    options?: { maxRetries?: number }
  ): StructuredResult<T>
}

// A result type that forces the caller to handle failure, instead of a
// value that might silently be null or half filled.
type StructuredResult<T> =
  | { ok: true, value: T }
  | { ok: false, error: string, rawOutput: string, attempts: number }

Two things this interface buys you. First, when the provider changes its API, or you add a second provider for fallback, only the implementation behind ModelClient changes, nothing that calls it. Second, StructuredResult forces every caller to handle the failure branch, instead of quietly assuming value is always populated, which is exactly the assumption that let the GSTIN bug reach production.

Below is the same interface satisfied in four ecosystems. The pattern is identical everywhere: define the schema, call the model, get back a typed value or a typed failure. Framework method names will drift over time. The shape of the contract should not.

Java (Spring AI)
// Illustrative: exact method names vary by Spring AI version, the
// interaction pattern (schema in, typed record or error out) does not.
public sealed interface StructuredResult<T> {
    record Ok<T>(T value) implements StructuredResult<T> {}
    record Failed<T>(String error, String rawOutput, int attempts) implements StructuredResult<T> {}
}

public record MerchantDetails(
    String businessName,
    String gstin,
    String pan,
    String bankAccount
) {}

public class SpringAiModelClient implements ModelClient {

    private final ChatClient chatClient;

    public <T> StructuredResult<T> generateStructured(
            String prompt, Class<T> targetType, int maxRetries) {
        for (int attempt = 1; attempt <= maxRetries; attempt++) {
            try {
                // .entity() asks Spring AI to constrain the response to the
                // shape of targetType using the provider's structured output mode.
                T value = chatClient.prompt(prompt)
                    .call()
                    .entity(targetType);
                return new StructuredResult.Ok<>(value);
            } catch (StructuredOutputException e) {
                if (attempt == maxRetries) {
                    return new StructuredResult.Failed<>(
                        e.getMessage(), e.getRawResponse(), attempt);
                }
                // retry with the failure appended, never loop unbounded
            }
        }
        throw new IllegalStateException("unreachable");
    }
}
.NET (Microsoft.Extensions.AI)
// Illustrative: Microsoft.Extensions.AI and Semantic Kernel both expose a
// JSON schema response format. Exact type names move between previews.
public record MerchantDetails(
    string BusinessName,
    string Gstin,
    string Pan,
    string BankAccount
);

public abstract record StructuredResult<T>
{
    public sealed record Ok(T Value) : StructuredResult<T>;
    public sealed record Failed(string Error, string RawOutput, int Attempts) : StructuredResult<T>;
}

public class DotnetModelClient : IModelClient
{
    private readonly IChatClient _chatClient;

    public async Task<StructuredResult<T>> GenerateStructuredAsync<T>(
        string prompt, int maxRetries = 2)
    {
        for (int attempt = 1; attempt <= maxRetries; attempt++)
        {
            var options = new ChatOptions
            {
                // Constrains generation to the JSON schema of T, enforced
                // by the provider, not just requested in the prompt text.
                ResponseFormat = ChatResponseFormat.ForJsonSchema(
                    JsonSchemaBuilder.Build(typeof(T)))
            };

            var response = await _chatClient.GetResponseAsync(prompt, options);

            if (TryParse<T>(response.Text, out var value, out var error))
                return new StructuredResult<T>.Ok(value!);

            if (attempt == maxRetries)
                return new StructuredResult<T>.Failed(error!, response.Text, attempt);
        }
        throw new InvalidOperationException("unreachable");
    }
}
Node / TypeScript (Vercel AI SDK)
// Illustrative: generateObject already returns this contract shape almost
// exactly. This wraps it so callers only ever see StructuredResult.
import { z } from 'zod';
import { generateObject, NoObjectGeneratedError } from 'ai';

const MerchantDetails = z.object({
  businessName: z.string(),
  gstin: z.string().length(15),
  pan: z.string().length(10),
  bankAccount: z.string(),
});

type StructuredResult<T> =
  | { ok: true; value: T }
  | { ok: false; error: string; rawOutput: string; attempts: number };

async function generateStructured<T>(
  prompt: string,
  schema: z.ZodType<T>,
  maxRetries = 2
): Promise<StructuredResult<T>> {
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      // schema is enforced by the provider's structured output mode, not
      // just described in the prompt text.
      const { object } = await generateObject({ model, prompt, schema });
      return { ok: true, value: object };
    } catch (err) {
      if (attempt === maxRetries || !NoObjectGeneratedError.isInstance(err)) {
        return {
          ok: false,
          error: String(err),
          rawOutput: NoObjectGeneratedError.isInstance(err) ? err.text : '',
          attempts: attempt,
        };
      }
      // fall through, retry with the same schema
    }
  }
  throw new Error('unreachable');
}
Python (LangChain)
# Illustrative: with_structured_output wraps a provider's native structured
# mode when the model supports it, and falls back to prompted JSON parsing
# with validation when it does not. Know which mode you are actually in.
from pydantic import BaseModel
from dataclasses import dataclass
from typing import Generic, TypeVar

T = TypeVar("T")

class MerchantDetails(BaseModel):
    business_name: str
    gstin: str
    pan: str
    bank_account: str

@dataclass
class Ok(Generic[T]):
    value: T

@dataclass
class Failed:
    error: str
    raw_output: str
    attempts: int

def generate_structured(prompt: str, schema: type[BaseModel], max_retries: int = 2):
    structured_model = model.with_structured_output(schema, strict=True)
    last_raw = ""
    for attempt in range(1, max_retries + 1):
        try:
            value = structured_model.invoke(prompt)
            return Ok(value)
        except Exception as exc:
            last_raw = getattr(exc, "raw_output", "")
            if attempt == max_retries:
                return Failed(str(exc), last_raw, attempt)
            # retry, do not loop unbounded
    raise RuntimeError("unreachable")

Notice what stays constant across all four. A schema goes in. A typed value or a typed failure comes out. Nothing in any of these four blocks is interesting on its own, and that is the point. The interesting decision, the ModelClient contract, was made once, in the neutral block above, and every language just satisfies it.

๐Ÿ”ด Architect's Corner

The Retry Loop That Cost Real Money

I have seen a team ship a schema with a typo in a regex pattern for a PIN code field, six digits required, and the pattern they wrote could never match six digits due to an off by one in the character class. Every single call against that schema failed validation. Their retry logic, written before anyone thought carefully about bounds, retried five times on every failure with no circuit breaker.

For three days, every request to that endpoint made five model calls instead of one, and every one of the five failed the same way, because the schema itself was broken, not the model's output. Nobody noticed because the retries succeeded eventually, in the sense that the request did not hang, it simply returned a final failure to the user after paying for five calls first.

The fix was two things. First, validate your schema against real data in a test before you ship it, the same discipline you would apply to a database migration. Second, distinguish a schema bug, which retrying can never fix, from a model drift, which retrying sometimes can. If the exact same validation error repeats on attempt one and attempt two with unchanged input, stop retrying immediately and alert, rather than burning the full retry budget every time.

Flat Schemas Beat Nested Ones

The deeper a schema nests, the more places a model can drift. A flat schema with ten string and number fields is close to reliable across most providers today. A schema with three levels of nested objects and arrays of objects inside arrays degrades noticeably, especially on smaller or cheaper models.

If accuracy on a complex extraction matters more than call count, split it. Extract the top level fields in one call, then make a second call per nested section rather than one call trying to hold the whole document's structure at once. This costs more calls and buys you meaningfully higher reliability on each one. Whether that tradeoff is worth it depends entirely on what a wrong field costs you downstream.

Offering Too Many Tools At Once

The same reliability curve shows up in tool calling. Offer a model three well described tools and it picks correctly almost every time. Offer it thirty, several with overlapping purposes, and tool selection accuracy drops, because the model now has to disambiguate between tools that look similar from the description alone.

Group tools by context and only expose the ones relevant to where the conversation currently is, rather than handing over your entire tool catalog on every call. This is also a security boundary, not just an accuracy one, and gets full treatment in Lesson 18 when we cover tool authorization.

Structured Modes Are Not Always The Same Latency Or Cost

Constrained decoding is not free. Depending on the provider, enforcing a schema can add latency compared to unconstrained generation, because the decoding process has extra bookkeeping at each token step. It is very rarely the wrong tradeoff for anything that feeds a downstream system, but budget for it rather than assuming structured calls cost the same as free text ones. We build the full latency budget in Phase 5.

How India-scale systems do it

Merchant onboarding at Indian fintech and payments companies is exactly the scenario this lesson opened with, and it is a genuinely hard structured extraction problem. A GST certificate photographed on a shopkeeper's phone, sometimes in poor light, sometimes with regional language text mixed with English, produces OCR output that is noisy before a model ever sees it. Schemas for this kind of extraction have to make most fields optional rather than required, because forcing every field to be present on a low quality photo just produces more invented data to fill the gap, not more real data.

WhatsApp support bots that extract intent and then call a real tool, block a card, raise a refund, escalate to a human, are the clearest case for why the model proposes and code disposes boundary matters. A Hinglish message like "mera card block kar do, kisi aur ne use kiya hai" has to become a structured call to blockCard with the right card reference, not a free text reply, and your code has to confirm the card actually belongs to the person messaging before it runs that call.

Cost compounds fast at Indian volume. A merchant onboarding pipeline processing lakhs of signups a month, each with a two or three attempt retry budget on a schema that occasionally fails, turns into a meaningful line item if nobody is watching the retry rate. The fix from the war story above, catching a broken schema before it burns budget on every call, matters more at this scale, not less.

Tradeoff: stricter schemas and lower retry budgets produce fewer bad records reaching your database, and a higher fraction of genuinely valid extractions get rejected into a manual review queue instead of being accepted, which needs enough human reviewers to keep up.

The Decision Matrix

Your situation Approach Reasoning Indian example
Extracting a few flat fields, provider supports structured output Native structured output mode Most reliable, enforced at generation, not checked after Merchant name and GSTIN from a chat message
Provider or model does not support structured modes Prompt for JSON plus strict validation and bounded retry Best available fallback, must fail closed Legacy or self hosted models without constrained decoding
Deeply nested document, accuracy critical Split into multiple flat calls Nested schemas degrade reliability, flat calls do not Full KYC document with multiple sections
Model should perform a real action Tool calling, with your code executing the call Keeps the model proposing and your code deciding, not the model acting directly WhatsApp bot blocking a card on request
Field feeds a financial or compliance decision Schema validation plus a separate semantic check Schema valid does not mean correct, checksum or lookup catches invented values GSTIN checksum, PAN format check before storing
High volume, cost sensitive Cap retries hard, alert on repeated identical failures A broken schema fails every attempt the same way, retrying it is pure waste Lakhs of merchant signups a month

Common Mistakes

1. "Tell it to reply in JSON and we're done." This is the GSTIN bug from the opening story. A plain instruction is a request, not a constraint. Use the provider's actual structured output or tool calling mode wherever it exists.

2. "It passed schema validation, so the data is correct." Schema validation checks shape. It says nothing about whether a GSTIN is real, whether an amount matches a policy, or whether a name is invented. Add a second, separate correctness check for anything that matters.

3. "Retry until it works." A retry loop with no cap and no circuit breaker on repeated identical failures will burn your budget on a broken schema for as long as nobody notices, exactly as it did in the war story above.

4. "One big schema captures the whole document." Nested schemas degrade reliability faster than flat ones. Split extraction into multiple calls when accuracy on a complex document actually matters.

5. "Tool calling means the model runs code." It does not. The model proposes a call. Your code decides whether to execute it, with whatever authorization and validation that action deserves. Treating a tool call payload as already authorised is how a misread message turns into a real world action.

๐Ÿง  Key Takeaways

  • Structured output is enforced at generation, not requested in prose. "Reply in JSON" is a request the model can still ignore in small ways. A real schema, using the provider's structured output or tool calling mode, constrains what tokens are even possible.
  • Schema valid is not the same as correct. Shape checking is free once you use a schema. Correctness needs its own check, applied after.
  • Cap your retries, and watch for identical repeated failures. That pattern means your schema is broken, not the model, and retrying will never fix it.
  • The model proposes a tool call, your code decides to run it. That boundary is a security control, not a technicality, and it gets deeper treatment in Lesson 18.
  • One interface, four implementations. ModelClient stays stable while every provider's SDK changes underneath it. Build the abstraction once.

Think About It

  1. Your team's schema for extracting a delivery address has 12 required fields. Real addresses from tier two Indian cities routinely omit two or three of them, apartment number, landmark, pin code. What would you change about the schema itself, before touching any retry logic?

  2. A colleague says "we don't need tool calling, we'll just have the model write the SQL query directly and run it." What is the strongest argument against that, using the model proposes, code disposes idea from this lesson?

  3. Your retry rate on a structured extraction endpoint jumps from 2 percent to 40 percent overnight, with no code deploy on your side. Walk through how you would tell whether the cause is a model change (Lesson 1 revisited) or a broken schema (this lesson's war story), and what you would check first.

Further Reading

  • OpenAI: Structured Outputs guide: the clearest public explanation of constrained decoding versus prompted JSON
  • Anthropic: Tool use overview: a clean description of the model proposes, application decides boundary
  • Simon Willison on tool calling failure modes: honest, practitioner level writing on where structured modes still go wrong

Learn it in your stack

  • Java: Spring AI reference, the structured output and ChatClient.entity() sections. LangChain4j offers a comparable structured output API.
  • .NET: Microsoft.Extensions.AI for ChatResponseFormat.ForJsonSchema, or Semantic Kernel if you need heavier orchestration alongside it.
  • Node and TypeScript: Vercel AI SDK, generateObject, which returns almost exactly the StructuredResult contract used in this lesson.
  • Python: LangChain, with_structured_output, and read the note in their docs on which providers support strict mode versus best effort mode before you rely on it.

Full quizzes, answers, progress, case studies and interview problems available in the paid path.