@tanstack/ai 0.10.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,336 @@
1
+ ---
2
+ name: ai-core/middleware
3
+ description: >
4
+ Chat lifecycle middleware hooks: onConfig, onStart, onChunk,
5
+ onBeforeToolCall, onAfterToolCall, onUsage, onFinish, onAbort, onError.
6
+ Use for analytics, event firing, tool caching (toolCacheMiddleware),
7
+ logging, and tracing. Middleware array in chat() config, left-to-right
8
+ execution order. NOT onEnd/onFinish callbacks on chat() — use middleware.
9
+ type: sub-skill
10
+ library: tanstack-ai
11
+ library_version: '0.10.0'
12
+ sources:
13
+ - 'TanStack/ai:docs/advanced/middleware.md'
14
+ ---
15
+
16
+ # Middleware
17
+
18
+ > **Dependency note:** This skill builds on ai-core. Read it first for critical rules.
19
+
20
+ ## Setup — Analytics Tracking Middleware
21
+
22
+ ```typescript
23
+ import { chat, toServerSentEventsResponse } from '@tanstack/ai'
24
+ import { openaiText } from '@tanstack/ai-openai'
25
+
26
+ const stream = chat({
27
+ adapter: openaiText('gpt-5.2'),
28
+ messages,
29
+ middleware: [
30
+ {
31
+ onStart: (ctx) => {
32
+ console.log('Chat started:', ctx.model)
33
+ },
34
+ onFinish: (ctx) => {
35
+ trackAnalytics({ model: ctx.model, tokens: ctx.usage })
36
+ },
37
+ onError: (ctx) => {
38
+ reportError(ctx.error)
39
+ },
40
+ },
41
+ ],
42
+ })
43
+
44
+ return toServerSentEventsResponse(stream)
45
+ ```
46
+
47
+ ## Hooks Reference
48
+
49
+ Every hook receives a `ChatMiddlewareContext` as its first argument, which provides
50
+ `requestId`, `streamId`, `phase`, `iteration`, `chunkIndex`, `model`, `provider`,
51
+ `signal`, `abort()`, `defer()`, and more.
52
+
53
+ | Hook | When | Second Argument |
54
+ | --------------------- | ------------------------------------------------------------- | ------------------------------------------------ |
55
+ | `onConfig` | Once at startup (`init`) + once per iteration (`beforeModel`) | `ChatMiddlewareConfig` (return partial to merge) |
56
+ | `onStart` | Once after initial `onConfig` | none |
57
+ | `onIteration` | Start of each agent loop iteration | `IterationInfo` |
58
+ | `onChunk` | Every streamed chunk | `StreamChunk` (return void/chunk/chunk[]/null) |
59
+ | `onBeforeToolCall` | Before each tool executes | `ToolCallHookContext` (return decision or void) |
60
+ | `onAfterToolCall` | After each tool executes | `AfterToolCallInfo` |
61
+ | `onToolPhaseComplete` | After all tool calls in an iteration | `ToolPhaseCompleteInfo` |
62
+ | `onUsage` | When `RUN_FINISHED` includes usage data | `UsageInfo` |
63
+ | `onFinish` | Run completed normally | `FinishInfo` |
64
+ | `onAbort` | Run was aborted | `AbortInfo` |
65
+ | `onError` | Unhandled error occurred | `ErrorInfo` |
66
+
67
+ Terminal hooks (`onFinish`, `onAbort`, `onError`) are **mutually exclusive** -- exactly
68
+ one fires per `chat()` invocation.
69
+
70
+ ## Core Patterns
71
+
72
+ ### Pattern 1: Analytics and Logging Middleware
73
+
74
+ Use `onStart`, `onFinish`, `onUsage`, and `onError` for comprehensive observability.
75
+ Use `ctx.defer()` for non-blocking async side effects that should not block the stream.
76
+
77
+ ```typescript
78
+ import {
79
+ chat,
80
+ toServerSentEventsResponse,
81
+ type ChatMiddleware,
82
+ } from '@tanstack/ai'
83
+ import { openaiText } from '@tanstack/ai-openai'
84
+
85
+ const analytics: ChatMiddleware = {
86
+ name: 'analytics',
87
+ onStart: (ctx) => {
88
+ console.log(`[${ctx.requestId}] Chat started — model: ${ctx.model}`)
89
+ },
90
+ onUsage: (ctx, usage) => {
91
+ console.log(`[${ctx.requestId}] Tokens: ${usage.totalTokens}`)
92
+ },
93
+ onFinish: (ctx, info) => {
94
+ ctx.defer(
95
+ fetch('/api/analytics', {
96
+ method: 'POST',
97
+ body: JSON.stringify({
98
+ requestId: ctx.requestId,
99
+ model: ctx.model,
100
+ duration: info.duration,
101
+ tokens: info.usage?.totalTokens,
102
+ finishReason: info.finishReason,
103
+ }),
104
+ }),
105
+ )
106
+ },
107
+ onError: (ctx, info) => {
108
+ ctx.defer(
109
+ fetch('/api/errors', {
110
+ method: 'POST',
111
+ body: JSON.stringify({
112
+ requestId: ctx.requestId,
113
+ error: String(info.error),
114
+ duration: info.duration,
115
+ }),
116
+ }),
117
+ )
118
+ },
119
+ }
120
+
121
+ const stream = chat({
122
+ adapter: openaiText('gpt-5.2'),
123
+ messages,
124
+ middleware: [analytics],
125
+ })
126
+
127
+ return toServerSentEventsResponse(stream)
128
+ ```
129
+
130
+ ### Pattern 2: Tool Interception Middleware
131
+
132
+ Use `onBeforeToolCall` to validate, gate, or transform tool arguments before execution.
133
+ Use `onAfterToolCall` to log results and timing. The first middleware that returns a
134
+ non-void decision from `onBeforeToolCall` short-circuits remaining middleware for that call.
135
+
136
+ ```typescript
137
+ import type { ChatMiddleware } from '@tanstack/ai'
138
+
139
+ const toolGuard: ChatMiddleware = {
140
+ name: 'tool-guard',
141
+ onBeforeToolCall: (ctx, hookCtx) => {
142
+ // Block dangerous tools
143
+ if (hookCtx.toolName === 'deleteDatabase') {
144
+ return { type: 'abort', reason: 'Dangerous operation blocked' }
145
+ }
146
+
147
+ // Enforce default arguments
148
+ if (hookCtx.toolName === 'search' && !hookCtx.args.limit) {
149
+ return {
150
+ type: 'transformArgs',
151
+ args: { ...hookCtx.args, limit: 10 },
152
+ }
153
+ }
154
+
155
+ // Return void to continue normally
156
+ },
157
+ onAfterToolCall: (ctx, info) => {
158
+ if (info.ok) {
159
+ console.log(`${info.toolName} completed in ${info.duration}ms`)
160
+ } else {
161
+ console.error(`${info.toolName} failed:`, info.error)
162
+ }
163
+ },
164
+ }
165
+ ```
166
+
167
+ **`onBeforeToolCall` decision types:**
168
+
169
+ | Decision | Effect |
170
+ | --------------------------------- | ------------------------------------------------------------------- |
171
+ | `void` / `undefined` | Continue normally, next middleware decides |
172
+ | `{ type: 'transformArgs', args }` | Replace tool arguments before execution |
173
+ | `{ type: 'skip', result }` | Skip execution, use provided result (used by `toolCacheMiddleware`) |
174
+ | `{ type: 'abort', reason? }` | Abort the entire chat run |
175
+
176
+ ### Pattern 3: Multiple Middleware Composition
177
+
178
+ Middleware executes in array order (left-to-right). Ordering matters for hooks that
179
+ pipe or short-circuit:
180
+
181
+ ```typescript
182
+ import { chat, type ChatMiddleware } from '@tanstack/ai'
183
+ import { toolCacheMiddleware } from '@tanstack/ai/middlewares'
184
+ import { openaiText } from '@tanstack/ai-openai'
185
+
186
+ const logging: ChatMiddleware = {
187
+ name: 'logging',
188
+ onStart: (ctx) => console.log(`[${ctx.requestId}] started`),
189
+ onChunk: (ctx, chunk) => {
190
+ console.log(`[${ctx.requestId}] chunk: ${chunk.type}`)
191
+ },
192
+ onFinish: (ctx, info) => {
193
+ console.log(`[${ctx.requestId}] done in ${info.duration}ms`)
194
+ },
195
+ }
196
+
197
+ const configTransform: ChatMiddleware = {
198
+ name: 'config-transform',
199
+ onConfig: (ctx, config) => {
200
+ if (ctx.phase === 'init') {
201
+ return {
202
+ systemPrompts: [...config.systemPrompts, 'Always respond in JSON.'],
203
+ }
204
+ }
205
+ },
206
+ }
207
+
208
+ const stream = chat({
209
+ adapter: openaiText('gpt-5.2'),
210
+ messages,
211
+ tools: [weatherTool, stockTool],
212
+ middleware: [
213
+ logging, // Runs first
214
+ configTransform, // Transforms config second
215
+ toolCacheMiddleware({ ttl: 60_000 }), // Caches tool results third
216
+ ],
217
+ })
218
+ ```
219
+
220
+ **Composition rules by hook:**
221
+
222
+ | Hook | Composition | Effect of Order |
223
+ | -------------------------- | --------------------------------------------- | ------------------------------------------ |
224
+ | `onConfig` | **Piped** -- each receives previous output | Earlier middleware transforms first |
225
+ | `onStart` | Sequential | All run in order |
226
+ | `onChunk` | **Piped** -- chunks flow through each | If first drops a chunk, later never see it |
227
+ | `onBeforeToolCall` | **First-win** -- first non-void decision wins | Earlier middleware has priority |
228
+ | `onAfterToolCall` | Sequential | All run in order |
229
+ | `onUsage` | Sequential | All run in order |
230
+ | `onFinish/onAbort/onError` | Sequential | All run in order |
231
+
232
+ ## Built-in: toolCacheMiddleware
233
+
234
+ Caches tool call results by name + arguments. Import from `@tanstack/ai/middlewares`:
235
+
236
+ ```typescript
237
+ import { chat } from '@tanstack/ai'
238
+ import { toolCacheMiddleware } from '@tanstack/ai/middlewares'
239
+
240
+ const stream = chat({
241
+ adapter,
242
+ messages,
243
+ tools: [weatherTool],
244
+ middleware: [
245
+ toolCacheMiddleware({
246
+ ttl: 60_000, // Cache entries expire after 60 seconds
247
+ maxSize: 50, // Max 50 entries (LRU eviction)
248
+ toolNames: ['getWeather'], // Only cache specific tools
249
+ }),
250
+ ],
251
+ })
252
+ ```
253
+
254
+ Options: `maxSize` (default 100), `ttl` (default Infinity), `toolNames` (default all),
255
+ `keyFn` (custom cache key), `storage` (custom backend like Redis). See
256
+ `docs/advanced/middleware.md` for custom storage examples.
257
+
258
+ ## Common Mistakes
259
+
260
+ ### a. MEDIUM: Trying to modify StreamChunks in middleware
261
+
262
+ ```typescript
263
+ // WRONG -- mutating the chunk object directly
264
+ const broken: ChatMiddleware = {
265
+ name: 'broken',
266
+ onChunk: (ctx, chunk) => {
267
+ chunk.delta = 'modified' // Mutation does nothing; chunk is not modified in-place
268
+ },
269
+ }
270
+
271
+ // CORRECT -- return a new chunk to replace the original
272
+ const correct: ChatMiddleware = {
273
+ name: 'correct',
274
+ onChunk: (ctx, chunk) => {
275
+ if (chunk.type === 'TEXT_MESSAGE_CONTENT') {
276
+ return { ...chunk, delta: chunk.delta.replace(/secret/g, '[REDACTED]') }
277
+ }
278
+ // Return void to pass through unchanged
279
+ },
280
+ }
281
+ ```
282
+
283
+ Middleware `onChunk` hooks are functional transforms. Return a new chunk, an array
284
+ of chunks, null (to drop), or void (to pass through). Mutating the input object
285
+ has no effect on the stream output.
286
+
287
+ Source: docs/advanced/middleware.md
288
+
289
+ ### b. MEDIUM: Middleware exceptions breaking the stream
290
+
291
+ ```typescript
292
+ // WRONG -- unhandled error kills the entire streaming response
293
+ const fragile: ChatMiddleware = {
294
+ name: 'fragile-analytics',
295
+ onFinish: async (ctx, info) => {
296
+ // If this fetch fails, the stream breaks
297
+ await fetch('/api/analytics', {
298
+ method: 'POST',
299
+ body: JSON.stringify({ duration: info.duration }),
300
+ })
301
+ },
302
+ }
303
+
304
+ // CORRECT -- wrap in try-catch and/or use ctx.defer()
305
+ const resilient: ChatMiddleware = {
306
+ name: 'resilient-analytics',
307
+ onFinish: (ctx, info) => {
308
+ // Option 1: defer (non-blocking, errors are isolated)
309
+ ctx.defer(
310
+ fetch('/api/analytics', {
311
+ method: 'POST',
312
+ body: JSON.stringify({ duration: info.duration }),
313
+ }),
314
+ )
315
+ },
316
+ onChunk: (ctx, chunk) => {
317
+ // Option 2: try-catch for synchronous/critical hooks
318
+ try {
319
+ logChunk(chunk)
320
+ } catch (err) {
321
+ console.error('Logging failed:', err)
322
+ }
323
+ // Return void to pass through
324
+ },
325
+ }
326
+ ```
327
+
328
+ Wrap all middleware hooks in try-catch to prevent analytics or logging failures
329
+ from killing the chat stream. For async side effects, prefer `ctx.defer()` which
330
+ runs after the terminal hook and isolates failures.
331
+
332
+ Source: docs/advanced/middleware.md
333
+
334
+ ## Cross-References
335
+
336
+ - See also: **ai-core/chat-experience/SKILL.md** -- Middleware hooks into the chat lifecycle
@@ -0,0 +1,203 @@
1
+ ---
2
+ name: ai-core/structured-outputs
3
+ description: >
4
+ Type-safe JSON schema responses from LLMs using outputSchema on chat().
5
+ Supports Zod, ArkType, and Valibot schemas. The adapter handles
6
+ provider-specific strategies transparently — never configure structured
7
+ output at the provider level. convertSchemaToJsonSchema() for manual
8
+ schema conversion.
9
+ type: sub-skill
10
+ library: tanstack-ai
11
+ library_version: '0.10.0'
12
+ sources:
13
+ - 'TanStack/ai:docs/chat/structured-outputs.md'
14
+ ---
15
+
16
+ # Structured Outputs
17
+
18
+ > **Dependency note:** This skill builds on ai-core. Read it first for critical rules.
19
+
20
+ ## Setup
21
+
22
+ ```typescript
23
+ import { chat } from '@tanstack/ai'
24
+ import { openaiText } from '@tanstack/ai-openai'
25
+ import { z } from 'zod'
26
+
27
+ const stream = chat({
28
+ adapter: openaiText('gpt-5.2'),
29
+ messages: [
30
+ {
31
+ role: 'user',
32
+ content: [
33
+ {
34
+ type: 'text',
35
+ content: 'Extract the person info from: John is 30 years old',
36
+ },
37
+ ],
38
+ },
39
+ ],
40
+ outputSchema: z.object({
41
+ name: z.string(),
42
+ age: z.number(),
43
+ }),
44
+ })
45
+ ```
46
+
47
+ When `outputSchema` is provided, `chat()` returns `Promise<InferSchemaType<TSchema>>` instead of `AsyncIterable<StreamChunk>`. The result is fully typed based on the schema.
48
+
49
+ ## Core Patterns
50
+
51
+ ### Pattern 1: Basic structured output with Zod
52
+
53
+ ```typescript
54
+ import { chat } from '@tanstack/ai'
55
+ import { openaiText } from '@tanstack/ai-openai'
56
+ import { z } from 'zod'
57
+
58
+ const PersonSchema = z.object({
59
+ name: z.string().meta({ description: "The person's full name" }),
60
+ age: z.number().meta({ description: "The person's age in years" }),
61
+ email: z.string().email().meta({ description: 'Email address' }),
62
+ })
63
+
64
+ // chat() returns Promise<{ name: string; age: number; email: string }>
65
+ const person = await chat({
66
+ adapter: openaiText('gpt-5.2'),
67
+ messages: [
68
+ {
69
+ role: 'user',
70
+ content:
71
+ 'Extract the person info: John Doe is 30 years old, email john@example.com',
72
+ },
73
+ ],
74
+ outputSchema: PersonSchema,
75
+ })
76
+
77
+ console.log(person.name) // "John Doe"
78
+ console.log(person.age) // 30
79
+ console.log(person.email) // "john@example.com"
80
+ ```
81
+
82
+ ### Pattern 2: Complex nested schemas
83
+
84
+ ```typescript
85
+ import { chat } from '@tanstack/ai'
86
+ import { anthropicText } from '@tanstack/ai-anthropic'
87
+ import { z } from 'zod'
88
+
89
+ const CompanySchema = z.object({
90
+ name: z.string(),
91
+ founded: z.number().meta({ description: 'Year the company was founded' }),
92
+ headquarters: z.object({
93
+ city: z.string(),
94
+ country: z.string(),
95
+ address: z.string().optional(),
96
+ }),
97
+ employees: z.array(
98
+ z.object({
99
+ name: z.string(),
100
+ role: z.string(),
101
+ department: z.string(),
102
+ }),
103
+ ),
104
+ financials: z
105
+ .object({
106
+ revenue: z
107
+ .number()
108
+ .meta({ description: 'Annual revenue in millions USD' }),
109
+ profitable: z.boolean(),
110
+ })
111
+ .optional(),
112
+ })
113
+
114
+ const company = await chat({
115
+ adapter: anthropicText('claude-sonnet-4-5'),
116
+ messages: [
117
+ {
118
+ role: 'user',
119
+ content: 'Extract company info from this article: ...',
120
+ },
121
+ ],
122
+ outputSchema: CompanySchema,
123
+ })
124
+
125
+ // Full type safety on nested properties
126
+ console.log(company.headquarters.city)
127
+ console.log(company.employees[0].role)
128
+ console.log(company.financials?.revenue)
129
+ ```
130
+
131
+ ## Common Mistakes
132
+
133
+ ### HIGH: Trying to implement provider-specific structured output strategies
134
+
135
+ The adapter already handles provider differences (OpenAI uses `response_format`, Anthropic uses tool-based extraction, Gemini uses `responseSchema`). Never configure this yourself.
136
+
137
+ ```typescript
138
+ // WRONG -- do not set provider-specific response format
139
+ chat({
140
+ adapter,
141
+ messages,
142
+ modelOptions: {
143
+ responseFormat: { type: 'json_schema', json_schema: mySchema },
144
+ },
145
+ })
146
+
147
+ // CORRECT -- just pass outputSchema, the adapter handles the rest
148
+ chat({
149
+ adapter,
150
+ messages,
151
+ outputSchema: z.object({ name: z.string(), age: z.number() }),
152
+ })
153
+ ```
154
+
155
+ There is no scenario where you need to know the provider's strategy. Just pass `outputSchema` to `chat()`.
156
+
157
+ Source: maintainer interview
158
+
159
+ ### HIGH: Passing raw objects instead of using the project's schema library
160
+
161
+ Agents often generate raw JSON Schema objects or plain TypeScript types instead
162
+ of using the schema validation library already in the project (Zod, ArkType,
163
+ Valibot). Always check what the project uses and match it.
164
+
165
+ ```typescript
166
+ // WRONG -- raw object, no runtime validation, no type inference
167
+ chat({
168
+ adapter,
169
+ messages,
170
+ outputSchema: {
171
+ type: 'object',
172
+ properties: {
173
+ name: { type: 'string' },
174
+ age: { type: 'number' },
175
+ },
176
+ required: ['name', 'age'],
177
+ additionalProperties: false,
178
+ },
179
+ })
180
+
181
+ // CORRECT -- use the project's schema library (e.g. Zod)
182
+ import { z } from 'zod'
183
+
184
+ chat({
185
+ adapter,
186
+ messages,
187
+ outputSchema: z.object({
188
+ name: z.string(),
189
+ age: z.number(),
190
+ }),
191
+ })
192
+ ```
193
+
194
+ Using the project's schema library gives you runtime validation, TypeScript
195
+ type inference on the result, and correct JSON Schema conversion automatically.
196
+ Check `package.json` for `zod`, `arktype`, or `valibot` and use whichever is
197
+ already installed.
198
+
199
+ Source: maintainer interview
200
+
201
+ ## Cross-References
202
+
203
+ - See also: ai-core/adapter-configuration/SKILL.md -- Adapter handles structured output strategy transparently