Structured output
Give an agent a Zod schema and get a typed result — with one automatic repair.
Set output to a Zod schema and run() returns RunResult<z.infer<typeof schema>>.
import { Agent, run } from "@rohhitrz/jonaki";
import * as z from "zod";
const Order = z.object({
customer: z.object({ name: z.string(), email: z.string() }),
orderId: z.string(),
items: z.array(
z.object({ sku: z.string(), quantity: z.number().int() }),
),
totalUSD: z.number(),
sentiment: z.enum(["happy", "neutral", "angry"]),
});
const extractor = new Agent({
name: "Extractor",
instructions: "Extract structured order data from customer emails.",
output: Order,
});
const result = await run(extractor, messyEmail);
result.output.customer.name; // string
result.output.totalUSD; // number
result.output.sentiment; // "happy" | "neutral" | "angry"No cast, no as. Hover result.output and TypeScript knows the whole shape.
How it works
- The JSON Schema is injected into the system prompt with strict instructions.
- The final assistant message is parsed — bare JSON, a fenced block, or JSON embedded in prose all work.
- The parsed value is validated against the schema.
- On failure, the validation error is sent back to the model once.
- A second failure throws
OUTPUT_INVALIDwith the raw text attached.
The repair round-trip
When the model gets it wrong, Jonaki tells it exactly what was wrong:
Your previous response did not match the required schema.
Problems:
- totalUSD: Invalid input: expected number, received string
- sentiment: Invalid option: expected one of "happy"|"neutral"|"angry"
Reply with only the corrected JSON object.That attempt is recorded in the trace:
const repairs = result.steps.filter((step) => step.type === "output_repair");
console.log(`${repairs.length} repair(s)`);When it gives up
import { JonakiError, run } from "@rohhitrz/jonaki";
try {
await run(extractor, "...");
} catch (error) {
if (JonakiError.is(error, "OUTPUT_INVALID")) {
console.log(error.details?.received); // the raw model text
console.log(error.details?.issues); // what failed validation
}
}Designing schemas that work
Describe your fields. The description reaches the model:
import * as z from "zod";
const Ticket = z.object({
priority: z
.enum(["low", "medium", "high"])
.describe("high only when the customer threatens to cancel"),
summary: z.string().describe("One sentence, no more than 100 characters"),
});Prefer nullable over optional for values the model may not find. An
explicit null is a clearer signal than a missing key:
import * as z from "zod";
const Contact = z.object({
name: z.string(),
phone: z.string().nullable(), // "not found" is representable
});Keep it flat where you can. Deeply nested schemas are harder for models to fill correctly, and a single bad leaf fails the whole parse.
Combining with tools
Tools and structured output work together — the agent gathers data, then answers in your shape:
import { Agent, run, tool } from "@rohhitrz/jonaki";
import * as z from "zod";
const lookupOrder = tool({
name: "lookup_order",
description: "Look up an order by id.",
input: z.object({ orderId: z.string() }),
run: ({ orderId }) => `${orderId}: shipped, $184.97, 3 items`,
});
const Summary = z.object({
orderId: z.string(),
status: z.string(),
totalUSD: z.number(),
});
const agent = new Agent({
name: "OrderBot",
tools: [lookupOrder],
output: Summary,
});
const result = await run(agent, "What's the status of A-7741?");
result.output.status; // stringArrays at the top level
Wrap them. A top-level array leaves nowhere to put anything else, and models handle objects more reliably:
import * as z from "zod";
const Result = z.object({
items: z.array(z.object({ sku: z.string(), qty: z.number() })),
});