Structured Output with Valibot Schemas
Tools let the AI do things. But sometimes you don't need the AI to do anything. You need it to understand something. Take a messy human sentence like "fresh pow at Palisades" and turn it into clean, typed JSON your app can trust. The AI SDK's generateText() with Output.object() does exactly this, reusing the same Valibot schemas you already have.
Outcome
Build a /api/parse-alert endpoint that uses generateText() with Output.object() to extract structured alert data from natural language.
Fast Track
- Wrap a Valibot schema with
valibotSchema()and pass it toOutput.object() - Use
generateText()with theoutputoption to get typed, validated responses - Validate the result with Valibot's
parse()for runtime safety
Tools vs Structured Output
Both give you structured data from the AI. The difference:
| Tools | Structured Output | |
|---|---|---|
| Mechanism | Model calls a function | Model returns a JSON object |
| Use when | You need to execute side effects | You need pure data extraction |
| Streaming | Text streams alongside tool calls | Object streams as partial JSON |
| Schema library | Valibot with valibotSchema() | Valibot with valibotSchema() |
The chat endpoint uses tools because creating an alert is a side effect. This new endpoint uses structured output because parsing text into data is pure extraction.
The Valibot Schema
The ski-alerts app already defines alert schemas in src/lib/schemas/alert.ts:
import * as v from 'valibot';
export const AlertConditionSchema = v.variant('type', [
v.object({
type: v.literal('snowfall'),
operator: v.picklist(['gt', 'gte', 'lt', 'lte']),
value: v.number(),
unit: v.literal('inches')
}),
v.object({
type: v.literal('temperature'),
operator: v.picklist(['gt', 'gte', 'lt', 'lte']),
value: v.number(),
unit: v.picklist(['fahrenheit', 'celsius'])
}),
v.object({
type: v.literal('conditions'),
match: v.picklist(['powder', 'clear', 'snowing', 'windy'])
})
]);You'll use this same schema to constrain the AI's output.
Hands-on exercise 2.3
Let's create a new endpoint that parses natural language alert descriptions into structured data:
Requirements:
- Complete the endpoint at
src/routes/api/parse-alert/+server.ts - Use
generateText()withOutput.object()andvalibotSchema(CreateAlertToolInputSchema)for structured output - Accept a
querystring in the POST body (e.g., "powder at Mammoth") - Return the parsed alert condition as validated JSON
- Validate the AI's output with Valibot's
parse()before returning
Implementation hints:
- Import
OutputfromaiandvalibotSchemafrom@ai-sdk/valibot - Reuse
CreateAlertToolInputSchemafrom$lib/schemas/alert, the same schema you used for the tool - After getting the AI result, validate it with
v.parse(AlertConditionSchema, output.condition)for double safety - Include the resort list in the prompt so the AI can resolve resort names to IDs
Try It
-
Test with curl:
$ curl -X POST http://localhost:5173/api/parse-alert \ -H "Content-Type: application/json" \ -d '{"query": "more than 6 inches of snow at Grand Targhee"}'Expected response:
{ "resortId": "grand-targhee", "resortName": "Grand Targhee", "condition": { "type": "snowfall", "operator": "gt", "value": 6, "unit": "inches" }, "originalQuery": "more than 6 inches of snow at Grand Targhee" } -
Test ambiguous input:
$ curl -X POST http://localhost:5173/api/parse-alert \ -H "Content-Type: application/json" \ -d '{"query": "fresh pow at Palisades"}'Expected:
{ "resortId": "palisades", "resortName": "Palisades Tahoe", "condition": { "type": "conditions", "match": "powder" }, "originalQuery": "fresh pow at Palisades" } -
Test invalid input:
$ curl -X POST http://localhost:5173/api/parse-alert \ -H "Content-Type: application/json" \ -d '{"query": "hello world"}'The AI should still attempt to parse it. If it can't extract a meaningful alert, the validation step catches it.
Commit
git add -A
git commit -m "feat(parse): add structured output endpoint with Valibot validation"
git pushDone-When
/api/parse-alertaccepts a natural language query and returns structured JSON- Output matches the
AlertConditionschema shape - Resort names are resolved to IDs correctly
- Invalid inputs return a clear error response
Solution
import { json } from '@sveltejs/kit';
import { createGateway, generateText, Output } from 'ai';
import { valibotSchema } from '@ai-sdk/valibot';
import * as v from 'valibot';
import { resorts } from '$lib/data/resorts';
import { CreateAlertToolInputSchema, AlertConditionSchema } from '$lib/schemas/alert';
import { AI_GATEWAY_API_KEY } from '$env/static/private';
import type { RequestHandler } from './$types';
const gateway = createGateway({
apiKey: AI_GATEWAY_API_KEY
});
export const POST: RequestHandler = async ({ request }) => {
const { query } = await request.json();
if (!query || typeof query !== 'string') {
return json({ error: 'query string required' }, { status: 400 });
}
const resortList = resorts
.map((r) => `- ${r.name} (id: ${r.id})`)
.join('\n');
const { output } = await generateText({
model: gateway('anthropic/claude-sonnet-4'),
output: Output.object({
schema: valibotSchema(CreateAlertToolInputSchema)
}),
prompt: `Parse this natural language alert request into structured data.
Available resorts:
${resortList}
Map common phrases:
- "fresh powder", "pow", "new snow" → conditions type with match: "powder"
- "snowing", "snowfall" with no amount → conditions type with match: "snowing"
- Specific amounts like "6 inches" → snowfall type with operator
- Temperature references → temperature type with operator
User request: "${query}"`
});
if (!output) {
return json(
{ error: 'AI returned no structured output' },
{ status: 422 }
);
}
// Validate the condition with Valibot for runtime type safety
try {
v.parse(AlertConditionSchema, output.condition);
} catch {
return json(
{ error: 'AI returned invalid condition structure' },
{ status: 422 }
);
}
const resort = resorts.find((r) => r.id === output.resortId);
return json({
resortId: output.resortId,
resortName: resort?.name ?? output.resortId,
condition: output.condition,
originalQuery: query
});
};generateText() with Output.object() constrains the model to output JSON matching the schema: no free-text, just data. We reuse the same CreateAlertToolInputSchema from the tool definition, so there's no schema duplication. The v.parse() call after the AI response adds a second validation layer for runtime safety. No streaming needed here. Structured output is a single request/response.
Troubleshooting
Your prompt may not be specific enough. Make sure you include the resort list and phrase mappings so the model has enough context to generate valid JSON. If the model can't figure out what the user wants, Output.object() returns null.
The AI generated a condition that doesn't match the schema. Log the raw output with console.log(output) before the validation step to see what the model actually returned. Common issue: the model picks an operator or match value that isn't in the picklist.
Advanced: Streaming Structured Output
For large objects, you can stream partial results with streamText() and Output.object(). The partialOutputStream async iterable emits progressively complete objects as the AI generates:
import { streamText, Output } from 'ai';
const { partialOutputStream } = streamText({
model: gateway('anthropic/claude-sonnet-4'),
output: Output.object({
schema: valibotSchema(CreateAlertToolInputSchema)
}),
prompt: `Parse: "${query}"`
});
for await (const partialObject of partialOutputStream) {
console.log(partialObject); // Progressively complete object
}Each iteration yields a more complete version of the final object. This is useful when the schema is large and you want to show progressive results in the UI.
Was this helpful?