Structured Output with Valibot Schemas
To turn "fresh pow at Palisades" into typed JSON without creating an alert, use the AI SDK's generateText() with Output.object(). It extracts structured data using the 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
Tools and structured output both return structured data, but differ in how they produce it and when to use them:
| 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
Create an 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.ts, the same schema you used for the tool - After getting the AI result, validate it again with
v.parse(AlertConditionSchema, output.condition) - 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 { createGateway, generateText, Output } from 'ai';
import { valibotSchema } from '@ai-sdk/valibot';
import * as v from 'valibot';
import { resorts } from '#lib/data/resorts.ts';
import { CreateAlertToolInputSchema, AlertConditionSchema } from '#lib/schemas/alert.ts';
import { AI_GATEWAY_API_KEY } from '$app/env/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 Response.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 Response.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 Response.json(
{ error: 'AI returned invalid condition structure' },
{ status: 422 }
);
}
const resort = resorts.find((r) => r.id === output.resortId);
return Response.json({
resortId: output.resortId,
resortName: resort?.name ?? output.resortId,
condition: output.condition,
originalQuery: query
});
};generateText() with Output.object() constrains the model to return JSON matching the schema. Reusing CreateAlertToolInputSchema from the tool definition avoids duplicating the schema. The v.parse() call validates the condition again at runtime. This endpoint returns the result in a single response without streaming.
Troubleshooting
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?