Agent Development Kit · Build
Structured output: prose in, a typed object out
One field turns an agent's reply from a paragraph into a value your code can branch on. Behind that field is a parser built for what models actually emit — fenced, prefaced, single-quoted, trailing-comma'd, sometimes truncated. The cells below run the shipped parser on that mess and show what it repairs, what it coerces, and what it refuses.
output, the JSON parser, parse failure
Cells · ten need no key, one needs yours
Package · @animahealth/adk (MIT)
Your key
Ten cells run without one
Everything up to the last cell scripts the model with the test kit, so it runs here with no
credentials. The final cell asks a real model for a typed object; that one needs a key. It
lives in this browser's localStorage and goes only to api.openai.com.
Step 1
A schema on output
An agent's output field accepts a Zod schema —
{ schema, key?, mode? }
— or the name of a key in the app's session schema. Give it a schema and the run hands back
the parsed, validated object alongside the text it came from. Build the agent first.
import { adk } from '@animahealth/adk'
import { openai } from '@animahealth/adk/openai'
import { z } from 'zod'
const app = adk()
const Triage = z.object({
urgency: z.enum(['routine', 'urgent', 'emergency']),
symptoms: z.array(z.string()),
followUpDays: z.number(),
})
const triager = app.agent({
name: 'triager',
model: openai('gpt-5.6-luna'),
context: [app.context.system('Triage the patient message.'), app.context.history()],
output: { schema: Triage },
})
triager.name
Now script the model's reply — deliberately the kind of thing a model returns when nobody is constraining it. Prose on both sides, an unquoted key, single quotes, a comma-separated string where an array belongs, a number sent as text. The test kit replaces only the model, so the real output path runs on it.
import { runTest, user, model } from '@animahealth/adk/testing'
const scripted = `Reading it back: { urgency: 'URGENT', symptoms: "chest tightness, shortness of breath", followUpDays: "2" } — book them in.`
const triaged = await runTest(triager, [
user('chest tightness since this morning and I cannot get a full breath'),
model(scripted),
])
const shape = {
text: triaged.result.output.text,
value: triaged.output,
assistantEvents: triaged.result.output.items.length,
status: triaged.status,
}
shape
A run result carries an Output: text, value,
items (every assistant event), and media. The raw sentence is still
there in text — the schema adds value, it does not replace anything.
The test kit's result is the same RunResult
app.run returns, and its shorthand .output is that result's
output.value.
Add a key and the parsed object is also written into session state, so the next
agent in a sequence reads it as data rather than re-reading the transcript. When the app
declares a session schema, naming that key alone — output: 'triage' — is
shorthand for the same { key, schema, mode: 'native' }, with the schema taken
from the declaration.
const recorder = app.agent({
name: 'recorder',
model: openai('gpt-5.6-luna'),
context: [app.context.history()],
output: { key: 'triage', schema: Triage },
})
const recorded = await runTest(recorder, [
user('sore throat for two days, no fever'),
model(`{ urgency: 'Routine', symptoms: "sore throat", followUpDays: 7 }`),
])
recorded.result.state
Step 2
The parser under it
Nothing about that was agent-specific. The output path calls the package's parser, and the
parser is exported from the core entry: parse, parsePartial,
createParser, parseJsonish, coerce. Run it directly on
the same string and you get the same object.
A ParseResult is a union — value on the success branch,
partial and errors on the failure branch — and both branches carry
corrections and totalScore. A correction is a receipt: the path it
touched, what it found, what it produced, and why. The score is the cost of getting there, so
a high score is a signal to look at your prompt, not a failure.
import { parse } from '@animahealth/adk'
const parsed = parse(scripted, Triage)
const receipts = {
parsed: parsed.success ? parsed.value : parsed.errors,
score: parsed.totalScore,
corrections: parsed.corrections.map((c) => ({
path: c.path.join('.'),
type: c.type,
from: c.from,
to: c.to,
})),
}
receipts
One layer below is parseJsonish: text to a plain value, no schema involved. It
strips code fences, finds JSON embedded in a sentence, closes unterminated strings and
brackets, and accepts single quotes, unquoted keys and trailing commas. It does not throw and
it does not report failure — given a sentence with no JSON in it, it hands the sentence back
as a string. That is why a schema, not this layer, is what rejects bad output.
import { parseJsonish } from '@animahealth/adk'
const messyInputs = [
'{"urgency": "urgent"}',
'```json\n{"urgency": "urgent"}\n```',
"{urgency: 'urgent', followUpDays: 2,}",
'The answer is {"urgency": "urgent"} — hope that helps.',
'{"urgency": "urgent", "symptoms": ["chest',
'I would rather just chat.',
]
messyInputs.map((text) => {
const repaired = parseJsonish(text)
return { input: text, value: repaired.value, got: typeof repaired.value }
})
Step 3
Coercion, and switching it off
Valid JSON with the wrong types is the common case, so the schema stage coerces before it
validates. Strings become numbers, booleans and dates; 'yes' is true; enum
members match case-insensitively and across underscores and spaces; a comma-separated string
becomes an array; a lone value becomes a one-element array; defaults fill absent keys. Every
one of those lands as a correction.
import { coerce } from '@animahealth/adk'
const Reading = z.object({
status: z.enum(['ok', 'high', 'low']),
systolic: z.number(),
tags: z.array(z.string()),
reviewed: z.boolean(),
notes: z.string().default('none'),
})
const coerced = coerce(
{ status: 'HIGH', systolic: '142', tags: 'urgent, recheck', reviewed: 'yes' },
Reading,
)
coerced.success ? coerced.value : coerced.errors
Coercion and the text extraction above it are both configuration, and both default to on:
createParser(schema, { coerceTypes, extractFromMarkdown }). Turn them off and the
parser becomes JSON.parse plus schema.safeParse — strict, and useful
when you would rather see the model's sloppiness than absorb it. Note the failure's
stage: json when the text never parsed, coercion or
validation when it parsed but did not fit.
import { createParser } from '@animahealth/adk'
const strict = createParser(Triage, { coerceTypes: false, extractFromMarkdown: false })
const refused = {
onProse: strict.parse(scripted).errors,
onCleanJsonWrongTypes: strict.parse('{"urgency":"urgent","symptoms":["cough"],"followUpDays":"2"}')
.errors,
}
refused
Step 4
When nothing valid comes back
If the parser cannot produce a value the schema accepts — and cannot rescue a partial object
that does — the run throws OutputParseError rather than handing you an
output.value you would have to re-check. Script a model that simply refuses to
answer in JSON and catch it.
let caught: unknown
try {
await runTest(triager, [
user('how are you today?'),
model('I would rather just chat, thanks.'),
])
} catch (error) {
caught = error
}
caught instanceof Error ? { name: caught.name, message: caught.message } : caught
Match on error.name, not instanceof. The
OutputParseError class is exported from the core entry and carries
rawOutput, schema, parseErrors,
partial and corrections where it is thrown — but an error that
crosses a run's event channel is reconstructed on the far side with its name and message
preserved and its own fields gone. The package's own retry logic checks both, for exactly this
reason.
That name is what app.ask(prompt, { schema }) retries on: a re-run budget that
defaults to two when a schema is set and zero when it is not. Only a parse error is retried —
a provider or transport error surfaces immediately, because re-asking cannot fix it.
Step 5
Native, prompt, and a real model
mode decides where the schema is enforced, and defaults to 'native':
the adapter sends the schema to the provider as a response format, so the model is constrained
before it writes a token. mode: 'prompt' withholds it, leaving the schema to your
own prompt — which is where ctx.outputSchema comes in. It is the schema rendered
as compact text for a system message. This cell asks for it, then reads back the system event
the renderer produced, so you can see exactly what the model was told.
let rendered: string[] = []
const narrator = app.agent({
name: 'narrator',
model: openai('gpt-5.6-luna'),
context: [
app.context.system((ctx) => `Reply with JSON matching:\n${ctx.outputSchema}`),
app.context((ctx) => {
rendered = ctx.events.flatMap((e) => (e.type === 'system' ? [e.text] : []))
return ctx
}),
app.context.history(),
],
output: { schema: Triage, mode: 'prompt' },
})
await runTest(narrator, [user('chest tightness since this morning'), model(scripted)])
rendered
Now the real thing. Same agent as step 1, same schema, an actual model on the other end — and because its mode is the default, the schema goes to the provider as a response format. The parser still runs on whatever comes back; native mode makes its job easy rather than unnecessary. Edit the message and run it again.
const live = await app.run(
triager,
'Since last night: fever of 39, a stiff neck, and the light is hurting my eyes.',
)
live.output.value
Native mode wants an object schema: a top-level array or scalar is still parsed and validated
on the way out, it just is not sent to the provider as a format. Either way the choice is
recorded — a model_start event names the output schema its call was rendered
with, so an audit of a past run can tell a free-text turn from a structured one without
replaying it. This last cell reads that back off the scripted run from step 1, so it needs no
key.
triaged.events.flatMap((event) =>
event.type === 'model_start'
? [{ agent: event.agentName, outputSchema: event.outputSchema }]
: [],
)
Where this goes next: a typed value is what an assertion should read, so
Testing agents without a model checks a run's
output rather than its prose. And the same field is what makes
app.ask(prompt, { schema }) hand back a parsed object instead of text — see
Many agents.