| name | ai-structured-output |
| description | Generate type-safe AI content using Gemini structured output with Zod validation and Code Execution Tool. Use when building AI generation functions that need guaranteed output format. |
| allowed-tools | Read, Edit, Write, Grep, Glob |
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:
1. Parse the pipe-delimited input data
2. Calculate totals, percentages, and changes
3. Identify top performers and trends
4. 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 patternsschema-design- Database schema for persisting generated contentworkflow-management- QStash workflow integrationredis-cache- Caching generated content
Reference Files
packages/ai/src/generate-post.ts- 2-step flow implementationpackages/ai/src/schemas.ts- Zod schema patternspackages/ai/src/tags.ts- Tag constantspackages/ai/src/config.ts- System instructionspackages/ai/src/instrumentation.ts- Langfuse setup