Structured output
Sometimes you don't want prose back. You want an object, with the fields you asked for, that your code can use without parsing anything.
const result = await axon.request({
prompt: "audit src/ and report every issue you find",
output: "{ files: number, issues: { file: string, line: number, message: string }[] }",
})
output is a TypeScript type, written as a string. That single string does three jobs:
it is checked before the model is called, shown to the model as its target, and enforced
against what the model produces.
Why TypeScript and not a schema library
The agent's whole world is already TypeScript. Every tool it can call arrives as a
declare block in its context window. Adding zod or JSON Schema would mean a second type
language in a system that already renders one — something new for you to learn, a
dependency shipped into every agent, and a translation layer between the shape you wrote
and the shape the model reads.
So there is no schema library. You write the type. The model sees exactly that type, in the same language as its tools. And if you already use zod or arktype, they can both emit a TypeScript type — so you are not locked out, you just don't have to be locked in.
The two forms
A type expression covers most cases:
output: "number"
output: "string[]"
output: "{ ok: boolean, count: number }"
Declarations are how you express a shape that repeats or nests. Name the target
Output:
output: `
type Issue = { file: string, line: number, message: string }
type Output = { issues: Issue[], summary: string }
`
Your own type names are what the model sees, and what appears in any error — so name them the way you would in real code.
Checked before the model runs
An invalid type throws immediately, at your call site, before any inference is spent:
await axon.request({
prompt: "...",
output: "{ files: nubmer }", // throws: Cannot find name 'nubmer'
})
This is what makes output a guarantee rather than a hint. A typo costs you a stack trace,
not three model calls and a confusing retry loop.
Because the check runs against the agent's live scope, an output type can also reference types the agent's own tools declare:
// src/tools/files.ts declares FileEntry
output: "{ entries: FileEntry[] }"
How the agent produces it
The agent builds the object in its <script> block as ordinary TypeScript, then hands it
over whole:
<script>
const entries = await files.list("src")
const result = { files: entries.length, issues: [] }
</script>
<template lang="json">{{ result }}</template>
The value is serialised, never typed. The model never writes JSON syntax by hand — no braces, no commas, no quoting — so the result is valid however large or deeply nested it gets. A ten-thousand-item array is one interpolation.
This is why a JSON template must contain exactly one interpolation and nothing else. Hand-written syntax around a value would forfeit the guarantee, so the runtime rejects it as a format error rather than emitting something that might not parse.
Enforcement and retries
After the model writes its script, the runtime typechecks it against your type — before running it. A mismatch comes back to the model as a real TypeScript diagnostic:
line 3: Type 'string' is not assignable to type 'number'.
Models correct that reliably, far more so than schema-validator prose. The model rewrites its script and tries again.
const result = await axon.request({
prompt: "audit src/",
output: "{ files: number }",
retries: 3, // default 2 — at most 3 model calls
})
retries counts attempts after the first. When the budget runs out the request throws
with the accumulated diagnostics. It never returns a value that failed its check — a
caller that asked for a shape gets that shape or an error, never an unvalidated object it
would treat as validated.
Two details that matter:
The script is checked before it runs. The script is real code that touches the world, so one that cannot satisfy the contract never executes. Otherwise every retry would double its side effects.
A failed check overrides <done/>. A model that declares itself finished while
producing the wrong shape has not finished.
What this does and does not guarantee
TypeScript proves a program is well-typed. It does not, by itself, prove a runtime value
has a shape — the language has two deliberate ways for well-typed code to lie about a
type. Both are rejected while an output type is in force:
Assertions. as T, <T>x and satisfies T all tell the checker to believe a claim
it never verified — JSON.parse(x) as Output is perfectly well-typed and can produce
anything. as const is still fine: it narrows rather than widens.
any. The wider hole in practice, because nobody writes it — it arrives from a
loosely typed tool. If db.query() returns Promise<any>, that value flows into
result and every check succeeds vacuously, since any is assignable to everything.
So the runtime asks the checker what result actually resolved to, and rejects it if
any is anywhere in the shape.
The fix the model is told to make is to narrow:
const rows = await db.query("select ...")
const result = { rows: Number(rows) } // now genuinely checked
unknown is not affected — it is assignable to nothing without narrowing, so it already
fails with an ordinary type error.
With those closed, a value reaching result has a shape the checker genuinely verified.
What remains outside its reach is anything TypeScript cannot see: this is output parsing,
not a sandbox. The capsule and OS permissions are what contain what an agent can do.
Streaming
output is for axon.request. Enforcement can only run once the script closes, so a
stream would emit output before it has been verified — and with a JSON template there is
nothing to read incrementally anyway: it is a single value.
Use axon.request when you want a shape. Use axon.stream when you want to watch the
agent work.