AI Engineering (Applied) · Structured Outputs & Tool Use

Structured Outputs & Tool Use

A chatbot writes prose for a human to read. But when a model sits inside your program, prose is a liability — your code can’t reliably act on a paragraph. The applied craft here is to make the model behave like an API: constrain its output to a schema you defined, so what comes back is data your code can parse, validate, and act on — and let it request tools in that same structured form. This is what turns “the model said something” into “my program did something.”

☺ Explain it like I’m 10

Imagine asking a friend “what should I wear?” and they ramble for a paragraph. Now imagine you gave them a little form with three boxes — top, bottom, shoes — and said “just fill these in.” Now a robot can read the answer instantly, because it always looks the same. Structured output is handing the model a form to fill instead of letting it write an essay.

🐦Your host for this topic: 🐦 Pip the Hummingbird — Pip is the messenger who carries clean, precise calls between the model and your code, and won’t deliver a message your program can’t read.

Why free text breaks programs

☺ Like you’re 10: If everyone answers a question in their own words, a robot trying to sort the answers gets confused. But if everyone fills in the exact same little form, the robot knows precisely where to look every time.

A model’s default output is free text — fluent, human-friendly, and shaped however it feels like that day. That’s perfect for a human reading a chat window. It’s a nightmare for a program. The moment you want your code to do something with the answer — save it to a database, branch on a value, call another service — you have to turn that paragraph back into structured data. That step is called parsing, and doing it on free text is where things quietly fall apart.

Say you ask a model to extract an order from an email and it replies:

Sure! It looks like the customer wants 3 blue mugs shipped to
Berlin, and they'd like it by Friday. Let me know if you need anything else!

Your code needs quantity=3, item="mug", city="Berlin". To get them out of that sentence you’d write brittle string-matching or a regex — and it breaks the instant the model says “three” instead of “3,” adds a friendly preamble, wraps the JSON in ```` ```json ```` fences, or decides today’s reply needs an emoji. Every one of these is a real, everyday failure:

The jump we’re making in this lesson is from “the model wrote something” (a string a human interprets) to “my code can act on it” (typed fields your program reads without guessing). You don’t get there by writing cleverer parsers. You get there by making the model produce the right shape in the first place.

Free text is for humans; structure is for code. If a program consumes the output, don’t parse prose — constrain the model to emit the exact shape you’ll act on.

Getting structure: JSON mode, constrained decoding, and tool schemas

☺ Like you’re 10: Instead of hoping the model fills the form neatly, you can put a frame around its answer so it literally can’t color outside the lines — it’s only allowed to write things that fit your form.

There are three levels of “make it structured,” from a gentle nudge to a hard guarantee. Modern model APIs offer some mix of these under names like JSON mode, structured outputs, or tool/function calling. The mechanics differ by provider, but the ideas are universal:

  1. JSON mode (a promise, not a guarantee). You ask the model to reply with valid JSON, often via a flag plus a clear instruction. It usually returns parseable JSON — but “valid JSON” only means it parses; it does not mean the JSON has your fields with your types. You still have to validate.
  2. Schema-constrained / grammar-guided decoding (a guarantee of shape). You hand the model a schema — typically a JSON Schema — and the decoding process is constrained so that, token by token, only outputs that conform to the schema are even possible. The model literally cannot emit a missing required field or a string where you asked for a number. This is the strongest structural guarantee: the shape is enforced by construction, not by hoping.
  3. Tool / function schemas (structure + intent to act). You describe the tools your program exposes — each as a name, a description, and a parameter schema. When the model wants to use one, it doesn’t run it; it emits a structured request — the tool name plus arguments matching that schema — for your code to execute. It’s the same schema-constrained idea, aimed at doing rather than just returning.

All three lean on the same primitive: a schema that says exactly what a valid answer looks like. Here’s the neutral pattern for “return typed data” — pseudocode, since the exact flag and field names differ by provider:

# NEUTRAL PSEUDOCODE — ask for output matching a schema
schema = {
  "type": "object",
  "properties": {
    "quantity": { "type": "integer" },
    "item":     { "type": "string" },
    "city":     { "type": "string" },
    "due_date": { "type": "string", "format": "date" }
  },
  "required": ["quantity", "item", "city"]
}

result = model.respond(
  prompt = "Extract the order from this email:\n" + email,
  output_schema = schema        # provider enforces the shape
)

# result is now DATA, not prose — your code can act on it directly
save_order(result.quantity, result.item, result.city, result.due_date)
◆ Key idea

JSON mode buys you “it parses.” Schema-constrained decoding buys you “it fits.” Only the second removes the whole class of “right JSON, wrong shape” bugs. Whenever your provider supports true schema enforcement for the task, prefer it — and still validate the values, because a schema constrains structure, not truth.

Getting the format to actually stick is a measurable property, not a vibe — “does the output conform to the schema every time?” is exactly the kind of thing you track with evals for format adherence. And the words in your prompt still matter even with hard enforcement: clear instructions make the content better, which is why prompting and schemas work together, not instead of each other.

Designing schemas the model fills correctly

☺ Like you’re 10: A good form has clear labels and checkboxes with only the right options — so it’s almost impossible to fill in wrong. A bad form has a giant blank box labeled “stuff,” and you get back a mess.

A schema isn’t just a validator your code runs afterward — the model reads it and uses your field names, descriptions, and types as instructions. A well-designed schema is half the prompt. The craft is making the schema so clear that the only easy thing to do is fill it in correctly.

# A schema that guides, not just validates
{
  "type": "object",
  "properties": {
    "sentiment":  { "enum": ["positive","neutral","negative"],
                    "description": "Overall tone of the review" },
    "issue_area": { "enum": ["shipping","billing","product","other"] },
    "summary":    { "type": "string",
                    "description": "One sentence, under 20 words" },
    "needs_human": { "type": "boolean",
                     "description": "True only if the customer is angry or asks to escalate" }
  },
  "required": ["sentiment", "issue_area", "summary", "needs_human"]
}

Notice how much of the “prompt” now lives in the schema itself: the enums pin the vocabulary, the descriptions set the rules, and needs_human is a clean boolean your routing code can branch on with zero parsing. Good schema design is the applied skill this whole lesson turns on. (For the sibling craft of engineering the surrounding context so the model has what it needs to fill the schema, see context engineering.)

The tool-calling loop

☺ Like you’re 10: The model can’t press buttons itself. So when it needs something done — look up the weather, charge a card — it hands you a filled-in request slip. You do the real action, hand back the result, and it keeps going. Back and forth, like passing notes.

Tool use is structured output pointed at action. The model never runs your code and never touches your database. Instead there’s a loop: the model proposes a tool call as structured data, your code executes it, you return the result, and the model continues with that result in hand. It may propose another call, or produce a final answer. You are always the one holding the keys.

Model proposes a call Structured request get_weather{city:"Berlin"} Your code 🐦 validate & run Real tool API · DB · service Result returned {temp: 21, sky:"clear"} model continues

Concretely, the round-trip looks like this — note that you run get_weather, never the model:

# NEUTRAL PSEUDOCODE — the propose → execute → return loop
tools = [{
  "name": "get_weather",
  "description": "Current weather for a city",
  "parameters": { "type": "object",
                  "properties": { "city": { "type": "string" } },
                  "required": ["city"] }
}]

messages = [ user("What should I pack for Berlin today?") ]

while True:
    reply = model.respond(messages, tools=tools)

    if reply.tool_call:                        # 1. model PROPOSES
        args = validate(reply.tool_call.args)  #    check before trusting
        output = run(reply.tool_call.name, args)   # 2. YOUR code EXECUTES
        messages.append(reply.tool_call)
        messages.append(tool_result(output))   # 3. RETURN result to model
        continue                               # 4. loop → model CONTINUES
    else:
        return reply.text                       # final answer, no more calls

This propose→execute→return loop is the engine under agents. It’s worth being precise about how it differs from two neighbours in this course:

◆ Key idea

An “agent” is, at its core, a loop around structured tool calls. The model’s only power is to propose — every real effect goes through your code, which is exactly where you get to validate, authorize, and log. Never let the proposal be the action.

Validation & repair

☺ Like you’re 10: Even with a form, someone might scribble outside a box or leave a blank. So before you act, you check the form. If it’s messed up, you hand it back and say “this part’s wrong, please fix it” — and usually the second try is clean.

Schema enforcement makes malformed output rare, but robust systems don’t assume it’s impossible — especially with plain JSON mode, streaming, or older models. The discipline is validate, then repair: check every tool call and every structured output against your schema before you act on it, and have a recovery path when the check fails.

# NEUTRAL PSEUDOCODE — validate, then repair
for attempt in range(3):
    reply = model.respond(messages, output_schema=schema)
    ok, value, error = validate(reply, schema)
    if ok:
        return value                        # good → act on it
    messages.append(reply)                   # show the model what it sent
    messages.append(user("That was invalid: " + error +
                         ". Return corrected data matching the schema."))
# out of attempts → safe fallback, don't guess
raise NeedsHumanReview(last_error=error)

Validation is also your security boundary. The arguments the model proposes are untrusted input — the model may have been steered by a poisoned document or a crafted user message. Checking types isn’t enough; you also enforce policy: is this city on the allow-list, is this amount within limits, is this user permitted to call this tool at all? That authorization layer is the subject of guardrails, and it belongs on the arguments before execution, every time.

🎬 At the AI Academy
🦊

Foxy: The refund bot keeps crashing my code! Yesterday it answered “Sure, refund about twenty dollars 😊” and my program exploded trying to read that.

🐦

Pip the Hummingbird: Because you asked for an essay when you needed a form. Give the model a schema: { amount_usd: number, order_id: string, reason: enum }. Now it can only hand back exactly those fields.

🐬

Delphi the Dolphin: Filling it in: { "amount_usd": 20, "order_id": "A-771", "reason": "damaged" }. And instead of doing the refund myself, I’ll just propose the issue_refund tool call and pass it to you.

🐦

Pip: Perfect — a clean request slip, not an action. I carry it to Foxy’s code, not to the bank.

🐢

Timmy the Turtle: Hold on — before anything runs, I validate. Twenty dollars is under the limit, order A-771 exists, and reason is a real enum value. Only now do we execute the refund — with an idempotency key so a retry can’t charge twice.

🐦

Pip: Result goes back to Delphi, Delphi writes the friendly confirmation for the human. Same message, but now it’s a message my program can actually read.

Pitfalls

☺ Like you’re 10: The form only helps if you make a good form and actually check it. A form with a hundred nested boxes confuses everyone, and a form nobody reads before acting on it is worse than no form at all.

Structured output removes a whole category of bugs — and quietly introduces a few new ways to shoot yourself in the foot. The common ones:

⚠ Shape is not safety

A validated schema tells you the request is well-formed — never that it is allowed or correct. Always separate the two checks: schema validation (is this the right shape?) and authorization (is this action permitted, in bounds, and for this user?). Skipping the second is how a tidy-looking tool call becomes a security incident.

🦫 Benny’s workshop · 5 min

Take any assistant you can send a prompt to. First ask it to “describe this made-up order in a sentence” and try to imagine parsing that reliably in code. Then ask it: “Return ONLY JSON with keys quantity (integer), item (string), city (string) — no other text.” Run the reply through a JSON validator (any online one works). Now deliberately break it — ask for a field it can’t know, like tracking_number — and watch it either invent a value or leave it blank. That fabrication is exactly why you validate before you act.

🐢 Timmy’s checkpoint

(1) Why is parsing free text unreliable, and what does constraining the model to a schema fix? (2) What’s the difference between JSON mode and schema-constrained decoding — which one guarantees the shape? (3) Walk through the four steps of the tool-calling loop, and say which step actually runs the tool. (4) When a tool call fails validation, what’s the most effective repair — and why must you still check authorization even after validation passes?

Check your answers
  1. Why free text is unreliable: A model’s default output is free text, shaped however it feels like that day, so parsing it back into structured data quietly breaks. Constraining the model to a schema fixes this by forcing the reply into predictable fields with known types, removing the fragile parsing step your code depends on.
  2. JSON mode vs. schema-constrained decoding: JSON mode is a promise, not a guarantee — it only ensures the reply parses as valid JSON, not that it has your fields with your types. Schema-constrained (grammar-guided) decoding constrains generation token by token so only schema-conforming output is possible, so it is the one that guarantees the shape; JSON mode buys “it parses,” schema-constrained decoding buys “it fits.”
  3. The four-step tool-calling loop: (1) the model proposes a structured tool call, (2) your code executes the real tool, (3) you return the result to the model, and (4) the loop continues until the model produces a final answer. Step 2 — your code — is the step that actually runs the tool; the model only ever proposes.
  4. Best repair, and why still authorize: The most effective repair is to re-prompt with the exact validation error and ask the model to correct just that, within a bounded number of retries. You must still check authorization because validation only confirms the request is well-formed, never that it is allowed — model-proposed arguments are untrusted, so policy and permission checks belong on every call before execution.