Structured Output
Get typed, validated responses from your agents.
Structured output lets you define a schema for the model's response. Better Agent parses the response as JSON, validates it against your schema, and returns a typed structured field on the run result.
Define an Output Schema
An output schema has a schema and optional name and strict fields.
import { defineAgent } from "@better-agent/core";
import { z } from "zod";
import { openai } from "./openai";
const summaryAgent = defineAgent({
name: "summary",
model: openai.model("gpt-4o"),
instruction: "Summarize the given text in one paragraph.",
outputSchema: {
schema: z.object({
summary: z.string(),
wordCount: z.number(),
}),
},
});The schema field accepts any Standard Schema library (Zod, Valibot, ArkType) or a raw JSON Schema object.
outputSchema: {
schema: z.object({
summary: z.string(),
wordCount: z.number(),
}),
}import * as v from "valibot";
outputSchema: {
schema: v.object({
summary: v.string(),
wordCount: v.number(),
}),
}import { type } from "arktype";
outputSchema: {
schema: type({
summary: "string",
wordCount: "number",
}),
}outputSchema: {
schema: {
type: "object",
properties: {
summary: { type: "string" },
wordCount: { type: "number" },
},
required: ["summary", "wordCount"],
},
}| Field | Description |
|---|---|
schema | The schema to validate the model's response against. Required. |
name | Name forwarded to providers that support named outputs. Defaults to ${agentName}_output. |
strict | Requests strict schema adherence from providers that support it. |
Accessing the Result
When an agent has an outputSchema, the run result includes a typed structured field.
const result = await app.run("summary", {
input: "Summarize this article about climate change...",
});
console.log(result.structured.summary);
console.log(result.structured.wordCount);The type of result.structured is inferred from your schema, with streaming, access structured through the result promise.
const stream = await app.stream("summary", {
input: "Summarize this article...",
});
for await (const event of stream.events) {
}
const result = await stream.result;
console.log(result.structured.summary);Per-Run Override
Use the output field on run() or stream() to override the agent's default schema for a single run.
const result = await app.run("summary", {
input: "Classify this support ticket...",
output: {
schema: z.object({
category: z.enum(["billing", "technical", "general"]),
priority: z.enum(["low", "medium", "high"]),
}),
},
});
console.log(result.structured.category);
console.log(result.structured.priority);The per-run output schema fully replaces the agent's default for that run. The return type updates accordingly.
Capability Gating
Structured output requires a model that supports it. If the model's structured_output capability is not enabled, the outputSchema field does not appear on the agent definition and output does not appear on run options.
const textAgent = defineAgent({
name: "text",
model: openai.model("gpt-4o"),
outputSchema: { schema: z.object({ answer: z.string() }) }, // ✓
});Structured output also requires text in the output modalities. If you set defaultModalities without "text", structured output will fail at runtime.
Error Recovery
After the agent loop finishes, Better Agent finalizes the result in three steps:
- Extract text: find the last non-empty text from the model's response
- Parse JSON: parse the text as JSON
- Validate: validate the parsed value against the schema
If any step fails, the behavior depends on outputErrorMode.
Default: throw
By default, any finalization failure throws immediately.
const strictAgent = defineAgent({
name: "strict",
model: openai.model("gpt-4o"),
outputSchema: {
schema: z.object({ answer: z.string() }),
},
});Repair mode
Set outputErrorMode to "repair" and provide an onOutputError hook to attempt recovery.
const resilientAgent = defineAgent({
name: "resilient",
model: openai.model("gpt-4o"),
outputSchema: {
schema: z.object({
sentiment: z.enum(["positive", "negative", "neutral"]),
confidence: z.number(),
}),
},
outputErrorMode: "repair",
onOutputError: (context) => {
if (context.errorKind === "validation" && typeof context.value === "object") {
return {
action: "repair_value",
value: {
...context.value,
confidence: Number((context.value as any).confidence) || 0,
},
};
}
if (context.errorKind === "parse") {
const match = context.text.match(/```json?\\s*([\\s\\S]*?)```/);
if (match) {
return { action: "repair_text", text: match[1].trim() };
}
}
return { action: "throw" };
},
});The hook receives a typed error context with one of three errorKind values:
| Error Kind | Fields | Description |
|---|---|---|
missing_text | error | Model returned no text to parse. |
parse | error, text | Text could not be parsed as JSON. |
validation | error, text, value | Parsed JSON failed schema validation. |
The hook can return one of these actions:
| Action | Description |
|---|---|
repair_text | Provide new text to parse and validate again. |
repair_value | Provide a new value to validate directly. |
throw | Throw the original error. |
skip | Fall through to default behavior (throws). |
Repair cycles are capped at a depth of 2 to prevent infinite loops. After that, errors throw regardless of the hook's return value.