Denshin / Blog / Engineering
Structured outputs and tool calling: the glue of AI features
The unglamorous layer that decides whether an AI feature ships: schema-constrained output versus asking for JSON in the prompt, designing schemas a model can fill correctly, validating with Zod at the boundary, repair retries and fallbacks, and tool calling rules on argument validation, idempotency and untrusted identifiers.
Denshin Engineering · Engineering Team · 27 August 2026 · 7 min read
The gap between an AI demo and a shipped AI feature is almost never the model. It is the boring layer in between: getting the model to return data your code can actually use, and letting it call your functions without letting it break anything. That layer decides whether the feature survives contact with real users. It gets a fraction of the attention that prompt tinkering does, and it is where we spend most of our engineering time on AI work.
Structured outputs and tool calling, defined
Structured output means constraining a model to return data matching a schema you specify, rather than prose you then have to parse. Tool calling (also called function calling) means giving the model a set of functions it may invoke, letting it choose one and supply the arguments, and running that function in your own code. Both are the same idea from different ends: a typed contract between a probabilistic system and a deterministic one.
Everything difficult about shipping AI features lives at that contract boundary. The model is a helpful component that will occasionally hand you something that is confidently wrong, so the boundary has to be defensive by construction rather than by good intentions.
Getting reliable JSON: constrained modes beat asking nicely
There are two ways to get JSON out of a model. The first is to ask for it in the prompt. The second is to use the schema-constrained or structured output mode that mainstream providers now offer, where the response is restricted to match a schema you supply.
Use the constrained mode wherever your provider offers one. Asking nicely produces a well known catalogue of failures: a markdown code fence wrapped around the JSON, a friendly sentence before it, a trailing comma, single quotes, or a truncated object because the response hit a token limit mid structure. You can defend against each of these with string surgery, and teams do, and the surgery becomes a permanent maintenance burden that a constrained mode removes for free.
| Approach | Reliability | Failure mode | Use when |
| Schema constrained output | High for shape | Valid shape, wrong values | Always, if available |
| Tool calling with a schema | High for shape | Wrong tool, wrong arguments | The model must act, not just answer |
| "Please reply in JSON" | Variable | Prose, fences, truncation | Only when nothing else exists |
Note the second column carefully. A constrained mode guarantees the shape of the response. It guarantees nothing about the content. A schema that requires an order id will get an order id shaped string, invented if necessary. This is exactly why the validation step below is not optional even when the provider promises valid JSON: shape validity and business validity are different properties, and only one of them is the provider's problem.
Designing schemas a model can fill correctly
Schema design changes accuracy more than most prompt edits do. Three rules carry most of the weight.
- Flat beats deeply nested. Every level of nesting is another place for the model to misplace a field. If you need a nested structure for your domain, consider extracting flat and assembling in code afterwards, where assembly is deterministic.
- Enums beat free text. Any field with a knowable set of values should be an enum. "urgent" versus "Urgent" versus "high priority" is a class of bug that simply stops existing. Include an explicit escape value such as "other" or "unknown", because without one the model is forced to pick a wrong option rather than admit uncertainty.
- Required fields beat optional soup. A schema where everything is optional teaches the model that omission is acceptable, and you end up handling absence everywhere downstream. Prefer required fields that are explicitly nullable, so "not present in the source" is a value the model states rather than a silence you have to interpret.
Two smaller habits pay off as well. Name fields the way a human would describe them, because the field name is itself a prompt and refund_reason outperforms field3. And give every field a description in the schema, because providers pass those through to the model.
Validate at the boundary, with Zod
Treat model output exactly as you treat a request body from an untrusted client. Parse it at the edge of your system, into a typed value, before it touches business logic. In a TypeScript codebase that means Zod, for the same reasons we adopted it everywhere else, described in why Zod replaced our hand-rolled validators.
const Extraction = z.object({
intent: z.enum(["refund", "billing_question", "cancellation", "other"]),
order_reference: z.string().min(3).max(40).nullable(),
urgency: z.enum(["low", "normal", "high"]),
summary: z.string().max(400),
});
type Extraction = z.infer<typeof Extraction>;
const parsed = Extraction.safeParse(candidate);
if (!parsed.success) {
// one repair attempt, then fall back
}
The payoff is that one schema does three jobs: it generates the JSON schema you send to the provider, it validates what comes back, and it gives you the TypeScript type for the rest of the code. One definition, no drift between what you asked for and what you accept.
What to do when validation fails
It will fail. Decide the policy now rather than in an incident. There are three moves and they compose in order.
- Retry with the validation error. Send the model its own output plus the specific Zod error message and ask for a corrected version. This is strikingly effective, because the model is being told precisely what is wrong rather than asked to guess again. Cap it at one retry. If a second attempt is needed, the problem is your schema or your prompt, and retrying is just spending money on it.
- Fall back to a safe default. An "unknown" classification that routes to a human is a perfectly good outcome. Design a degraded path that is correct rather than a clever path that is sometimes wrong.
- Fail loudly to your logs, quietly to the user. Log the raw output, the schema version and the validation error. Without those three, structured output bugs are undebuggable, because the failing input no longer exists anywhere by the time anyone notices.
One thing not to do: never widen the schema to make the errors go away. A schema loosened until everything passes has stopped being a contract and has become documentation of what the model happens to emit.
Tool calling: design for a caller you do not trust
Tool calling is structured output with consequences, because the arguments are about to be executed. The mental model that keeps systems safe is simple: a tool call is an untrusted request from the public internet. Not because the model is malicious, but because its input may include text from a user, a document or a web page, and that text can influence what it asks for. That is the whole basis of prompt injection, which we cover in a defender's checklist.
Never trust model-supplied identifiers
This is the rule that prevents the worst class of bug. If the model returns user_id, account_id, tenant_id or anything else that determines whose data is touched, ignore it. Take those values from the authenticated session on your server, always. The model may supply a search term or a description; it must never supply the subject of an authorisation decision. Every tool handler should run the same permission check it would run for a direct API call from that user, with no exception for "but it came from our own agent".
Validate arguments like any other input
Parse every tool argument with the same schema discipline as above, then apply business rules the schema cannot express: the record exists, it belongs to this user, the amount is within a sane range, the status transition is legal. A well formed argument is not a permitted argument.
Make writes idempotent
Models retry. Loops re-enter. Timeouts fire while the request is still in flight. Any tool that changes state needs an idempotency key so a repeat call is a no-op rather than a second refund. Derive the key deterministically from the operation, and check it inside the same transaction as the write.
Keep the tool surface small and blunt
Fewer, clearly distinguished tools outperform many overlapping ones, because tool selection is itself a classification problem and near duplicate options make it harder. Separate read tools from write tools, put the risky ones behind a human confirmation step, and return errors as clear text the model can act on, such as "order not found, ask the user to confirm the reference", rather than a stack trace. That last point matters more than it sounds, because the error message is the model's only feedback channel, a theme we develop in AI agent architecture.
What to do next
- Move any "reply in JSON" prompt to a schema constrained output mode if your provider offers one, and check current provider documentation, since this area has changed repeatedly and will again.
- Define one Zod schema per model output and use it for the request schema, the validation and the type.
- Flatten your schemas, convert free text fields to enums with an explicit unknown value, and make fields required and nullable rather than optional.
- Implement one repair retry carrying the validation error, then a safe fallback. Log raw output on every failure.
- Audit every tool handler for two things: identifiers taken from the session rather than the model, and idempotency keys on every write.
Most AI features that feel unreliable in production are not suffering from a model problem. They are missing this layer. If you want help putting it in place on a product you are already shipping, talk to us.
Tags: Structured Outputs, Tool Calling, Zod, LLM, TypeScript, AI Agents
All posts · Work with Denshin