ai-core/structured-outputs
The structured-outputs skill enables Claude-based applications to request and receive validated JSON data from language models by defining output schemas with Zod. Use this when you need type-safe, guaranteed-shape responses from chat operations, such as extracting entities, building forms that update field-by-field as the model streams, or iterating over structured data across multiple conversation turns.
git clone --depth 1 https://github.com/TanStack/ai /tmp/ai-core-structured-outputs && cp -r /tmp/ai-core-structured-outputs/packages/ai/skills/ai-core/structured-outputs ~/.claude/skills/ai-core-structured-outputsSKILL.md
# Structured Outputs
> **Dependency note:** This skill builds on ai-core. Read it first for critical rules. The `useChat` patterns below build on ai-core/chat-experience — read that for the base hook surface, then come back here for the structured-output specifics.
## Setup
```typescript
import { chat } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { z } from 'zod'
const person = await chat({
adapter: openaiText('gpt-5.2'),
messages: [{ role: 'user', content: 'John Doe, 30' }],
outputSchema: z.object({
name: z.string(),
age: z.number(),
}),
})
person.name // string — fully typed, no cast
person.age // number
```
When `outputSchema` is provided, `chat()` returns `Promise<InferSchemaType<TSchema>>` instead of `AsyncIterable<StreamChunk>`. The result is fully typed.
Adding `stream: true` switches the return to `StructuredOutputStream<InferSchemaType<TSchema>>` — incremental JSON deltas plus a terminal validated object. See **Pattern 3** below for direct iteration, **Pattern 4** for the `useChat` shape on the client, and **Pattern 5** for multi-turn structured chats.
## Decision: which pattern fits
| Building this | Use |
| ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
| One prompt in → one typed object out (script, server endpoint, CLI) | Pattern 1 (basic) or 2 (nested) |
| A UI that fills in field by field as the model streams (progressive form, live card) | Pattern 4 — `useChat({ outputSchema })` |
| Direct iteration of the stream in Node or tests | Pattern 3 — async iterable |
| Users iterate on a structured object across multiple turns (recipe builder, ticket refinement) | Pattern 5 — multi-turn structured chat |
| Tools that gather info, then return a typed object | Combine any of the above with `tools` — see ai-core/tool-calling |
## Core Patterns
### Pattern 1: Basic structured output with Zod
```typescript
import { chat } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { z } from 'zod'
const PersonSchema = z.object({
name: z.string().meta({ description: "The person's full name" }),
age: z.number().meta({ description: "The person's age in years" }),
email: z.string().email().meta({ description: 'Email address' }),
})
// chat() returns Promise<{ name: string; age: number; email: string }>
const person = await chat({
adapter: openaiText('gpt-5.2'),
messages: [
{
role: 'user',
content:
'Extract the person info: John Doe is 30 years old, email john@example.com',
},
],
outputSchema: PersonSchema,
})
console.log(person.name) // "John Doe"
console.log(person.age) // 30
console.log(person.email) // "john@example.com"
```
### Pattern 2: Complex nested schemas
```typescript
import { chat } from '@tanstack/ai'
import { anthropicText } from '@tanstack/ai-anthropic'
import { z } from 'zod'
const CompanySchema = z.object({
name: z.string(),
founded: z.number().meta({ description: 'Year the company was founded' }),
headquarters: z.object({
city: z.string(),
country: z.string(),
address: z.string().optional(),
}),
employees: z.array(
z.object({
name: z.string(),
role: z.string(),
department: z.string(),
}),
),
financials: z
.object({
revenue: z
.number()
.meta({ description: 'Annual revenue in millions USD' }),
profitable: z.boolean(),
})
.optional(),
})
const company = await chat({
adapter: anthropicText('claude-sonnet-4-5'),
messages: [
{
role: 'user',
content: 'Extract company info from this article: ...',
},
],
outputSchema: CompanySchema,
})
// Full type safety on nested properties
console.log(company.headquarters.city)
console.log(company.employees[0].role)
console.log(company.financials?.revenue)
```
### Pattern 3: Direct stream iteration
Pass `stream: true` alongside `outputSchema` to get an async iterable of standard streaming chunks plus a terminal validated object. Use this when you're a single process end-to-end — Node script, CLI, test, or a server endpoint that responds with one JSON blob. For the in-browser progressive-UI case, jump to Pattern 4 instead.
```typescript
import { chat } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { z } from 'zod'
const PersonSchema = z.object({
name: z.string(),
age: z.number(),
email: z.string().email(),
})
const stream = chat({
adapter: openaiText('gpt-5.2'),
messages: [
{ role: 'user', content: 'Extract: John Doe is 30, john@example.com' },
],
outputSchema: PersonSchema,
stream: true,
})
for await (const chunk of stream) {
if (chunk.type === 'CUSTOM' && chunk.name === 'structured-output.complete') {
// Terminal event. `chunk.value.object` is fully validated and typed
// against the schema you passed in — no helper or cast required.
chunk.value.object.name // string
chunk.value.object.age // number
chunk.value.reasoning // string | undefined (thinking models only)
}
}
```
The terminal event is a `CUSTOM` chunk: `{ type: 'CUSTOM', name: 'structured-output.complete', value: { object: T, raw: string, reasoning?: string } }`. The return type of `chat({ outputSchema, stream: true })` carries `T` through, so a plain discriminated narrow (`chunk.type === 'CUSTOM' && chunk.name === 'structured-output.complete'`) is enough — no type guard helper.
**Adapter coverage for streaming:**
| Adapter>
Triage all open GitHub issues, PRs, and discussions in the current repository by fanning out up to 100 parallel subagents (one per item), then produce a single prioritized report ranking which PRs to review first, which issues to address first, and which discussions need maintainer attention. Use when the user asks to "triage open issues/PRs", "triage discussions", "prioritize the backlog", "what should I review first", "sweep the repo", or any request to bulk-evaluate open GitHub work and recommend an order.
>
>
>
>
>
>