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
-
Use objects for complex outputs -- they provide structure and validation. Avoid plain
z.string()when richer structure would help. -
Use
z.record()for dynamic keys -- when you do not know the field names ahead of time, preferz.record(z.string(), z.any())over an object with many optional fields. -
Use arrays for lists --
z.array(z.object({...}))is better than an object with numbered keys. -
Add
.describe()to every field -- descriptions go straight to the LLM and significantly improve accuracy. Describe the meaning, not the data type. -
Be specific in descriptions -- explain decision criteria, provide examples, and handle edge cases explicitly.
-
Use Zod schemas (not raw JSON Schema) -- Zod gives you both runtime validation and automatic TypeScript type inference.
-
Let TypeScript infer -- avoid manual type annotations on
process()results; the types flow automatically from your schemas.