Skip to main content

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

  1. Use Output.object for complex outputs — structure and validation in one place. Omit output only when plain text is enough.

  2. Use Output.choice for simple classification — cleaner than a one-field object with a Zod enum.

  3. Use Output.array for lists — pass the item schema; don't wrap arrays manually in Output.object.

  4. Use z.record() for dynamic keys — when field names aren't known ahead of time, prefer z.record(z.string(), z.number()) inside an object over many optional fields.

  5. Add .describe() to every field — descriptions go straight to the LLM and significantly improve accuracy.

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

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