AI Structured Output Patterns
This skill documents patterns for generating type-safe, validated AI content using Google Gemini with Vercel AI SDK.
When to Use This Skill
-
Generating structured content (blog posts, reports, summaries) with guaranteed format
-
Need type-safe LLM output with TypeScript inference
-
Preventing hallucinations in data analysis through code execution
-
Creating reusable AI generation functions for workflows
-
Building multi-step AI pipelines with separate analysis and generation phases
Architecture Overview
The recommended architecture uses a 2-step generation flow:
Step 1: Analysis (Accuracy) Step 2: Structured Output (Presentation) ┌─────────────────────────────┐ ┌─────────────────────────────┐ │ generateText() │ │ generateObject() │ │ + Code Execution Tool │ ──▶ │ + Zod Schema │ │ + Extended Thinking │ │ + Field Descriptions │ │ = Accurate calculations │ │ = Type-safe output │ └─────────────────────────────┘ └─────────────────────────────┘
Why 2 steps?
-
Step 1 focuses on accuracy: Code execution prevents calculation errors
-
Step 2 focuses on format: Zod schema ensures consistent structure
-
Separation allows optimisation: extended thinking only where needed
2-Step Generation Implementation
Complete Example
import { google } from "@ai-sdk/google"; import { generateText, generateObject } from "ai"; import { z } from "zod";
// Define output schema with field descriptions const outputSchema = z.object({ title: z.string().max(100).describe("SEO-optimised title, max 60 chars preferred"), excerpt: z.string().max(500).describe("2-3 sentence summary for meta description"), content: z.string().describe("Full markdown content without H1 title"), tags: z.array(z.string()).min(1).max(10).describe("3-5 category tags in Title Case"), highlights: z.array(z.object({ value: z.string().describe('Metric value, e.g. "52.60%", "$125,000"'), label: z.string().describe('Short label, e.g. "Electric Vehicles Lead"'), detail: z.string().describe('Context, e.g. "2,081 units registered"'), })).min(3).max(10).describe("3-6 key statistics for visual display"), });
type GeneratedOutput = z.infer<typeof outputSchema>;
export async function generate2Step(data: string): Promise<GeneratedOutput> {
// STEP 1: Analysis with Code Execution
const analysisResult = await generateText({
model: google("gemini-2.5-flash"),
system: ANALYSIS_INSTRUCTIONS,
tools: { code_execution: google.tools.codeExecution({}) },
prompt: Analyse this data:\n${data}\n\nProvide detailed analysis with accurate calculations.,
providerOptions: {
google: {
thinkingConfig: {
thinkingBudget: -1, // Unlimited thinking for complex analysis
},
},
},
});
// STEP 2: Structured Output Generation
const { object } = await generateObject({
model: google("gemini-2.5-flash"),
schema: outputSchema,
system: GENERATION_INSTRUCTIONS,
prompt: Based on this analysis:\n\n${analysisResult.text}\n\nGenerate the structured output.,
});
return object; // Fully typed! }
Code Execution Tool
The Code Execution Tool is critical for preventing hallucinations in data analysis.
Configuration
tools: { code_execution: google.tools.codeExecution({}) }
Why It Matters
Without Code Execution With Code Execution
LLM guesses calculations Python executes actual math
Plausible but wrong numbers Verified accurate results
Cannot validate data Can parse and validate input
Unreliable for financial data Safe for market analysis
When to Use
-
Always use for: calculations, aggregations, percentages, comparisons
-
Skip for: creative writing, summaries, opinion pieces
-
Use in Step 1 only: Code execution is for analysis, not generation
Example: Data Analysis with Code Execution
const analysisResult = await generateText({ model: google("gemini-2.5-flash"), tools: { code_execution: google.tools.codeExecution({}) }, system: `You are a data analyst. Use Python code execution for ALL calculations. Never estimate or guess numbers. Execute code to:
- Parse the input data
- Calculate totals and percentages
- Compare values and trends
- Validate data consistency
, prompt:Analyse this sales data:\n${pipeDelimitedData}`, });
Extended Thinking Configuration
Extended thinking improves analysis quality but increases latency. Use selectively.
Configuration
providerOptions: { google: { thinkingConfig: { thinkingBudget: -1, // -1 = unlimited, or set specific token budget }, }, }
When to Use
Step Extended Thinking Reason
Analysis (Step 1) YES Complex reasoning, data patterns
Generation (Step 2) NO Speed matters, schema guides output
Example: Selective Extended Thinking
// Step 1: WITH extended thinking (complex analysis) const analysis = await generateText({ model: google("gemini-2.5-flash"), tools: { code_execution: google.tools.codeExecution({}) }, providerOptions: { google: { thinkingConfig: { thinkingBudget: -1 }, }, }, prompt: analysisPrompt, });
// Step 2: WITHOUT extended thinking (faster generation) const { object } = await generateObject({ model: google("gemini-2.5-flash"), schema: outputSchema, prompt: generationPrompt, // No thinkingConfig = faster response });
Zod Schema Design Patterns
Field Descriptions
Use .describe() to guide LLM output:
const schema = z.object({ // Constraints + description = better output title: z.string() .max(100) .describe("SEO title, max 60 chars preferred, include main keyword"),
// Array bounds prevent over/under generation tags: z.array(z.string()) .min(3) .max(5) .describe("Category tags in Title Case, first tag is primary category"),
// Nested objects with descriptions author: z.object({ name: z.string().describe("Full name"), role: z.string().describe("Job title or role"), }).describe("Content author information"), });
Type Inference
// Infer TypeScript type from schema type Output = z.infer<typeof schema>;
// Use in function signatures async function generate(): Promise<Output> { const { object } = await generateObject({ model: google("gemini-2.5-flash"), schema, prompt: "...", }); return object; // Typed as Output }
Common Patterns
// Optional fields with defaults z.string().optional().default("Unknown")
// Enum-like constraints z.enum(["draft", "published", "archived"])
// Numeric constraints z.number().min(0).max(100).describe("Percentage value 0-100")
// Date strings z.string().describe("ISO 8601 date string, e.g. 2024-01-15")
// Markdown content z.string().describe("Markdown formatted content, use ## for sections")
Tag Constants Pattern
Use controlled vocabulary for consistent categorisation:
// Define constants with as const export const CATEGORY_TAGS = [ "Technology", "Business", "Finance", "Market Analysis", "Monthly Update", ] as const;
// Extract type from constants export type CategoryTag = (typeof CATEGORY_TAGS)[number];
// Use in schema const schema = z.object({ tags: z.array(z.enum(CATEGORY_TAGS)) .min(1) .max(5) .describe("Select from predefined categories"), });
Multiple Category Sets
export const CARS_TAGS = [ "Cars", "Registrations", "Fuel Types", "Vehicle Types", "Monthly Update", "New Registration", "Market Trends", ] as const;
export const COE_TAGS = [ "COE", "Quota Premium", "1st Bidding Round", "2nd Bidding Round", "Monthly Update", "PQP", ] as const;
// Type union export type DataTag = (typeof CARS_TAGS)[number] | (typeof COE_TAGS)[number];
System Instruction Separation
Separate instructions for analysis vs generation:
// Analysis instructions focus on accuracy const ANALYSIS_INSTRUCTIONS = `You are a data analyst. Use Python code execution for ALL calculations. Never estimate or guess numbers.
Required analysis:
- Parse the pipe-delimited input data
- Calculate totals, percentages, and changes
- Identify top performers and trends
- Compare with previous periods if available
Output: Detailed analysis with verified numbers.`;
// Generation instructions focus on format const GENERATION_INSTRUCTIONS = `You are a content writer. Transform the analysis into structured output.
Requirements:
- Title: SEO-optimised, max 60 characters
- Excerpt: 2-3 sentences, under 300 characters
- Content: Markdown without H1, 500-700 words
- Tags: 3-5 from the allowed vocabulary
- Highlights: 3-6 key statistics with value/label/detail
Tone: Professional, accessible, data-driven.`;
Telemetry Integration
Track generation performance with Langfuse:
import { generateText, generateObject } from "ai";
// Step 1: Analysis telemetry const analysisResult = await generateText({ model: google("gemini-2.5-flash"), tools: { code_execution: google.tools.codeExecution({}) }, prompt: analysisPrompt, experimental_telemetry: { isEnabled: true, functionId: "content-analysis/cars", metadata: { step: "analysis", dataType: "cars", month: "2024-01", tags: ["cars", "2024-01", "analysis"], }, }, });
// Step 2: Generation telemetry const { object } = await generateObject({ model: google("gemini-2.5-flash"), schema: outputSchema, prompt: generationPrompt, experimental_telemetry: { isEnabled: true, functionId: "content-generation/cars", metadata: { step: "generation", dataType: "cars", month: "2024-01", tags: ["cars", "2024-01", "generation"], }, }, });
Langfuse Setup
// instrumentation.ts import { registerOTel } from "@vercel/otel"; import { LangfuseExporter } from "@langfuse/otel";
export function startTracing() { registerOTel({ serviceName: "ai-generation", traceExporter: new LangfuseExporter({ publicKey: process.env.LANGFUSE_PUBLIC_KEY, secretKey: process.env.LANGFUSE_SECRET_KEY, baseUrl: process.env.LANGFUSE_HOST, }), }); }
export async function shutdownTracing() { // Flush pending traces before exit await new Promise((resolve) => setTimeout(resolve, 1000)); }
Function Patterns
Standalone Function (No Workflow)
export interface GenerateParams { data: string; month: string; dataType: "cars" | "coe"; }
export interface GenerateResult { object: GeneratedOutput; usage: { inputTokens: number; outputTokens: number; totalTokens: number }; response: { id: string; modelId: string; timestamp: Date }; }
export async function generateContent( params: GenerateParams ): Promise<GenerateResult> { startTracing();
try { // Step 1: Analysis const analysis = await generateText({ /* ... */ });
// Step 2: Generation
const { object, usage, response } = await generateObject({ /* ... */ });
return { object, usage, response };
} finally { await shutdownTracing(); } }
Workflow-Aware Wrapper
import type { WorkflowContext } from "@upstash/workflow";
export async function generateInWorkflow( context: WorkflowContext, params: GenerateParams ): Promise<GenerateResult> { // Use workflow context for step orchestration const result = await context.run("generate-content", async () => { return generateContent(params); });
// Additional workflow steps await context.run("save-to-database", async () => { await saveToDatabase(result); });
await context.run("invalidate-cache", async () => { await revalidateTag("content:list"); });
return result; }
Error Handling
import { APIError } from "@ai-sdk/google";
export async function generateWithRetry( params: GenerateParams, maxRetries = 3 ): Promise<GenerateResult> { for (let attempt = 1; attempt <= maxRetries; attempt++) { try { return await generateContent(params); } catch (error) { if (error instanceof APIError) { // Handle rate limits if (error.status === 429 && attempt < maxRetries) { await new Promise(r => setTimeout(r, 2000 * attempt)); continue; } // Handle quota exceeded if (error.status === 403) { throw new Error("API quota exceeded. Check billing."); } } throw error; } } throw new Error("Max retries exceeded"); }
Environment Variables
Required:
GOOGLE_GENERATIVE_AI_API_KEY=... # Google AI API key
Optional (for telemetry):
LANGFUSE_PUBLIC_KEY=pk-lf-... LANGFUSE_SECRET_KEY=sk-lf-... LANGFUSE_HOST=https://cloud.langfuse.com
Best Practices
-
Always use 2-step flow for data-driven content
-
Use Code Execution Tool for any calculations
-
Enable extended thinking for analysis step only
-
Add .describe() to all schema fields
-
Use tag constants for controlled vocabulary
-
Separate instructions for analysis vs generation
-
Enable telemetry from the start
-
Handle errors with retries for rate limits
Related Skills
-
gemini-blog
-
Blog-specific generation patterns
-
schema-design
-
Database schema for persisting generated content
-
workflow-management
-
QStash workflow integration
-
redis-cache
-
Caching generated content
Reference Files
-
packages/ai/src/generate-post.ts
-
2-step flow implementation
-
packages/ai/src/schemas.ts
-
Zod schema patterns
-
packages/ai/src/tags.ts
-
Tag constants
-
packages/ai/src/config.ts
-
System instructions
-
packages/ai/src/instrumentation.ts
-
Langfuse setup