Typed Structured Output
Agents produce prose. Sometimes you need a typed object — an invoice, a parsed
entity, a classification result — not a string. Typed structured output gives
you that: call .withOutputSchema(schema) in the builder chain and the agent
populates result.object as your declared type, while result.output carries
the steered JSON answer the agent was guided to emit.
Two engines handle the extraction depending on what your stack supports:
- Fast — single-shot native JSON enforcement (frontier models with native function calling and no tool overlap). Lowest latency.
- Grounded — extracts, grounds each field against the tool-result evidence corpus from the run, surgically re-extracts missing required fields, and scores per-field confidence. Enables provenance + abstention. The differentiator for research or data-extraction agents.
The mode is auto-selected by default and can be overridden.
Works across all supported providers and tiers — Anthropic, OpenAI, Gemini, and local Ollama models (qwen, gemma, etc.) — making it a practical option even on self-hosted infrastructure.
Quick start
Section titled “Quick start”.withOutputSchema() is a builder method — call it in the builder chain
before .build(), then use agent.run() or agent.streamObject() on the
built agent.
import { ReactiveAgents } from "reactive-agents";import { z } from "zod";
const InvoiceSchema = z.object({ vendor: z.string(), total: z.number(), currency: z.string(), lineItems: z.array( z.object({ description: z.string(), amount: z.number() }) ),});
const agent = await ReactiveAgents.create() .withName("invoice-extractor") .withSystemPrompt("You extract structured data from documents.") .withTools({ builtins: ["file-read"] }) .withOutputSchema(InvoiceSchema) .build();
const result = await agent.run( "Extract the invoice details from invoice.pdf");
if (result.object) { // result.object is typed as z.infer<typeof InvoiceSchema> console.log(`${result.object.vendor}: ${result.object.total} ${result.object.currency}`);}import { ReactiveAgents } from "reactive-agents";import * as v from "valibot";
const InvoiceSchema = v.object({ vendor: v.string(), total: v.number(), currency: v.string(), lineItems: v.array( v.object({ description: v.string(), amount: v.number() }) ),});
const agent = await ReactiveAgents.create() .withName("invoice-extractor") .withSystemPrompt("You extract structured data from documents.") .withTools({ builtins: ["file-read"] }) .withOutputSchema(InvoiceSchema) .build();
const result = await agent.run( "Extract the invoice details from invoice.pdf");
if (result.object) { // result.object is typed as v.InferOutput<typeof InvoiceSchema> console.log(`${result.object.vendor}: ${result.object.total} ${result.object.currency}`);}import { ReactiveAgents } from "reactive-agents";import { type } from "arktype";
const InvoiceSchema = type({ vendor: "string", total: "number", currency: "string", lineItems: type({ description: "string", amount: "number" }).array(),});
const agent = await ReactiveAgents.create() .withName("invoice-extractor") .withSystemPrompt("You extract structured data from documents.") .withTools({ builtins: ["file-read"] }) .withOutputSchema(InvoiceSchema) .build();
const result = await agent.run( "Extract the invoice details from invoice.pdf");
if (result.object) { // result.object is typed as typeof InvoiceSchema.infer console.log(`${result.object.vendor}: ${result.object.total} ${result.object.currency}`);}import { ReactiveAgents } from "reactive-agents";import { Schema } from "effect";
const Invoice = Schema.Struct({ vendor: Schema.String, total: Schema.Number, currency: Schema.String, lineItems: Schema.Array( Schema.Struct({ description: Schema.String, amount: Schema.Number }) ),});
const agent = await ReactiveAgents.create() .withName("invoice-extractor") .withSystemPrompt("You extract structured data from documents.") .withTools({ builtins: ["file-read"] }) .withOutputSchema(Invoice) .build();
const result = await agent.run( "Extract the invoice details from invoice.pdf");
if (result.object) { // result.object is typed as Schema.Schema.Type<typeof Invoice> console.log(`${result.object.vendor}: ${result.object.total} ${result.object.currency}`);}.withOutputSchema() accepts any Standard Schema v1
validator (Zod 3.24+, Valibot, ArkType) or an Effect Schema.Schema<A>
imported from "effect" (not "@effect/schema"). The returned builder is
re-typed so result.object is A at compile time. Reactive Agents supports
all four of these schema libraries out of the box — most frameworks support only
one or two.
Top-level arrays
Section titled “Top-level arrays”All four schema libraries support top-level array schemas directly:
import { z } from "zod";
const LineItemList = z.array( z.object({ description: z.string(), amount: z.number() }));
const agent = await ReactiveAgents.create() .withOutputSchema(LineItemList) .build();
const result = await agent.run("List all line items from the invoice");// result.object is typed as Array<{ description: string; amount: number }>if (result.object) { result.object.forEach((item) => console.log(item.description, item.amount));}import * as v from "valibot";
const LineItemList = v.array( v.object({ description: v.string(), amount: v.number() }));
const agent = await ReactiveAgents.create() .withOutputSchema(LineItemList) .build();
const result = await agent.run("List all line items from the invoice");// result.object is typed as Array<{ description: string; amount: number }>if (result.object) { result.object.forEach((item) => console.log(item.description, item.amount));}import { type } from "arktype";
const LineItemList = type({ description: "string", amount: "number" }).array();
const agent = await ReactiveAgents.create() .withOutputSchema(LineItemList) .build();
const result = await agent.run("List all line items from the invoice");// result.object is typed as Array<{ description: string; amount: number }>if (result.object) { result.object.forEach((item) => console.log(item.description, item.amount));}import { Schema } from "effect";
const LineItemList = Schema.Array( Schema.Struct({ description: Schema.String, amount: Schema.Number }));
const agent = await ReactiveAgents.create() .withOutputSchema(LineItemList) .build();
const result = await agent.run("List all line items from the invoice");// result.object is typed as ReadonlyArray<{ readonly description: string; readonly amount: number }>if (result.object) { result.object.forEach((item) => console.log(item.description, item.amount));}result.output in structured mode
Section titled “result.output in structured mode”When .withOutputSchema() is set, the agent is steered to emit JSON matching
the schema as its final answer. As a result:
result.outputis the raw JSON string the agent produced (not prose).result.objectis the parsed, typed value derived from that JSON.
If you have existing code that reads result.output as prose, be aware it
will contain JSON when structured output is active. Use result.object for
the typed value and fall back to result.output only as a last resort (e.g.,
when result.object is undefined after a parse failure).
result.object and result.objectError
Section titled “result.object and result.objectError”By default the agent is lenient: a parse failure does not throw. Instead:
result.objectisundefined.result.objectErroris set to a human-readable description of what went wrong.
const result = await agent.run("Extract invoice details");
if (result.object) { // happy path} else if (result.objectError) { console.error("Extraction failed:", result.objectError); // fall back to result.output (the raw JSON / text answer)}Lenient vs strict (onParseFail)
Section titled “Lenient vs strict (onParseFail)”Choose between two failure modes via the onParseFail option:
| Mode | Behaviour |
|---|---|
"degrade" (default) | object is undefined, objectError is set. Never throws. |
"throw" | Throws StructuredOutputError (carries .rawText + .issues). |
import { StructuredOutputError } from "reactive-agents";
const strictAgent = await ReactiveAgents.create() .withOutputSchema(InvoiceSchema, { onParseFail: "throw" }) .build();
try { const result = await strictAgent.run("Extract invoice details"); console.log(result.object);} catch (e) { if (e instanceof StructuredOutputError) { console.error("Parse failed:", e.issues); console.log("Raw text was:", e.rawText); }}The grounded path — provenance, confidence, and abstention
Section titled “The grounded path — provenance, confidence, and abstention”For extraction tasks where you need to know why a value was chosen — or when
model hallucination is a concern — use mode: "grounded". Grounded mode is
most useful when tools are registered: the engine grounds each field against
the actual tool-result evidence corpus the agent accumulated during the run.
Without tools there is no evidence corpus, so the grounded path falls back to
a best-effort extraction.
The grounded engine:
- Extracts fields from the agent’s final answer.
- Grounds each field against the tool-result evidence corpus accumulated during the run (the actual data the agent read, not its prose summary).
- Scores per-field confidence (0–1).
- Surgically re-extracts missing required fields if they were omitted.
const agent = await ReactiveAgents.create() .withSystemPrompt("You extract financial data from SEC filings.") .withTools({ builtins: ["web-search", "file-read"] }) .withOutputSchema(InvoiceSchema, { mode: "grounded" }) .build();
const result = await agent.run("Extract Q3 revenue from the attached 10-Q");
if (result.object) { console.log(result.object.total);
// Per-field evidence trace console.log(result.provenance?.total); // → { source: "10-Q page 4", evidence: "Net revenues for Q3 were $..." }
// Per-field confidence (0..1) console.log(result.confidence?.total); // → 0.97}Abstention (opt-in)
Section titled “Abstention (opt-in)”Set abstainBelow to have the grounded engine omit non-required fields whose
confidence falls below a threshold, rather than emitting a low-confidence guess:
const agent = await ReactiveAgents.create() .withOutputSchema(InvoiceSchema, { mode: "grounded", abstainBelow: 0.7, }) .build();
const result = await agent.run("...");
// Fields below 0.7 confidence are omitted from result.object// and recorded here instead:console.log(result.abstained);// → { currency: "confidence 0.42 below abstainBelow threshold" }abstainBelow is opt-in (off by default) and requires the grounded path.
Grounded result fields
Section titled “Grounded result fields”| Field | Type | When present |
|---|---|---|
result.object | A | undefined | Parse succeeded |
result.objectError | string | Parse failed (lenient mode) |
result.provenance | Record<string, { source, evidence }> | Grounded path |
result.confidence | Record<string, number> | Grounded path |
result.abstained | Record<string, string> | Grounded path + abstainBelow set |
mode option
Section titled “mode option”| Value | Behaviour |
|---|---|
"auto" (default) | fast for frontier models with native JSON + no tool overlap; else grounded. |
"fast" | Single-shot extraction. No grounding, provenance, or abstention. |
"grounded" | Loop-integrated extraction with evidence grounding. |
Streaming structured output (streamObject)
Section titled “Streaming structured output (streamObject)”Use streamObject() to receive a DeepPartial<A> as tokens arrive, finishing
with the complete validated object:
const agent = await ReactiveAgents.create() .withOutputSchema(InvoiceSchema) .build();
for await (const { object } of agent.streamObject("Extract invoice details")) { // object is DeepPartial<Invoice> — fields appear as the model emits them if (object.vendor) process.stdout.write(`\rVendor so far: ${object.vendor}`);}// Final iteration carries the full validated objectstreamObject() throws synchronously if .withOutputSchema() was not called.
When onParseFail: "throw" is set, it throws StructuredOutputError at the
end if the final buffer fails validation.
Note that runStream() and resumeRun() return the base stream / result and do
not carry the typed object; use run() or streamObject() for typed
structured output.
Using with the Compose API
Section titled “Using with the Compose API”.withOutputSchema() composes with the full builder chain, including
Compose API killswitches. The agent loop runs under the
composed harness; structured extraction fires after the loop completes.
import { ReactiveAgents } from "reactive-agents";import { budgetLimit } from "@reactive-agents/compose";import { z } from "zod";
const ReportSchema = z.object({ summary: z.string(), riskLevel: z.enum(["low", "medium", "high"]), findings: z.array(z.string()),});
const agent = await ReactiveAgents.create() .withSystemPrompt("You are a risk analysis agent.") .withTools({ builtins: ["web-search", "file-read"] }) .compose(budgetLimit({ maxTokens: 50_000 })) .withOutputSchema(ReportSchema) .build();
const result = await agent.run("Analyse the attached contract for risk");if (result.object) { console.log(result.object.riskLevel, result.object.findings);}Note: structured output is not itself a composable chokepoint — it runs as a post-loop extraction step. The harness governs the reasoning loop; the extraction call (when the parse-first path misses) happens outside harness governance.
Limitations
Section titled “Limitations”- Lenient by default —
onParseFail: "degrade"means failures are silent unless you checkresult.objectError. Use"throw"when you want failures to be loud. - Grounded field-level features are richest with Effect Schema. Standard Schema inputs (Zod/Valibot/ArkType) get provenance and confidence scoring, but requirement-tracking and surgical re-extraction of missing required fields are Effect-Schema-only today.
abstainBelowand grounded-default routing are opt-in pending cross-tier ablation (project lift rule). Auto-mode selectsgroundedonly when the fast path is not applicable.runStream()/resumeRun()results do not carryresult.object— userun()orstreamObject()for typed output.- On slow local models, the extraction adds latency only when the parse-first path misses (the agent is steered to emit JSON, so the common path is a free parse of the model’s own output).
result.outputis JSON, not prose, when structured output is active. Code relying onresult.outputas a human-readable string should switch toresult.objectfor the typed value.
See also
Section titled “See also”- Reasoning — the kernel loop that populates the evidence corpus the grounded engine draws from.
- Durable Execution — crash-resume for long extraction runs.
- Tools — tools produce the evidence that grounded extraction grounds against.