diff --git a/.changeset/kiira-package-skills.md b/.changeset/kiira-package-skills.md new file mode 100644 index 0000000000..01a59e87a6 --- /dev/null +++ b/.changeset/kiira-package-skills.md @@ -0,0 +1,11 @@ +--- +'@tanstack/ai': patch +'@tanstack/ai-mcp': patch +'@tanstack/ai-code-mode': patch +'@tanstack/ai-sandbox': patch +'@tanstack/ai-persistence': patch +'@tanstack/ai-memory': patch +'@tanstack/ai-skills': patch +--- + +docs(skills): type-check the code fences in every package skill with kiira and fix the ones that did not compile diff --git a/kiira.config.ts b/kiira.config.ts index 6eb1417843..66ee435fb4 100644 --- a/kiira.config.ts +++ b/kiira.config.ts @@ -1,9 +1,9 @@ import { defineConfig } from 'kiira-core' export default defineConfig({ - // skills/** are the repo-level discovery skills; package skills under - // packages/*/skills are not yet clean and stay out until they are. - include: ['docs/**/*.md', 'skills/**/*.md'], + // skills/** are the repo-level discovery skills; packages/*/skills are the + // per-package skills that ship in each npm package. + include: ['docs/**/*.md', 'skills/**/*.md', 'packages/*/skills/**/*.md'], tsconfig: 'tsconfig.docs.json', exclude: [ // docs/reference/** is auto-generated by TypeDoc (`pnpm generate-docs`); @@ -77,7 +77,7 @@ export default defineConfig({ // hook that returns a decision or continues), which is valid and shouldn't // force an explicit return type or trailing `return`. { - include: ['docs/**/*.md', 'skills/**/*.md'], + include: ['docs/**/*.md', 'skills/**/*.md', 'packages/*/skills/**/*.md'], jsx: 'react-jsx', jsxImportSource: 'react', noImplicitReturns: false, diff --git a/nx.json b/nx.json index 6b205bc5d8..af38973f36 100644 --- a/nx.json +++ b/nx.json @@ -65,6 +65,8 @@ "inputs": [ "{workspaceRoot}/docs/**/*", "{workspaceRoot}/packages/*/src/**/*", + "{workspaceRoot}/packages/*/skills/**/*", + "{workspaceRoot}/skills/**/*", "{workspaceRoot}/kiira.config.ts" ] }, diff --git a/packages/ai-code-mode/skills/ai-code-mode/SKILL.md b/packages/ai-code-mode/skills/ai-code-mode/SKILL.md index 06a9646689..7c117c127b 100644 --- a/packages/ai-code-mode/skills/ai-code-mode/SKILL.md +++ b/packages/ai-code-mode/skills/ai-code-mode/SKILL.md @@ -26,11 +26,10 @@ sources: Complete Code Mode setup with Node.js isolate driver: ```typescript -import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { chat, toServerSentEventsResponse, toolDefinition } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' import { createCodeModeTool } from '@tanstack/ai-code-mode' import { createNodeIsolateDriver } from '@tanstack/ai-isolate-node' -import { toolDefinition } from '@tanstack/ai' import { z } from 'zod' // Define a tool that code can call @@ -54,22 +53,37 @@ const codeModeTool = createCodeModeTool({ }) // Use in chat -const stream = chat({ - adapter: openaiText('gpt-5.2'), - messages, - tools: [codeModeTool], -}) +export async function POST(request: Request) { + const { messages } = await request.json() + + const stream = chat({ + adapter: openaiText('gpt-5.5'), + messages, + tools: [codeModeTool], + }) -return toServerSentEventsResponse(stream) + return toServerSentEventsResponse(stream) +} ``` The recommended higher-level entry point is `createCodeMode()`, which returns both the tool and a matching system prompt: ```typescript -import { chat } from '@tanstack/ai' +import { chat, toServerSentEventsResponse, toolDefinition } from '@tanstack/ai' import { createCodeMode } from '@tanstack/ai-code-mode' import { createNodeIsolateDriver } from '@tanstack/ai-isolate-node' import { openaiText } from '@tanstack/ai-openai' +import { z } from 'zod' + +const fetchWeather = toolDefinition({ + name: 'fetchWeather', + description: 'Get current weather for a city', + inputSchema: z.object({ city: z.string() }), + outputSchema: z.object({ temp: z.number(), condition: z.string() }), +}).server(async ({ city }) => { + const res = await fetch(`https://api.weather.com/${city}`) + return res.json() +}) const { tool, systemPrompt } = createCodeMode({ driver: createNodeIsolateDriver(), @@ -77,12 +91,18 @@ const { tool, systemPrompt } = createCodeMode({ timeout: 30_000, }) -const stream = chat({ - adapter: openaiText('gpt-4o'), - systemPrompts: ['You are a helpful assistant.', systemPrompt], - tools: [tool], - messages, -}) +export async function POST(request: Request) { + const { messages } = await request.json() + + const stream = chat({ + adapter: openaiText('gpt-5.5'), + systemPrompts: ['You are a helpful assistant.', systemPrompt], + tools: [tool], + messages, + }) + + return toServerSentEventsResponse(stream) +} ``` `createCodeMode` calls `createCodeModeTool` and `createCodeModeSystemPrompt` internally. The system prompt includes generated TypeScript type stubs for each tool so the LLM writes correct calls. @@ -154,22 +174,36 @@ const driver = createCloudflareIsolateDriver({ Snippets let the LLM save reusable code snippets. On future requests, relevant snippets are loaded and exposed as callable tools. ```typescript -import { chat, maxIterations } from '@tanstack/ai' +import { + chat, + maxIterations, + toServerSentEventsResponse, + toolDefinition, +} from '@tanstack/ai' import { createNodeIsolateDriver } from '@tanstack/ai-isolate-node' -import { codeModeWithSnippets } from '@tanstack/ai-code-mode-snippets' -import { createFileSnippetStorage } from '@tanstack/ai-code-mode-snippets/storage' import { + codeModeWithSnippets, createDefaultTrustStrategy, - createAlwaysTrustedStrategy, - createCustomTrustStrategy, } from '@tanstack/ai-code-mode-snippets' +import { createFileSnippetStorage } from '@tanstack/ai-code-mode-snippets/storage' import { openaiText } from '@tanstack/ai-openai' +import { z } from 'zod' + +const fetchWeather = toolDefinition({ + name: 'fetchWeather', + description: 'Get current weather for a city', + inputSchema: z.object({ city: z.string() }), + outputSchema: z.object({ temp: z.number(), condition: z.string() }), +}).server(async ({ city }) => { + const res = await fetch(`https://api.weather.com/${city}`) + return res.json() +}) // Trust strategies control how snippets earn trust through executions -// Default: untrusted -> provisional (10+ runs, >=90%) -> trusted (100+ runs, >=95%) -// Relaxed: untrusted -> provisional (3+ runs, >=80%) -> trusted (10+ runs, >=90%) -// Always trusted: immediately trusted (dev/testing) -// Custom: configurable thresholds +// Default (createDefaultTrustStrategy): untrusted -> provisional (10+ runs, >=90%) -> trusted (100+ runs, >=95%) +// Relaxed (createRelaxedTrustStrategy): untrusted -> provisional (3+ runs, >=80%) -> trusted (10+ runs, >=90%) +// Always trusted (createAlwaysTrustedStrategy): immediately trusted (dev/testing) +// Custom (createCustomTrustStrategy): configurable thresholds const trustStrategy = createDefaultTrustStrategy() // Storage options: file system (production) or memory (testing) @@ -180,16 +214,18 @@ const storage = createFileSnippetStorage({ const driver = createNodeIsolateDriver() -// High-level API: automatic LLM-based snippet selection -const { toolsRegistry, systemPrompt, selectedSnippets } = - await codeModeWithSnippets({ +export async function POST(request: Request) { + const { messages } = await request.json() + + // High-level API: automatic LLM-based snippet selection + const { toolsRegistry, systemPrompt } = await codeModeWithSnippets({ config: { driver, - tools: [myTool1, myTool2], + tools: [fetchWeather], timeout: 60_000, memoryLimit: 128, }, - adapter: openaiText('gpt-4o-mini'), // cheap model for snippet selection + adapter: openaiText('gpt-5-mini'), // cheap model for snippet selection snippets: { storage, maxSnippetsInContext: 5, @@ -197,13 +233,16 @@ const { toolsRegistry, systemPrompt, selectedSnippets } = messages, }) -const stream = chat({ - adapter: openaiText('gpt-4o'), - tools: toolsRegistry.getTools(), - messages, - systemPrompts: ['You are a helpful assistant.', systemPrompt], - agentLoopStrategy: maxIterations(15), -}) + const stream = chat({ + adapter: openaiText('gpt-5.5'), + tools: toolsRegistry.getTools(), + messages, + systemPrompts: ['You are a helpful assistant.', systemPrompt], + agentLoopStrategy: maxIterations(15), + }) + + return toServerSentEventsResponse(stream) +} ``` The registry includes: `execute_typescript`, `search_snippets`, `get_snippet`, `register_snippet`, and one tool per selected snippet. @@ -211,6 +250,8 @@ The registry includes: `execute_typescript`, `search_snippets`, `get_snippet`, ` Custom trust strategy example: ```typescript +import { createCustomTrustStrategy } from '@tanstack/ai-code-mode-snippets' + const strategy = createCustomTrustStrategy({ initialLevel: 'untrusted', provisionalThreshold: { executions: 5, successRate: 0.85 }, @@ -244,7 +285,7 @@ Events emitted: | `code_mode:external_result` | After successful external\_\* call | `function`, `result`, `duration` | | `code_mode:external_error` | When external\_\* call fails | `function`, `error`, `duration` | -```typescript +```tsx import { useCallback, useRef, useState } from 'react' import { useChat, fetchServerSentEvents } from '@tanstack/ai-react' @@ -262,11 +303,7 @@ export function CodeModeChat() { const eventIdCounter = useRef(0) const handleCustomEvent = useCallback( - ( - eventType: string, - data: unknown, - context: { toolCallId?: string }, - ) => { + (eventType: string, data: unknown, context: { toolCallId?: string }) => { const { toolCallId } = context if (!toolCallId) return @@ -296,9 +333,9 @@ export function CodeModeChat() {
{messages.map((message) => (
- {message.parts.map((part) => { + {message.parts.map((part, index) => { if (part.type === 'text') { - return

{part.content}

+ return

{part.content}

} if ( part.type === 'tool-call' && @@ -331,7 +368,11 @@ export function CodeModeChat() { The `onCustomEvent` callback signature is identical across all framework integrations (`@tanstack/ai-react`, `@tanstack/ai-solid`, `@tanstack/ai-vue`, `@tanstack/ai-svelte`): ```typescript -(eventType: string, data: unknown, context: { toolCallId?: string }) => void +type OnCustomEvent = ( + eventType: string, + data: unknown, + context: { toolCallId?: string }, +) => void ``` Snippet-specific events (when using `codeModeWithSnippets`): @@ -349,10 +390,20 @@ When a large tool catalog would bloat the `execute_typescript` system prompt, ma **Marking a tool lazy:** -```typescript +```typescript group=lazy-tools import { toolDefinition } from '@tanstack/ai' import { z } from 'zod' +const eagerTool = toolDefinition({ + name: 'fetchWeather', + description: 'Get current weather for a city', + inputSchema: z.object({ city: z.string() }), + outputSchema: z.object({ temp: z.number(), condition: z.string() }), +}).server(async ({ city }) => { + const res = await fetch(`https://api.weather.com/${city}`) + return res.json() +}) + const rarelyUsedTool = toolDefinition({ name: 'fetchStocks', description: 'Get stock prices for a ticker. Returns a price quote.', @@ -360,8 +411,8 @@ const rarelyUsedTool = toolDefinition({ outputSchema: z.object({ price: z.number() }), lazy: true, // <-- opt out of full system-prompt documentation }).server(async ({ ticker }) => { - // ... - return { price: 0 } + const res = await fetch(`https://api.stocks.com/${ticker}`) + return res.json() }) ``` @@ -369,8 +420,8 @@ const rarelyUsedTool = toolDefinition({ `createCodeMode()` returns `{ tool, discoveryTool, tools, systemPrompt }`. When lazy tools are present `discoveryTool` is a `discover_tools` server tool; otherwise it is `null`. Always spread `tools` (not just `tool`) into `chat()` so the discovery tool is registered: -```typescript -import { chat } from '@tanstack/ai' +```typescript group=lazy-tools +import { chat, toServerSentEventsResponse } from '@tanstack/ai' import { createCodeMode } from '@tanstack/ai-code-mode' import { createNodeIsolateDriver } from '@tanstack/ai-isolate-node' import { openaiText } from '@tanstack/ai-openai' @@ -380,12 +431,18 @@ const { tools, systemPrompt } = createCodeMode({ tools: [eagerTool, rarelyUsedTool], // rarelyUsedTool has lazy: true }) -const stream = chat({ - adapter: openaiText('gpt-5.5'), - systemPrompts: ['You are a helpful assistant.', systemPrompt], - tools: [...tools, ...otherTools], // spread tools, not just tool - messages, -}) +export async function POST(request: Request) { + const { messages } = await request.json() + + const stream = chat({ + adapter: openaiText('gpt-5.5'), + systemPrompts: ['You are a helpful assistant.', systemPrompt], + tools: [...tools], // spread tools, not just tool + messages, + }) + + return toServerSentEventsResponse(stream) +} ``` `tools` equals `[tool]` when there are no lazy tools (backward compatible) and `[tool, discoveryTool]` when lazy tools exist. @@ -412,6 +469,10 @@ Control how much of each lazy tool's description appears in the Discoverable API | `'full'` | `external_fetchStocks — Get stock prices. Returns a price quote.` | ```typescript +import { createCodeMode } from '@tanstack/ai-code-mode' +import { createNodeIsolateDriver } from '@tanstack/ai-isolate-node' +import { eagerTool, rarelyUsedTool } from './tools' + const { tools, systemPrompt } = createCodeMode({ driver: createNodeIsolateDriver(), tools: [eagerTool, rarelyUsedTool], @@ -430,11 +491,17 @@ Code Mode executes LLM-generated code. Any secrets available in the sandbox cont Wrong: ```typescript +import { toolDefinition } from '@tanstack/ai' +import { createCodeModeTool } from '@tanstack/ai-code-mode' +import { createNodeIsolateDriver } from '@tanstack/ai-isolate-node' +import { z } from 'zod' + const codeModeTool = createCodeModeTool({ - driver, + driver: createNodeIsolateDriver(), tools: [ toolDefinition({ name: 'callApi', + description: 'Call an HTTP API', inputSchema: z.object({ url: z.string(), apiKey: z.string() }), outputSchema: z.any(), }).server(async ({ url, apiKey }) => @@ -449,16 +516,22 @@ const codeModeTool = createCodeModeTool({ Right: ```typescript +import { toolDefinition } from '@tanstack/ai' +import { createCodeModeTool } from '@tanstack/ai-code-mode' +import { createNodeIsolateDriver } from '@tanstack/ai-isolate-node' +import { z } from 'zod' + const codeModeTool = createCodeModeTool({ - driver, + driver: createNodeIsolateDriver(), tools: [ toolDefinition({ name: 'callApi', + description: 'Call an HTTP API', inputSchema: z.object({ url: z.string() }), outputSchema: z.any(), }).server(async ({ url }) => fetch(url, { - headers: { Authorization: process.env.API_KEY }, // secret stays in host + headers: { Authorization: `Bearer ${process.env.API_KEY}` }, // secret stays in host }), ), ], @@ -474,12 +547,16 @@ LLM-generated code may contain infinite loops. The default timeout is 30s, but d Wrong: ```typescript +import { createNodeIsolateDriver } from '@tanstack/ai-isolate-node' + const driver = createNodeIsolateDriver({ timeout: 0 }) ``` Right: ```typescript +import { createNodeIsolateDriver } from '@tanstack/ai-isolate-node' + const driver = createNodeIsolateDriver({ timeout: 30_000 }) ``` diff --git a/packages/ai-mcp/skills/ai-mcp/SKILL.md b/packages/ai-mcp/skills/ai-mcp/SKILL.md index 7bf16c9a2f..23e2ecdacd 100644 --- a/packages/ai-mcp/skills/ai-mcp/SKILL.md +++ b/packages/ai-mcp/skills/ai-mcp/SKILL.md @@ -68,6 +68,8 @@ const client = await createMCPClient({ #### Streamable HTTP (default for internet-facing servers) ```typescript +import { createMCPClient } from '@tanstack/ai-mcp' + const client = await createMCPClient({ transport: { type: 'http', @@ -80,6 +82,8 @@ const client = await createMCPClient({ #### SSE ```typescript +import { createMCPClient } from '@tanstack/ai-mcp' + const client = await createMCPClient({ transport: { type: 'sse', @@ -109,8 +113,9 @@ const client = await createMCPClient({ Pass any SDK `Transport` instance directly: ```typescript -import { createMCPClient } from '@tanstack/ai-mcp' -import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' +// InMemoryTransport (from @modelcontextprotocol/sdk) is re-exported for +// in-process testing; any SDK Transport instance works the same way. +import { createMCPClient, InMemoryTransport } from '@tanstack/ai-mcp' const [clientTransport] = InMemoryTransport.createLinkedPair() const client = await createMCPClient({ transport: clientTransport }) @@ -129,9 +134,9 @@ Two levels: ```typescript import { createMCPClient } from '@tanstack/ai-mcp' -import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js' - -declare const myOAuthProvider: OAuthClientProvider // backed by stored tokens +// An OAuthClientProvider (from @modelcontextprotocol/sdk/client/auth.js) +// backed by tokens you persist server-side. +import { myOAuthProvider } from './oauth-provider' const client = await createMCPClient({ transport: { @@ -158,12 +163,20 @@ config form is sufficient. at compile time but the tool's JSON Schema is forwarded to the LLM. ```typescript +import { chat } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { createMCPClient } from '@tanstack/ai-mcp' + +const client = await createMCPClient({ + transport: { type: 'http', url: 'https://mcp.example.com/mcp' }, +}) + const tools = await client.tools() -// tools: ServerTool[] (args unknown) +// tools: McpServerTool[] (args unknown) const stream = chat({ adapter: openaiText('gpt-5.5'), - messages, + messages: [{ role: 'user', content: 'What is the weather in Paris?' }], tools, }) ``` @@ -171,6 +184,12 @@ const stream = chat({ Use `{ lazy: true }` to defer schema sending via the existing `LazyToolManager`: ```typescript +import { createMCPClient } from '@tanstack/ai-mcp' + +const client = await createMCPClient({ + transport: { type: 'http', url: 'https://mcp.example.com/mcp' }, +}) + const tools = await client.tools({ lazy: true }) ``` @@ -222,48 +241,67 @@ body streams; use a middleware terminal hook there instead (see Common Mistakes below). ```typescript -// Option 1: middleware terminal hooks (streaming route handlers) -const client = await createMCPClient({ - transport: { type: 'http', url: '...' }, -}) -const stream = chat({ - adapter: openaiText('gpt-5.5'), - messages, - tools: await client.tools(), - middleware: [ - { - name: 'mcp-close', - onFinish: () => client.close(), - onAbort: () => client.close(), - onError: () => client.close(), - }, - ], -}) -return toServerSentEventsResponse(stream) +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import type { ModelMessage } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { createMCPClient } from '@tanstack/ai-mcp' -// Option 2: explicit close after in-scope consumption -const client = await createMCPClient({ - transport: { type: 'http', url: '...' }, -}) -try { +// Option 1: middleware terminal hooks (streaming route handlers) +export async function POST(request: Request) { + const { messages } = await request.json() + const client = await createMCPClient({ + transport: { type: 'http', url: 'https://mcp.example.com/mcp' }, + }) const stream = chat({ adapter: openaiText('gpt-5.5'), messages, tools: await client.tools(), + middleware: [ + { + name: 'mcp-close', + onFinish: () => client.close(), + onAbort: () => client.close(), + onError: () => client.close(), + }, + ], }) - for await (const chunk of stream) { - // stream fully consumed inside this block + return toServerSentEventsResponse(stream) +} + +// Option 2: explicit close after in-scope consumption +export async function runToCompletion(messages: Array) { + const client = await createMCPClient({ + transport: { type: 'http', url: 'https://mcp.example.com/mcp' }, + }) + try { + const stream = chat({ + adapter: openaiText('gpt-5.5'), + messages, + tools: await client.tools(), + }) + for await (const chunk of stream) { + // stream fully consumed inside this block + } + } finally { + await client.close() } -} finally { - await client.close() } // Option 3: await using (TypeScript 5.2+ with Symbol.asyncDispose) — // same rule: consume the stream before the scope exits. -await using client = await createMCPClient({ - transport: { type: 'http', url: '...' }, -}) -// ... consume the stream in this scope; close() runs at scope exit +export async function runWithUsing(messages: Array) { + await using client = await createMCPClient({ + transport: { type: 'http', url: 'https://mcp.example.com/mcp' }, + }) + const stream = chat({ + adapter: openaiText('gpt-5.5'), + messages, + tools: await client.tools(), + }) + for await (const chunk of stream) { + // ... consume the stream in this scope; close() runs at scope exit + } +} ``` ## `chat({ mcp })` — discovery + lifecycle in one prop @@ -304,55 +342,62 @@ Rather than calling `client.tools()` and `client.close()` yourself, pass the **Server-side example:** ```typescript -import { createFileRoute } from '@tanstack/react-router' +// Any framework route handler that receives a Request works (TanStack Start, +// Next.js, Hono, ...). import { chat, toServerSentEventsResponse } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' import { createMCPClient } from '@tanstack/ai-mcp' -export const Route = createFileRoute('/api/chat')({ - server: { - handlers: { - POST: async ({ request }) => { - const { messages } = await request.json() - - const mcpClient = await createMCPClient({ - transport: { type: 'http', url: 'https://mcp.example.com/mcp' }, - }) - - const stream = chat({ - adapter: openaiText('gpt-5.5'), - messages, - mcp: { - clients: [mcpClient], - connection: 'keep-alive', // chat() won't close it — reuse across requests - onDiscoveryError: (err, source) => { - console.warn('MCP discovery failed for source, skipping:', err) - // returning skips this source; throw to fail the whole call fast - }, - }, - }) - - return toServerSentEventsResponse(stream) - // connection: 'keep-alive' — chat() never closes mcpClient; it stays warm for the next request. +// Created once at module scope; connection: 'keep-alive' below keeps it warm +// across requests. +const mcpClient = await createMCPClient({ + transport: { type: 'http', url: 'https://mcp.example.com/mcp' }, +}) + +export async function POST(request: Request) { + const { messages } = await request.json() + + const stream = chat({ + adapter: openaiText('gpt-5.5'), + messages, + mcp: { + clients: [mcpClient], + connection: 'keep-alive', // chat() won't close it — reuse across requests + onDiscoveryError: (err, source) => { + console.warn('MCP discovery failed for source, skipping:', err) + // returning skips this source; throw to fail the whole call fast }, }, - }, -}) + }) + + return toServerSentEventsResponse(stream) + // connection: 'keep-alive' — chat() never closes mcpClient; it stays warm for the next request. +} ``` You can also pass an `MCPClients` pool directly: ```typescript +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { createMCPClients } from '@tanstack/ai-mcp' + const pool = await createMCPClients({ github: { transport: { type: 'http', url: 'https://mcp.github.com/mcp' } }, linear: { transport: { type: 'http', url: 'https://mcp.linear.app/mcp' } }, }) -const stream = chat({ - adapter: openaiText('gpt-5.5'), - messages, - mcp: { clients: [pool], connection: 'keep-alive' }, -}) +export async function POST(request: Request) { + const { messages } = await request.json() + + const stream = chat({ + adapter: openaiText('gpt-5.5'), + messages, + mcp: { clients: [pool], connection: 'keep-alive' }, + }) + + return toServerSentEventsResponse(stream) +} ``` ## `createMCPClients` — multiple servers @@ -374,8 +419,9 @@ const tools = await pool.tools() // Forward lazy flag to every server: const lazyTools = await pool.tools({ lazy: true }) -// Per-server typed access: -const githubTools = await pool.clients.github.tools() +// Per-server typed access (keys are typed as string here; generated +// MCPServers types make them literal — see Codegen CLI below): +const githubTools = await pool.clients.github!.tools() ``` `createMCPClients` connects in parallel, closes already-connected clients if @@ -385,9 +431,19 @@ failed server(s). Override or disable prefixing: ```typescript +import { createMCPClients } from '@tanstack/ai-mcp' + await using pool = await createMCPClients({ - github: { transport: { ... }, prefix: 'gh' }, // 'gh_search_repos' - linear: { transport: { ... }, prefix: '' }, // 'create_issue' (no prefix) + // 'gh_search_repos' + github: { + transport: { type: 'http', url: 'https://mcp.github.com/mcp' }, + prefix: 'gh', + }, + // 'create_issue' (no prefix) + linear: { + transport: { type: 'http', url: 'https://mcp.linear.app/mcp' }, + prefix: '', + }, }) ``` @@ -400,9 +456,18 @@ through `ToolExecutionContext` into every `callTool` call with no extra code. You can also read it in a hand-written server tool that wraps an MCP call: ```typescript -const myTool = myDef.server(async (args, ctx) => { +import { toolDefinition } from '@tanstack/ai' +import { z } from 'zod' + +const fetchData = toolDefinition({ + name: 'fetch_data', + description: 'Fetch a record from a slow upstream API', + inputSchema: z.object({ id: z.string() }), +}) + +const myTool = fetchData.server(async (args, ctx) => { // Forward to any async work that accepts an AbortSignal. - const result = await fetch('https://slow.api/data', { + const result = await fetch(`https://slow.api/data/${args.id}`, { signal: ctx?.abortSignal, }) return result.json() @@ -412,16 +477,20 @@ const myTool = myDef.server(async (args, ctx) => { ## Resources ```typescript +import { createMCPClient, mcpResourceToContentPart } from '@tanstack/ai-mcp' + +const client = await createMCPClient({ + transport: { type: 'http', url: 'https://mcp.example.com/mcp' }, +}) + // List all resources the server exposes. const resources = await client.resources() // Read a specific resource by URI. -const resource = await client.readResource(resources[0].uri) +const resource = await client.readResource(resources[0]!.uri) // Convert one content block to a TanStack ContentPart. -import { mcpResourceToContentPart } from '@tanstack/ai-mcp' - -const part = mcpResourceToContentPart(resource.contents[0]) +const part = mcpResourceToContentPart(resource.contents[0]!) // part: ContentPart (type: 'text' always for v1) ``` @@ -429,10 +498,11 @@ Inject resources into a chat turn: ```typescript import { chat } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' import { createMCPClient, mcpResourceToContentPart } from '@tanstack/ai-mcp' const client = await createMCPClient({ - transport: { type: 'http', url: '...' }, + transport: { type: 'http', url: 'https://mcp.example.com/mcp' }, }) const resource = await client.readResource('file:///project/README.md') const parts = resource.contents.map(mcpResourceToContentPart) @@ -454,6 +524,14 @@ const stream = chat({ ## Prompts ```typescript +import { chat } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { createMCPClient, mcpPromptToMessages } from '@tanstack/ai-mcp' + +const client = await createMCPClient({ + transport: { type: 'http', url: 'https://mcp.example.com/mcp' }, +}) + // List prompts the server exposes. const prompts = await client.prompts() @@ -461,14 +539,12 @@ const prompts = await client.prompts() const prompt = await client.getPrompt('review_code', { language: 'TypeScript' }) // Convert to TanStack ModelMessage[] for use in chat(). -import { mcpPromptToMessages } from '@tanstack/ai-mcp' - const messages = mcpPromptToMessages(prompt) // messages: ModelMessage[] (role: 'user' | 'assistant') const stream = chat({ adapter: openaiText('gpt-5.5'), - messages: [...messages, ...userMessages], + messages: [...messages, { role: 'user', content: 'Review src/index.ts.' }], }) ``` @@ -518,7 +594,7 @@ For a pool, the `serverId` on the `UIResourcePart` is the config key (the tool prefix); for a single client it is the client's `prefix` (or the sole default when `serverId` is absent and there is exactly one client). -```typescript +```typescript group=mcp-app-handler import { createMCPClients } from '@tanstack/ai-mcp' import { createMcpAppCallHandler, @@ -556,9 +632,13 @@ const handlerWithStore = createMcpAppCallHandler({ The handler invokes the server (`body: { threadId, serverId?, toolName, args?, messageId? }`): -```typescript -const result = await handler(body) -// { ok: true; result: unknown } | { ok: false; error: string } +```typescript group=mcp-app-handler +export async function POST(request: Request) { + const body = await request.json() + const result = await handler(body) + // { ok: true; result: unknown } | { ok: false; error: string } + return Response.json(result) +} ``` ### Client side — `useMcpAppBridge` + `MCPAppResource` @@ -659,7 +739,7 @@ per server plus a combined `interface MCPServers` for pool typing. ```typescript // Single server — narrows tools() return to descriptor-keyed tool names. import type { GithubServer } from './src/mcp-types.generated' -import { createMCPClient } from '@tanstack/ai-mcp' +import { createMCPClient, createMCPClients } from '@tanstack/ai-mcp' const client = await createMCPClient({ transport: { type: 'http', url: 'https://mcp.github.com/mcp' }, @@ -711,54 +791,48 @@ import { MCPDuplicateToolNameError } from '@tanstack/ai' ## Complete server-route example ```typescript -// src/routes/api.chat.ts -import { createFileRoute } from '@tanstack/react-router' +// src/routes/api.chat.ts — mount POST in your framework's route handler +// (TanStack Start server route, Next.js route handler, Hono, ...). import { chat, toServerSentEventsResponse } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' import { createMCPClients } from '@tanstack/ai-mcp' -export const Route = createFileRoute('/api/chat')({ - server: { - handlers: { - POST: async ({ request }) => { - const { messages } = await request.json() - - const pool = await createMCPClients({ - github: { - transport: { type: 'http', url: 'https://mcp.github.com/mcp' }, - }, - linear: { - transport: { - type: 'http', - url: 'https://mcp.linear.app/mcp', - headers: { - Authorization: `Bearer ${process.env.LINEAR_KEY ?? ''}`, - }, - }, - }, - }) - - const stream = chat({ - adapter: openaiText('gpt-5.5'), - messages, - tools: await pool.tools(), - // Close after the run ends — tools execute while the response streams, - // so `await using` / try-finally would close the pool too early here. - middleware: [ - { - name: 'mcp-close', - onFinish: () => pool.close(), - onAbort: () => pool.close(), - onError: () => pool.close(), - }, - ], - }) - - return toServerSentEventsResponse(stream) +export async function POST(request: Request) { + const { messages } = await request.json() + + const pool = await createMCPClients({ + github: { + transport: { type: 'http', url: 'https://mcp.github.com/mcp' }, + }, + linear: { + transport: { + type: 'http', + url: 'https://mcp.linear.app/mcp', + headers: { + Authorization: `Bearer ${process.env.LINEAR_KEY ?? ''}`, + }, }, }, - }, -}) + }) + + const stream = chat({ + adapter: openaiText('gpt-5.5'), + messages, + tools: await pool.tools(), + // Close after the run ends — tools execute while the response streams, + // so `await using` / try-finally would close the pool too early here. + middleware: [ + { + name: 'mcp-close', + onFinish: () => pool.close(), + onAbort: () => pool.close(), + onError: () => pool.close(), + }, + ], + }) + + return toServerSentEventsResponse(stream) +} ``` ## Common Mistakes @@ -772,10 +846,20 @@ in-flight tool calls will fail. Wrong: ```typescript -const tools = await client.tools() -const stream = chat({ adapter, messages, tools }) -await client.close() // closes before the stream runs tools -return toServerSentEventsResponse(stream) +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { createMCPClient } from '@tanstack/ai-mcp' + +export async function POST(request: Request) { + const { messages } = await request.json() + const client = await createMCPClient({ + transport: { type: 'http', url: 'https://mcp.example.com/mcp' }, + }) + const tools = await client.tools() + const stream = chat({ adapter: openaiText('gpt-5.5'), messages, tools }) + await client.close() // closes before the stream runs tools + return toServerSentEventsResponse(stream) +} ``` This includes `try/finally` around the `return`, and `await using` at function @@ -787,26 +871,30 @@ before closing: ```typescript import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' import { createMCPClient } from '@tanstack/ai-mcp' -const client = await createMCPClient({ - transport: { type: 'http', url: '...' }, -}) +export async function POST(request: Request) { + const { messages } = await request.json() + const client = await createMCPClient({ + transport: { type: 'http', url: 'https://mcp.example.com/mcp' }, + }) -const stream = chat({ - adapter: openaiText('gpt-5.5'), - messages, - tools: await client.tools(), - middleware: [ - { - name: 'mcp-close', - onFinish: () => client.close(), - onAbort: () => client.close(), - onError: () => client.close(), - }, - ], -}) -return toServerSentEventsResponse(stream) + const stream = chat({ + adapter: openaiText('gpt-5.5'), + messages, + tools: await client.tools(), + middleware: [ + { + name: 'mcp-close', + onFinish: () => client.close(), + onAbort: () => client.close(), + onError: () => client.close(), + }, + ], + }) + return toServerSentEventsResponse(stream) +} ``` ### b. HIGH: importing `stdioTransport` from the main entry point @@ -817,7 +905,7 @@ bundle Node.js child-process code into edge bundles. Wrong: -```typescript +```typescript ignore import { stdioTransport } from '@tanstack/ai-mcp' // does not exist here ``` diff --git a/packages/ai-memory/skills/tanstack-ai-memory-hindsight/SKILL.md b/packages/ai-memory/skills/tanstack-ai-memory-hindsight/SKILL.md index 08c6cd4119..b853b8ee64 100644 --- a/packages/ai-memory/skills/tanstack-ai-memory-hindsight/SKILL.md +++ b/packages/ai-memory/skills/tanstack-ai-memory-hindsight/SKILL.md @@ -16,9 +16,15 @@ model can retain/recall/reflect directly. import { memoryMiddleware } from '@tanstack/ai-memory' import { hindsight } from '@tanstack/ai-memory/hindsight' -const memory = hindsight({ user: currentUserId }) // baseUrl defaults to HINDSIGHT_URL - -memoryMiddleware({ adapter: memory, scope }) +// Build per request from the server-validated session — never from req.body. +function memoryFor(session: { userId: string; threadId: string }) { + const memory = hindsight({ user: session.userId }) // baseUrl defaults to HINDSIGHT_URL + + return memoryMiddleware({ + adapter: memory, + scope: { threadId: session.threadId, userId: session.userId }, + }) +} ``` `@vectorize-io/hindsight-client` is an **optional peer dependency**, loaded lazily on diff --git a/packages/ai-memory/skills/tanstack-ai-memory-honcho/SKILL.md b/packages/ai-memory/skills/tanstack-ai-memory-honcho/SKILL.md index 92c8d4296a..ffb2c99353 100644 --- a/packages/ai-memory/skills/tanstack-ai-memory-honcho/SKILL.md +++ b/packages/ai-memory/skills/tanstack-ai-memory-honcho/SKILL.md @@ -16,9 +16,15 @@ appends the turn's messages to the session. import { memoryMiddleware } from '@tanstack/ai-memory' import { honcho } from '@tanstack/ai-memory/honcho' -const memory = honcho({ user: currentUserId }) // baseURL defaults to HONCHO_URL - -memoryMiddleware({ adapter: memory, scope }) +// Build per request from the server-validated session — never from req.body. +function memoryFor(session: { userId: string; threadId: string }) { + const memory = honcho({ user: session.userId }) // baseURL defaults to HONCHO_URL + + return memoryMiddleware({ + adapter: memory, + scope: { threadId: session.threadId, userId: session.userId }, + }) +} ``` `@honcho-ai/sdk` is an **optional peer dependency**, loaded lazily on first use — install diff --git a/packages/ai-memory/skills/tanstack-ai-memory-in-memory/SKILL.md b/packages/ai-memory/skills/tanstack-ai-memory-in-memory/SKILL.md index 03f62719ea..09600e59e2 100644 --- a/packages/ai-memory/skills/tanstack-ai-memory-in-memory/SKILL.md +++ b/packages/ai-memory/skills/tanstack-ai-memory-in-memory/SKILL.md @@ -30,7 +30,11 @@ import { inMemory } from '@tanstack/ai-memory/in-memory' const memory = inMemory() -memoryMiddleware({ adapter: memory, scope }) +// A static scope is fine for dev/tests; derive it from the session in real apps. +memoryMiddleware({ + adapter: memory, + scope: { threadId: 'demo-thread', userId: 'alice' }, +}) ``` ## Options diff --git a/packages/ai-memory/skills/tanstack-ai-memory-mem0/SKILL.md b/packages/ai-memory/skills/tanstack-ai-memory-mem0/SKILL.md index 3a8d828bc1..54e1abdbf0 100644 --- a/packages/ai-memory/skills/tanstack-ai-memory-mem0/SKILL.md +++ b/packages/ai-memory/skills/tanstack-ai-memory-mem0/SKILL.md @@ -14,9 +14,15 @@ server-side. Talks to the server over plain HTTP — **no SDK peer dependency**. import { memoryMiddleware } from '@tanstack/ai-memory' import { mem0 } from '@tanstack/ai-memory/mem0' -const memory = mem0({ user: currentUserId }) // baseUrl defaults to MEM0_URL - -memoryMiddleware({ adapter: memory, scope }) +// Build per request from the server-validated session — never from req.body. +function memoryFor(session: { userId: string; threadId: string }) { + const memory = mem0({ user: session.userId }) // baseUrl defaults to MEM0_URL + + return memoryMiddleware({ + adapter: memory, + scope: { threadId: session.threadId, userId: session.userId }, + }) +} ``` Requires a running mem0 server (self-hosted or hosted). Point it via `baseUrl` (or diff --git a/packages/ai-memory/skills/tanstack-ai-memory-redis/SKILL.md b/packages/ai-memory/skills/tanstack-ai-memory-redis/SKILL.md index cb5ed2fbb9..afa589cc83 100644 --- a/packages/ai-memory/skills/tanstack-ai-memory-redis/SKILL.md +++ b/packages/ai-memory/skills/tanstack-ai-memory-redis/SKILL.md @@ -20,10 +20,16 @@ import Redis from 'ioredis' import { memoryMiddleware } from '@tanstack/ai-memory' import { redis } from '@tanstack/ai-memory/redis' -const client = new Redis(process.env.REDIS_URL) +const client = new Redis(process.env.REDIS_URL ?? 'redis://localhost:6379') const memory = redis({ redis: client, prefix: 'myapp:memory' }) -memoryMiddleware({ adapter: memory, scope }) +// Resolve scope per request from the server-validated session — never from req.body. +function memoryFor(session: { userId: string; threadId: string }) { + return memoryMiddleware({ + adapter: memory, + scope: { threadId: session.threadId, userId: session.userId }, + }) +} ``` ### Option B: `redis` (node-redis v4+) @@ -41,7 +47,12 @@ const memory = redis({ prefix: 'myapp:memory', }) -memoryMiddleware({ adapter: memory, scope }) +function memoryFor(session: { userId: string; threadId: string }) { + return memoryMiddleware({ + adapter: memory, + scope: { threadId: session.threadId, userId: session.userId }, + }) +} ``` node-redis exposes a camelCase API (`sAdd`, `mGet`); `fromNodeRedis` translates it diff --git a/packages/ai-memory/skills/tanstack-ai-memory/SKILL.md b/packages/ai-memory/skills/tanstack-ai-memory/SKILL.md index 2b386ec12d..97a04c209e 100644 --- a/packages/ai-memory/skills/tanstack-ai-memory/SKILL.md +++ b/packages/ai-memory/skills/tanstack-ai-memory/SKILL.md @@ -22,28 +22,33 @@ Memory is for cross-turn / cross-session recall, not within-turn history. ## Wire it up ```ts -import { chat } from '@tanstack/ai' +import { chat, toServerSentEventsResponse } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' import { memoryMiddleware } from '@tanstack/ai-memory' import { inMemory } from '@tanstack/ai-memory/in-memory' +import { requireSession } from './auth' const memory = inMemory() // dev/tests only — see the in-memory skill -const stream = chat({ - adapter: openaiText('gpt-5.5'), - messages, - context: { session }, // attached by your auth middleware - middleware: [ - memoryMiddleware({ - adapter: memory, - // Derive scope server-side from trusted session state. - scope: (ctx) => { - const session = getSession(ctx) - return { threadId: session.threadId, userId: session.userId } - }, - }), - ], -}) +export async function POST(request: Request) { + const { messages } = await request.json() + // Resolved by your auth layer from cookies/headers — never from the request body. + const session = await requireSession(request) + + const stream = chat({ + adapter: openaiText('gpt-5.5'), + messages, + context: { session }, + middleware: [ + memoryMiddleware({ + adapter: memory, + // Derive scope server-side from trusted session state. + scope: () => ({ threadId: session.threadId, userId: session.userId }), + }), + ], + }) + return toServerSentEventsResponse(stream) +} ``` `memoryMiddleware` options: `adapter`, `scope` (static or a function of `ctx`), @@ -53,12 +58,21 @@ callbacks. ## The contract ```ts +import type { + MemoryFact, + MemoryScope, + MemorySnapshot, + MemoryTurn, + RecallResult, + SaveReceipt, +} from '@tanstack/ai-memory' + interface MemoryAdapter { - id: string - recall(scope, query): Promise // { systemPrompt, fragments?, tools?, toolGuidance? } - save(scope, turn): Promise> // turn = { user, assistant }; extraction lives HERE - inspect?(scope): Promise // optional (devtools) - listFacts?(scope): Promise> // optional (devtools) + readonly id: string + recall: (scope: MemoryScope, query: string) => Promise // { systemPrompt, fragments?, tools?, toolGuidance? } + save: (scope: MemoryScope, turn: MemoryTurn) => Promise> // turn = { user, assistant }; extraction lives HERE + inspect?: (scope: MemoryScope) => Promise // optional (devtools) + listFacts?: (scope: MemoryScope) => Promise> // optional (devtools) } ``` diff --git a/packages/ai-persistence/skills/ai-persistence/build-cloudflare-artifact-store/SKILL.md b/packages/ai-persistence/skills/ai-persistence/build-cloudflare-artifact-store/SKILL.md index 0255952498..4f565d9f14 100644 --- a/packages/ai-persistence/skills/ai-persistence/build-cloudflare-artifact-store/SKILL.md +++ b/packages/ai-persistence/skills/ai-persistence/build-cloudflare-artifact-store/SKILL.md @@ -28,6 +28,17 @@ type an object literal inline (autocomplete + contract checking, no separate annotation). ```ts +import type { + ArtifactRecord, + BlobBody, + BlobGetOptions, + BlobListOptions, + BlobListPage, + BlobObject, + BlobPutOptions, + BlobRecord, +} from '@tanstack/ai-persistence' + // BlobStore — the byte layer. R2 backs it. interface BlobStore { put: ( diff --git a/packages/ai-persistence/skills/ai-persistence/build-custom-adapter/SKILL.md b/packages/ai-persistence/skills/ai-persistence/build-custom-adapter/SKILL.md index 0b7f9459f5..bd27472244 100644 --- a/packages/ai-persistence/skills/ai-persistence/build-custom-adapter/SKILL.md +++ b/packages/ai-persistence/skills/ai-persistence/build-custom-adapter/SKILL.md @@ -259,7 +259,7 @@ inspects your storage. You rarely need all four stores at once. Implement what you own and fill the rest from another base: -```ts ignore +```ts import { composePersistence, memoryPersistence } from '@tanstack/ai-persistence' import { messages, runs } from './my-stores' @@ -307,7 +307,7 @@ This matters more here than anywhere else: there is no reference driver to compare against, so the testkit is the only thing standing between a subtle idempotency bug and stuck approvals in production. -```ts ignore +```ts import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit' import { chatPersistence } from '../src/lib/chat-persistence' diff --git a/packages/ai-persistence/skills/ai-persistence/build-drizzle-adapter/SKILL.md b/packages/ai-persistence/skills/ai-persistence/build-drizzle-adapter/SKILL.md index f1e7cd8233..ead5acb2df 100644 --- a/packages/ai-persistence/skills/ai-persistence/build-drizzle-adapter/SKILL.md +++ b/packages/ai-persistence/skills/ai-persistence/build-drizzle-adapter/SKILL.md @@ -516,7 +516,7 @@ route** — derive the user from the session, never trust a client-supplied id. ## 5. Verify -```ts ignore +```ts import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit' import { chatPersistence } from '../src/lib/chat-persistence' diff --git a/packages/ai-persistence/skills/ai-persistence/build-prisma-adapter/SKILL.md b/packages/ai-persistence/skills/ai-persistence/build-prisma-adapter/SKILL.md index 07a594ffc3..cd506f2cec 100644 --- a/packages/ai-persistence/skills/ai-persistence/build-prisma-adapter/SKILL.md +++ b/packages/ai-persistence/skills/ai-persistence/build-prisma-adapter/SKILL.md @@ -474,7 +474,7 @@ route** — derive the user from the session, never trust a client-supplied id. ## 5. Verify -```ts ignore +```ts import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit' import { chatPersistence } from '../src/lib/chat-persistence' diff --git a/packages/ai-persistence/skills/ai-persistence/server/SKILL.md b/packages/ai-persistence/skills/ai-persistence/server/SKILL.md index eb98ae1c08..82cbcbda8e 100644 --- a/packages/ai-persistence/skills/ai-persistence/server/SKILL.md +++ b/packages/ai-persistence/skills/ai-persistence/server/SKILL.md @@ -98,6 +98,9 @@ preserve plain-text and structured-output assistant messages separately when those messages use different ids. ```ts +import { withPersistence } from '@tanstack/ai-persistence' +import { persistence } from './persistence' + withPersistence(persistence, { snapshotStreaming: true, snapshotIntervalMs: 1000, // default @@ -159,6 +162,8 @@ Server-authoritative clients load history by `threadId` (often `GET`): ```ts import { reconstructChat } from '@tanstack/ai-persistence' +import { persistence } from './persistence' +import { sessionUserId, userOwnsThread } from './auth' export async function GET(request: Request) { return reconstructChat(persistence, request, { diff --git a/packages/ai-persistence/skills/ai-persistence/stores/SKILL.md b/packages/ai-persistence/skills/ai-persistence/stores/SKILL.md index 3989bff091..63375d380b 100644 --- a/packages/ai-persistence/skills/ai-persistence/stores/SKILL.md +++ b/packages/ai-persistence/skills/ai-persistence/stores/SKILL.md @@ -37,6 +37,7 @@ a complete `node:sqlite` implementation lives in ```ts import { defineAIPersistence } from '@tanstack/ai-persistence' import type { ChatWithInterruptsPersistence } from '@tanstack/ai-persistence' +import { messages, runs, interrupts } from './stores' // Sparse is fine — only implement what you need. export const persistence: ChatWithInterruptsPersistence = defineAIPersistence({ @@ -73,9 +74,11 @@ mistake when writing an adapter. ### `MessageStore` ```ts +import type { ModelMessage } from '@tanstack/ai' + interface MessageStore { - loadThread(threadId: string): Promise> - saveThread(threadId: string, messages: Array): Promise + loadThread: (threadId: string) => Promise> + saveThread: (threadId: string, messages: Array) => Promise } ``` @@ -120,6 +123,9 @@ always a choice you made on purpose rather than a check that quietly did not run. Declare yours and the suite reports them as skipped with a reason: ```ts +import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit' +import { persistence } from './persistence' + // The shipped sqlite example implements findActiveRun and listReclaimable and // declares only the one it omits. runPersistenceConformance('sqlite', () => persistence, { @@ -128,14 +134,16 @@ runPersistenceConformance('sqlite', () => persistence, { ``` ```ts +import type { RunRecord, RunStatus } from '@tanstack/ai-persistence' + interface RunStore { // Required - createOrResume( + createOrResume: ( input: Pick & { status?: RunStatus }, - ): Promise - update( + ) => Promise + update: ( runId: string, patch: Partial< Pick< @@ -150,16 +158,16 @@ interface RunStore { | 'driverEpoch' > >, - ): Promise - get(runId: string): Promise - findActiveRun(threadId: string): Promise + ) => Promise + get: (runId: string) => Promise + findActiveRun: (threadId: string) => Promise // Optional - listByThread?(threadId: string): Promise> - listReclaimable?(opts: { + listByThread?: (threadId: string) => Promise> + listReclaimable?: (opts: { now: number ttlMs: number - }): Promise> + }) => Promise> } ``` @@ -293,15 +301,25 @@ and each must be declared via `skipMethods` when absent. ### `InterruptStore` ```ts +import type { + InterruptCommitEntry, + InterruptRecord, +} from '@tanstack/ai-persistence' + interface InterruptStore { - create(record: Omit): Promise - resolve(interruptId: string, response?: unknown): Promise - cancel(interruptId: string): Promise - get(interruptId: string): Promise - list(threadId: string): Promise> - listPending(threadId: string): Promise> - listByRun(runId: string): Promise> - listPendingByRun(runId: string): Promise> + create: ( + record: Omit, + ) => Promise + resolve: (interruptId: string, response?: unknown) => Promise + cancel: (interruptId: string) => Promise + // Optional: apply a validated resume batch all-or-nothing instead of + // per-entry resolve/cancel. + commitBatch?: (entries: ReadonlyArray) => Promise + get: (interruptId: string) => Promise + list: (threadId: string) => Promise> + listPending: (threadId: string) => Promise> + listByRun: (runId: string) => Promise> + listPendingByRun: (runId: string) => Promise> } ``` @@ -314,9 +332,9 @@ interface InterruptStore { ```ts interface MetadataStore { - get(namespace: string, key: string): Promise - set(namespace: string, key: string, value: unknown): Promise - delete(namespace: string, key: string): Promise + get: (namespace: string, key: string) => Promise + set: (namespace: string, key: string, value: unknown) => Promise + delete: (namespace: string, key: string) => Promise } ``` diff --git a/packages/ai-sandbox/skills/ai-sandbox/SKILL.md b/packages/ai-sandbox/skills/ai-sandbox/SKILL.md index 94e04a9847..4523600890 100644 --- a/packages/ai-sandbox/skills/ai-sandbox/SKILL.md +++ b/packages/ai-sandbox/skills/ai-sandbox/SKILL.md @@ -48,9 +48,10 @@ agent CLI **inside** the sandbox and streams its events back. ## Setup — Claude Code in a Docker sandbox ```typescript -import { chat } from '@tanstack/ai' +import { chat, toServerSentEventsResponse } from '@tanstack/ai' import { claudeCodeText } from '@tanstack/ai-claude-code' import { + createSecrets, defineSandbox, defineWorkspace, withSandbox, @@ -65,17 +66,25 @@ const sandbox = defineSandbox({ packageManager: 'pnpm', setup: ['corepack enable', 'pnpm install'], scripts: { test: 'pnpm test' }, - secrets: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY ?? '' }, + secrets: createSecrets({ + ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY ?? '', + }), }), lifecycle: { reuse: 'thread', snapshot: 'after-setup', keepAlive: '30m' }, }) -const stream = chat({ - threadId, - adapter: claudeCodeText('sonnet'), - messages, - middleware: [withSandbox(sandbox)], -}) +export async function POST(request: Request) { + const { threadId, messages } = await request.json() + + const stream = chat({ + threadId, + adapter: claudeCodeText('sonnet'), + messages, + middleware: [withSandbox(sandbox)], + }) + + return toServerSentEventsResponse(stream) +} ``` ## Type-safe secrets @@ -165,6 +174,8 @@ serial and parallel groups over a **persistent shell** whose cwd/env carry over between serial steps: ```typescript +import { githubRepo, defineWorkspace } from '@tanstack/ai-sandbox' + defineWorkspace({ source: githubRepo({ repo: 'owner/app' }), setup: ({ serial, parallel }) => { @@ -183,10 +194,17 @@ When the provider supports snapshots, bootstrap takes one automatically after Override or add a TTL: ```typescript -lifecycle: { - snapshot: 'after-setup', // default when provider.capabilities().snapshots - snapshotMaxAge: '24h', // re-create when the snapshot is older than this -} +import { defineSandbox } from '@tanstack/ai-sandbox' +import { dockerSandbox } from '@tanstack/ai-sandbox-docker' + +const sandbox = defineSandbox({ + id: 'repo-agent', + provider: dockerSandbox({ image: 'node:22' }), + lifecycle: { + snapshot: 'after-setup', // default when provider.capabilities().snapshots + snapshotMaxAge: '24h', // re-create when the snapshot is older than this + }, +}) ``` Providers without snapshot support skip the step silently. @@ -199,8 +217,15 @@ middleware in this order, with the same persistence value in both places: ```typescript import { withPersistence } from '@tanstack/ai-persistence' -import { memorySandboxSnapshots, withSandbox } from '@tanstack/ai-sandbox' +import { + InMemorySandboxInstanceStore, + memorySandboxSnapshots, + withSandbox, +} from '@tanstack/ai-sandbox' +// Your `defineSandbox(...)` result. +import { sandbox } from './sandbox' +const instances = new InMemorySandboxInstanceStore() const snapshots = await memorySandboxSnapshots({ sandbox, instances }) const middleware = [ @@ -321,20 +346,30 @@ distributed lock: either `withLocks` from `@tanstack/ai/locks` (ordered **before** `withSandbox`) or the `locks` option. ```typescript -import { chat } from '@tanstack/ai' +import { chat, toServerSentEventsResponse } from '@tanstack/ai' import { InMemoryLockStore, withLocks } from '@tanstack/ai/locks' +import { claudeCodeText } from '@tanstack/ai-claude-code' import { withSandbox } from '@tanstack/ai-sandbox' +// Your `defineSandbox(...)` result. +import { sandbox } from './sandbox' // Production: your BYO store — docs/sandbox/durability.md import { instanceStore } from './sandbox-instance-store' -chat({ - adapter, - messages, - middleware: [ - withLocks(new InMemoryLockStore()), // multi-replica: distributed lock - withSandbox(sandbox, { instances: instanceStore }), - ], -}) +export async function POST(request: Request) { + const { threadId, messages } = await request.json() + + const stream = chat({ + threadId, + adapter: claudeCodeText('sonnet'), + messages, + middleware: [ + withLocks(new InMemoryLockStore()), // multi-replica: distributed lock + withSandbox(sandbox, { instances: instanceStore }), + ], + }) + + return toServerSentEventsResponse(stream) +} ``` The store option takes precedence over an ambient `SandboxInstanceStoreCapability` @@ -360,8 +395,11 @@ middleware via the `sandbox` group (run-scoped): import { defineSandbox, withSandbox } from '@tanstack/ai-sandbox' // `defineChatMiddleware` is core's, not this package's — `@tanstack/ai-sandbox` // consumes it too (see its own `src/middleware.ts`). -import { defineChatMiddleware } from '@tanstack/ai' +import { chat, defineChatMiddleware } from '@tanstack/ai' +import { claudeCodeText } from '@tanstack/ai-claude-code' import { dockerSandbox } from '@tanstack/ai-sandbox-docker' +import { db } from './db' +import { metrics } from './metrics' // Sandbox-scoped hooks (all optional): const sandbox = defineSandbox({ @@ -392,6 +430,13 @@ const auditMiddleware = defineChatMiddleware({ // No extra middleware needed — sandbox.file CUSTOM events are emitted // automatically. Read them from the stream: +const stream = chat({ + threadId: 'thread-1', + adapter: claudeCodeText('sonnet'), + messages: [{ role: 'user', content: 'Add a README.' }], + middleware: [auditMiddleware, withSandbox(sandbox)], +}) + for await (const chunk of stream) { if (chunk.type === 'CUSTOM' && chunk.name === 'sandbox.file') { const value = chunk.value @@ -412,7 +457,10 @@ outside a `chat()` run: ```typescript import { watchWorkspace } from '@tanstack/ai-sandbox' +// Your `defineSandbox(...)` result. +import { sandbox } from './sandbox' +const handle = await sandbox.ensure({ threadId: 'thread-1', runId: 'run-1' }) const watcher = await watchWorkspace(handle, { onEvent: (e) => console.log(e.type, e.path), ignore: ['.git', 'node_modules'], // default @@ -424,8 +472,24 @@ Enable the `sandbox` debug category to log watcher start/stop, event dispatch, and lifecycle transitions: ```typescript -chat({ threadId, adapter, messages, debug: { sandbox: true } }) -// or debug: true to enable all categories +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { claudeCodeText } from '@tanstack/ai-claude-code' +import { withSandbox } from '@tanstack/ai-sandbox' +import { sandbox } from './sandbox' + +export async function POST(request: Request) { + const { threadId, messages } = await request.json() + + const stream = chat({ + threadId, + adapter: claudeCodeText('sonnet'), + messages, + middleware: [withSandbox(sandbox)], + debug: { sandbox: true }, // or debug: true to enable all categories + }) + + return toServerSentEventsResponse(stream) +} ``` ## Edge / serverless execution @@ -551,12 +615,17 @@ file from byte 0 at any point, including after the original host has died. ```typescript import { spawnNdjson } from '@tanstack/ai-sandbox' - -for await (const event of spawnNdjson(sandbox, agentCommand, { - cwd, - journal: { runId }, // durability is opt-in: pass `journal` to route through it -})) { - // parsed NDJSON objects, translated by the harness adapter as usual +import type { SandboxHandle } from '@tanstack/ai-sandbox' + +export async function runAgent(handle: SandboxHandle, runId: string) { + const agentCommand = 'claude -p --output-format stream-json' + for await (const event of spawnNdjson(handle, agentCommand, { + cwd: '/workspace', + journal: { runId }, // durability is opt-in: pass `journal` to route through it + })) { + // parsed NDJSON objects, translated by the harness adapter as usual + console.log(event) + } } ``` @@ -1006,6 +1075,17 @@ import { } from '@tanstack/ai-sandbox' import type { RunRecord } from '@tanstack/ai' import type { ReapResult, RunExitProbe } from '@tanstack/ai-sandbox' +// Your distributed LockStore, the same one `withSandbox` gets. +import { locks } from './locks' +// Your persistence — the SAME RunStore the chat routes use. +import { runs } from './persistence' +// Your `defineSandbox(...)` result and the `SandboxInstanceStore` you passed to +// `withSandbox(sandbox, { instances })`. +import { instances, sandbox } from './sandbox' +// The per-run log factory, resolving the SAME log the producing route wrote. +import { durabilityFor } from './durability' +// The same `drive` the attach route passes to `sandboxRunDriver`. +import { driveRun } from './drive-run' async function hasFinished(record: RunRecord): Promise { if (record.sandboxKey === undefined) return { state: 'unknown' } diff --git a/packages/ai-skills/skills/ai-skills/SKILL.md b/packages/ai-skills/skills/ai-skills/SKILL.md index bd9064c36e..99d4ea6e57 100644 --- a/packages/ai-skills/skills/ai-skills/SKILL.md +++ b/packages/ai-skills/skills/ai-skills/SKILL.md @@ -51,11 +51,17 @@ const pptx = inlineSkill({ instructions: '# Building a deck\nUse python-pptx. Edit slides, then save.', }) -const stream = chat({ - adapter: anthropicText('claude-sonnet-4-5'), - messages, - middleware: [withSkills(pptx)], -}) +export async function POST(request: Request) { + const { messages } = await request.json() + + const stream = chat({ + adapter: anthropicText('claude-sonnet-4-6'), + messages, + middleware: [withSkills(pptx)], + }) + + return toServerSentEventsResponse(stream) +} ``` `withSkills` adds a catalog to the system prompt and a `load_skill` tool whose @@ -93,9 +99,11 @@ your own execution tool to `chat({ tools })` alongside `withSkills` and write th skill so it tells the model to call that tool. `withSkills` composes with any tools you provide. -```ts ignore +```ts import { toolDefinition } from '@tanstack/ai' import { z } from 'zod' +// Your own runner: a provider sandbox, a Code Mode isolate, a remote worker. +import { runSomewhere } from './shell' const executeShell = toolDefinition({ name: 'execute_shell', @@ -119,6 +127,10 @@ Implement `SkillSource` (`list` + `load`, optional `revision`/`listResources`/ ```typescript import { runSkillSourceConformance } from '@tanstack/ai-skills/testing' +// Your SkillSource implementation, seeded with the `alpha` / `beta` fixture +// skills the suite expects. +import { myS3Source } from './my-s3-source' +import { fixtures } from './fixtures' runSkillSourceConformance(() => myS3Source(fixtures), 's3') ``` diff --git a/packages/ai/skills/ai-core/adapter-configuration/SKILL.md b/packages/ai/skills/ai-core/adapter-configuration/SKILL.md index f9843ea2e3..8817401c3f 100644 --- a/packages/ai/skills/ai-core/adapter-configuration/SKILL.md +++ b/packages/ai/skills/ai-core/adapter-configuration/SKILL.md @@ -45,16 +45,20 @@ Create an adapter and use it with `chat()`: import { chat, toServerSentEventsResponse } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' -const stream = chat({ - adapter: openaiText('gpt-5.2'), - messages, - modelOptions: { - temperature: 0.7, - max_output_tokens: 1000, - }, -}) +export async function POST(request: Request) { + const { messages } = await request.json() + + const stream = chat({ + adapter: openaiText('gpt-5.2'), + messages, + modelOptions: { + temperature: 0.7, + max_output_tokens: 1000, + }, + }) -return toServerSentEventsResponse(stream) + return toServerSentEventsResponse(stream) +} ``` The adapter factory function takes the model name as a string literal and an @@ -95,7 +99,7 @@ The text adapter is the primary one for chat/completions: ```typescript // Each factory takes model as first arg, optional config as second -import { openaiText } from '@tanstack/ai-openai' +import { openaiText, createOpenaiChat } from '@tanstack/ai-openai' import { anthropicText } from '@tanstack/ai-anthropic' import { geminiText } from '@tanstack/ai-gemini' import { grokText } from '@tanstack/ai-grok' @@ -109,17 +113,16 @@ import { byteplusText } from '@tanstack/ai-byteplus' const adapter = openaiText('gpt-5.2') const adapter2 = anthropicText('claude-sonnet-4-6') const adapter3 = geminiText('gemini-2.5-pro') -const adapter4 = grokText('grok-4') +const adapter4 = grokText('grok-4.6') const adapter5 = groqText('llama-3.3-70b-versatile') const adapter6 = openRouterText('anthropic/claude-sonnet-4') -const adapter7 = ollamaText('llama3.3') +const adapter7 = ollamaText('llama3.3:latest') const adapter8 = bedrockText('us.anthropic.claude-3-7-sonnet-20250219-v1:0') const adapter9 = byteplusText('seed-2-0-lite-260428') -// Optional: pass explicit API key -const adapterWithKey = openaiText('gpt-5.2', { - apiKey: 'sk-...', -}) +// Optional: pass an explicit API key via the create* sibling +// (the plain factory reads it from the environment) +const adapterWithKey = createOpenaiChat('gpt-5.2', 'sk-...') ``` `@tanstack/ai-bedrock` (Amazon Bedrock) branches on `config.api`: @@ -137,26 +140,32 @@ input or configuration: ```typescript import { chat, toServerSentEventsResponse } from '@tanstack/ai' -import type { TextAdapter } from '@tanstack/ai/adapters' +import type { ModelMessage } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' import { anthropicText } from '@tanstack/ai-anthropic' import { geminiText } from '@tanstack/ai-gemini' // Define a map of provider+model to adapter factory calls -const adapters: Record TextAdapter> = { +const adapters = { 'openai/gpt-5.2': () => openaiText('gpt-5.2'), 'anthropic/claude-sonnet-4-6': () => anthropicText('claude-sonnet-4-6'), 'gemini/gemini-2.5-pro': () => geminiText('gemini-2.5-pro'), } -export function handleChat(providerModel: string, messages: Array) { - const createAdapter = adapters[providerModel] - if (!createAdapter) { +function isKnownProviderModel(key: string): key is keyof typeof adapters { + return key in adapters +} + +export function handleChat( + providerModel: string, + messages: Array, +) { + if (!isKnownProviderModel(providerModel)) { throw new Error(`Unknown provider/model: ${providerModel}`) } const stream = chat({ - adapter: createAdapter(), + adapter: adapters[providerModel](), messages, }) @@ -174,6 +183,10 @@ import { openaiText } from '@tanstack/ai-openai' import { anthropicText } from '@tanstack/ai-anthropic' import { geminiText } from '@tanstack/ai-gemini' +const messages = [ + { role: 'user' as const, content: 'Plan a database migration.' }, +] + // OpenAI: reasoning with effort and summary const openaiStream = chat({ adapter: openaiText('gpt-5.2'), @@ -199,16 +212,18 @@ const anthropicStream = chat({ }, }) -// Anthropic: adaptive thinking (claude-sonnet-4-6 and newer) +// Anthropic: adaptive thinking (Sonnet 5, Fable 5, Opus 4.7+) — depth is +// tuned with output_config.effort instead of a token budget const adaptiveStream = chat({ - adapter: anthropicText('claude-sonnet-4-6'), + adapter: anthropicText('claude-sonnet-5'), messages, modelOptions: { max_tokens: 16000, thinking: { type: 'adaptive', + display: 'summarized', // stream the reasoning text (default 'omitted') }, - effort: 'high', // 'max' | 'high' | 'medium' | 'low' + output_config: { effort: 'high' }, // 'low' | 'medium' | 'high' | 'xhigh' | 'max' }, }) @@ -263,6 +278,14 @@ inside `modelOptions` using each provider's **native** key. They are not top-level fields on `chat()`/`ai()`/`generate()`. ```typescript +import { chat } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { anthropicText } from '@tanstack/ai-anthropic' +import { geminiText } from '@tanstack/ai-gemini' +import { ollamaText } from '@tanstack/ai-ollama' + +const messages = [{ role: 'user' as const, content: 'Hello' }] + // OpenAI — native keys chat({ adapter: openaiText('gpt-5.2'), @@ -285,8 +308,9 @@ chat({ }) // Ollama — NESTED under modelOptions.options +// (use the `family:tag` id — a bare `llama3.3` falls back to untyped options) chat({ - adapter: ollamaText('llama3.3'), + adapter: ollamaText('llama3.3:latest'), messages, modelOptions: { options: { temperature: 0.7, top_p: 0.9, num_predict: 1000 }, @@ -301,7 +325,7 @@ Per-provider sampling keys (all live inside `modelOptions`): | OpenAI | `temperature` | `top_p` | `max_output_tokens` | | Anthropic | `temperature` | `top_p` | `max_tokens` | | Gemini | `temperature` | `topP` | `maxOutputTokens` | -| Grok (xAI) | `temperature` | `top_p` | `max_tokens` | +| Grok (xAI) | `temperature` | `top_p` | `max_output_tokens` | | Groq | `temperature` | `top_p` | `max_completion_tokens` | | OpenRouter (chat) | `temperature` | `topP` | `maxCompletionTokens` | | Ollama | `temperature` | `top_p` | `num_predict` (nested in `options`) | @@ -326,7 +350,16 @@ some sampling options use provider-native names. Ollama nests all sampling under Adapters can declare an optional capability method: ```ts -supportsCombinedToolsAndSchema?(modelOptions?: TProviderOptions): boolean +import { AnthropicTextAdapter } from '@tanstack/ai-anthropic' + +// The TextAdapter contract: +// supportsCombinedToolsAndSchema?: (modelOptions?: TProviderOptions) => boolean +// Subclasses override it to narrow the capability: +class LegacyPathAnthropic extends AnthropicTextAdapter<'claude-sonnet-4-6'> { + override supportsCombinedToolsAndSchema(): boolean { + return false + } +} ``` When `true`, the engine wires `outputSchema` into the regular @@ -338,16 +371,16 @@ runs. Current per-adapter status (#605): -| Adapter | Returns | -| -------------------------------------------- | ----------------------------------------------------------------------------------------------------- | -| `openaiText` / `openaiChatCompletions` | `true` (all supported models) | -| `anthropicText` | `true` for Claude 4.5+ (gated by `ANTHROPIC_COMBINED_TOOLS_AND_SCHEMA_MODELS`), `false` otherwise | -| `geminiText` | `true` for Gemini 3.x (gated by `GEMINI_COMBINED_TOOLS_AND_SCHEMA_MODELS`), `false` otherwise | -| `grokText` | `true` for Grok 4 family (gated by `GROK_COMBINED_TOOLS_AND_SCHEMA_MODELS`), `false` otherwise | -| `groqText` | `false` (Groq API rejects schema + tools + stream) | -| `openRouterText` / `openRouterResponsesText` | `false` (per-call resolution is a follow-up) | -| `ollamaText` | `false` (constrained-decoding vs tool-call grammar conflict) | -| `byteplusText` | Per model — `true` only for the 10 ids in `BYTEPLUS_STRUCTURED_OUTPUT_CHAT_MODELS`, `false` otherwise | +| Adapter | Returns | +| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `openaiText` / `openaiChatCompletions` | `true` (all supported models) | +| `anthropicText` | `true` for Claude 4.5+ (gated by `ANTHROPIC_COMBINED_TOOLS_AND_SCHEMA_MODELS`), `false` otherwise | +| `geminiText` | `true` for Gemini 3.x (gated by `GEMINI_COMBINED_TOOLS_AND_SCHEMA_MODELS`), `false` otherwise | +| `grokText` | `true` (all chat models — inherits the OpenAI Responses base; no per-model gate) | +| `groqText` | `false` (Groq API rejects schema + tools + stream) | +| `openRouterText` / `openRouterResponsesText` | Per model — `true` only when the model and every `modelOptions.models` fallback are in `OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS` | +| `ollamaText` | `false` (constrained-decoding vs tool-call grammar conflict) | +| `byteplusText` | Per model — `true` only for the 10 ids in `BYTEPLUS_STRUCTURED_OUTPUT_CHAT_MODELS`, `false` otherwise | Subclasses can override to narrow the capability. When extending an adapter for a custom model that doesn't support the combination, return @@ -371,7 +404,9 @@ dedicated package required. ```typescript import { openaiCompatible } from '@tanstack/ai-openai/compatible' -import { createModel } from '@tanstack/ai' +import { chat, createModel } from '@tanstack/ai' + +const messages = [{ role: 'user' as const, content: 'Hello' }] // Provider-factory: configure baseURL + apiKey + models ONCE, // then select a model per call (the model arg is a type-safe union). @@ -400,6 +435,9 @@ For a single model, use the one-shot helper: ```typescript import { openaiCompatibleText } from '@tanstack/ai-openai/compatible' +import { chat } from '@tanstack/ai' + +const messages = [{ role: 'user' as const, content: 'Hello' }] chat({ adapter: openaiCompatibleText('deepseek-chat', { @@ -428,13 +466,17 @@ ElevenLabs `baseUrl`/`headers`). The vendor names still work; when both are set, `baseURL` and `defaultHeaders` win. ```typescript +import { createGeminiChat } from '@tanstack/ai-gemini' + const gateway = { baseURL: 'https://gateway.example.com/google-ai-studio', defaultHeaders: { 'cf-aig-authorization': `Bearer ${process.env.GATEWAY_TOKEN}`, }, } -createGeminiChat('gemini-3.8-flash', apiKey, { ...gateway }) +createGeminiChat('gemini-3.8-flash', process.env.GOOGLE_API_KEY!, { + ...gateway, +}) ``` ## Common Mistakes @@ -444,13 +486,19 @@ createGeminiChat('gemini-3.8-flash', apiKey, { ...gateway }) The legacy `openai()` (and `anthropic()`, etc.) monolithic adapters are deprecated. They take the model in `chat()`, not in the factory. -```typescript -// WRONG: Legacy monolithic adapter pattern +```typescript ignore +// WRONG: Legacy monolithic adapter pattern (no longer exported) import { openai } from '@tanstack/ai-openai' chat({ adapter: openai(), model: 'gpt-5.2', messages }) +``` +```typescript // CORRECT: Tree-shakeable adapter, model in factory +import { chat } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' + +const messages = [{ role: 'user' as const, content: 'Hello' }] + chat({ adapter: openaiText('gpt-5.2'), messages }) ``` diff --git a/packages/ai/skills/ai-core/adapter-configuration/references/anthropic-adapter.md b/packages/ai/skills/ai-core/adapter-configuration/references/anthropic-adapter.md index 036e81b003..eae98820e0 100644 --- a/packages/ai/skills/ai-core/adapter-configuration/references/anthropic-adapter.md +++ b/packages/ai/skills/ai-core/adapter-configuration/references/anthropic-adapter.md @@ -21,45 +21,58 @@ import { anthropicText } from '@tanstack/ai-anthropic' ## Key Chat Models -| Model | Context Window | Max Output | Notes | -| ------------------- | -------------- | ---------- | ------------------------------------------- | -| `claude-fable-5` | 1M | 128K | Most capable; thinking always on (adaptive) | -| `claude-sonnet-5` | 1M | 128K | Best balance; adaptive thinking by default | -| `claude-opus-4-8` | 1M | 128K | Opus tier; adaptive thinking, no sampling | -| `claude-opus-4-7` | 1M | 128K | Older Opus; adaptive thinking, no sampling | -| `claude-opus-4-6` | 200K | 128K | Older Opus, adaptive + budget thinking | -| `claude-sonnet-4-6` | 1M | 64K | Previous gen balanced, adaptive + budget | -| `claude-sonnet-4-5` | 200K | 64K | Previous gen balanced | -| `claude-opus-4-5` | 200K | 32K | Previous gen most capable | -| `claude-opus-4-1` | 200K | 64K | Deprecated (retires 2026-08-05) | -| `claude-haiku-4-5` | 200K | 64K | Fast and affordable | +| Model | Context Window | Max Output | Notes | +| -------------------- | -------------- | ---------- | ------------------------------------------- | +| `claude-fable-5-1` | 1M | 128K | Newest; thinking always on (adaptive) | +| `claude-fable-5` | 1M | 128K | Most capable; thinking always on (adaptive) | +| `claude-opus-5` | 1M | 128K | Opus tier; budget thinking + sampling | +| `claude-opus-5-fast` | 1M | 128K | Fast-mode Opus 5; same options as opus-5 | +| `claude-sonnet-5` | 1M | 128K | Best balance; adaptive thinking by default | +| `claude-opus-4-8` | 1M | 128K | Opus tier; adaptive thinking, no sampling | +| `claude-opus-4-7` | 1M | 128K | Older Opus; adaptive thinking, no sampling | +| `claude-opus-4-6` | 200K | 128K | Older Opus, adaptive + budget thinking | +| `claude-sonnet-4-6` | 1M | 64K | Previous gen balanced, adaptive + budget | +| `claude-sonnet-4-5` | 200K | 64K | Previous gen balanced | +| `claude-opus-4-5` | 200K | 32K | Previous gen most capable | +| `claude-opus-4-1` | 200K | 64K | Deprecated (retires 2026-08-05) | +| `claude-haiku-4-5` | 200K | 64K | Fast and affordable | Note: Model IDs use the format `claude-sonnet-5`, `claude-opus-4-8`, etc. -Retired models (Claude 3.x, Sonnet 3.7, Opus 4 / Sonnet 4) and the `-fast` -variant ids were removed — every registered id resolves against the -first-party Anthropic API. +Retired models (Claude 3.x, Sonnet 3.7, Opus 4 / Sonnet 4) were removed — +every registered id resolves against the first-party Anthropic API. +`claude-opus-5-fast` is the only `-fast` id that remains. + +`output_config.effort` is typed only on the adaptive-era models +(`claude-opus-4-7`, `claude-opus-4-8`, `claude-sonnet-5`, `claude-fable-5`, +`claude-fable-5-1`). There is no top-level `effort` option on any model; +`claude-opus-4-6` / `claude-sonnet-4-6` accept `thinking: { type: 'adaptive' }` +but no effort knob. ## Provider-Specific modelOptions ```typescript +import { chat } from '@tanstack/ai' +import { anthropicText } from '@tanstack/ai-anthropic' + +const messages = [{ role: 'user' as const, content: 'Hello' }] + chat({ adapter: anthropicText('claude-sonnet-4-6'), messages, modelOptions: { // Sampling temperature: 0.7, - top_p: 0.9, // cannot be combined with temperature + // top_p: 0.9, // cannot be combined with temperature max_tokens: 16000, // Extended thinking (budget-based) thinking: { type: 'enabled', budget_tokens: 8000, // must be >= 1024 and < max_tokens }, - // Adaptive thinking (claude-sonnet-4-6, claude-opus-4-6+) - thinking: { - type: 'adaptive', - }, - effort: 'high', // 'max' | 'high' | 'medium' | 'low' + // Adaptive thinking (claude-sonnet-4-6, claude-opus-4-6+) — the + // alternative to the budget shape above; effort is tuned via + // output_config.effort on the adaptive-era models (see below) + // thinking: { type: 'adaptive' }, // Service tier service_tier: 'auto', // 'auto' | 'standard_only' // Stop sequences @@ -99,6 +112,11 @@ ANTHROPIC_API_KEY The per-model types restrict `modelOptions` on the newest models: ```typescript +import { chat } from '@tanstack/ai' +import { anthropicText } from '@tanstack/ai-anthropic' + +const messages = [{ role: 'user' as const, content: 'Hello' }] + chat({ adapter: anthropicText('claude-sonnet-5'), // or 'claude-fable-5', 'claude-opus-4-8' messages, diff --git a/packages/ai/skills/ai-core/adapter-configuration/references/byteplus-adapter.md b/packages/ai/skills/ai-core/adapter-configuration/references/byteplus-adapter.md index 3ffcf8573c..44415dec86 100644 --- a/packages/ai/skills/ai-core/adapter-configuration/references/byteplus-adapter.md +++ b/packages/ai/skills/ai-core/adapter-configuration/references/byteplus-adapter.md @@ -62,6 +62,11 @@ Media models: `BYTEPLUS_VIDEO_MODELS` (Seedance — ## Provider-Specific modelOptions ```typescript +import { chat } from '@tanstack/ai' +import { byteplusText } from '@tanstack/ai-byteplus' + +const messages = [{ role: 'user' as const, content: 'Hello' }] + chat({ adapter: byteplusText('dola-seed-2-1-turbo-260628'), messages, diff --git a/packages/ai/skills/ai-core/adapter-configuration/references/gemini-adapter.md b/packages/ai/skills/ai-core/adapter-configuration/references/gemini-adapter.md index fb27b468b1..e5a1b67626 100644 --- a/packages/ai/skills/ai-core/adapter-configuration/references/gemini-adapter.md +++ b/packages/ai/skills/ai-core/adapter-configuration/references/gemini-adapter.md @@ -38,6 +38,15 @@ Most Gemini text models accept `text`, `image`, `audio`, `video`, and `document` ## Provider-Specific modelOptions ```typescript +import { chat } from '@tanstack/ai' +import { + geminiText, + HarmBlockThreshold, + HarmCategory, +} from '@tanstack/ai-gemini' + +const messages = [{ role: 'user' as const, content: 'Hello' }] + chat({ adapter: geminiText('gemini-2.5-pro'), messages, @@ -47,15 +56,14 @@ chat({ includeThoughts: true, thinkingBudget: 4096, }, - // Thinking (level-based, advanced models) - thinkingConfig: { - thinkingLevel: 'THINKING_LEVEL_HIGH', - }, + // Thinking (level-based, advanced models) — the alternative to the + // budget shape above: + // thinkingConfig: { thinkingLevel: 'THINKING_LEVEL_HIGH' }, // Safety settings safetySettings: [ { - category: 'HARM_CATEGORY_HATE_SPEECH', - threshold: 'BLOCK_MEDIUM_AND_ABOVE', + category: HarmCategory.HARM_CATEGORY_HATE_SPEECH, + threshold: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE, }, ], // Tool config diff --git a/packages/ai/skills/ai-core/adapter-configuration/references/grok-adapter.md b/packages/ai/skills/ai-core/adapter-configuration/references/grok-adapter.md index 971bcba41c..5442be22c3 100644 --- a/packages/ai/skills/ai-core/adapter-configuration/references/grok-adapter.md +++ b/packages/ai/skills/ai-core/adapter-configuration/references/grok-adapter.md @@ -23,34 +23,41 @@ import { grokImage } from '@tanstack/ai-grok' ## Key Chat Models -| Model | Context Window | Notes | -| ----------------------------- | -------------- | ---------------------------- | -| `grok-4-1-fast-reasoning` | 2M | Latest, fast reasoning | -| `grok-4-1-fast-non-reasoning` | 2M | Latest, no reasoning | -| `grok-code-fast-1` | 256K | Code-specialized, reasoning | -| `grok-4` | 256K | Full reasoning, tool calling | -| `grok-4-fast-reasoning` | 2M | Fast reasoning variant | -| `grok-3` | 131K | Previous gen, no reasoning | -| `grok-3-mini` | 131K | Budget reasoning | -| `grok-2-vision-1212` | 32K | Vision input | - -Image model: `grok-2-image-1212` +| Model | Context Window | Notes | +| ---------------- | -------------- | -------------------------------------------------------- | +| `grok-4.6` | 500K | Latest; reasoning, tools, structured output; document in | +| `grok-4.5` | 500K | Reasoning, tools, structured output; document in | +| `grok-4.3` | 1M | Reasoning, tools, structured output; text + image in | +| `grok-build-0.1` | 256K | Code-specialized; `reasoning` option is not accepted | + +`GROK_CHAT_MODELS` is exactly these four ids. Image models +(`GROK_IMAGE_MODELS`): `grok-2-image-1212`, `grok-imagine-image`, +`grok-imagine-image-2.0`, `grok-imagine-image-quality`. ## Provider-Specific modelOptions -Grok uses an OpenAI-compatible API. Options are straightforward: +Grok speaks the OpenAI **Responses** API (the adapter uses the OpenAI SDK +against `https://api.x.ai/v1`), so option names follow that API: ```typescript +import { chat } from '@tanstack/ai' +import { grokText } from '@tanstack/ai-grok' + +const messages = [{ role: 'user' as const, content: 'Hello' }] + chat({ - adapter: grokText('grok-4'), + adapter: grokText('grok-4.6'), messages, modelOptions: { + // Sampling (Responses API names) temperature: 0.7, - max_tokens: 4096, top_p: 0.9, - frequency_penalty: 0.5, - presence_penalty: 0.5, - stop: ['\n\n'], + max_output_tokens: 4096, + // Reasoning (reasoning-capable models) + reasoning: { effort: 'high' }, // 'none' | 'low' | 'medium' | 'high' + // Response storage (adapter default: false) + store: false, + // End-user id for abuse monitoring user: 'user-123', }, }) @@ -68,10 +75,11 @@ The adapter uses the OpenAI SDK with xAI's base URL (`https://api.x.ai/v1`). ## Gotchas - Uses the OpenAI SDK under the hood with a custom `baseURL`. -- `grok-4-1-fast-non-reasoning` and `grok-4-fast-non-reasoning` explicitly - do NOT support reasoning. Other grok-4+ models do. -- `grok-2-vision-1212` is the only model with image input support in the - older generation. -- The grok-4-1 fast models have a massive 2M context window. -- Provider options are simpler than OpenAI's (no Responses API features, - no structured outputs config, no metadata). +- All four chat models support reasoning; `grok-build-0.1` is the exception + in that it rejects the `reasoning` option (`GrokBuildProviderOptions`). +- `grok-4.5` / `grok-4.6` accept `text`, `image`, and `document` input; + `grok-4.3` / `grok-build-0.1` accept `text` and `image`. +- Provider options are a subset of OpenAI's Responses options: + `temperature`, `top_p`, `max_output_tokens`, `reasoning`, `store`, + `include`, `user`. There is no `max_tokens`, `frequency_penalty`, + `presence_penalty`, `stop`, or `metadata`. diff --git a/packages/ai/skills/ai-core/adapter-configuration/references/groq-adapter.md b/packages/ai/skills/ai-core/adapter-configuration/references/groq-adapter.md index 38fc185e6f..a2726f4483 100644 --- a/packages/ai/skills/ai-core/adapter-configuration/references/groq-adapter.md +++ b/packages/ai/skills/ai-core/adapter-configuration/references/groq-adapter.md @@ -38,6 +38,11 @@ Guard models: `meta-llama/llama-guard-4-12b`, `meta-llama/llama-prompt-guard-2-8 ## Provider-Specific modelOptions ```typescript +import { chat } from '@tanstack/ai' +import { groqText } from '@tanstack/ai-groq' + +const messages = [{ role: 'user' as const, content: 'Hello' }] + chat({ adapter: groqText('llama-3.3-70b-versatile'), messages, @@ -49,7 +54,7 @@ chat({ // Response format response_format: { type: 'json_schema', - json_schema: {/* ... */}, + json_schema: { name: 'answer', schema: {/* JSON Schema */} }, }, // Sampling temperature: 0.7, @@ -67,7 +72,7 @@ chat({ // Citations citation_options: 'enabled', // Documents for context - documents: [{ text: '...' }], + documents: [{ source: { type: 'text', text: '...' } }], // Search settings (for web search tool) search_settings: {/* SearchSettings */}, // Service tier diff --git a/packages/ai/skills/ai-core/adapter-configuration/references/ollama-adapter.md b/packages/ai/skills/ai-core/adapter-configuration/references/ollama-adapter.md index 148ebd376b..883613b291 100644 --- a/packages/ai/skills/ai-core/adapter-configuration/references/ollama-adapter.md +++ b/packages/ai/skills/ai-core/adapter-configuration/references/ollama-adapter.md @@ -24,15 +24,20 @@ import { ollamaText } from '@tanstack/ai-ollama' Ollama runs models locally. The adapter supports a large catalog of models. Key families include: -| Model Family | Example Names | Notes | -| ------------ | -------------------------------- | ----------------------- | -| Llama 4 | `llama4`, `llama4:scout` | Latest Meta models | -| Llama 3.3 | `llama3.3`, `llama3.3:70b` | Strong general purpose | -| Qwen 3 | `qwen3`, `qwen3:32b` | Reasoning capable | -| DeepSeek R1 | `deepseek-r1`, `deepseek-r1:70b` | Reasoning focused | -| Gemma 3 | `gemma3`, `gemma3:27b` | Google's open model | -| Phi 4 | `phi4`, `phi4:14b` | Microsoft's small model | -| Mistral | `mistral`, `mistral-large` | Mistral AI models | +| Model Family | Example Names | Notes | +| ------------ | ---------------------------------------- | ----------------------- | +| Llama 4 | `llama4:latest`, `llama4:16x17b` | Latest Meta models | +| Llama 3.3 | `llama3.3:latest`, `llama3.3:70b` | Strong general purpose | +| Qwen 3 | `qwen3:latest`, `qwen3:32b` | Reasoning capable | +| DeepSeek R1 | `deepseek-r1:latest`, `deepseek-r1:70b` | Reasoning focused | +| Gemma 3 | `gemma3:latest`, `gemma3:27b` | Google's open model | +| Phi 4 | `phi4:latest`, `phi4:14b` | Microsoft's small model | +| Mistral | `mistral:latest`, `mistral-large:latest` | Mistral AI models | + +Typed ids are always `family:tag` (`OLLAMA_TEXT_MODELS`). `ollamaText()` +accepts any string, but a bare `llama3.3` falls outside the typed catalog and +`modelOptions` degrades to the raw Ollama `ChatRequest` (which then demands a +`model` field). Use `llama3.3:latest`. Models must be pulled first: `ollama pull llama3.3` @@ -48,8 +53,10 @@ Ollama's own request shape) — `temperature`, `top_p`, and `num_predict` import { chat } from '@tanstack/ai' import { ollamaText } from '@tanstack/ai-ollama' +const messages = [{ role: 'user' as const, content: 'Hello' }] + const stream = chat({ - adapter: ollamaText('llama3.3'), + adapter: ollamaText('llama3.3:latest'), messages, modelOptions: { options: { @@ -64,9 +71,15 @@ const stream = chat({ ## Configuration +`ollamaText(model)` takes no config — it reads `OLLAMA_HOST`. To point at +another server (or pass headers / `baseURL` for a gateway), use +`createOllamaChat(model, hostOrConfig)`: + ```typescript -// With explicit host -const adapter = ollamaText('llama3.3', { +import { createOllamaChat } from '@tanstack/ai-ollama' + +// With explicit host (ollamaText() reads OLLAMA_HOST instead) +const adapter = createOllamaChat('llama3.3:latest', { host: 'http://my-server:11434', }) ``` diff --git a/packages/ai/skills/ai-core/adapter-configuration/references/openai-adapter.md b/packages/ai/skills/ai-core/adapter-configuration/references/openai-adapter.md index 2017bdd6fc..047e04d8fe 100644 --- a/packages/ai/skills/ai-core/adapter-configuration/references/openai-adapter.md +++ b/packages/ai/skills/ai-core/adapter-configuration/references/openai-adapter.md @@ -28,20 +28,30 @@ import { openaiSpeech } from '@tanstack/ai-openai' ## Key Chat Models -| Model | Context Window | Max Output | Notes | -| --------------------- | -------------- | ---------- | -------------------------------------- | -| `gpt-5.4` | 400K | 128K | Flagship, reasoning, image input | -| `gpt-5.4-pro` | 400K | 128K | Higher reasoning, no structured output | -| `gpt-5.4-chat-latest` | 128K | 16K | Chat-optimized variant | -| `gpt-5.1` | 400K | 128K | Previous flagship, image I/O | -| `gpt-5` | 400K | 128K | Previous gen flagship | -| `gpt-5-mini` | 400K | 128K | Cost-efficient | +| Model | Context Window | Max Output | Notes | +| -------------- | -------------- | ---------- | ---------------------------------------------- | +| `gpt-6-astra` | 1M | 128K | Newest; reasoning, tools, image input | +| `gpt-5.6` | 1M | 128K | Reasoning, tools, image input | +| `gpt-5.5` | 1M | 128K | Flagship used in examples; text/image/document | +| `gpt-5.5-pro` | 1M | 128K | Higher reasoning tier | +| `gpt-5.4-mini` | 400K | 128K | Cost-efficient (no bare `gpt-5.4` chat id) | +| `gpt-5.2` | 400K | 128K | Previous flagship; text/image/document | +| `gpt-5-mini` | 400K | 128K | Budget | + +`OPENAI_CHAT_MODELS` is the full list (also `gpt-6-astra-pro`, the +`gpt-5.6-luna/sol/terra` family, `gpt-5.4-nano`, `gpt-5.2-pro`, +`gpt-5.1`, `gpt-5`, the `o3`/`o4-mini` reasoning models, and `gpt-4.1`/`gpt-4o`). ## Provider-Specific modelOptions ```typescript +import { chat } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' + +const messages = [{ role: 'user' as const, content: 'Hello' }] + chat({ - adapter: openaiText('gpt-5.4'), + adapter: openaiText('gpt-5.5'), messages, modelOptions: { // Sampling diff --git a/packages/ai/skills/ai-core/adapter-configuration/references/openrouter-adapter.md b/packages/ai/skills/ai-core/adapter-configuration/references/openrouter-adapter.md index 386fa7596b..36601058ba 100644 --- a/packages/ai/skills/ai-core/adapter-configuration/references/openrouter-adapter.md +++ b/packages/ai/skills/ai-core/adapter-configuration/references/openrouter-adapter.md @@ -38,24 +38,29 @@ the format `provider/model-name`: OpenRouter has unique routing and provider selection options: ```typescript +import { chat } from '@tanstack/ai' +import { openRouterText } from '@tanstack/ai-openrouter' + +const messages = [{ role: 'user' as const, content: 'Hello' }] + +// Options are narrowed per model from OpenRouter's published metadata — +// e.g. 'anthropic/claude-sonnet-4' only accepts temperature/topP/ +// maxCompletionTokens/stop/toolChoice/reasoning. This model takes the full set. chat({ - adapter: openRouterText('anthropic/claude-sonnet-4'), + adapter: openRouterText('deepseek/deepseek-v4-pro'), messages, modelOptions: { // Reasoning reasoning: { - effort: 'high', // 'none' | 'minimal' | 'low' | 'medium' | 'high' - max_tokens: 4096, - exclude: false, + effort: 'high', // 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max' + summary: 'auto', + // enabled: false — explicit opt-out (normalized to effort: 'none') }, // Sampling temperature: 0.7, topP: 0.9, - topK: 40, frequencyPenalty: 0.5, presencePenalty: 0.5, - repetitionPenalty: 1.1, - minP: 0.05, seed: 42, // Token limits maxCompletionTokens: 8192, @@ -66,12 +71,11 @@ chat({ parallelToolCalls: true, // Response format responseFormat: { type: 'json_object' }, - // Web search - webSearchOptions: { - search_context_size: 'medium', // 'low' | 'medium' | 'high' - }, - // Verbosity - verbosity: 'medium', + // Routing (available on every model) + variant: 'nitro', // 'free' | 'nitro' | 'online' | 'exacto' | 'extended' | 'thinking' + models: ['deepseek/deepseek-v4-flash'], // fallbacks, tried in order + provider: { order: ['DeepSeek'], allowFallbacks: true }, + plugins: [{ id: 'web' }], // web search // Logprobs logprobs: true, topLogprobs: 5, @@ -89,11 +93,20 @@ OPENROUTER_API_KEY - Model IDs are `provider/model-name` format (e.g., `openai/gpt-5.2`). - OpenRouter has unique features not found in direct provider adapters: - - `variant` option: `'free'`, `'nitro'`, `'online'`, `'thinking'`, etc. - - `provider` routing preferences (order, fallbacks, data collection policies) - - `transforms: ['middle-out']` for context compression - - `prediction` for latency reduction - - `plugins: [{ id: 'web' }]` for web search -- Uses `camelCase` for option names (e.g., `topP`, `frequencyPenalty`), - unlike OpenAI's `snake_case`. -- `route: 'fallback'` with `models` array tries models in order. + - `variant` option: `'free'`, `'nitro'`, `'online'`, `'exacto'`, + `'extended'`, `'thinking'` + - `provider` routing preferences (`order`, `allowFallbacks`, data + collection policies — camelCase keys) + - `models` array of fallback ids, tried in order + - `plugins: [{ id: 'web' }]` for web search (also `file-parser`, + `response-healing`, `moderation`, `auto-router`) +- Uses `camelCase` for option names (e.g., `topP`, `frequencyPenalty`, + `maxCompletionTokens`), unlike OpenAI's `snake_case`. +- `reasoning` is `{ effort, summary, enabled }` — there is no + `max_tokens`/`exclude` inside it; `enabled: false` is normalized to + `effort: 'none'`. +- Per-model options are narrowed from OpenRouter's published metadata, so + keys like `frequencyPenalty`, `seed`, `logprobs`, or `responseFormat` are + only accepted on models that support them. `topK`, `minP`, + `repetitionPenalty`, `webSearchOptions`, `verbosity`, `transforms`, and + `route` are not exposed by the adapter. diff --git a/packages/ai/skills/ai-core/ag-ui-protocol/SKILL.md b/packages/ai/skills/ai-core/ag-ui-protocol/SKILL.md index 90e7cab0f3..6f1ceac0fe 100644 --- a/packages/ai/skills/ai-core/ag-ui-protocol/SKILL.md +++ b/packages/ai/skills/ai-core/ag-ui-protocol/SKILL.md @@ -29,7 +29,7 @@ import { openaiText } from '@tanstack/ai-openai' export async function POST(request: Request) { const { messages } = await request.json() const stream = chat({ - adapter: openaiText('gpt-5.2'), + adapter: openaiText('gpt-5.6'), messages, }) return toServerSentEventsResponse(stream) @@ -49,7 +49,7 @@ import { mergeAgentTools, toServerSentEventsResponse, } from '@tanstack/ai' -import { openaiText } from '@tanstack/ai-openai/adapters' +import { openaiText } from '@tanstack/ai-openai' import { serverTools } from './tools' export async function POST(req: Request) { @@ -64,7 +64,7 @@ export async function POST(req: Request) { } const stream = chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.6'), messages: params.messages, tools: mergeAgentTools(serverTools, params.tools), }) @@ -87,7 +87,7 @@ export async function POST(req: Request) { **Wire format:** Each event is `data: \n\n`. Stream ends with `data: [DONE]\n\n`. -```typescript +```typescript group=sse-response import { chat, toServerSentEventsStream, @@ -95,10 +95,12 @@ import { } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' +const messages = [{ role: 'user' as const, content: 'Hello' }] + // Option A: Get a ReadableStream (manual Response construction) const abortController = new AbortController() const stream = chat({ - adapter: openaiText('gpt-5.2'), + adapter: openaiText('gpt-5.6'), messages, abortController, }) @@ -127,7 +129,7 @@ const response2 = toServerSentEventsResponse(stream, { abortController }) Custom headers merge on top (user headers override defaults): -```typescript +```typescript group=sse-response toServerSentEventsResponse(stream, { headers: { 'X-Accel-Buffering': 'no', // Disable nginx buffering @@ -149,10 +151,12 @@ aborted, the error event is suppressed and the stream closes silently. import { chat, toHttpStream, toHttpResponse } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' +const messages = [{ role: 'user' as const, content: 'Hello' }] + // Option A: Get a ReadableStream const abortController = new AbortController() const stream = chat({ - adapter: openaiText('gpt-5.2'), + adapter: openaiText('gpt-5.6'), messages, abortController, }) @@ -199,7 +203,7 @@ All events extend `BaseAGUIEvent` which carries `type`, `timestamp`, optional | `STATE_DELTA` | Incremental state update. Carries `delta: Record`. | | `CUSTOM` | Extension point. Carries `name` (string) and optional `value` (unknown). | | `RUN_FINISHED` | Stream complete. Carries `runId` and `finishReason` (`'stop'` / `'length'` / `'content_filter'` / `'tool_calls'` / `null`). | -| `RUN_ERROR` | Error during stream. Carries optional `runId` and `error: { message, code? }`. | +| `RUN_ERROR` | Error during stream. Carries `message`, optional `code` and `runId`; a nested `error: { message, code? }` copy is kept too. | **Typical event sequence for a text-only response:** @@ -235,8 +239,10 @@ no helper, no cast: import { chat } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' +const messages = [{ role: 'user' as const, content: 'Hello' }] + const stream = chat({ - adapter: openaiText('gpt-5.2'), + adapter: openaiText('gpt-5.6'), messages, }) @@ -284,7 +290,7 @@ causing events to arrive in batches instead of streaming token-by-token. Fix: Set proxy-bypass headers on the response. -```typescript +```typescript group=sse-response toServerSentEventsResponse(stream, { headers: { 'X-Accel-Buffering': 'no', // nginx diff --git a/packages/ai/skills/ai-core/chat-experience/SKILL.md b/packages/ai/skills/ai-core/chat-experience/SKILL.md index 823ec2a9a6..54c739ddbc 100644 --- a/packages/ai/skills/ai-core/chat-experience/SKILL.md +++ b/packages/ai/skills/ai-core/chat-experience/SKILL.md @@ -28,7 +28,7 @@ This skill builds on ai-core. Read it first for critical rules. ### Server: API Route (TanStack Start) -```typescript +```typescript ignore // src/routes/api.chat.ts import { createFileRoute } from '@tanstack/react-router' import { chat, toServerSentEventsResponse } from '@tanstack/ai' @@ -58,7 +58,7 @@ export const Route = createFileRoute('/api/chat')({ ### Client: React Component -```typescript +```tsx // src/routes/index.tsx import { useState } from 'react' import { useChat, fetchServerSentEvents } from '@tanstack/ai-react' @@ -136,18 +136,23 @@ Server returns a streaming SSE Response; client parses it automatically. import { chat, toServerSentEventsResponse } from '@tanstack/ai' import { anthropicText } from '@tanstack/ai-anthropic' -const stream = chat({ - adapter: anthropicText('claude-sonnet-4-5'), - messages, - modelOptions: { - temperature: 0.7, - max_tokens: 2000, // Anthropic-native key - }, - systemPrompts: ['You are a helpful assistant.'], - abortController, -}) +export async function POST(request: Request) { + const { messages } = await request.json() + const abortController = new AbortController() -return toServerSentEventsResponse(stream, { abortController }) + const stream = chat({ + adapter: anthropicText('claude-opus-5'), + messages, + modelOptions: { + temperature: 0.7, + max_tokens: 2000, // Anthropic-native key + }, + systemPrompts: ['You are a helpful assistant.'], + abortController, + }) + + return toServerSentEventsResponse(stream, { abortController }) +} ``` To make the SSE response resumable (reconnect after a drop/refresh without @@ -167,7 +172,7 @@ import { useChat, fetchServerSentEvents } from '@tanstack/ai-react' const { messages, sendMessage, isLoading, error, stop, status } = useChat({ connection: fetchServerSentEvents('/api/chat'), - body: { provider: 'anthropic', model: 'claude-sonnet-4-5' }, + body: { provider: 'anthropic', model: 'claude-opus-5' }, onFinish: (message) => { console.log('Response complete:', message.id) }, @@ -186,7 +191,7 @@ The `status` field tracks the chat lifecycle: `'ready'` | `'submitted'` | `'stre Models with extended thinking (Claude, Gemini) emit `ThinkingPart` in the message parts array. -```typescript +```tsx import type { UIMessage } from '@tanstack/ai-react' function MessageRenderer({ message }: { message: UIMessage }) { @@ -199,7 +204,9 @@ function MessageRenderer({ message }: { message: UIMessage }) { .some((p) => p.type === 'text') return (
- {isComplete ? 'Thought process' : 'Thinking...'} + + {isComplete ? 'Thought process' : 'Thinking...'} +
{part.content}
) @@ -227,18 +234,25 @@ function MessageRenderer({ message }: { message: UIMessage }) { Server-side, enable thinking via `modelOptions` on the adapter: ```typescript +import { chat, toServerSentEventsResponse } from '@tanstack/ai' import { geminiText } from '@tanstack/ai-gemini' -const stream = chat({ - adapter: geminiText('gemini-2.5-flash'), - messages, - modelOptions: { - thinkingConfig: { - includeThoughts: true, - thinkingBudget: 100, +export async function POST(request: Request) { + const { messages } = await request.json() + + const stream = chat({ + adapter: geminiText('gemini-3.8-flash'), + messages, + modelOptions: { + thinkingConfig: { + includeThoughts: true, + thinkingLevel: 'HIGH', // Gemini 3.x; Gemini 2.x uses thinkingBudget + }, }, - }, -}) + }) + + return toServerSentEventsResponse(stream) +} ``` ### 3. Sending Multimodal Content (Images) @@ -280,13 +294,16 @@ function sendImageUrl(text: string, imageUrl: string) { Render image parts in received messages: -```typescript -if (part.type === 'image') { +```tsx +import type { UIMessage } from '@tanstack/ai-react' + +function ImagePart({ part }: { part: UIMessage['parts'][number] }) { + if (part.type !== 'image') return null const src = part.source.type === 'url' ? part.source.value : `data:${part.source.mimeType};base64,${part.source.value}` - return Attached image + return Attached image } ``` @@ -328,13 +345,18 @@ Use `toHttpResponse` + `fetchHttpStream` for newline-delimited JSON instead of S import { chat, toHttpResponse } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' -const stream = chat({ - adapter: openaiText('gpt-5.5'), - messages, - abortController, -}) +export async function POST(request: Request) { + const { messages } = await request.json() + const abortController = new AbortController() + + const stream = chat({ + adapter: openaiText('gpt-5.6'), + messages, + abortController, + }) -return toHttpResponse(stream, { abortController }) + return toHttpResponse(stream, { abortController }) +} ``` **Client:** @@ -396,36 +418,29 @@ clients across calls. **Server-side example:** ```typescript -import { createFileRoute } from '@tanstack/react-router' import { chat, toServerSentEventsResponse } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' import { createMCPClient } from '@tanstack/ai-mcp' -export const Route = createFileRoute('/api/chat')({ - server: { - handlers: { - POST: async ({ request }) => { - const { messages } = await request.json() - - const mcpClient = await createMCPClient({ - transport: { type: 'http', url: 'https://mcp.example.com/mcp' }, - }) +export async function POST(request: Request) { + const { messages } = await request.json() - const stream = chat({ - adapter: openaiText('gpt-5.5'), - messages, - mcp: { - clients: [mcpClient], - connection: 'keep-alive', // chat() won't close it — reuse across requests - }, - }) + const mcpClient = await createMCPClient({ + transport: { type: 'http', url: 'https://mcp.example.com/mcp' }, + }) - return toServerSentEventsResponse(stream) - // connection: 'keep-alive' — chat() never closes mcpClient; it stays open for reuse across runs. - }, + const stream = chat({ + adapter: openaiText('gpt-5.6'), + messages, + mcp: { + clients: [mcpClient], + connection: 'keep-alive', // chat() won't close it — reuse across requests }, - }, -}) + }) + + return toServerSentEventsResponse(stream) + // connection: 'keep-alive' — chat() never closes mcpClient; it stays open for reuse across runs. +} ``` ### 7. Queueing Messages Sent While Streaming @@ -470,19 +485,37 @@ generation, `stop()`, `clear()`, `unsubscribe()`, and `reload()`. from `messages` — render pending sends distinctly and cancel with `cancelQueued(id)`: -```typescript -{queue.map((q) => ( -
- {typeof q.content === 'string' ? q.content : '[attachment]'} - -
-))} +```tsx +import { useChat, fetchServerSentEvents } from '@tanstack/ai-react' + +function QueuedMessages() { + const { queue, cancelQueued } = useChat({ + connection: fetchServerSentEvents('/api/chat'), + }) + + return ( +
+ {queue.map((q) => ( +
+ {typeof q.content === 'string' ? q.content : '[attachment]'} + +
+ ))} +
+ ) +} ``` Override the configured policy for a single send with the second argument to `sendMessage`: ```typescript +import { useChat, fetchServerSentEvents } from '@tanstack/ai-react' + +const { sendMessage } = useChat({ + connection: fetchServerSentEvents('/api/chat'), +}) + sendMessage('Never mind, do this instead', { whenBusy: 'interrupt' }) ``` @@ -561,63 +594,100 @@ option, so it works identically in `@tanstack/ai-react`, `-solid`, `-vue`, // WRONG import { streamText } from 'ai' import { openai } from '@ai-sdk/openai' -const result = streamText({ model: openai('gpt-5.5'), messages }) +const messages = [{ role: 'user' as const, content: 'Hello' }] +const result = streamText({ model: openai('gpt-5.6'), messages }) +``` + +```typescript // CORRECT import { chat } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' -const stream = chat({ adapter: openaiText('gpt-5.5'), messages }) + +const messages = [{ role: 'user' as const, content: 'Hello' }] +const stream = chat({ adapter: openaiText('gpt-5.6'), messages }) ``` ### b. CRITICAL: Using Vercel createOpenAI() provider pattern ```typescript // WRONG +import { streamText } from 'ai' import { createOpenAI } from '@ai-sdk/openai' -const openai = createOpenAI({ apiKey }) -streamText({ model: openai('gpt-5.5'), messages }) +const messages = [{ role: 'user' as const, content: 'Hello' }] +const openai = createOpenAI({ apiKey: process.env.OPENAI_API_KEY }) +streamText({ model: openai('gpt-5.6'), messages }) +``` + +```typescript // CORRECT import { openaiText } from '@tanstack/ai-openai' import { chat } from '@tanstack/ai' -chat({ adapter: openaiText('gpt-5.5'), messages }) + +const messages = [{ role: 'user' as const, content: 'Hello' }] +chat({ adapter: openaiText('gpt-5.6'), messages }) ``` ### c. CRITICAL: Using monolithic openai() instead of openaiText() -```typescript -// WRONG +```typescript ignore +// WRONG — `openai()` is no longer exported from @tanstack/ai-openai import { openai } from '@tanstack/ai-openai' -chat({ adapter: openai(), model: 'gpt-5.5', messages }) +chat({ adapter: openai(), model: 'gpt-5.6', messages }) +``` +```typescript // CORRECT +import { chat } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' -chat({ adapter: openaiText('gpt-5.5'), messages }) + +const messages = [{ role: 'user' as const, content: 'Hello' }] +chat({ adapter: openaiText('gpt-5.6'), messages }) ``` -The monolithic `openai()` adapter is deprecated. Use tree-shakeable adapters: +The monolithic `openai()` adapter no longer exists. Use tree-shakeable adapters: `openaiText()`, `openaiImage()`, `openaiSpeech()`, etc. ### d. HIGH: Using toResponseStream instead of toServerSentEventsResponse -```typescript -// WRONG +```typescript ignore +// WRONG — toResponseStream does not exist import { toResponseStream } from '@tanstack/ai' return toResponseStream(stream, { abortController }) +``` +```typescript // CORRECT -import { toServerSentEventsResponse } from '@tanstack/ai' -return toServerSentEventsResponse(stream, { abortController }) +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' + +export async function POST(request: Request) { + const { messages } = await request.json() + const abortController = new AbortController() + const stream = chat({ + adapter: openaiText('gpt-5.6'), + messages, + abortController, + }) + return toServerSentEventsResponse(stream, { abortController }) +} ``` ### e. HIGH: Passing model as separate parameter to chat() -```typescript +```typescript ignore // WRONG -chat({ adapter: openaiText(), model: 'gpt-5.5', messages }) +chat({ adapter: openaiText(), model: 'gpt-5.6', messages }) +``` +```typescript // CORRECT -chat({ adapter: openaiText('gpt-5.5'), messages }) +import { chat } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' + +const messages = [{ role: 'user' as const, content: 'Hello' }] +chat({ adapter: openaiText('gpt-5.6'), messages }) ``` The model is passed to the adapter factory, not to `chat()`. @@ -628,22 +698,30 @@ Sampling options (`temperature`, token limits, `top_p`/`topP`) are **not** top-level fields on `chat()`. They live inside `modelOptions` using the provider's native key. -```typescript +```typescript ignore // WRONG — temperature/maxTokens are not root options chat({ adapter, messages, temperature: 0.7, maxTokens: 1000 }) // WRONG — there is no `options` field either chat({ adapter, messages, options: { temperature: 0.7, maxTokens: 1000 } }) +``` +```typescript // CORRECT — inside modelOptions, provider-native keys (OpenAI shown) +import { chat } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' + +const messages = [{ role: 'user' as const, content: 'Hello' }] + chat({ - adapter, + adapter: openaiText('gpt-5.6'), messages, modelOptions: { temperature: 0.7, max_output_tokens: 1000 }, }) ``` -`temperature` is universal across providers; token limits use provider-native +`temperature` works on most models (Claude 5 models reject sampling +parameters; see ai-core/adapter-configuration/SKILL.md). Token limits use provider-native keys (`max_output_tokens` for OpenAI, `max_tokens` for Anthropic/Grok, `maxOutputTokens` for Gemini, `max_completion_tokens` for Groq, `maxCompletionTokens` for OpenRouter, and `num_predict` nested under @@ -651,19 +729,26 @@ keys (`max_output_tokens` for OpenAI, `max_tokens` for Anthropic/Grok, ### g. HIGH: Using providerOptions instead of modelOptions -```typescript +```typescript ignore // WRONG chat({ adapter, messages, - providerOptions: { responseFormat: { type: 'json_object' } }, + providerOptions: { text: { format: { type: 'json_object' } } }, }) +``` + +```typescript +// CORRECT — provider-native option under modelOptions (OpenAI Responses shown) +import { chat } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' + +const messages = [{ role: 'user' as const, content: 'Hello' }] -// CORRECT chat({ - adapter, + adapter: openaiText('gpt-5.6'), messages, - modelOptions: { responseFormat: { type: 'json_object' } }, + modelOptions: { text: { format: { type: 'json_object' } } }, }) ``` @@ -671,23 +756,44 @@ chat({ ```typescript // WRONG -const readable = new ReadableStream({ - async start(controller) { - const encoder = new TextEncoder() - for await (const chunk of stream) { - controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`)) - } - controller.enqueue(encoder.encode('data: [DONE]\n\n')) - controller.close() - }, -}) -return new Response(readable, { - headers: { 'Content-Type': 'text/event-stream' }, -}) +import { chat } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' + +export async function POST(request: Request) { + const { messages } = await request.json() + const stream = chat({ adapter: openaiText('gpt-5.6'), messages }) + + const readable = new ReadableStream({ + async start(controller) { + const encoder = new TextEncoder() + for await (const chunk of stream) { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`)) + } + controller.enqueue(encoder.encode('data: [DONE]\n\n')) + controller.close() + }, + }) + return new Response(readable, { + headers: { 'Content-Type': 'text/event-stream' }, + }) +} +``` +```typescript // CORRECT -import { toServerSentEventsResponse } from '@tanstack/ai' -return toServerSentEventsResponse(stream, { abortController }) +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' + +export async function POST(request: Request) { + const { messages } = await request.json() + const abortController = new AbortController() + const stream = chat({ + adapter: openaiText('gpt-5.6'), + messages, + abortController, + }) + return toServerSentEventsResponse(stream, { abortController }) +} ``` `toServerSentEventsResponse` handles SSE formatting, abort signals, @@ -695,8 +801,8 @@ error events (RUN_ERROR), and correct headers automatically. ### i. HIGH: Implementing custom onEnd/onFinish callbacks instead of middleware -```typescript -// WRONG +```typescript ignore +// WRONG — chat() has no onEnd/onFinish option chat({ adapter, messages, @@ -704,9 +810,14 @@ chat({ trackAnalytics(result) }, }) +``` +```typescript // CORRECT +import { chat } from '@tanstack/ai' import type { ChatMiddleware } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { trackAnalytics, trackTokens } from './analytics' const analytics: ChatMiddleware = { name: 'analytics', @@ -718,7 +829,8 @@ const analytics: ChatMiddleware = { }, } -chat({ adapter, messages, middleware: [analytics] }) +const messages = [{ role: 'user' as const, content: 'Hello' }] +chat({ adapter: openaiText('gpt-5.6'), messages, middleware: [analytics] }) ``` `chat()` has no `onEnd`/`onFinish` option. Use `middleware` for lifecycle events. @@ -730,7 +842,9 @@ See also: ai-core/middleware/SKILL.md. // WRONG import { fetchServerSentEvents } from '@tanstack/ai-client' import { useChat } from '@tanstack/ai-react' +``` +```typescript // CORRECT import { useChat, fetchServerSentEvents } from '@tanstack/ai-react' ``` @@ -746,9 +860,15 @@ exceptions. The `useChat` hook surfaces these via the `error` state and check for `RUN_ERROR` chunks: ```typescript +import { chat } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' + +const messages = [{ role: 'user' as const, content: 'Hello' }] +const stream = chat({ adapter: openaiText('gpt-5.6'), messages }) + for await (const chunk of stream) { if (chunk.type === 'RUN_ERROR') { - console.error('Stream error:', chunk.error.message) + console.error('Stream error:', chunk.message) break } if (chunk.type === 'TEXT_MESSAGE_CONTENT') { diff --git a/packages/ai/skills/ai-core/client-persistence/SKILL.md b/packages/ai/skills/ai-core/client-persistence/SKILL.md index 0991800791..d0395c91d0 100644 --- a/packages/ai/skills/ai-core/client-persistence/SKILL.md +++ b/packages/ai/skills/ai-core/client-persistence/SKILL.md @@ -61,6 +61,12 @@ required for normal use. ## Mode A — cache everything (client-authoritative) ```tsx +import { + useChat, + fetchServerSentEvents, + localStoragePersistence, +} from '@tanstack/ai-react' + function Chat() { const { messages, sendMessage } = useChat({ threadId: 'support-chat', // stable — required @@ -79,6 +85,8 @@ Best for: SPA, offline-first, single device, moderate conversation size. ## Mode B — server-authoritative (`persistence: true`) ```tsx +import { useChat, fetchServerSentEvents } from '@tanstack/ai-react' + function Chat({ threadId }: { threadId: string }) { const { messages, sendMessage } = useChat({ threadId, @@ -135,18 +143,22 @@ The hook return is exactly `generate` / `result` / `isLoading` / `error` / ### Turning it on (`persistence: true`) ```tsx -const image = useGenerateImage({ - threadId, // REQUIRED — the scope the last generation is hydrated under - connection: fetchServerSentEvents('/api/generate/image'), - persistence: true, -}) -// After a reload: image.status / image.result / image.error are the last -// generation for `threadId`, fetched from the server — nothing was cached. +import { useGenerateImage, fetchServerSentEvents } from '@tanstack/ai-react' + +function ImageGenerator({ threadId }: { threadId: string }) { + const image = useGenerateImage({ + threadId, // REQUIRED — the scope the last generation is hydrated under + connection: fetchServerSentEvents('/api/generate/image'), + persistence: true, + }) + // After a reload: image.status / image.result / image.error are the last + // generation for `threadId`, fetched from the server — nothing was cached. +} ``` The server half — the same route handles the run and the hydration `GET`: -```ts +```ts group=generation-persistence import { generateImage, generationParamsFromRequest, @@ -222,7 +234,7 @@ export function GET(request: Request) { (`stores.artifacts` + `stores.blobs`) AND `withGenerationPersistence` is given an `artifactUrl` mapper: -```ts +```ts group=generation-persistence withGenerationPersistence(persistence, { artifactUrl: (ref) => `/api/generate/image/artifact?id=${ref.artifactId}`, }) diff --git a/packages/ai/skills/ai-core/custom-backend-integration/SKILL.md b/packages/ai/skills/ai-core/custom-backend-integration/SKILL.md index 7e0a63e17f..3126805a2a 100644 --- a/packages/ai/skills/ai-core/custom-backend-integration/SKILL.md +++ b/packages/ai/skills/ai-core/custom-backend-integration/SKILL.md @@ -22,8 +22,9 @@ This skill builds on ai-core and ai-core/chat-experience. Read them first. Connect `useChat` to a custom SSE backend with auth headers: -```typescript +```tsx import { useChat, fetchServerSentEvents } from '@tanstack/ai-react' +import { token } from './auth' function Chat() { const { messages, sendMessage, isLoading } = useChat({ @@ -69,6 +70,7 @@ framing. This is the recommended default. ```typescript import { useChat, fetchServerSentEvents } from '@tanstack/ai-react' +import { token, tenantId } from './auth' const { messages, sendMessage } = useChat({ connection: fetchServerSentEvents('https://my-api.com/chat', { @@ -85,6 +87,7 @@ const { messages, sendMessage } = useChat({ ```typescript import { useChat, fetchServerSentEvents } from '@tanstack/ai-react' +import { sessionId, getAccessToken } from './auth' const { messages, sendMessage } = useChat({ connection: fetchServerSentEvents( @@ -95,7 +98,7 @@ const { messages, sendMessage } = useChat({ }, body: { provider: 'openai', - model: 'gpt-4o', + model: 'gpt-5.5', }, }), ), @@ -110,6 +113,10 @@ The `body` field in options is merged into the POST request body alongside ```typescript import { useChat, fetchServerSentEvents } from '@tanstack/ai-react' +// Same signature as globalThis.fetch — wrap it however you need. +const myCustomFetch: typeof fetch = (input, init) => + fetch(input, { ...init, credentials: 'include' }) + const { messages, sendMessage } = useChat({ connection: fetchServerSentEvents('/api/chat', { fetchClient: myCustomFetch, @@ -124,6 +131,7 @@ instead of SSE. Each line is one JSON-encoded `StreamChunk` followed by `\n`. ```typescript import { useChat, fetchHttpStream } from '@tanstack/ai-react' +import { token } from './auth' const { messages, sendMessage } = useChat({ connection: fetchHttpStream('https://my-api.com/chat', { @@ -143,6 +151,7 @@ JSON object per line. ```typescript import { useChat, fetchHttpStream } from '@tanstack/ai-react' +import { region, refreshToken } from './auth' const { messages, sendMessage } = useChat({ connection: fetchHttpStream( @@ -168,15 +177,11 @@ This is the simpler model and covers most HTTP-based protocols. ```typescript import { useChat } from '@tanstack/ai-react' -import type { ConnectionAdapter } from '@tanstack/ai-react' -import type { StreamChunk, UIMessage } from '@tanstack/ai' - -const websocketAdapter: ConnectionAdapter = { - async *connect( - messages: Array, - data?: Record, - abortSignal?: AbortSignal, - ): AsyncGenerator { +import type { ConnectConnectionAdapter } from '@tanstack/ai-react' +import type { StreamChunk } from '@tanstack/ai' + +const websocketAdapter: ConnectConnectionAdapter = { + async *connect(messages, data, abortSignal) { const ws = new WebSocket('wss://my-api.com/chat') // Wait for connection @@ -243,25 +248,51 @@ returns an `AsyncIterable` that stays open, and `send` dispatches messages through it. ```typescript -import type { StreamChunk, UIMessage } from '@tanstack/ai' - -// SubscribeConnectionAdapter is exported from @tanstack/ai-client -// (not re-exported by framework packages -- use ConnectionAdapter -// union type from @tanstack/ai-react for typing) -const pushAdapter = { - subscribe(abortSignal?: AbortSignal): AsyncIterable { - // Return a long-lived async iterable that yields chunks - // whenever the server pushes them - return createPersistentStream(abortSignal) +import { useChat } from '@tanstack/ai-react' +import type { SubscribeConnectionAdapter } from '@tanstack/ai-react' +import type { StreamChunk } from '@tanstack/ai' + +// One socket for the lifetime of the client; every run's chunks arrive on it. +const ws = new WebSocket('wss://my-api.com/chat') +const ready = new Promise((resolve) => { + ws.addEventListener('open', () => resolve(), { once: true }) +}) + +const pushAdapter: SubscribeConnectionAdapter = { + async *subscribe(abortSignal) { + // Long-lived async iterable: yields chunks whenever the server pushes + // them, until the socket closes or the signal aborts + const queue: Array = [] + let wake: (() => void) | null = null + let closed = false + + ws.addEventListener('message', (event) => { + const chunk: StreamChunk = JSON.parse(event.data) + queue.push(chunk) + wake?.() + }) + ws.addEventListener('close', () => { + closed = true + wake?.() + }) + abortSignal?.addEventListener('abort', () => ws.close()) + + while (!closed || queue.length > 0) { + const next = queue.shift() + if (next !== undefined) { + yield next + continue + } + await new Promise((r) => { + wake = r + }) + } }, - async send( - messages: Array, - data?: Record, - abortSignal?: AbortSignal, - ): Promise { + async send(messages, data) { // Dispatch messages; chunks arrive through subscribe() - await persistentConnection.send(JSON.stringify({ messages, ...data })) + await ready + ws.send(JSON.stringify({ messages, ...data })) }, } @@ -279,12 +310,9 @@ a shorthand for creating a `ConnectConnectionAdapter` from an async generator: ```typescript import { useChat, stream } from '@tanstack/ai-react' -import type { StreamChunk, UIMessage } from '@tanstack/ai' +import type { StreamChunk } from '@tanstack/ai' -const directAdapter = stream(async function* ( - messages: Array, - data?: Record, -): AsyncGenerator { +const directAdapter = stream(async function* (messages, data) { const response = await fetch('https://my-api.com/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -305,7 +333,8 @@ const directAdapter = stream(async function* ( for (const line of lines) { if (line.trim()) { - yield JSON.parse(line) as StreamChunk + const chunk: StreamChunk = JSON.parse(line) + yield chunk } } } @@ -324,35 +353,43 @@ The `ConnectionAdapter` interface has two mutually exclusive modes. Providing both throws at runtime. ```typescript -// WRONG -- throws "Connection adapter must provide either connect or both -// subscribe and send, not both modes" -const adapter = { +import type { + ConnectConnectionAdapter, + ConnectionAdapter, + SubscribeConnectionAdapter, +} from '@tanstack/ai-react' +import { channel } from './channel' + +// WRONG -- type-checks (ConnectionAdapter is a union) but throws at runtime: +// "Connection adapter must provide either connect or both subscribe and +// send, not both modes" +const adapter: ConnectionAdapter = { async *connect(messages) { /* ... */ }, subscribe(signal) { - /* ... */ + return channel.chunks(signal) }, async send(messages) { - /* ... */ + await channel.send(messages) }, } // CORRECT -- pick one mode // Option A: ConnectConnectionAdapter (pull-based) -const pullAdapter = { +const pullAdapter: ConnectConnectionAdapter = { async *connect(messages, data, abortSignal) { // ... yield StreamChunks }, } // Option B: SubscribeConnectionAdapter (push-based) -const pushAdapter = { +const pushAdapter: SubscribeConnectionAdapter = { subscribe(abortSignal) { - return longLivedAsyncIterable + return channel.chunks(abortSignal) }, async send(messages, data, abortSignal) { - await connection.dispatch({ messages, ...data }) + await channel.send({ messages, ...data }, abortSignal) }, } ``` @@ -388,15 +425,11 @@ streaming, implement retry logic in your connection adapter: ```typescript import { useChat } from '@tanstack/ai-react' -import type { ConnectionAdapter } from '@tanstack/ai-react' -import type { StreamChunk, UIMessage } from '@tanstack/ai' - -const resilientAdapter: ConnectionAdapter = { - async *connect( - messages: Array, - data?: Record, - abortSignal?: AbortSignal, - ): AsyncGenerator { +import type { ConnectConnectionAdapter } from '@tanstack/ai-react' +import type { StreamChunk } from '@tanstack/ai' + +const resilientAdapter: ConnectConnectionAdapter = { + async *connect(messages, data, abortSignal) { const maxRetries = 3 let attempt = 0 @@ -427,7 +460,8 @@ const resilientAdapter: ConnectionAdapter = { for (const line of lines) { if (line.trim()) { - yield JSON.parse(line) as StreamChunk + const chunk: StreamChunk = JSON.parse(line) + yield chunk } } } diff --git a/packages/ai/skills/ai-core/debug-logging/SKILL.md b/packages/ai/skills/ai-core/debug-logging/SKILL.md index 926501dbe3..9219b2e3e0 100644 --- a/packages/ai/skills/ai-core/debug-logging/SKILL.md +++ b/packages/ai/skills/ai-core/debug-logging/SKILL.md @@ -30,8 +30,10 @@ printed, or pipe logs into a custom logger (pino, winston, etc.). The same import { chat } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' +const messages = [{ role: 'user' as const, content: 'Hello' }] + const stream = chat({ - adapter: openaiText('gpt-5.2'), + adapter: openaiText('gpt-5.5'), messages, debug: true, // all categories on, prints to console }) @@ -49,8 +51,13 @@ Each log line is prefixed with an emoji and `[tanstack-ai:]`: ## Turn it off ```typescript +import { chat } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' + +const messages = [{ role: 'user' as const, content: 'Hello' }] + chat({ - adapter: openaiText('gpt-5.2'), + adapter: openaiText('gpt-5.5'), messages, debug: false, // silence everything, including errors }) @@ -63,6 +70,9 @@ Omitting `debug` is **not** the same as `debug: false`. When omitted, the ## `DebugOption` — the accepted shapes ```typescript +import type { Logger } from '@tanstack/ai' + +// As exported by '@tanstack/ai' type DebugOption = boolean | DebugConfig interface DebugConfig { @@ -95,8 +105,13 @@ Pass a `DebugConfig` object. Unspecified categories default to `true`, so it's easiest to toggle by setting specific flags to `false`: ```typescript +import { chat } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' + +const messages = [{ role: 'user' as const, content: 'Hello' }] + chat({ - adapter: openaiText('gpt-5.2'), + adapter: openaiText('gpt-5.5'), messages, debug: { middleware: false }, // everything except middleware }) @@ -105,8 +120,13 @@ chat({ To print only a specific set, set the rest to `false` explicitly: ```typescript +import { chat } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' + +const messages = [{ role: 'user' as const, content: 'Hello' }] + chat({ - adapter: openaiText('gpt-5.2'), + adapter: openaiText('gpt-5.5'), messages, debug: { provider: true, @@ -124,7 +144,8 @@ chat({ ## Pipe into your own logger ```typescript -import type { Logger } from '@tanstack/ai' +import { chat, type Logger } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' import pino from 'pino' const pinoLogger = pino() @@ -135,8 +156,10 @@ const logger: Logger = { error: (msg, meta) => pinoLogger.error(meta, msg), } +const messages = [{ role: 'user' as const, content: 'Hello' }] + chat({ - adapter: openaiText('gpt-5.2'), + adapter: openaiText('gpt-5.5'), messages, debug: { logger }, // all categories on, piped to pino }) @@ -170,11 +193,48 @@ concepts don't exist in their pipelines. Same `debug` option everywhere: ```typescript -summarize({ adapter, text, debug: true }) -generateImage({ adapter, prompt: 'a cat', debug: { logger } }) -generateSpeech({ adapter, text, debug: { request: true } }) -generateTranscription({ adapter, audio, debug: false }) -generateVideo({ adapter, prompt: 'a wave', debug: { output: true } }) +import { + summarize, + generateImage, + generateSpeech, + generateTranscription, + generateVideo, +} from '@tanstack/ai' +import { + openaiSummarize, + openaiImage, + openaiSpeech, + openaiTranscription, + openaiVideo, +} from '@tanstack/ai-openai' +import { logger } from './logger' +import { audio } from './recording' + +summarize({ + adapter: openaiSummarize('gpt-5.5'), + text: 'Long article…', + debug: true, +}) +generateImage({ + adapter: openaiImage('gpt-image-2'), + prompt: 'a cat', + debug: { logger }, +}) +generateSpeech({ + adapter: openaiSpeech('tts-1-hd'), + text: 'Hello', + debug: { request: true }, +}) +generateTranscription({ + adapter: openaiTranscription('gpt-4o-transcribe'), + audio, + debug: false, +}) +generateVideo({ + adapter: openaiVideo('sora-2'), + prompt: 'a wave', + debug: { output: true }, +}) ``` Realtime session adapters in provider packages (e.g. `openaiRealtime`, @@ -187,6 +247,12 @@ categories don't apply. ### a. HIGH: Treating omitted `debug` as silent ```typescript +import { chat } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' + +const adapter = openaiText('gpt-5.5') +const messages = [{ role: 'user' as const, content: 'Hello' }] + // WRONG — expecting this to be completely silent chat({ adapter, messages }) // Errors still print via [tanstack-ai:errors] ... on failure. @@ -203,6 +269,12 @@ Source: docs/advanced/debug-logging.md ### b. MEDIUM: Reaching for middleware when `debug` would do ```typescript +import { chat, type ChatMiddleware } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' + +const adapter = openaiText('gpt-5.5') +const messages = [{ role: 'user' as const, content: 'Hello' }] + // WRONG — writing logging middleware to see chunks flow const chunkLogger: ChatMiddleware = { name: 'chunk-logger', @@ -234,22 +306,32 @@ prefer implementations that don't throw — silenced exceptions are harder to debug than loud ones. ```typescript +import type { Logger } from '@tanstack/ai' + // WRONG — a logger that can throw on serialization const fragile: Logger = { debug: (msg, meta) => console.debug(msg, JSON.stringify(meta)), // cyclic meta → throws - /* ... */ + info: (msg, meta) => console.info(msg, JSON.stringify(meta)), + warn: (msg, meta) => console.warn(msg, JSON.stringify(meta)), + error: (msg, meta) => console.error(msg, JSON.stringify(meta)), } // CORRECT — guard serialization in the logger itself -const safe: Logger = { - debug: (msg, meta) => { +const guarded = + (log: (...args: Array) => void): Logger['debug'] => + (msg, meta) => { try { - console.debug(msg, meta) + log(msg, JSON.stringify(meta)) } catch { - console.debug(msg) + log(msg) // fall back to the bare message rather than throw } - }, - /* ... */ + } + +const safe: Logger = { + debug: guarded(console.debug), + info: guarded(console.info), + warn: guarded(console.warn), + error: guarded(console.error), } ``` diff --git a/packages/ai/skills/ai-core/locks/SKILL.md b/packages/ai/skills/ai-core/locks/SKILL.md index e5ae38a3c9..d81c21bfe9 100644 --- a/packages/ai/skills/ai-core/locks/SKILL.md +++ b/packages/ai/skills/ai-core/locks/SKILL.md @@ -35,20 +35,40 @@ per-thread (or other) lock yourself when multi-writer races matter. ## Wire locks ```ts +import { chat } from '@tanstack/ai' import { withLocks, InMemoryLockStore } from '@tanstack/ai/locks' +import { openaiText } from '@tanstack/ai-openai' -middleware: [ - withLocks(new InMemoryLockStore()), // single process -] +const messages = [{ role: 'user' as const, content: 'Hello' }] + +chat({ + adapter: openaiText('gpt-5.6'), + messages, + middleware: [ + withLocks(new InMemoryLockStore()), // single process + ], +}) ``` Alongside persistence — optional, locks do not require it: ```ts +import { chat } from '@tanstack/ai' import { withLocks, InMemoryLockStore } from '@tanstack/ai/locks' -import { withPersistence } from '@tanstack/ai-persistence' - -middleware: [withPersistence(persistence), withLocks(new InMemoryLockStore())] +import { openaiText } from '@tanstack/ai-openai' +import { memoryPersistence, withPersistence } from '@tanstack/ai-persistence' + +const persistence = memoryPersistence() +const messages = [{ role: 'user' as const, content: 'Hello' }] + +chat({ + adapter: openaiText('gpt-5.6'), + messages, + middleware: [ + withPersistence(persistence), + withLocks(new InMemoryLockStore()), + ], +}) ``` `withLocks` provides `LocksCapability` for downstream middleware (e.g. @@ -73,7 +93,9 @@ annotation), then hand it to `withLocks`. Acquire the key, run `fn`, release whe `fn` settles: ```ts +import { chat } from '@tanstack/ai' import { defineLock, withLocks } from '@tanstack/ai/locks' +import { openaiText } from '@tanstack/ai-openai' import { acquire } from './my-lock-backend' const locks = defineLock({ @@ -87,7 +109,13 @@ const locks = defineLock({ }, }) -middleware: [withLocks(locks)] +const messages = [{ role: 'user' as const, content: 'Hello' }] + +chat({ + adapter: openaiText('gpt-5.6'), + messages, + middleware: [withLocks(locks)], +}) ``` ## Lease semantics diff --git a/packages/ai/skills/ai-core/media-generation/SKILL.md b/packages/ai/skills/ai-core/media-generation/SKILL.md index 4323015775..3786984140 100644 --- a/packages/ai/skills/ai-core/media-generation/SKILL.md +++ b/packages/ai/skills/ai-core/media-generation/SKILL.md @@ -110,9 +110,10 @@ parses it as SSE automatically: import { createServerFn } from '@tanstack/react-start' import { generateImage, toServerSentEventsResponse } from '@tanstack/ai' import { openaiImage } from '@tanstack/ai-openai' +import type { OpenAIImageModel } from '@tanstack/ai-openai' export const generateImageStreamFn = createServerFn({ method: 'POST' }) - .inputValidator((data: { prompt: string; model?: string }) => data) + .inputValidator((data: { prompt: string; model?: OpenAIImageModel }) => data) .handler(({ data }) => { return toServerSentEventsResponse( generateImage({ @@ -183,7 +184,7 @@ const openaiResult = await generateImage({ modelOptions: { quality: 'high', background: 'transparent', - outputFormat: 'png', + output_format: 'png', }, }) @@ -250,10 +251,10 @@ await generateImage({ adapter: openaiImage('gpt-image-2'), prompt: [ { type: 'text', content: 'Replace the masked region with a tree' }, - { type: 'image', source: { type: 'url', value: photoUrl } }, + { type: 'image', source: { type: 'url', value: 'https://…/photo.png' } }, { type: 'image', - source: { type: 'url', value: maskUrl }, + source: { type: 'url', value: 'https://…/mask.png' }, metadata: { role: 'mask' }, }, ], @@ -267,11 +268,11 @@ import { falVideo } from '@tanstack/ai-fal' await generateVideo({ adapter: falVideo('fal-ai/kling-video/v3/pro/image-to-video'), prompt: [ - { type: 'image', source: { type: 'url', value: firstFrameUrl } }, + { type: 'image', source: { type: 'url', value: 'https://…/first.png' } }, { type: 'text', content: 'Slow cinematic push-in' }, { type: 'image', - source: { type: 'url', value: lastFrameUrl }, + source: { type: 'url', value: 'https://…/last.png' }, metadata: { role: 'end_frame' }, }, ], @@ -404,35 +405,58 @@ gpt-4o-mini-transcribe, gpt-4o-transcribe-diarize) and `byteplusTranscription` > **Capturing audio in the browser:** Use `useAudioRecorder` from `@tanstack/ai-react` to record directly in the browser, then pass the recording as the `audio` input to `generate()`, or use `recording.part` as a prompt part in chat/generation calls. No transcoding or extra dependencies required — the recorder returns the native browser format (`audio/webm` or `audio/mp4`). For transcription, wrap it as a `data:` URL so the provider gets the real content type; passing raw `recording.base64` makes the adapter assume `audio/mpeg` and mislabel the webm/mp4 bytes. > -> ```typescript -> const { isRecording, start, stop } = useAudioRecorder() -> const { generate } = useTranscription({ -> connection: fetchServerSentEvents('/api/transcribe'), -> }) -> // ... -> const recording = await stop() -> const mimeType = recording.mimeType.split(';')[0] // strip ;codecs=... -> await generate({ audio: `data:${mimeType};base64,${recording.base64}` }) +> ```tsx +> import { +> useAudioRecorder, +> useTranscription, +> fetchServerSentEvents, +> } from '@tanstack/ai-react' +> +> function VoiceNote() { +> const { isRecording, start, stop } = useAudioRecorder() +> const { generate } = useTranscription({ +> connection: fetchServerSentEvents('/api/transcribe'), +> }) +> +> async function finish() { +> const recording = await stop() +> const mimeType = recording.mimeType.split(';')[0] // strip ;codecs=... +> await generate({ audio: `data:${mimeType};base64,${recording.base64}` }) +> } +> +> return ( +> +> ) +> } > ``` ```typescript -import { generateTranscription } from '@tanstack/ai' +// routes/api/transcribe.ts +import { generateTranscription, toServerSentEventsResponse } from '@tanstack/ai' import { openaiTranscription } from '@tanstack/ai-openai' -const result = await generateTranscription({ - adapter: openaiTranscription('whisper-1'), - audio: audioFile, // File, Blob, base64 string, or data URL - language: 'en', - responseFormat: 'verbose_json', - modelOptions: { - timestamp_granularities: ['word', 'segment'], - }, -}) +export async function POST(request: Request) { + // The client hook below posts { data: { audio: dataUrl, language } } + const { audio, language } = (await request.json()).data + + const stream = generateTranscription({ + adapter: openaiTranscription('whisper-1'), + audio, // File, Blob, base64 string, or data URL + language, + responseFormat: 'verbose_json', + modelOptions: { + timestamp_granularities: ['word', 'segment'], + }, + stream: true, + }) -// result.text -- full transcribed text -// result.language -- detected/specified language -// result.duration -- audio duration in seconds -// result.segments -- timestamped segments (word-level timestamps are in result.words) + // On the client, result.text is the transcript, result.language the + // detected language, result.duration the seconds, result.segments the + // timestamped segments (word-level timestamps are in result.words). + return toServerSentEventsResponse(stream) +} ``` For speaker diarization, use `openaiTranscription('gpt-4o-transcribe-diarize')`. @@ -486,14 +510,17 @@ while (status.status !== 'completed' && status.status !== 'failed') { } // Streaming: server handles polling, client gets real-time updates -const stream = generateVideo({ - adapter: openaiVideo('sora-2'), - prompt: 'A flying car over a city', - stream: true, - pollingInterval: 3000, - maxDuration: 600_000, -}) -return toServerSentEventsResponse(stream) +export async function POST(request: Request) { + const { prompt } = await request.json() + const stream = generateVideo({ + adapter: openaiVideo('sora-2'), + prompt, + stream: true, + pollingInterval: 3000, + maxDuration: 600_000, + }) + return toServerSentEventsResponse(stream) +} ``` Google Veo (`@tanstack/ai-gemini`) uses the same jobs/polling flow. Its @@ -505,6 +532,7 @@ Image prompt parts route by `metadata.role`: first un-roled / `'reference'` / `'character'` → `referenceImages`: ```typescript +import { generateVideo } from '@tanstack/ai' import { geminiVideo } from '@tanstack/ai-gemini' const adapter = geminiVideo('veo-3.1-generate-preview') @@ -538,6 +566,7 @@ media). For conversational editing, pass a prior generation's `jobId` as on 2026-09-30. ```typescript +import { generateVideo } from '@tanstack/ai' import { geminiVideo } from '@tanstack/ai-gemini' const omni = geminiVideo('gemini-omni-1.1-flash') @@ -590,6 +619,7 @@ from OpenRouter's published metadata, with the same `availableDurations()` / `snapDuration()` helpers: ```typescript +import { generateVideo } from '@tanstack/ai' import { openRouterVideo } from '@tanstack/ai-openrouter' const adapter = openRouterVideo('bytedance/seedance-2.0') @@ -643,6 +673,7 @@ const result = await generateImage({ // usage.billed.quantity is the priced quantity. Multiply by the endpoint unit // price (GET https://api.fal.ai/v1/models/pricing?endpoint_id=…) for exact cost. +const unitPrice = 0.025 // USD per unit, from the pricing endpoint if (result.usage?.billed) { const cost = result.usage.billed.quantity * unitPrice } @@ -769,6 +800,8 @@ Provide either `connection` (streaming SSE transport) or `fetcher` to transform what is stored: ```tsx +import { useGenerateSpeech, fetchServerSentEvents } from '@tanstack/ai-react' + const { result } = useGenerateSpeech({ connection: fetchServerSentEvents('/api/generate/speech'), onResult: (raw) => ({ @@ -790,7 +823,7 @@ Agents trained on older code may still generate this pattern. **Wrong:** -```typescript +```typescript ignore import { embedding } from '@tanstack/ai' import { openaiEmbed } from '@tanstack/ai-openai' @@ -825,27 +858,34 @@ stream from a server function will not work. **Wrong:** -```typescript -export const generateImageStreamFn = createServerFn({ method: 'POST' }).handler( - ({ data }) => { +```typescript ignore +import { createServerFn } from '@tanstack/react-start' +import { generateImage } from '@tanstack/ai' +import { openaiImage } from '@tanstack/ai-openai' + +export const generateImageStreamFn = createServerFn({ method: 'POST' }) + .inputValidator((data: { prompt: string }) => data) + .handler(({ data }) => { // BUG: returning raw stream -- client cannot parse this + // (also a type error: an AsyncIterable is not a valid server-function return) return generateImage({ adapter: openaiImage('gpt-image-1'), prompt: data.prompt, stream: true, }) - }, -) + }) ``` **Correct:** ```typescript +import { createServerFn } from '@tanstack/react-start' import { generateImage, toServerSentEventsResponse } from '@tanstack/ai' import { openaiImage } from '@tanstack/ai-openai' -export const generateImageStreamFn = createServerFn({ method: 'POST' }).handler( - ({ data }) => { +export const generateImageStreamFn = createServerFn({ method: 'POST' }) + .inputValidator((data: { prompt: string }) => data) + .handler(({ data }) => { return toServerSentEventsResponse( generateImage({ adapter: openaiImage('gpt-image-1'), @@ -853,8 +893,7 @@ export const generateImageStreamFn = createServerFn({ method: 'POST' }).handler( stream: true, }), ) - }, -) + }) ``` > Source: maintainer interview. @@ -866,6 +905,9 @@ later, the image will silently break. Always download or display the image immediately, or convert to base64 for persistence. ```typescript +import { generateImage } from '@tanstack/ai' +import { openaiImage } from '@tanstack/ai-openai' + const result = await generateImage({ adapter: openaiImage('dall-e-3'), prompt: 'A mountain landscape', @@ -904,7 +946,7 @@ Gemini's `GenerateContentConfig` (used by Lyria 3 Pro / Lyria 3 Clip) does returns 30-second `audio/mp3`; Lyria 3 Pro returns `audio/mp3`. These fields are not in `GeminiAudioProviderOptions` — don't reach for them via `as any`. -```typescript +```typescript ignore // WRONG — both fields are silently ignored or rejected by the SDK generateAudio({ adapter: geminiAudio('lyria-3-pro-preview'), @@ -914,6 +956,11 @@ generateAudio({ negativePrompt: 'vocals', // unsupported } as any, }) +``` + +```typescript +import { generateAudio } from '@tanstack/ai' +import { geminiAudio } from '@tanstack/ai-gemini' // CORRECT — shape the prompt itself for what you want generateAudio({ @@ -934,6 +981,10 @@ model's native field like `music_length_ms` or `seconds_total`), but not for Lyria. ```typescript +import { generateAudio } from '@tanstack/ai' +import { geminiAudio } from '@tanstack/ai-gemini' +import { falAudio } from '@tanstack/ai-fal' + // For Lyria: put length guidance in the prompt generateAudio({ adapter: geminiAudio('lyria-3-pro-preview'), @@ -958,6 +1009,9 @@ generateAudio({ `as any`. ```typescript +import { generateSpeech } from '@tanstack/ai' +import { geminiSpeech } from '@tanstack/ai-gemini' + generateSpeech({ adapter: geminiSpeech('gemini-2.5-pro-preview-tts'), text: '[Alice] Hi. [Bob] Hello!', @@ -988,7 +1042,7 @@ narrowed per model, so passing an image part to a text-only model also throw a clear runtime error as a backstop, so users learn at call time rather than getting silently wrong output. -```typescript +```typescript ignore // WRONG — dall-e-3 has no edit/inputs API; image parts are a type error generateImage({ adapter: openaiImage('dall-e-3'), @@ -1006,6 +1060,14 @@ generateImage({ { type: 'image', source: { type: 'url', value: url } }, // ❌ type error ], }) +``` + +```typescript +import { generateImage } from '@tanstack/ai' +import { openaiImage } from '@tanstack/ai-openai' +import { geminiImage } from '@tanstack/ai-gemini' + +const url = 'https://…/photo.png' // CORRECT — use a model that supports image-conditioned generation generateImage({ @@ -1035,6 +1097,9 @@ same `debug?: DebugOption` option that `chat()` does. Reach for `debug` instead of wiring up logging middleware. ```typescript +import { generateSpeech } from '@tanstack/ai' +import { openaiSpeech } from '@tanstack/ai-openai' + // When a speech generation sounds wrong or a transcription returns garbage generateSpeech({ adapter: openaiSpeech('tts-1'), diff --git a/packages/ai/skills/ai-core/middleware/SKILL.md b/packages/ai/skills/ai-core/middleware/SKILL.md index 853fcf471f..d41230b097 100644 --- a/packages/ai/skills/ai-core/middleware/SKILL.md +++ b/packages/ai/skills/ai-core/middleware/SKILL.md @@ -24,26 +24,31 @@ sources: ```typescript import { chat, toServerSentEventsResponse } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' +import { trackAnalytics, reportError } from './analytics' -const stream = chat({ - adapter: openaiText('gpt-5.2'), - messages, - middleware: [ - { - onStart: (ctx) => { - console.log('Chat started:', ctx.model) - }, - onFinish: (ctx, info) => { - trackAnalytics({ model: ctx.model, tokens: info.usage?.totalTokens }) - }, - onError: (ctx, info) => { - reportError(info.error) +export async function POST(request: Request) { + const { messages } = await request.json() + + const stream = chat({ + adapter: openaiText('gpt-5.5'), + messages, + middleware: [ + { + onStart: (ctx) => { + console.log('Chat started:', ctx.model) + }, + onFinish: (ctx, info) => { + trackAnalytics({ model: ctx.model, tokens: info.usage?.totalTokens }) + }, + onError: (ctx, info) => { + reportError(info.error) + }, }, - }, - ], -}) + ], + }) -return toServerSentEventsResponse(stream) + return toServerSentEventsResponse(stream) +} ``` ## Hooks Reference @@ -119,19 +124,30 @@ specific config changes that should not affect the agent-loop adapter calls. **Signature:** ```ts -onStructuredOutputConfig?: ( - ctx: ChatMiddlewareContext, - config: StructuredOutputMiddlewareConfig, -) => - | void - | null - | Partial - | Promise> +import type { + ChatMiddlewareContext, + StructuredOutputMiddlewareConfig, +} from '@tanstack/ai' + +// Excerpt of the `ChatMiddleware` interface exported by '@tanstack/ai' +interface ChatMiddleware { + onStructuredOutputConfig?: ( + ctx: ChatMiddlewareContext, + config: StructuredOutputMiddlewareConfig, + ) => + | void + | null + | Partial + | Promise> +} ``` **`StructuredOutputMiddlewareConfig` shape:** ```ts +import type { ChatMiddlewareConfig, JSONSchema } from '@tanstack/ai' + +// As exported by '@tanstack/ai' interface StructuredOutputMiddlewareConfig extends Omit< ChatMiddlewareConfig, 'tools' @@ -203,13 +219,17 @@ const analytics: ChatMiddleware = { }, } -const stream = chat({ - adapter: openaiText('gpt-5.2'), - messages, - middleware: [analytics], -}) +export async function POST(request: Request) { + const { messages } = await request.json() + + const stream = chat({ + adapter: openaiText('gpt-5.5'), + messages, + middleware: [analytics], + }) -return toServerSentEventsResponse(stream) + return toServerSentEventsResponse(stream) +} ``` ### Pattern 2: Tool Interception Middleware @@ -278,11 +298,14 @@ native-combined schema. ```typescript import type { ChatMiddleware } from '@tanstack/ai' +import { trace } from '@opentelemetry/api' const tracing: ChatMiddleware = { name: 'tracing', onChunk(ctx, chunk) { - span.addEvent('chunk', { phase: ctx.phase, type: chunk.type }) + trace + .getActiveSpan() + ?.addEvent('chunk', { phase: ctx.phase, type: chunk.type }) }, } ``` @@ -296,6 +319,7 @@ the native-combined path, it observes the structured stream with ```typescript import type { ChatMiddleware } from '@tanstack/ai' +import { sharedDefs } from './defs' const injectDefs: ChatMiddleware = { name: 'inject-defs', @@ -317,9 +341,27 @@ Middleware executes in array order (left-to-right). Ordering matters for hooks t pipe or short-circuit: ```typescript -import { chat, type ChatMiddleware } from '@tanstack/ai' +import { + chat, + toolDefinition, + toServerSentEventsResponse, + type ChatMiddleware, +} from '@tanstack/ai' import { toolCacheMiddleware } from '@tanstack/ai/middlewares' import { openaiText } from '@tanstack/ai-openai' +import { z } from 'zod' + +const weatherTool = toolDefinition({ + name: 'getWeather', + description: 'Get the current weather for a city', + inputSchema: z.object({ city: z.string() }), +}).server(async ({ city }) => ({ city, tempC: 21 })) + +const stockTool = toolDefinition({ + name: 'getStock', + description: 'Get the latest price for a ticker symbol', + inputSchema: z.object({ symbol: z.string() }), +}).server(async ({ symbol }) => ({ symbol, price: 123.45 })) const logging: ChatMiddleware = { name: 'logging', @@ -347,16 +389,22 @@ const configTransform: ChatMiddleware = { }, } -const stream = chat({ - adapter: openaiText('gpt-5.2'), - messages, - tools: [weatherTool, stockTool], - middleware: [ - logging, // Runs first - configTransform, // Transforms config second - toolCacheMiddleware({ ttl: 60_000 }), // Caches tool results third - ], -}) +export async function POST(request: Request) { + const { messages } = await request.json() + + const stream = chat({ + adapter: openaiText('gpt-5.5'), + messages, + tools: [weatherTool, stockTool], + middleware: [ + logging, // Runs first + configTransform, // Transforms config second + toolCacheMiddleware({ ttl: 60_000 }), // Caches tool results third + ], + }) + + return toServerSentEventsResponse(stream) +} ``` **Composition rules by hook:** @@ -378,7 +426,21 @@ Not a built-in. Cap fan-out with `onBeforeToolCall` skip + `onShouldContinue`. See `docs/chat/agentic-cycle.md` ("Tool-call budgets"). ```typescript -import { chat, maxIterations, type ChatMiddleware } from '@tanstack/ai' +import { + chat, + maxIterations, + toolDefinition, + toServerSentEventsResponse, + type ChatMiddleware, +} from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { z } from 'zod' + +const weatherTool = toolDefinition({ + name: 'getWeather', + description: 'Get the current weather for a city', + inputSchema: z.object({ city: z.string() }), +}).server(async ({ city }) => ({ city, tempC: 21 })) function toolCallBudget(opts: { max?: number @@ -409,13 +471,19 @@ function toolCallBudget(opts: { } } -chat({ - adapter, - messages, - tools: [weatherTool], - agentLoopStrategy: maxIterations(20), - middleware: [toolCallBudget({ maxPerTurn: 10, max: 20 })], -}) +export async function POST(request: Request) { + const { messages } = await request.json() + + const stream = chat({ + adapter: openaiText('gpt-5.5'), + messages, + tools: [weatherTool], + agentLoopStrategy: maxIterations(20), + middleware: [toolCallBudget({ maxPerTurn: 10, max: 20 })], + }) + + return toServerSentEventsResponse(stream) +} ``` ## Built-in: toolCacheMiddleware @@ -423,21 +491,35 @@ chat({ Caches tool call results by name + arguments. Import from `@tanstack/ai/middlewares`: ```typescript -import { chat } from '@tanstack/ai' +import { chat, toolDefinition, toServerSentEventsResponse } from '@tanstack/ai' import { toolCacheMiddleware } from '@tanstack/ai/middlewares' +import { openaiText } from '@tanstack/ai-openai' +import { z } from 'zod' -const stream = chat({ - adapter, - messages, - tools: [weatherTool], - middleware: [ - toolCacheMiddleware({ - ttl: 60_000, // Cache entries expire after 60 seconds - maxSize: 50, // Max 50 entries (LRU eviction) - toolNames: ['getWeather'], // Only cache specific tools - }), - ], -}) +const weatherTool = toolDefinition({ + name: 'getWeather', + description: 'Get the current weather for a city', + inputSchema: z.object({ city: z.string() }), +}).server(async ({ city }) => ({ city, tempC: 21 })) + +export async function POST(request: Request) { + const { messages } = await request.json() + + const stream = chat({ + adapter: openaiText('gpt-5.5'), + messages, + tools: [weatherTool], + middleware: [ + toolCacheMiddleware({ + ttl: 60_000, // Cache entries expire after 60 seconds + maxSize: 50, // Max 50 entries (LRU eviction) + toolNames: ['getWeather'], // Only cache specific tools + }), + ], + }) + + return toServerSentEventsResponse(stream) +} ``` Options: `maxSize` (default 100), `ttl` (default Infinity), `toolNames` (default all), @@ -550,7 +632,12 @@ implement, and what `@tanstack/ai-sandbox`'s run driver resolves per run — its `snapshot()` method alongside `append`, `read`, and `close`: ```ts -snapshot: () => Promise> +import type { StreamChunk } from '@tanstack/ai' + +// Excerpt of the `StreamDurability` interface exported by '@tanstack/ai' +interface StreamDurability { + snapshot: () => Promise> +} ``` It returns everything stored for a run right now, in append order, then @@ -695,11 +782,15 @@ Source: docs/sandbox/observability.md ### a. MEDIUM: Trying to modify StreamChunks in middleware ```typescript +import type { ChatMiddleware } from '@tanstack/ai' + // WRONG -- mutating the chunk object directly const broken: ChatMiddleware = { name: 'broken', onChunk: (ctx, chunk) => { - chunk.delta = 'modified' // Mutation does nothing; chunk is not modified in-place + if (chunk.type === 'TEXT_MESSAGE_CONTENT') { + chunk.delta = 'modified' // Mutation does nothing; chunk is not modified in-place + } }, } @@ -736,6 +827,9 @@ middleware had decided to reject. A throw from either fails the whole stream. Th is where an unhandled error actually costs you a response: ```typescript +import type { ChatMiddleware } from '@tanstack/ai' +import { logChunk, requireEnv } from './logging' + // WRONG -- an unhandled error in onChunk kills the entire streaming response const fragile: ChatMiddleware = { name: 'fragile-chunk-logger', @@ -745,7 +839,12 @@ const fragile: ChatMiddleware = { }, onConfig: (ctx, config) => { // Same for a config transform that reads an env var that is not set - return { model: requireEnv('MODEL_OVERRIDE') } + return { + modelOptions: { + ...config.modelOptions, + temperature: Number(requireEnv('TEMPERATURE')), + }, + } }, } @@ -761,9 +860,15 @@ const resilient: ChatMiddleware = { // Return void to pass through }, onConfig: (ctx, config) => { - const override = process.env.MODEL_OVERRIDE + const temperature = process.env.TEMPERATURE // Decide, do not throw: no override means no transform. - return override === undefined ? undefined : { model: override } + if (temperature === undefined) return undefined + return { + modelOptions: { + ...config.modelOptions, + temperature: Number(temperature), + }, + } }, onFinish: (ctx, info) => { // Already guarded by core — but prefer ctx.defer() anyway, so a slow diff --git a/packages/ai/skills/ai-core/structured-outputs/SKILL.md b/packages/ai/skills/ai-core/structured-outputs/SKILL.md index 799d816267..3bea8cbaea 100644 --- a/packages/ai/skills/ai-core/structured-outputs/SKILL.md +++ b/packages/ai/skills/ai-core/structured-outputs/SKILL.md @@ -139,7 +139,7 @@ const company = await chat({ // Full type safety on nested properties console.log(company.headquarters.city) -console.log(company.employees[0].role) +console.log(company.employees[0]?.role) console.log(company.financials?.revenue) ``` @@ -147,7 +147,7 @@ console.log(company.financials?.revenue) Pass `stream: true` alongside `outputSchema` to get an async iterable of standard streaming chunks plus a completed typed 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 +```typescript group=person-stream import { chat } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' import { z } from 'zod' @@ -316,7 +316,7 @@ function RecipeBuilder() { .filter((p) => p.type === 'text') .map((p) => p.content) .join('') - return + return

{text}

} if (m.role === 'assistant') { // `data` is `Recipe` because the schema generic flows from @@ -338,8 +338,8 @@ function RecipeBuilder() { function RecipeCard({ part }: { part: RecipePart }) { // `data` lands on complete, `partial` fills in while streaming. // Both are typed against the schema. No casts. - const recipe = part.data ?? part.partial ?? ({} as Partial) - return

{recipe.title ?? 'Plating up…'}

+ const recipe = part.data ?? part.partial + return

{recipe?.title ?? 'Plating up…'}

} ``` @@ -398,12 +398,19 @@ const ReportSchema = z.object({ oneLiner: z.string(), }) -const { final } = useChat({ - connection: fetchServerSentEvents('/api/repo-report'), - outputSchema: ReportSchema, -}) +function RepoReport() { + const { final, sendMessage } = useChat({ + connection: fetchServerSentEvents('/api/repo-report'), + outputSchema: ReportSchema, + }) -final?.name + return ( +
+ + {final &&

{final.name}

} +
+ ) +} ``` - Claude Code: `--json-schema`. Codex: `--output-schema`. OpenCode, Grok Build, and `acpCompatible`: prompt-and-parse. @@ -419,28 +426,51 @@ final?.name Earlier versions of the library routed structured-output JSON deltas through `TextPart`, so renderers had to filter them out: -```tsx -// OBSOLETE — this guard was needed only because JSON used to land in a TextPart -const last = messages.at(-1) -last?.parts.map((part) => { - if (part.type === 'text') return null // ❌ hides the structured JSON - // ... +```tsx group=recipe-renderer +import { useChat, fetchServerSentEvents } from '@tanstack/ai-react' +import { z } from 'zod' +import { ReasoningView, ToolCallView, RecipeCard } from './views' + +const RecipeSchema = z.object({ + title: z.string(), + steps: z.array(z.string()), }) + +function useRecipeChat() { + return useChat({ + connection: fetchServerSentEvents('/api/recipes'), + outputSchema: RecipeSchema, + }) +} + +function ObsoleteRenderer() { + const { messages } = useRecipeChat() + const last = messages.at(-1) + // OBSOLETE — this guard was needed only because JSON used to land in a TextPart + return last?.parts.map((part, i) => { + if (part.type === 'text') return null // ❌ hides the structured JSON + return
{JSON.stringify(part)}
+ }) +} ``` That hack is **gone**. With `outputSchema` set, `TEXT_MESSAGE_CONTENT` deltas now route into a dedicated `StructuredOutputPart` (with `raw`, `partial`, `data`, `status`, optional `errorMessage`). Render the structured part directly; let real `TextPart`s through. -```tsx -// CORRECT — find the structured-output part directly; let actual TextParts render -last?.parts.map((part, i) => { - if (part.type === 'thinking') - return - if (part.type === 'tool-call') return - if (part.type === 'structured-output') - return - if (part.type === 'text') return

{part.content}

// ← real text, not JSON - return null -}) +```tsx group=recipe-renderer +function RecipeRenderer() { + const { messages } = useRecipeChat() + const last = messages.at(-1) + // CORRECT — find the structured-output part directly; let actual TextParts render + return last?.parts.map((part, i) => { + if (part.type === 'thinking') + return + if (part.type === 'tool-call') return + if (part.type === 'structured-output') + return + if (part.type === 'text') return

{part.content}

// ← real text, not JSON + return null + }) +} ``` If you still have an `if (part.type === 'text') return null` line in a structured-output renderer specifically for "hiding the JSON," delete it. @@ -456,18 +486,24 @@ Source: PR #577 — structured-output became a typed UIMessage part. To render history, walk `messages` directly (see Pattern 5). Use `partial` / `final` for a sticky summary of the **most recent** turn only. -```tsx -// WRONG — `final` only reflects the latest turn; earlier recipes vanish from this view -{final && } - -// CORRECT for history — walk messages, render each structured-output part -{messages.map((m) => - m.role === 'assistant' - ? m.parts.find((p) => p.type === 'structured-output') - ? - : null - : null -)} +```tsx group=recipe-renderer +function RecipeHistory() { + const { messages, final } = useRecipeChat() + + return ( + <> + {/* WRONG — `final` only reflects the latest turn; earlier recipes vanish from this view */} + {final &&

{final.title}

} + + {/* CORRECT for history — walk messages, render each structured-output part */} + {messages.map((m) => { + if (m.role !== 'assistant') return null + const part = m.parts.find((p) => p.type === 'structured-output') + return part ? : null + })} + + ) +} ``` Source: PR #577 — partial/final derive from the most recent structured-output part after the latest user message. @@ -476,7 +512,7 @@ Source: PR #577 — partial/final derive from the most recent structured-output When iterating `chat({ outputSchema, stream: true })` directly (Pattern 3), the `TEXT_MESSAGE_CONTENT` chunks contain _partial_ JSON fragments — they are not valid JSON until the stream completes. Read the completed typed object from the terminal `structured-output.complete` event. Standard Schema validation remains the consumer's responsibility. -```typescript +```typescript group=person-stream // WRONG -- partial JSON, throws SyntaxError mid-stream, no schema validation for await (const chunk of stream) { if (chunk.type === 'TEXT_MESSAGE_CONTENT') { @@ -500,8 +536,9 @@ Source: maintainer interview The adapter already handles provider differences (OpenAI uses `response_format`, Anthropic uses tool-based extraction, Gemini uses `responseSchema`). Never configure this yourself. -```typescript +```typescript ignore // WRONG -- do not set provider-specific response format +// (this does not compile: modelOptions has no response-format field) chat({ adapter, messages, @@ -509,11 +546,17 @@ chat({ responseFormat: { type: 'json_schema', json_schema: mySchema }, }, }) +``` +```typescript // CORRECT -- just pass outputSchema, the adapter handles the rest -chat({ - adapter, - messages, +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() }), }) ``` @@ -529,8 +572,15 @@ of using the schema validation library already in the project (Zod, ArkType, Valibot). Always check what the project uses and match it. ```typescript -// WRONG -- raw schema object, no schema-library type inference -chat({ +import { chat } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { z } from 'zod' + +const adapter = openaiText('gpt-5.2') +const messages = [{ role: 'user' as const, content: 'John Doe, 30' }] + +// WRONG -- raw schema object, no schema-library type inference (result is unknown) +const untyped = await chat({ adapter, messages, outputSchema: { @@ -545,9 +595,7 @@ chat({ }) // CORRECT -- use the project's schema library (e.g. Zod) -import { z } from 'zod' - -chat({ +const person = await chat({ adapter, messages, outputSchema: z.object({ @@ -555,6 +603,7 @@ chat({ age: z.number(), }), }) +person.name // string ``` Using the project's schema library gives you TypeScript type inference and diff --git a/packages/ai/skills/ai-core/tool-calling/SKILL.md b/packages/ai/skills/ai-core/tool-calling/SKILL.md index 210e8bb487..62da45d393 100644 --- a/packages/ai/skills/ai-core/tool-calling/SKILL.md +++ b/packages/ai/skills/ai-core/tool-calling/SKILL.md @@ -26,8 +26,9 @@ This skill builds on ai-core. Read it first for critical rules. ## Setup Complete end-to-end example: shared definition, server tool, client tool, server route, React client. +The four files below share one scope, so later files use the earlier exports directly. -```typescript +```typescript group=product-catalog // tools/definitions.ts import { toolDefinition } from '@tanstack/ai' import { z } from 'zod' @@ -54,24 +55,23 @@ export const updateCartUIDef = toolDefinition({ }) ``` -```typescript -// tools/server.ts -import { getProductsDef } from './definitions' +```typescript group=product-catalog +// tools/server.ts (uses getProductsDef from tools/definitions.ts) +import { db } from './db' export const getProducts = getProductsDef.server(async ({ query, limit }) => { - const results = await db.products.search(query, { limit: limit ?? 10 }) + const results: Array<{ id: string; name: string; price: number }> = + await db.products.search(query, { limit: limit ?? 10 }) return { products: results.map((p) => ({ id: p.id, name: p.name, price: p.price })), } }) ``` -```typescript -// api/chat/route.ts +```typescript group=product-catalog +// api/chat/route.ts (uses getProducts and updateCartUIDef from tools/) import { chat, toServerSentEventsResponse } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' -import { getProducts } from '@/tools/server' -import { updateCartUIDef } from '@/tools/definitions' export async function POST(request: Request) { const { messages } = await request.json() @@ -84,32 +84,31 @@ export async function POST(request: Request) { } ``` -```typescript -// app/chat.tsx +```tsx group=product-catalog +// app/chat.tsx (uses updateCartUIDef from tools/definitions.ts) import { useChat, fetchServerSentEvents, - clientTools, createChatClientOptions, type InferChatMessages, -} from "@tanstack/ai-react"; -import { updateCartUIDef } from "@/tools/definitions"; -import { useState } from "react"; +} from '@tanstack/ai-react' +import { clientTools } from '@tanstack/ai-client' +import { useState } from 'react' function ChatPage() { - const [cartCount, setCartCount] = useState(0); + const [cartCount, setCartCount] = useState(0) const updateCartUI = updateCartUIDef.client((input) => { - setCartCount(input.itemCount); - return { displayed: true }; - }); + setCartCount(input.itemCount) + return { displayed: true } + }) - const tools = clientTools(updateCartUI); + const tools = clientTools(updateCartUI) const chatOptions = createChatClientOptions({ - connection: fetchServerSentEvents("/api/chat"), + connection: fetchServerSentEvents('/api/chat'), tools, - }); - const { messages, sendMessage } = useChat(chatOptions); + }) + const { messages, sendMessage } = useChat(chatOptions) // InferChatMessages ties part types to the configured tools when needed: // type Messages = InferChatMessages @@ -119,16 +118,20 @@ function ChatPage() { {messages.map((msg) => (
{msg.parts.map((part) => { - if (part.type === "text") return

{part.content}

; - if (part.type === "tool-call") { - return
Tool: {part.name} ({part.state})
; + if (part.type === 'text') return

{part.content}

+ if (part.type === 'tool-call') { + return ( +
+ Tool: {part.name} ({part.state}) +
+ ) } - return null; + return null })}
))}
- ); + ) } ``` @@ -192,8 +195,10 @@ Define with `toolDefinition()`, implement with `.server()`, pass to `chat({ tool The server executes it automatically. The client never runs code for this tool. ```typescript -import { toolDefinition } from '@tanstack/ai' +import { chat, toolDefinition, toServerSentEventsResponse } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' import { z } from 'zod' +import { db } from './db' const getUserDataDef = toolDefinition({ name: 'get_user_data', @@ -210,11 +215,15 @@ const getUserData = getUserDataDef.server(async ({ userId }) => { }) // In your route handler: -const stream = chat({ - adapter: openaiText('gpt-5.5'), - messages, - tools: [getUserData], -}) +export async function POST(request: Request) { + const { messages } = await request.json() + const stream = chat({ + adapter: openaiText('gpt-5.5'), + messages, + tools: [getUserData], + }) + return toServerSentEventsResponse(stream) +} ``` ### Pattern 2: Client-Only Tool @@ -222,7 +231,8 @@ const stream = chat({ Pass the bare definition (no `.server()`) to `chat({ tools })` so the LLM knows about it. Pass the `.client()` implementation to `useChat` via `clientTools()`. -```typescript +```typescript group=notification-tool +// tools/definitions.ts import { toolDefinition } from '@tanstack/ai' import { z } from 'zod' @@ -239,41 +249,49 @@ export const showNotificationDef = toolDefinition({ Server -- pass definition only (no execute function): -```typescript -const stream = chat({ - adapter: openaiText('gpt-5.5'), - messages, - tools: [showNotificationDef], -}) +```typescript group=notification-tool +// api/chat/route.ts (uses showNotificationDef from tools/definitions.ts) +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' + +export async function POST(request: Request) { + const { messages } = await request.json() + const stream = chat({ + adapter: openaiText('gpt-5.5'), + messages, + tools: [showNotificationDef], + }) + return toServerSentEventsResponse(stream) +} ``` Client -- pass `.client()` implementation: -```typescript +```tsx group=notification-tool +// app/chat.tsx (uses showNotificationDef from tools/definitions.ts) import { useChat, fetchServerSentEvents, - clientTools, createChatClientOptions, -} from "@tanstack/ai-react"; -import { showNotificationDef } from "@/tools/definitions"; -import { useState } from "react"; +} from '@tanstack/ai-react' +import { clientTools } from '@tanstack/ai-client' +import { useState } from 'react' function ChatPage() { - const [toast, setToast] = useState(null); + const [toast, setToast] = useState(null) const showNotification = showNotificationDef.client((input) => { - setToast(input.message); - setTimeout(() => setToast(null), 3000); - return { shown: true }; - }); + setToast(input.message) + setTimeout(() => setToast(null), 3000) + return { shown: true } + }) const { messages, sendMessage } = useChat( createChatClientOptions({ - connection: fetchServerSentEvents("/api/chat"), + connection: fetchServerSentEvents('/api/chat'), tools: clientTools(showNotification), - }) - ); + }), + ) return (
@@ -281,12 +299,12 @@ function ChatPage() { {messages.map((msg) => (
{msg.parts.map((part) => - part.type === "text" ?

{part.content}

: null + part.type === 'text' ?

{part.content}

: null, )}
))}
- ); + ) } ``` @@ -298,9 +316,11 @@ Set `needsApproval: true` in the definition. Execution pauses with `addToolApprovalResponse` and `pendingInterrupts` remain as deprecated compatibility shims during migration. -```typescript +```typescript group=email-approval +// tools/email.ts import { toolDefinition } from '@tanstack/ai' import { z } from 'zod' +import { emailService } from './email-service' export const sendEmailDef = toolDefinition({ name: 'send_email', @@ -323,18 +343,20 @@ export const sendEmail = sendEmailDef.server(async ({ to, subject, body }) => { Server route must forward `resume` / `parentRunId` (via `chatParamsFromRequest` or equivalent). Client -- render bound interrupts: -```typescript -import { useChat, fetchServerSentEvents } from "@tanstack/ai-react"; +```tsx group=email-approval +// app/chat.tsx (registers sendEmailDef so the approval interrupt is typed) +import { useChat, fetchServerSentEvents } from '@tanstack/ai-react' function ChatPage() { const { messages, interrupts, sendMessage } = useChat({ - connection: fetchServerSentEvents("/api/chat"), - }); + connection: fetchServerSentEvents('/api/chat'), + tools: [sendEmailDef], + }) return (
{interrupts.map((interrupt) => { - if (interrupt.kind !== "tool-approval") return null; + if (interrupt.kind !== 'tool-approval') return null return (

Approve "{interrupt.toolName}"?

@@ -347,33 +369,54 @@ function ChatPage() {
- ); + ) })} {messages.map((msg) => (
{msg.parts.map((part) => - part.type === "text" ?

{part.content}

: null + part.type === 'text' ? ( +

{part.content}

+ ) : null, )}
))}
- ); + ) } ``` Batch all pending approvals with `resolveInterrupts` (void — submission is async; watch `resuming` / `interruptErrors`): -```typescript -// Payloadless tool-approvals only -resolveInterrupts(true) +```tsx group=email-approval +function ApproveAllButton() { + const { resolveInterrupts, resuming } = useChat({ + connection: fetchServerSentEvents('/api/chat'), + tools: [sendEmailDef], + }) -// Or per-item: -resolveInterrupts((interrupt) => { - if (interrupt.kind === 'tool-approval') { - interrupt.resolveInterrupt(true) - } -}) + // Payloadless tool-approvals only + const approveAll = () => resolveInterrupts(true) + + // Or per-item: + const approveEach = () => + resolveInterrupts((interrupt) => { + if (interrupt.kind === 'tool-approval') { + interrupt.resolveInterrupt(true) + } + }) + + return ( + <> + + + + ) +} ``` Migration: `pendingInterrupts` aliases `interrupts`; `addToolApprovalResponse` @@ -385,15 +428,17 @@ above for new code. See `docs/interrupts/`. Set `lazy: true` on rarely-needed tools. The LLM sees their names via a synthetic `__lazy__tool__discovery__` tool and discovers schemas on demand. Saves tokens. -```typescript +```typescript group=lazy-tools import { toolDefinition, chat, toServerSentEventsResponse, maxIterations, + type ModelMessage, } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' import { z } from 'zod' +import { db } from './db' const getProductsDef = toolDefinition({ name: 'getProducts', @@ -440,14 +485,17 @@ When all lazy tools are discovered, the discovery tool is removed automatically. By default the discovery-tool catalog lists only bare names (`'none'`). Pass `lazyToolsConfig` to `chat()` to include more context: -```typescript -const stream = chat({ - adapter: openaiText('gpt-5.5'), - messages, - tools: [getProducts, compareProducts], - agentLoopStrategy: maxIterations(20), - lazyToolsConfig: { includeDescription: 'first-sentence' }, -}) +```typescript group=lazy-tools +// Same tools as the route above, with a richer discovery catalog: +export function chatWithCatalog(messages: Array) { + return chat({ + adapter: openaiText('gpt-5.5'), + messages, + tools: [getProducts, compareProducts], + agentLoopStrategy: maxIterations(20), + lazyToolsConfig: { includeDescription: 'first-sentence' }, + }) +} ``` `includeDescription` values: @@ -475,48 +523,41 @@ See the `@tanstack/ai-mcp` skill for the full MCP Apps API ### Basic usage — auto-discovery ```typescript -// src/routes/api.chat.ts -import { createFileRoute } from '@tanstack/react-router' +// api/chat/route.ts import { chat, toServerSentEventsResponse } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' import { createMCPClient } from '@tanstack/ai-mcp' -export const Route = createFileRoute('/api/chat')({ - server: { - handlers: { - POST: async ({ request }) => { - const { messages } = await request.json() - - // 1. Connect to the MCP server. - const mcp = await createMCPClient({ - transport: { type: 'http', url: 'https://mcp.example.com/mcp' }, - }) - - // 2. Discover all tools from the server (returns ServerTool[]). - const mcpTools = await mcp.tools() - - // 3. Spread them into chat() — they work exactly like hand-written tools. - // Caller owns the lifecycle — chat() never closes the client. Tools run - // while the response streams, so close in a middleware terminal hook - // (a try/finally around the return would close before tools execute). - const stream = chat({ - adapter: openaiText('gpt-5.5'), - messages, - tools: [...mcpTools], - middleware: [ - { - name: 'mcp-close', - onFinish: () => mcp.close(), - onAbort: () => mcp.close(), - onError: () => mcp.close(), - }, - ], - }) - return toServerSentEventsResponse(stream) +export async function POST(request: Request) { + const { messages } = await request.json() + + // 1. Connect to the MCP server. + const mcp = await createMCPClient({ + transport: { type: 'http', url: 'https://mcp.example.com/mcp' }, + }) + + // 2. Discover all tools from the server (returns ServerTool[]). + const mcpTools = await mcp.tools() + + // 3. Spread them into chat() — they work exactly like hand-written tools. + // Caller owns the lifecycle — chat() never closes the client. Tools run + // while the response streams, so close in a middleware terminal hook + // (a try/finally around the return would close before tools execute). + const stream = chat({ + adapter: openaiText('gpt-5.5'), + messages, + tools: [...mcpTools], + middleware: [ + { + name: 'mcp-close', + onFinish: () => mcp.close(), + onAbort: () => mcp.close(), + onError: () => mcp.close(), }, - }, - }, -}) + ], + }) + return toServerSentEventsResponse(stream) +} ``` ### Typed path — pass toolDefinition instances @@ -526,7 +567,8 @@ The MCP client supplies a `callTool` proxy as the execute function, while input/output validation and types come from the definitions' Zod schemas. ```typescript -import { toolDefinition } from '@tanstack/ai' +import { chat, toolDefinition } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' import { createMCPClient } from '@tanstack/ai-mcp' import { z } from 'zod' @@ -545,12 +587,15 @@ const mcp = await createMCPClient({ // Throws MCPToolNotFoundError if the server does not expose a tool with that name. const tools = await mcp.tools([getWeather]) +const messages = [{ role: 'user' as const, content: 'Weather in Paris?' }] const stream = chat({ adapter: openaiText('gpt-5.5'), messages, tools }) ``` ### Multiple servers with `createMCPClients` ```typescript +import { chat } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' import { createMCPClients } from '@tanstack/ai-mcp' // Each key becomes the default prefix for that server's tools. @@ -562,6 +607,7 @@ await using pool = await createMCPClients({ // Tools auto-prefixed: 'github_search_repos', 'linear_create_issue', etc. const tools = await pool.tools() +const messages = [{ role: 'user' as const, content: 'Open an issue for #42' }] const stream = chat({ adapter: openaiText('gpt-5.5'), messages, tools }) ``` @@ -578,9 +624,18 @@ cancelled automatically. You can also forward it from your own server tools: ```typescript -const longRunningTool = myToolDef.server(async (args, ctx) => { +import { toolDefinition } from '@tanstack/ai' +import { z } from 'zod' + +const fetchReportDef = toolDefinition({ + name: 'fetch_report', + description: 'Fetch a report from the slow reporting API', + inputSchema: z.object({ reportId: z.string() }), +}) + +const fetchReport = fetchReportDef.server(async ({ reportId }, ctx) => { // Forward to fetch, a DB query, or an MCP callTool call. - const response = await fetch('https://slow.api/data', { + const response = await fetch(`https://slow.api/reports/${reportId}`, { signal: ctx?.abortSignal, }) return response.json() @@ -639,39 +694,33 @@ Instead of manually calling `client.tools()` and managing `close()`, pass an **Example:** ```typescript -import { createFileRoute } from '@tanstack/react-router' +// api/chat/route.ts import { chat, toServerSentEventsResponse } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' import { createMCPClient } from '@tanstack/ai-mcp' -export const Route = createFileRoute('/api/chat')({ - server: { - handlers: { - POST: async ({ request }) => { - const { messages } = await request.json() - - const mcpClient = await createMCPClient({ - transport: { type: 'http', url: 'https://mcp.example.com/mcp' }, - }) - - const stream = chat({ - adapter: openaiText('gpt-5.5'), - messages, - mcp: { - clients: [mcpClient], - connection: 'keep-alive', - onDiscoveryError: (err, source) => { - console.warn('MCP discovery failed, skipping source:', err) - // returning (not throwing) skips this source and continues - }, - }, - }) - - return toServerSentEventsResponse(stream) +export async function POST(request: Request) { + const { messages } = await request.json() + + const mcpClient = await createMCPClient({ + transport: { type: 'http', url: 'https://mcp.example.com/mcp' }, + }) + + const stream = chat({ + adapter: openaiText('gpt-5.5'), + messages, + mcp: { + clients: [mcpClient], + connection: 'keep-alive', + onDiscoveryError: (err) => { + console.warn('MCP discovery failed, skipping source:', err) + // returning (not throwing) skips this source and continues }, }, - }, -}) + }) + + return toServerSentEventsResponse(stream) +} ``` ## Provider Skills @@ -763,23 +812,61 @@ Server tools need `chat({ tools })`. Client tools need their definition in Wrong -- tool only on server, client cannot execute: -```typescript +```tsx group=tool-wiring +import { chat, toolDefinition } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { useChat, fetchServerSentEvents } from '@tanstack/ai-react' +import { clientTools } from '@tanstack/ai-client' +import { z } from 'zod' + +const myToolDef = toolDefinition({ + name: 'my_tool', + description: 'Example client-executed tool', + inputSchema: z.object({ id: z.string() }), + outputSchema: z.object({ success: z.boolean() }), +}) +const adapter = openaiText('gpt-5.5') +const messages = [{ role: 'user' as const, content: 'Run my tool' }] + +// server chat({ adapter, messages, tools: [myToolDef] }) -useChat({ connection: fetchServerSentEvents('/api/chat') }) // no tools +// client +function ChatServerOnly() { + useChat({ connection: fetchServerSentEvents('/api/chat') }) // no tools + return null +} ``` Wrong -- tool only on client, LLM does not know about it: -```typescript -chat({ adapter, messages }); // no tools -useChat({ ..., tools: clientTools(myToolDef.client(() => result)) }); +```tsx group=tool-wiring +// server +chat({ adapter, messages }) // no tools +// client +function ChatClientOnly() { + useChat({ + connection: fetchServerSentEvents('/api/chat'), + tools: clientTools(myToolDef.client(() => ({ success: true }))), + }) + return null +} ``` Correct: -```typescript -chat({ adapter, messages, tools: [myToolDef] }); -useChat({ ..., tools: clientTools(myToolDef.client((input) => ({ success: true }))) }); +```tsx group=tool-wiring +// server +chat({ adapter, messages, tools: [myToolDef] }) +// client +function ChatWired() { + useChat({ + connection: fetchServerSentEvents('/api/chat'), + tools: clientTools( + myToolDef.client((input) => ({ success: input.id !== '' })), + ), + }) + return null +} ``` Source: docs/tools/tools.md