Structured output
Typed, schema-validated results instead of a string.
Give an agent an outputSchema and result.output stops being a string you have
to parse. It becomes an object the compiler already knows the shape of, validated
before you ever see it.
import * as z from 'zod'
const Ticket = z.object({
category: z.enum(['bug', 'feature', 'question']),
severity: z.number().int().min(1).max(5),
summary: z.string(),
})
const agent = new Agent({
name: 'triage',
instructions: 'Read the customer message and classify it.',
model,
outputSchema: Ticket,
})
const result = await agent.run(customerEmail)
result.output.category // 'bug' | 'feature' | 'question'
result.output.severity + 1 // a real number — no cast anywhere
result.text // the raw JSON the model producedThe type comes from the schema. You never write run<Ticket>(), and there is no
as at the call site.
Any Standard Schema validator works — Zod, Valibot,
ArkType — the same interop tool() uses.
Configuration
Prop
Type
How it works
Three layers, applied in order, because no single one is enough.
- Native constraints. The derived JSON Schema is sent as the provider's
response_format, so a vendor that supports constrained decoding is prevented from producing invalid output rather than corrected afterwards. - A prompt instruction. The schema is also appended to the system prompt,
always. Plenty of gateways — Ollama, older vLLM builds, some OpenRouter
upstreams — accept
response_formatand silently ignore it, and there is no capability flag to ask. Sixty tokens is cheaper than the class of bug this prevents. - Extraction and validation. The answer is parsed — bare JSON, a fenced
block, or an object embedded in prose all work — and then validated. Only then
does it become
result.output.
Composing with tools
outputSchema and tools work together, and that is the useful case: call
whatever you need, then return a typed object.
const agent = new Agent({
name: 'triage',
model,
tools: [lookupCustomer],
outputSchema: Ticket,
})
const result = await agent.run(email)
result.output.severity // informed by the tool resultThe loop is unchanged. The schema rides along on every model call, and only the final answer — the turn where the model stops calling tools — is validated.
When the model gets it wrong
A model that returns prose, a fenced block, or one field of the wrong type gets one chance to fix it. The failed answer and the specific validation errors go back, and the model tries again:
Your previous response did not match the required JSON Schema.
• severity: Expected number, received string
Reply with only the corrected JSON object. No prose, no code fences.A repair is recorded in steps but does not count as a turn:
result.steps.map((step) => step.kind) // ['turn', 'repair']
result.turns // 1
result.usage // includes both callsThat split is deliberate. maxTurns is your tool-loop budget, and a repair is
not progress on the task — folding them would let a non-compliant model eat the
budget and end the run as max_turns with the failure reported on entirely the
wrong path.
maxOutputRetries and maxRetries are additive, never
multiplicative. maxRetries re-sends an identical request after a transport
failure; this one sends a different conversation. A schema mismatch is never
retried as a transport error.
InvalidOutputError
Once the repair budget is spent, the run throws. This is the third documented way
a completed run can throw, alongside a provider failure and
onToolError: 'throw'.
import { InvalidOutputError } from 'just-another-sdk'
try {
const result = await agent.run(input)
} catch (error) {
if (error instanceof InvalidOutputError) {
error.issues // [{ path: ['severity'], message: 'Expected number, received string' }]
error.rawText // exactly what the model said
error.attempts // repairs made before giving up
}
}error.issues is empty when the model returned no JSON at all — the validator
never saw a value, so there is no path to report.
rawText is on the instance and deliberately not in details, so it is
absent from toJSON(). details is the log-safe form the console tracer and
the SSE serializer both print, and raw model output is unbounded — it can echo
back whatever your user pasted in. Read rawText when you are debugging; do
not ship it to a log pipeline.
With a sessionId, a run that never validated persists nothing — same rule
as any other failed run. The alternative is storing an unanswered exchange plus a
schema scolding, which poisons the next turn.
Streaming
With an outputSchema there are no text.delta events. The model's only text
is the JSON object, and half an object is not something a UI can render — the
same reason partial tool arguments are withheld.
const stream = agent.stream(input)
for await (const event of stream) {
// tool.start, tool.end, output.invalid, run.finish — but no text.delta
}
const result = await stream
result.output.severity // validated, same as run()toTextStream() and toResponse() therefore produce an empty body. For a
browser, use toEventResponse(): tool progress, output.invalid, and
run.finish all still flow, and run.finish.text carries the raw JSON.
There is no partial-object API. Structured output and text streaming are mutually exclusive by construction.
Provider notes
The derived schema is sent with strict: false. OpenAI's strict mode rejects any
schema that does not set additionalProperties: false and mark every property
required, and no validator's JSON Schema output guarantees either — one
optional field would fail the request outright. Non-strict json_schema still
constrains the model hard, and the runtime validates regardless.
To opt in, supply a schema that already satisfies those rules:
new Agent({
name: 'triage',
model,
outputSchema: Ticket,
outputJsonSchema: {
type: 'object',
properties: {/* ... */},
required: ['category', 'severity', 'summary'],
additionalProperties: false,
},
})Testing it
mockProvider needs nothing special to exercise the repair path — a
malformed-then-valid script is the repair:
const model = mockProvider([
{ text: 'Sure! ```json\n{"severity":"high"}\n```' },
{ text: '{"category":"bug","severity":4,"summary":"..."}' },
])
const result = await new Agent({
name: 'triage',
model,
outputSchema: Ticket,
}).run('x')
expect(result.output.severity).toBe(4)
expect(result.steps.map((s) => s.kind)).toEqual(['turn', 'repair'])When to prefer a tool instead
If what you want is a side effect with validated arguments rather than a return value, force a tool call. The tool's own schema does the same validation, and the model's intent is clearer:
const fileTicket = tool({
name: 'file_ticket',
description: 'File a support ticket.',
inputSchema: Ticket,
execute: (fields) => ticketSystem.create(fields),
})
await new Agent({ name: 'triage', model, tools: [fileTicket] }).run(text, {
toolChoice: { type: 'tool', name: 'file_ticket' },
})Use outputSchema when you want the answer. Use a forced tool when you want the
action.