Structured Outputs
Baleybot validates LLM output against Zod schemas. Define schemas with Output helpers from @baleybots/core — they wrap AI SDK v7 structured output and give you automatic TypeScript inference.
import { Baleybot, Output } from '@baleybots/core';
import { z } from 'zod';
const bot = Baleybot.create({
name: 'sentiment-analyzer',
goal: 'Analyze sentiment',
output: Output.object({
schema: z.object({
sentiment: z.enum(['positive', 'negative', 'neutral']),
confidence: z.number().min(0).max(1),
}),
}),
});
const result = await bot.process('I love this product!');
// result.sentiment — typed as 'positive' | 'negative' | 'neutral'
Output helpers
Three helpers cover most cases. Import Output from @baleybots/core.
Output.object — structured objects
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',
output: Output.object({ schema: SentimentResult }),
});
Output.array — lists of items
Pass the item schema; Baleybots wraps it as an array for the provider.
const LinkItem = z.object({
url: z.string().url(),
title: z.string(),
});
const bot = Baleybot.create({
name: 'link-extractor',
goal: 'Extract links',
output: Output.array({ schema: LinkItem }),
});
const links = await bot.process('Check https://example.com and https://baleybots.dev');
// links is typed as { url: string; title: string }[]
Output.choice — pick one option
Simpler than hand-rolling a Zod enum when you only need a fixed set of labels:
const bot = Baleybot.create({
name: 'classifier',
goal: 'Classify the sentiment',
output: Output.choice(['positive', 'negative', 'neutral']),
});
const label = await bot.process('I love this!');
// label is typed as 'positive' | 'negative' | 'neutral'
Plain text (no schema)
Omit output when you want an unstructured string response:
const bot = Baleybot.create({
name: 'summarizer',
goal: 'Summarize the text concisely',
});
const summary: string = await bot.process('Long article...');
Zod schemas
Schemas inside Output.object and Output.array use standard Zod. Define them separately for reuse and readability.
Basic types (as object fields)
const ExtractedText = z.object({
text: z.string(),
wordCount: z.number(),
isComplete: z.boolean(),
});
const bot = Baleybot.create({
name: 'text-analyzer',
goal: 'Extract text metadata',
output: Output.object({ schema: ExtractedText }),
});
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',
output: Output.object({ schema: UserProfile }),
});
Enums inside objects
For enums nested in a larger object, use z.enum(). For a top-level single choice, prefer Output.choice.
const Sentiment = z.enum(['positive', 'negative', 'neutral']);
const Analysis = z.object({
sentiment: Sentiment,
score: z.number(),
});
const bot = Baleybot.create({
name: 'sentiment',
goal: 'Analyze sentiment',
output: Output.object({ schema: Analysis }),
});
Union types
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',
output: Output.object({
schema: z.union([SuccessResponse, ErrorResponse]),
}),
});
Tuple types
const bot = Baleybot.create({
name: 'coordinate-extractor',
goal: 'Extract lat/lng coordinates',
output: Output.object({
schema: z.object({
coordinates: z.tuple([z.number(), z.number()]),
}),
}),
});
const result = await bot.process('San Francisco, CA');
// result.coordinates = [37.7749, -122.4194]
Record types
const bot = Baleybot.create({
name: 'score-calculator',
goal: 'Calculate scores',
output: Output.object({
schema: z.object({
scores: z.record(z.string(), z.number()),
}),
}),
});
const result = await bot.process('Calculate test scores');
// result.scores = { math: 95, english: 88, science: 92 }
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.
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',
output: Output.object({ schema: 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',
output: Output.object({ schema: 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'),
});
const bot = Baleybot.create({
name: 'feature-extractor',
goal: 'Extract feature mentions from reviews',
output: Output.object({ schema: 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 TicketClassification = z.object({
priority: Priority,
segment: z.enum(['enterprise', 'smb', 'individual'])
.describe('Customer type: enterprise=large company, smb=small business, individual=personal use'),
});
Type inference
Baleybot infers TypeScript types from your schemas. No manual annotations needed.
Processor output inference
const ResultSchema = z.object({
sentiment: z.enum(['positive', 'negative']),
score: z.number(),
});
const bot = Baleybot.create({
output: Output.object({ schema: ResultSchema }),
});
const result = await bot.process('text');
// result is { sentiment: 'positive' | 'negative', score: number }
result.sentiment;
result.score;
Pipeline chain inference
Type inference flows through chains. The output type comes from the last processor:
import { pipeline } from '@baleybots/core';
const chain = pipeline(bot1, bot2, bot3);
const result = await chain.process('input');
// result is 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
import { tool } from '@baleybots/core';
const fetchTool = tool('fetch', 'Fetch data', schema, async (params) => {
return { id: 1, name: 'Alice', role: 'admin' as const };
});
// Return type is inferred as { id: number, name: string, role: 'admin' }
Type utilities
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
Output.objectfor complex outputs — structure and validation in one place. Omitoutputonly when plain text is enough. -
Use
Output.choicefor simple classification — cleaner than a one-field object with a Zod enum. -
Use
Output.arrayfor lists — pass the item schema; don't wrap arrays manually inOutput.object. -
Use
z.record()for dynamic keys — when field names aren't known ahead of time, preferz.record(z.string(), z.number())inside an object over many optional fields. -
Add
.describe()to every field — descriptions go straight to the LLM and significantly improve accuracy. -
Be specific in descriptions — explain decision criteria, provide examples, and handle edge cases explicitly.
-
Let TypeScript infer — avoid manual type annotations on
process()results; types flow from your schemas.