Exercise
Beginner
Working with models45 min

Validate structured model output

Take a tool call that satisfies its schema and still says something false, and catch it in code.

What it costs to run

No API key. The fixtures are recorded responses; you write the checks.

The task

A refund assistant returns a tool call. The schema is satisfied every time. Your job is to decide, in code, whether the call should be allowed to happen.

Here are four calls the model produced against the same schema. Exactly one of them is safe to execute.

// The schema every one of these satisfies.
{
  "name": "issue_refund",
  "input_schema": {
    "type": "object",
    "properties": {
      "order_id": { "type": "string" },
      "amount_cents": { "type": "integer", "minimum": 0 },
      "reason": { "type": "string", "enum": ["damaged", "late", "unwanted"] },
    },
    "required": ["order_id", "amount_cents", "reason"],
    "additionalProperties": false,
  },
}
[
  { "order_id": "ORD-4417", "amount_cents": 2499, "reason": "damaged" },
  { "order_id": "ORD-4417", "amount_cents": 24990, "reason": "damaged" },
  { "order_id": "ORD-9999", "amount_cents": 2499, "reason": "late" },
  { "order_id": "ORD-4417", "amount_cents": 0, "reason": "unwanted" }
]

Starter material

The order the assistant was talking about:

{
  "order_id": "ORD-4417",
  "total_cents": 2499,
  "placed_at": "2026-08-02",
  "delivered_at": "2026-08-04",
  "refunded_cents": 0,
  "status": "delivered"
}

Your refund policy, which is not in the prompt and never should be:

  • A refund cannot exceed what is left to refund on the order.
  • late requires delivery more than seven days after the order.
  • A zero-value refund is not an action; it is a no-op that still emails the customer.

Write a function with this shape:

type Decision = { allow: true } | { allow: false; reason: string }

function check(call: RefundCall, order: Order): Decision {
  // yours
}

What to check

Work through each call and say which rule catches it. Then write the checks.

  1. Does the order exist, and is it the one under discussion?
  2. Is the amount within what remains refundable?
  3. Does the stated reason hold against the order's own dates?
  4. Is the call a no-op?

Hints

The second call looks like a typo. Is that the right way to think about it?

No. 24990 is a well-formed integer that satisfies minimum: 0. Nothing about it is malformed — it is simply ten times the order total. That is the whole point of the exercise: a schema constrains shape, and shape was never the problem. Your check compares against total_cents - refunded_cents, which is data the model never saw.

Should the third call be rejected for the order ID or the reason?

Both, and the order matters. Check identity first and return before you evaluate anything else: a reason check against an order you have not confirmed exists is a check against undefined. This is the ordinary rule that authorisation comes before business logic, and it is easy to lose when the input arrives as a tool call rather than as a request body.

Is a zero-value refund really a failure?

It depends on your product, and that is the answer worth arriving at. It is not a policy violation, so allow: false with a reason is defensible, and so is allowing it and making the email conditional. What is not defensible is not noticing: a call that does nothing but sends a message is the kind of thing that reaches production because no rule named it.

Solution

One way to write it
function check(call: RefundCall, order: Order | undefined): Decision {
  if (!order || order.order_id !== call.order_id) {
    return { allow: false, reason: 'Unknown order' }
  }

  const remaining = order.total_cents - order.refunded_cents
  if (call.amount_cents > remaining) {
    return { allow: false, reason: `Exceeds ${remaining} remaining` }
  }

  if (call.amount_cents === 0) {
    return { allow: false, reason: 'Zero-value refund is a no-op' }
  }

  if (call.reason === 'late') {
    const days = daysBetween(order.placed_at, order.delivered_at)
    if (days <= 7) return { allow: false, reason: `Delivered in ${days} days` }
  }

  return { allow: true }
}

Only the first call passes.

The part worth keeping is not the code. It is that every check reads data the model never had access to, and none of them could have been expressed in the schema. strict: true guarantees the call matches your schema exactly, and the same documentation notes that a model may infer a value it was not given. Both are true at once, which is why the shape being right tells you nothing about the value being right.

Going further

  • Add a check the model can act on. When you reject a call, return a tool execution error whose text states the rule and supplies the number, so the model can correct itself rather than guess again.
  • Make the whole thing safe to call twice, then test it by calling it twice.