Give an agent a zod schema as output and its final reply becomes a typed,
validated object: result.object. The agent can still call tools first; only
the final answer has to match the schema. The vendor/ model prefix chooses
the provider; with OpenRouter use openrouter/<vendor>/<model> (e.g.
openrouter/openai/gpt-4o-mini).
import { z } from 'zod';
import { createAgent } from '@lousho/build-ai-agent';
const agent = createAgent({
model: 'openai/gpt-4o-mini',
instructions: 'You report the weather.',
output: z.object({ city: z.string(), tempC: z.number(), summary: z.string() }),
});
const result = await agent.send('Weather in Paris?');
if (result.object) {
console.log(result.object.tempC); // typed: number
} else {
console.error(result.finishReason, result.outputError?.issues); // 'output-invalid', [{ path, message }]
}result.object is typed as z.output<typeof schema>, so zod defaults and
transforms apply. result.text keeps the raw JSON text the model wrote.
The schema can come from zod 3 or zod 4: it is rendered with z.toJSONSchema
for zod 4 and with the ai SDK's converter for zod 3, and validated with the
schema's own safeParse either way. output is typed as a Standard Schema,
the same as defineTool({ input }), so result.object is inferred without a
cast from a zod 3 schema, a zod 4 schema (zod/v4 on zod 3.25, or zod 4
itself) or any other Standard Schema that can produce JSON Schema:
import { z } from 'zod/v4';
import { createAgent } from '@lousho/build-ai-agent';
const agent = createAgent({ model: 'openai/gpt-4o-mini', output: z.object({ city: z.string() }) });
const { object } = await agent.send('Where is the Eiffel Tower?');
console.log(object?.city); // typed: stringWhen the output schema mirrors a side-effect tool's payload (say a
comments[] array in output next to a post_comment tool), models often
put the data in the final object and never call the tool, or do both. Do not
rely on the model to pick one. Either perform the side effect in your own
code from the validated result.object, or do not mirror the payload: let
the tool take the data and keep output for a summary or status.
import { z } from 'zod';
import { createAgent } from '@lousho/build-ai-agent';
declare function postComment(comment: { file: string; body: string }): Promise<void>;
const agent = createAgent({
model: 'openai/gpt-4o-mini',
instructions: 'Review the diff and return your comments.',
output: z.object({ comments: z.array(z.object({ file: z.string(), body: z.string() })) }),
});
const result = await agent.send('Review this diff: ...');
for (const comment of result.object?.comments ?? []) {
await postComment(comment); // host code, once per validated comment
}For extract, then approve, then act, keep the three steps apart. The agent
only extracts into output (no side-effect tool). Your code shows the
validated object to a person, and acts only on a yes, with an idempotency key
from the data (a claim id), because a rerun of the whole flow extracts again:
import { z } from 'zod';
import { createAgent } from '@lousho/build-ai-agent';
declare function askReviewer(claim: { claimId: string; amountCents: number }): Promise<boolean>;
declare const claims: { file(claim: { claimId: string; amountCents: number }, opts: { idempotencyKey: string }): Promise<void> };
const extractor = createAgent({
model: 'openai/gpt-4o-mini',
instructions: 'Extract the claim from the email. Do not file it.',
output: z.object({ claimId: z.string(), amountCents: z.number() }),
});
const { object: claim } = await extractor.send('Email: ...');
if (claim && (await askReviewer(claim))) {
await claims.file(claim, { idempotencyKey: `claim:${claim.claimId}` });
}If the model has to call the tool itself, gate it with needsApproval and see
Approvals for stopping the
run on a rejection.
agent.session() on an agent with output returns a session whose
send() result (and stream()'s result) carries the typed object of each
turn. There is no per-call output override: the schema is the agent's.
import { z } from 'zod';
import { createAgent } from '@lousho/build-ai-agent';
const agent = createAgent({ model: 'openai/gpt-4o-mini', output: z.object({ city: z.string(), tempC: z.number() }) });
const session = agent.session();
const { object } = await session.send('Weather in Paris?');
console.log(object?.tempC); // typed: numberA sub-agent created with its own output schema answers the lead with its
validated object. The task tool result (and agent_await's result for a
background task) is the object as JSON, then a blank line and the usual
[sub-agent '<name>': ... taskId '<id>'] footer, so the lead model sees the
object rather than a prose rendering. If the sub-agent's reply is still invalid
after its repair step, the task call fails with the structured tool error
(kind: 'execution', a message naming the schema issues) and the lead can
retry or adapt. A sub-agent that runs out of steps and then gives a valid
object in its forced final call (Out of steps) answers the
lead with that object, like a finished one; the footer shows finish reason
'max-steps' and a line after it says the steps ran out.
A sub-agent does not inherit the lead's output: each agent's output is its
own, and a sub-agent without output returns text as before. A
remoteAgent() whose deployed agent has an
output schema returns the remote object the same way (JSON, then its footer),
read from the object of the remote stream's run.done event.
import { z } from 'zod';
import { createAgent } from '@lousho/build-ai-agent';
const reporter = createAgent({
model: 'openai/gpt-4o-mini',
description: 'Reports the weather as { city, tempC }',
output: z.object({ city: z.string(), tempC: z.number() }),
});
const lead = createAgent({ model: 'openai/gpt-4o-mini', subagents: { reporter } });
const result = await lead.send('What is the weather in Paris?');
console.log(result.text);Optional fields work on strict structured-output endpoints (OpenAI,
OpenRouter), which require every property to be listed in required. In the
JSON Schema sent to the model, each optional property (.optional(),
.default()) is required and nullable instead, at every level: nested
objects, array items and union branches. A null the model returns for such a
field is removed before validation, so the schema you wrote still validates it
and result.object has no key for it. A field that accepts null itself
(.nullable(), .nullish()) keeps the null.
import { z } from 'zod';
import { createAgent } from '@lousho/build-ai-agent';
const agent = createAgent({
model: 'openrouter/openai/gpt-4o-mini',
output: z.object({ verdict: z.string(), note: z.string().optional() }),
});
const { object } = await agent.send('Review this change.');
// The model may answer {"verdict":"approve","note":null}; object is { verdict: 'approve' }.
console.log(object?.note); // typed: string | undefined- The system prompt gets an
## Output formatsection asking for the final answer as only a JSON object matching the schema, rendered as JSON Schema. - Model calls that offer no tools carry
responseFormat: { type: 'json', schema }onGenerateOptions: every call of an agent without tools, the "answer now" call when steps run out (see below), and the repair step. It is a hint: the built-inai-SDK providers turn on the model's JSON mode (with the schema, for models that support structured outputs; OpenRouter and thepiprovider send a strictjson_schemaresponse format); a custom provider may use it or ignore it. A call that offers tools goes without it, and the model answers from the schema in the prompt: with a strict response format next to tools, some models (openai/gpt-4o-minivia OpenRouter) keep calling tools untilmaxStepsinstead of answering.toolChoice: 'none'counts as no tools offered. To send it on every call anyway, passoutput: { schema, responseFormat: 'always' }. - When the model replies without tool calls, the reply is parsed as JSON (a
```jsoncode fence around it is tolerated) and validated with the schema. A near miss is read without a repair call: when the whole reply is not JSON,<think>…</think>blocks are removed and the first fenced block, or balanced{…}/[…], that parses and validates is used. So a prose prefix, trailing prose after a fenced block, or a reasoning block before the JSON costs nothing extra.result.textkeeps the reply as the model wrote it. - If it is invalid, the model gets one repair step: a user message
starting with
[output-invalid]that lists the issues, for example1 issue (tempC: Expected number, received string). When the reply was the JSON Schema itself (some local models echo it), the message says so and shows an example object of the right shape instead. The repair step counts againstmaxSteps, and there is none when the budget is spent. There is none either when the reply was cut off at themaxTokenslimit (the model'sfinishReasonwas'length'): a repair would be cut off the same way, so the run ends at once withoutputError.kind: 'truncated'and a message that says to raisemodelSettings.maxTokens. The repair message and the reply it rejected are sent to that one model call only: they are not inresult.messagesor the session transcript, so a later turn never sees them. - Still invalid, the run resolves (it does not reject) with
finishReason: 'output-invalid', noobject, andoutputError:{ kind, message, issues: [{ path, message }] }, wherekindis'invalid'or'truncated'.
When the model is still calling tools as maxSteps runs out, a run with
output set does not give up: it makes one last model call with no tools
offered ("answer now") and validates that reply the same way. A valid
answer ends the run finishReason: 'max-steps' with object set; an
invalid one ends it 'output-invalid' with outputError. The extra call
is skipped when another budget limit (tokens, cost, duration) is already
spent. A limits.maxSteps that runs out gets the same call; that run
still ends 'budget-exceeded' with result.budget, plus object when the
answer validated. Like the repair message, the "answer now" prompt is sent to that
call only and is not kept in result.messages or the session transcript.
Steps 1 and 2 send the schema twice on a call without tools: once inside the
## Output format system-prompt block, and once on responseFormat. When the
provider enforces responseFormat (OpenAI structured outputs, OpenRouter, the
pi provider) the prompt copy is redundant - on a large schema it costs
around a thousand tokens per call. Pass output as { schema, promptSchema: false } to leave the schema out of the prompt; the JSON-only ask stays, and
since responseFormat is then the only copy of the schema it goes on every
call, tool-offering ones included (responseFormat defaults to 'always'
here; set 'final' to keep it off those calls):
import { z } from 'zod';
import { createAgent } from '@lousho/build-ai-agent';
const agent = createAgent({
model: 'openai/gpt-4o',
output: { schema: z.object({ city: z.string(), tempC: z.number() }), promptSchema: false },
});
const { object } = await agent.send('Weather in Paris?');Keep the default (promptSchema: true, or a bare output: schema) when the
provider - or model - treats responseFormat as a JSON-mode hint only (the
built-in ai-SDK providers drop the schema for models without
structured-outputs support; a custom provider may ignore it entirely). The
prompt copy is then the only place the model sees the shape to answer with.
Keep it too for an agent with tools: its tool-offering calls carry no
responseFormat by default, so the prompt copy is what they answer from.
output |
Schema in the prompt | responseFormat on calls offering tools |
on calls without tools |
|---|---|---|---|
schema or { schema } |
yes | no | yes |
{ schema, responseFormat: 'always' } |
yes | yes | yes |
{ schema, promptSchema: false } |
no | yes | yes |
{ schema, promptSchema: false, responseFormat: 'final' } |
no | no | yes |
When object came from the repair step, the result has
outputRepaired: true. A repair answers the issues it is shown, so it can
change values to make them validate. If the schema encodes a business
rule, such as a refinement that total equals subtotal + tax, a model
asked to repair an invoice whose printed total breaks the rule may rewrite
the total so the object passes. Keep the schema to the document's
structure, and check business rules on result.object after extraction,
where a failure is yours to report:
import { z } from 'zod';
import { createAgent } from '@lousho/build-ai-agent';
const Invoice = z.object({ subtotal: z.number(), tax: z.number(), total: z.number() });
const agent = createAgent({ model: 'openai/gpt-4o-mini', output: Invoice });
const result = await agent.send('Extract the invoice: subtotal 100.00, tax 19.00, total 120.00');
const invoice = result.object;
if (invoice && Math.abs(invoice.subtotal + invoice.tax - invoice.total) > 0.005) {
console.warn('Totals do not add up', invoice, result.outputRepaired ? '(after a repair)' : '');
}Strict structured-output endpoints (OpenAI, Anthropic, and OpenRouter in
front of them) reject some JSON-Schema shapes with a 400, and Claude answered
a record with {}. The schema sent to the model is rewritten into shapes
they accept, and the model's reply is turned back into your schema's shape
before validation, so result.object always has the type you wrote. Only
result.text (the raw reply) shows the rewritten form.
| Your schema | Sent to the model as | Reply decoded from |
|---|---|---|
z.discriminatedUnion (oneOf) |
anyOf |
(unchanged) |
A root that is not an object (z.union, z.record, z.string(), ...) |
{ result: <schema> } |
{"result": ...} |
z.record(key, value) |
an array of { key, value } objects |
[{"key":"a","value":2}] → { a: 2 } |
z.tuple([a, b], rest?) |
an object { _0, _1, _rest? } |
{"_0":1,"_1":"x"} → [1, 'x'] |
A reply in your schema's own shape (from a model that does not enforce the schema) still validates.
- No
z.date(). JSON has no date type, so a zod 4z.date()field could never validate.createAgent()throws aConfigurationErrornaming the field. Usez.iso.date()orz.iso.datetime()for an ISO string, orz.iso.datetime().pipe(z.coerce.date())for aDateinresult.object.
agent.stream() works the same way: run.result resolves with object,
and the final run.done event carries object (JSON-encoded) when the
reply was valid. The repair step shows up as one more
step.start / step.done pair. See Streaming.
While the reply streams, each text.delta that changes the parsed reply is
followed by an object.delta event whose object is the partial object so
far: an unfinished string value is cut where the text is, an unfinished key or
number is left out, and the open objects and arrays are closed. It is not
validated against the schema (a field may still be missing); use it to render
progress, and result.object for the answer. The UI bindings keep it as
partialObject.
import { createAgent } from '@lousho/build-ai-agent';
import { z } from 'zod';
const planner = createAgent({ model: 'openai/gpt-4o-mini', output: z.object({ days: z.array(z.string()) }) });
for await (const event of planner.stream('Plan a 3-day trip to Lisbon.')) {
if (event.type === 'object.delta') console.log(event.object); // { days: ['Alfama and the castle', 'Bel'] }, ...
}AgentExecutor.execute() and AgentExecutor.stream() take the same
output option. There result.object is unknown; parse it again with your
schema, or use createAgent() for the inferred type.
import { z } from 'zod';
import { AgentBuilder, AgentExecutor } from '@lousho/build-ai-agent/executor';
import { createMockProvider } from '@lousho/build-ai-agent';
const Ticket = z.object({ title: z.string(), priority: z.enum(['low', 'high']) });
const agent = AgentBuilder.create().setName('triage').setPrompt('You triage bug reports.').build();
const result = await AgentExecutor.execute({ agent, input: 'The app crashes on login', provider: createMockProvider(), output: Ticket });
const ticket = result.object === undefined ? undefined : Ticket.parse(result.object);With mockModel, script the JSON text the model would write:
import { z } from 'zod';
import { createAgent } from '@lousho/build-ai-agent';
import { mockModel } from '@lousho/build-ai-agent/testing';
const agent = createAgent({ provider: mockModel(['{"city":"Paris","tempC":21}']), output: z.object({ city: z.string(), tempC: z.number() }) });
const { object } = await agent.send('Weather in Paris?');
console.log(object?.city); // 'Paris'- Decisions:
decide()answers predicate / choice / score questions over one input on a dedicated fast endpoint — the right call when you need a classification or confidence, not an object.