Skip to main content

Structured Outputs

Baleybot uses Zod schemas to define and validate structured outputs from LLMs. Schemas are automatically converted to JSON Schema for provider compatibility.

Zod schemas

Basic types

import { z } from 'zod';
import { Baleybot } from '@baleybots/core';

// String
const bot = Baleybot.create({
name: 'text-analyzer',
goal: 'Extract text',
outputSchema: z.string()
});

// Number
const bot = Baleybot.create({
name: 'calculator',
goal: 'Calculate result',
outputSchema: z.number()
});

// Boolean
const bot = Baleybot.create({
name: 'classifier',
goal: 'Classify as spam or not',
outputSchema: z.boolean()
});

Object types

// Define schemas separately for reuse and readability
const SentimentResult = z.object({
sentiment: z.enum(['positive', 'negative', 'neutral']),
confidence: z.number().min(0).max(1),
keywords: z.array(z.string()),
});

const bot = Baleybot.create({
name: 'sentiment-analyzer',
goal: 'Analyze sentiment',
outputSchema: SentimentResult,
});

// Optional fields
const UserProfile = z.object({
name: z.string(),
email: z.string().optional(),
age: z.number().optional(),
});

const bot = Baleybot.create({
name: 'profile-extractor',
goal: 'Extract profile',
outputSchema: UserProfile,
});

Array types

// Array of primitives
const bot = Baleybot.create({
name: 'keyword-extractor',
goal: 'Extract keywords',
outputSchema: z.array(z.string()),
});

// Array of objects — define the item schema first
const LinkItem = z.object({
url: z.string().url(),
title: z.string(),
});

const bot = Baleybot.create({
name: 'link-extractor',
goal: 'Extract links',
outputSchema: z.array(LinkItem),
});

Enum types

// String enum
const bot = Baleybot.create({
name: 'classifier',
goal: 'Classify sentiment',
outputSchema: z.enum(['positive', 'negative', 'neutral'])
});

// With a reusable Zod enum
const Sentiment = z.enum(['positive', 'negative', 'neutral']);
const bot = Baleybot.create({
name: 'sentiment',
goal: 'Analyze sentiment',
outputSchema: Sentiment
});

Union types

// Simple union
const bot = Baleybot.create({
name: 'flexible',
goal: 'Return string or number',
outputSchema: z.union([z.string(), z.number()]),
});

// Union of objects — define each variant separately
const SuccessResponse = z.object({
success: z.literal(true),
data: z.string(),
});

const ErrorResponse = z.object({
success: z.literal(false),
error: z.string(),
});

const bot = Baleybot.create({
name: 'api-response',
goal: 'Parse API response',
outputSchema: z.union([SuccessResponse, ErrorResponse]),
});

Tuple types

// Fixed-length array with typed positions
const bot = Baleybot.create({
name: 'coordinate-extractor',
goal: 'Extract lat/lng coordinates',
outputSchema: z.tuple([
z.number(), // latitude
z.number() // longitude
])
});

const result = await bot.process('San Francisco, CA');
// result = [37.7749, -122.4194]

Record types

// Generic object with any keys
const bot = Baleybot.create({
name: 'metadata-extractor',
goal: 'Extract metadata',
outputSchema: z.record(z.string(), z.any())
});

const result = await bot.process('Extract metadata from this...');
// result = { author: 'John', date: '2024', tags: ['ai', 'ml'] }

// Record with typed values
const bot = Baleybot.create({
name: 'score-calculator',
goal: 'Calculate scores',
outputSchema: z.record(z.string(), z.number())
});

const result = await bot.process('Calculate test scores');
// result = { math: 95, english: 88, science: 92 }

Flexible types

// z.any() - accepts anything (use sparingly)
const bot = Baleybot.create({
name: 'flexible',
goal: 'Return whatever makes sense',
outputSchema: z.any()
});

// z.unknown() - safer than any (requires type checking)
const bot = Baleybot.create({
name: 'unknown-parser',
goal: 'Parse unknown format',
outputSchema: z.unknown()
});

Partially supported types

Native enums do not fully work yet; use z.enum() as a workaround.

Discriminated unions work, but discriminator metadata is not preserved in the JSON Schema output.

Unsupported types

These Zod types cannot be serialized for LLM outputs: z.function(), z.promise(), z.lazy(), z.map(), z.set(), z.branded(), z.pipeline().

Schema descriptions

JSON Schema description fields are passed directly to the LLM, helping it understand what each field should contain and produce more accurate outputs.

Basic example

const SentimentAnalysis = z.object({
sentiment: z.enum(['positive', 'negative', 'neutral', 'mixed'])
.describe('Overall sentiment. Use "mixed" for reviews with both positive and negative aspects.'),

confidence: z.number().min(0).max(1)
.describe('Confidence score (0-1) based on clarity and definitiveness of language'),

reasoning: z.string()
.describe('Brief explanation (1-2 sentences) of why this sentiment was chosen'),
});

const bot = Baleybot.create({
name: 'sentiment-analyzer',
goal: 'Analyze sentiment of customer reviews',
outputSchema: SentimentAnalysis,
});

Nested objects

Descriptions work at all levels of nesting:

const Reviewer = z.object({
name: z.string()
.describe('Reviewer name if mentioned, otherwise "Anonymous"'),
verified: z.boolean()
.describe('Whether the review indicates an actual purchaser'),
}).describe('Information about the person writing the review');

const Product = z.object({
name: z.string()
.describe('Product name as mentioned in the review'),
priceMentioned: z.boolean()
.describe('Whether the review discusses pricing'),
}).describe('Information about the product being reviewed');

const ReviewResult = z.object({
reviewer: Reviewer,
product: Product,
});

const bot = Baleybot.create({
name: 'review-parser',
goal: 'Parse product reviews',
outputSchema: ReviewResult,
});

Arrays

Describe both the array and its items:

const FeatureMention = z.object({
feature: z.string()
.describe('Name of the feature (e.g., "battery life", "screen quality")'),
positive: z.boolean()
.describe('Whether this feature was praised (true) or criticized (false)'),
});

const ReviewFeatures = z.object({
features: z.array(FeatureMention)
.describe('Specific features or aspects mentioned in the review'),
});

// Use it in a bot
const bot = Baleybot.create({
name: 'feature-extractor',
goal: 'Extract feature mentions from reviews',
outputSchema: ReviewFeatures,
});

Enums

Explain what each option means:

const Priority = z.enum(['low', 'medium', 'high', 'urgent'])
.describe('Priority level: low=informational, medium=needs attention, high=important issue, urgent=blocking/critical');

const CustomerSegment = z.enum(['enterprise', 'smb', 'individual'])
.describe('Customer type: enterprise=large company, smb=small business, individual=personal use');

const TicketClassification = z.object({
priority: Priority,
segment: CustomerSegment,
});

Type inference

Baleybot provides automatic TypeScript type inference from Zod schemas. No manual type annotations needed.

Baleybot output inference

const ResultSchema = z.object({
sentiment: z.enum(['positive', 'negative']),
score: z.number(),
});

const bot = Baleybot.create({
outputSchema: ResultSchema,
});

const result = await bot.process('text');
// result is automatically typed as { sentiment: 'positive' | 'negative', score: number }
result.sentiment; // Type: 'positive' | 'negative'
result.score; // Type: number

Pipeline chain inference

Type inference flows through chains automatically. The output type is inferred from the last bot in the chain:

import { pipeline } from '@baleybots/core';

const chain = pipeline()
.step(bot1)
.step(bot2)
.step(bot3)
.build();

const result = await chain.process('input');
// result is automatically typed as bot3's output type

Loop inference

const loop = new Loop(bot, config);

const result = await loop.process('input');
result.result; // Typed as bot's output type
result.state; // Typed as custom state type (if specified)

Tool return type inference

const fetchTool = tool('fetch', 'Fetch data', schema, async (params) => {
return { id: 1, name: 'Alice', role: 'admin' as const };
});
// Return type is automatically inferred as { id: number, name: string, role: 'admin' }

Type utilities

The package exports utilities for advanced type operations:

import type { InferOutput, InferChainOutput, InferBotOutput } from '@baleybots/core';

type Output = InferOutput<typeof mySchema>;
type ChainOutput = InferChainOutput<typeof bots>;
type BotOutput = InferBotOutput<typeof bot>;

Type inference happens entirely at compile time with zero runtime overhead.

Best practices

  1. Use objects for complex outputs -- they provide structure and validation. Avoid plain z.string() when richer structure would help.

  2. Use z.record() for dynamic keys -- when you do not know the field names ahead of time, prefer z.record(z.string(), z.any()) over an object with many optional fields.

  3. Use arrays for lists -- z.array(z.object({...})) is better than an object with numbered keys.

  4. Add .describe() to every field -- descriptions go straight to the LLM and significantly improve accuracy. Describe the meaning, not the data type.

  5. Be specific in descriptions -- explain decision criteria, provide examples, and handle edge cases explicitly.

  6. Use Zod schemas (not raw JSON Schema) -- Zod gives you both runtime validation and automatic TypeScript type inference.

  7. Let TypeScript infer -- avoid manual type annotations on process() results; the types flow automatically from your schemas.