jonaki

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

  1. The JSON Schema is injected into the system prompt with strict instructions.
  2. The final assistant message is parsed — bare JSON, a fenced block, or JSON embedded in prose all work.
  3. The parsed value is validated against the schema.
  4. On failure, the validation error is sent back to the model once.
  5. A second failure throws OUTPUT_INVALID with 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; // string

Arrays 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() })),
});

On this page