@tanstack/ai 0.9.2 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/dist/esm/activities/chat/stream/processor.js +3 -0
  2. package/dist/esm/activities/chat/stream/processor.js.map +1 -1
  3. package/dist/esm/index.d.ts +1 -0
  4. package/dist/esm/index.js +3 -0
  5. package/dist/esm/index.js.map +1 -1
  6. package/dist/esm/tool-registry.d.ts +81 -0
  7. package/dist/esm/tool-registry.js +49 -0
  8. package/dist/esm/tool-registry.js.map +1 -0
  9. package/package.json +6 -4
  10. package/skills/ai-core/SKILL.md +59 -0
  11. package/skills/ai-core/adapter-configuration/SKILL.md +283 -0
  12. package/skills/ai-core/adapter-configuration/references/anthropic-adapter.md +97 -0
  13. package/skills/ai-core/adapter-configuration/references/gemini-adapter.md +102 -0
  14. package/skills/ai-core/adapter-configuration/references/grok-adapter.md +77 -0
  15. package/skills/ai-core/adapter-configuration/references/groq-adapter.md +106 -0
  16. package/skills/ai-core/adapter-configuration/references/ollama-adapter.md +82 -0
  17. package/skills/ai-core/adapter-configuration/references/openai-adapter.md +95 -0
  18. package/skills/ai-core/adapter-configuration/references/openrouter-adapter.md +99 -0
  19. package/skills/ai-core/ag-ui-protocol/SKILL.md +232 -0
  20. package/skills/ai-core/chat-experience/SKILL.md +506 -0
  21. package/skills/ai-core/custom-backend-integration/SKILL.md +463 -0
  22. package/skills/ai-core/media-generation/SKILL.md +471 -0
  23. package/skills/ai-core/middleware/SKILL.md +336 -0
  24. package/skills/ai-core/structured-outputs/SKILL.md +203 -0
  25. package/skills/ai-core/tool-calling/SKILL.md +411 -0
  26. package/src/activities/chat/stream/processor.ts +7 -0
  27. package/src/index.ts +7 -0
  28. package/src/tool-registry.ts +150 -0
@@ -0,0 +1,411 @@
1
+ ---
2
+ name: ai-core/tool-calling
3
+ description: >
4
+ Isomorphic tool system: toolDefinition() with Zod schemas,
5
+ .server() and .client() implementations, passing tools to both
6
+ chat() on server and useChat/clientTools on client, tool approval
7
+ flows with needsApproval and addToolApprovalResponse(), lazy tool
8
+ discovery with lazy:true, rendering ToolCallPart and ToolResultPart
9
+ in UI.
10
+ type: sub-skill
11
+ library: tanstack-ai
12
+ library_version: '0.10.0'
13
+ sources:
14
+ - 'TanStack/ai:docs/tools/tools.md'
15
+ - 'TanStack/ai:docs/tools/server-tools.md'
16
+ - 'TanStack/ai:docs/tools/client-tools.md'
17
+ - 'TanStack/ai:docs/tools/tool-approval.md'
18
+ - 'TanStack/ai:docs/tools/lazy-tool-discovery.md'
19
+ ---
20
+
21
+ # Tool Calling
22
+
23
+ This skill builds on ai-core. Read it first for critical rules.
24
+
25
+ ## Setup
26
+
27
+ Complete end-to-end example: shared definition, server tool, client tool, server route, React client.
28
+
29
+ ```typescript
30
+ // tools/definitions.ts
31
+ import { toolDefinition } from '@tanstack/ai'
32
+ import { z } from 'zod'
33
+
34
+ export const getProductsDef = toolDefinition({
35
+ name: 'get_products',
36
+ description: 'Search for products in the catalog',
37
+ inputSchema: z.object({
38
+ query: z.string().meta({ description: 'Search keyword' }),
39
+ limit: z.number().optional().meta({ description: 'Max results' }),
40
+ }),
41
+ outputSchema: z.object({
42
+ products: z.array(
43
+ z.object({ id: z.string(), name: z.string(), price: z.number() }),
44
+ ),
45
+ }),
46
+ })
47
+
48
+ export const updateCartUIDef = toolDefinition({
49
+ name: 'update_cart_ui',
50
+ description: 'Update the shopping cart UI with item count',
51
+ inputSchema: z.object({ itemCount: z.number(), message: z.string() }),
52
+ outputSchema: z.object({ displayed: z.boolean() }),
53
+ })
54
+ ```
55
+
56
+ ```typescript
57
+ // tools/server.ts
58
+ import { getProductsDef } from './definitions'
59
+
60
+ export const getProducts = getProductsDef.server(async ({ query, limit }) => {
61
+ const results = await db.products.search(query, { limit: limit ?? 10 })
62
+ return {
63
+ products: results.map((p) => ({ id: p.id, name: p.name, price: p.price })),
64
+ }
65
+ })
66
+ ```
67
+
68
+ ```typescript
69
+ // api/chat/route.ts
70
+ import { chat, toServerSentEventsResponse } from '@tanstack/ai'
71
+ import { openaiText } from '@tanstack/ai-openai'
72
+ import { getProducts } from '@/tools/server'
73
+ import { updateCartUIDef } from '@/tools/definitions'
74
+
75
+ export async function POST(request: Request) {
76
+ const { messages } = await request.json()
77
+ const stream = chat({
78
+ adapter: openaiText('gpt-4o'),
79
+ messages,
80
+ tools: [getProducts, updateCartUIDef], // server tool + client definition
81
+ })
82
+ return toServerSentEventsResponse(stream)
83
+ }
84
+ ```
85
+
86
+ ```typescript
87
+ // app/chat.tsx
88
+ import {
89
+ useChat,
90
+ fetchServerSentEvents,
91
+ clientTools,
92
+ createChatClientOptions,
93
+ type InferChatMessages,
94
+ } from "@tanstack/ai-react";
95
+ import { updateCartUIDef } from "@/tools/definitions";
96
+ import { useState } from "react";
97
+
98
+ function ChatPage() {
99
+ const [cartCount, setCartCount] = useState(0);
100
+
101
+ const updateCartUI = updateCartUIDef.client((input) => {
102
+ setCartCount(input.itemCount);
103
+ return { displayed: true };
104
+ });
105
+
106
+ const tools = clientTools(updateCartUI);
107
+ const chatOptions = createChatClientOptions({
108
+ connection: fetchServerSentEvents("/api/chat"),
109
+ tools,
110
+ });
111
+ type Messages = InferChatMessages<typeof chatOptions>;
112
+
113
+ const { messages, sendMessage } = useChat(chatOptions);
114
+
115
+ return (
116
+ <div>
117
+ <span>Cart: {cartCount}</span>
118
+ {(messages as Messages).map((msg) => (
119
+ <div key={msg.id}>
120
+ {msg.parts.map((part) => {
121
+ if (part.type === "text") return <p>{part.content}</p>;
122
+ if (part.type === "tool-call") {
123
+ return <div key={part.id}>Tool: {part.name} ({part.state})</div>;
124
+ }
125
+ return null;
126
+ })}
127
+ </div>
128
+ ))}
129
+ </div>
130
+ );
131
+ }
132
+ ```
133
+
134
+ ## Core Patterns
135
+
136
+ ### Pattern 1: Server-Only Tool
137
+
138
+ Define with `toolDefinition()`, implement with `.server()`, pass to `chat({ tools })`.
139
+ The server executes it automatically. The client never runs code for this tool.
140
+
141
+ ```typescript
142
+ import { toolDefinition } from '@tanstack/ai'
143
+ import { z } from 'zod'
144
+
145
+ const getUserDataDef = toolDefinition({
146
+ name: 'get_user_data',
147
+ description: 'Look up user by ID',
148
+ inputSchema: z.object({
149
+ userId: z.string().meta({ description: "The user's ID" }),
150
+ }),
151
+ outputSchema: z.object({ name: z.string(), email: z.string() }),
152
+ })
153
+
154
+ const getUserData = getUserDataDef.server(async ({ userId }) => {
155
+ const user = await db.users.findUnique({ where: { id: userId } })
156
+ return { name: user.name, email: user.email }
157
+ })
158
+
159
+ // In your route handler:
160
+ const stream = chat({
161
+ adapter: openaiText('gpt-4o'),
162
+ messages,
163
+ tools: [getUserData],
164
+ })
165
+ ```
166
+
167
+ ### Pattern 2: Client-Only Tool
168
+
169
+ Pass the bare definition (no `.server()`) to `chat({ tools })` so the LLM knows
170
+ about it. Pass the `.client()` implementation to `useChat` via `clientTools()`.
171
+
172
+ ```typescript
173
+ import { toolDefinition } from '@tanstack/ai'
174
+ import { z } from 'zod'
175
+
176
+ export const showNotificationDef = toolDefinition({
177
+ name: 'show_notification',
178
+ description: 'Display a toast notification to the user',
179
+ inputSchema: z.object({
180
+ message: z.string(),
181
+ type: z.enum(['success', 'error', 'info']),
182
+ }),
183
+ outputSchema: z.object({ shown: z.boolean() }),
184
+ })
185
+ ```
186
+
187
+ Server -- pass definition only (no execute function):
188
+
189
+ ```typescript
190
+ const stream = chat({
191
+ adapter: openaiText('gpt-4o'),
192
+ messages,
193
+ tools: [showNotificationDef],
194
+ })
195
+ ```
196
+
197
+ Client -- pass `.client()` implementation:
198
+
199
+ ```typescript
200
+ import {
201
+ useChat,
202
+ fetchServerSentEvents,
203
+ clientTools,
204
+ createChatClientOptions,
205
+ } from "@tanstack/ai-react";
206
+ import { showNotificationDef } from "@/tools/definitions";
207
+ import { useState } from "react";
208
+
209
+ function ChatPage() {
210
+ const [toast, setToast] = useState<string | null>(null);
211
+
212
+ const showNotification = showNotificationDef.client((input) => {
213
+ setToast(input.message);
214
+ setTimeout(() => setToast(null), 3000);
215
+ return { shown: true };
216
+ });
217
+
218
+ const { messages, sendMessage } = useChat(
219
+ createChatClientOptions({
220
+ connection: fetchServerSentEvents("/api/chat"),
221
+ tools: clientTools(showNotification),
222
+ })
223
+ );
224
+
225
+ return (
226
+ <div>
227
+ {toast && <div className="toast">{toast}</div>}
228
+ {messages.map((msg) => (
229
+ <div key={msg.id}>
230
+ {msg.parts.map((part) =>
231
+ part.type === "text" ? <p>{part.content}</p> : null
232
+ )}
233
+ </div>
234
+ ))}
235
+ </div>
236
+ );
237
+ }
238
+ ```
239
+
240
+ ### Pattern 3: Tool with Approval Flow
241
+
242
+ Set `needsApproval: true` in the definition. Execution pauses until the client
243
+ calls `addToolApprovalResponse()`. The part has `state: "approval-requested"`
244
+ and an `approval` object with an `id`.
245
+
246
+ ```typescript
247
+ import { toolDefinition } from '@tanstack/ai'
248
+ import { z } from 'zod'
249
+
250
+ export const sendEmailDef = toolDefinition({
251
+ name: 'send_email',
252
+ description: 'Send an email to a recipient',
253
+ inputSchema: z.object({
254
+ to: z.string().email(),
255
+ subject: z.string(),
256
+ body: z.string(),
257
+ }),
258
+ outputSchema: z.object({ success: z.boolean(), messageId: z.string() }),
259
+ needsApproval: true,
260
+ })
261
+
262
+ export const sendEmail = sendEmailDef.server(async ({ to, subject, body }) => {
263
+ const result = await emailService.send({ to, subject, body })
264
+ return { success: true, messageId: result.id }
265
+ })
266
+ ```
267
+
268
+ Client -- render approval UI and respond:
269
+
270
+ ```typescript
271
+ import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
272
+
273
+ function ChatPage() {
274
+ const { messages, addToolApprovalResponse } = useChat({
275
+ connection: fetchServerSentEvents("/api/chat"),
276
+ });
277
+
278
+ return (
279
+ <div>
280
+ {messages.map((msg) => (
281
+ <div key={msg.id}>
282
+ {msg.parts.map((part) => {
283
+ if (part.type === "text") return <p>{part.content}</p>;
284
+ if (
285
+ part.type === "tool-call" &&
286
+ part.state === "approval-requested" &&
287
+ part.approval
288
+ ) {
289
+ return (
290
+ <div key={part.id}>
291
+ <p>Approve "{part.name}"?</p>
292
+ <pre>{part.arguments}</pre>
293
+ <button
294
+ onClick={() =>
295
+ addToolApprovalResponse({
296
+ id: part.approval!.id,
297
+ approved: true,
298
+ })
299
+ }
300
+ >
301
+ Approve
302
+ </button>
303
+ <button
304
+ onClick={() =>
305
+ addToolApprovalResponse({
306
+ id: part.approval!.id,
307
+ approved: false,
308
+ })
309
+ }
310
+ >
311
+ Deny
312
+ </button>
313
+ </div>
314
+ );
315
+ }
316
+ return null;
317
+ })}
318
+ </div>
319
+ ))}
320
+ </div>
321
+ );
322
+ }
323
+ ```
324
+
325
+ ### Pattern 4: Lazy Tool Discovery
326
+
327
+ Set `lazy: true` on rarely-needed tools. The LLM sees their names via a synthetic
328
+ `__lazy__tool__discovery__` tool and discovers schemas on demand. Saves tokens.
329
+
330
+ ```typescript
331
+ import {
332
+ toolDefinition,
333
+ chat,
334
+ toServerSentEventsResponse,
335
+ maxIterations,
336
+ } from '@tanstack/ai'
337
+ import { openaiText } from '@tanstack/ai-openai'
338
+ import { z } from 'zod'
339
+
340
+ const getProductsDef = toolDefinition({
341
+ name: 'getProducts',
342
+ description: 'List all products',
343
+ inputSchema: z.object({}),
344
+ outputSchema: z.array(
345
+ z.object({ id: z.number(), name: z.string(), price: z.number() }),
346
+ ),
347
+ })
348
+ const getProducts = getProductsDef.server(async () => db.products.findMany())
349
+
350
+ const compareProductsDef = toolDefinition({
351
+ name: 'compareProducts',
352
+ description: 'Compare two or more products side by side',
353
+ inputSchema: z.object({ productIds: z.array(z.number()).min(2) }),
354
+ lazy: true, // not sent to LLM upfront
355
+ })
356
+ const compareProducts = compareProductsDef.server(async ({ productIds }) => {
357
+ return db.products.findMany({ where: { id: { in: productIds } } })
358
+ })
359
+
360
+ export async function POST(request: Request) {
361
+ const { messages } = await request.json()
362
+ const stream = chat({
363
+ adapter: openaiText('gpt-4o'),
364
+ messages,
365
+ tools: [getProducts, compareProducts],
366
+ agentLoopStrategy: maxIterations(20),
367
+ })
368
+ return toServerSentEventsResponse(stream)
369
+ }
370
+ ```
371
+
372
+ The LLM sees `getProducts` and `__lazy__tool__discovery__` upfront.
373
+ To compare, it first calls `__lazy__tool__discovery__({ toolNames: ["compareProducts"] })`,
374
+ gets the full schema, then calls `compareProducts` directly.
375
+ Once discovered, a tool stays available for the conversation.
376
+ When all lazy tools are discovered, the discovery tool is removed automatically.
377
+
378
+ ## Common Mistakes
379
+
380
+ ### a. HIGH: Not passing tool definitions to both server and client
381
+
382
+ Server tools need `chat({ tools })`. Client tools need their definition in
383
+ `chat({ tools })` AND their `.client()` in `useChat({ tools: clientTools(...) })`.
384
+
385
+ Wrong -- tool only on server, client cannot execute:
386
+
387
+ ```typescript
388
+ chat({ adapter, messages, tools: [myToolDef] })
389
+ useChat({ connection: fetchServerSentEvents('/api/chat') }) // no tools
390
+ ```
391
+
392
+ Wrong -- tool only on client, LLM does not know about it:
393
+
394
+ ```typescript
395
+ chat({ adapter, messages }); // no tools
396
+ useChat({ ..., tools: clientTools(myToolDef.client(() => result)) });
397
+ ```
398
+
399
+ Correct:
400
+
401
+ ```typescript
402
+ chat({ adapter, messages, tools: [myToolDef] });
403
+ useChat({ ..., tools: clientTools(myToolDef.client((input) => ({ success: true }))) });
404
+ ```
405
+
406
+ Source: docs/tools/tools.md
407
+
408
+ ## Cross-References
409
+
410
+ - See also: ai-core/chat-experience/SKILL.md -- Tools are used within chat
411
+ - See also: `@tanstack/ai-code-mode` package skills -- Code Mode is an alternative to tools for complex multi-step operations
@@ -967,6 +967,13 @@ export class StreamProcessor {
967
967
  // Transition the tool call to input-complete (the authoritative completion signal)
968
968
  const existingToolCall = msgState.toolCalls.get(chunk.toolCallId)
969
969
  if (existingToolCall && existingToolCall.state !== 'input-complete') {
970
+ // If TOOL_CALL_END provides parsed input and no TOOL_CALL_ARGS were
971
+ // received, back-fill the arguments string so the UIMessage ToolCallPart
972
+ // carries the correct value (defensive against adapters that skip ARGS).
973
+ if (chunk.input !== undefined && !existingToolCall.arguments) {
974
+ existingToolCall.arguments = JSON.stringify(chunk.input)
975
+ }
976
+
970
977
  const index = msgState.toolCallOrder.indexOf(chunk.toolCallId)
971
978
  this.completeToolCall(messageId, index, existingToolCall)
972
979
  // If TOOL_CALL_END provides parsed input, use it as the canonical parsed
package/src/index.ts CHANGED
@@ -70,6 +70,13 @@ export {
70
70
  combineStrategies,
71
71
  } from './activities/chat/agent-loop-strategies'
72
72
 
73
+ // Tool registry
74
+ export {
75
+ createToolRegistry,
76
+ createFrozenRegistry,
77
+ type ToolRegistry,
78
+ } from './tool-registry'
79
+
73
80
  // Chat middleware
74
81
  export type {
75
82
  ChatMiddleware,
@@ -0,0 +1,150 @@
1
+ import type { Tool } from './types'
2
+
3
+ /**
4
+ * A registry that holds tools and allows dynamic tool management.
5
+ *
6
+ * The registry can be either mutable (allowing additions/removals during execution)
7
+ * or frozen (static tool list, for backward compatibility with tools arrays).
8
+ */
9
+ export interface ToolRegistry {
10
+ /**
11
+ * Get all current tools in the registry.
12
+ * Called each agent loop iteration to get the latest tool list.
13
+ */
14
+ getTools: () => ReadonlyArray<Tool>
15
+
16
+ /**
17
+ * Add a tool to the registry dynamically.
18
+ * For frozen registries, this is a no-op.
19
+ *
20
+ * @param tool - The tool to add
21
+ */
22
+ add: (tool: Tool) => void
23
+
24
+ /**
25
+ * Remove a tool from the registry by name.
26
+ * For frozen registries, this always returns false.
27
+ *
28
+ * @param name - The name of the tool to remove
29
+ * @returns true if the tool was removed, false if not found or frozen
30
+ */
31
+ remove: (name: string) => boolean
32
+
33
+ /**
34
+ * Check if a tool exists in the registry.
35
+ *
36
+ * @param name - The name of the tool to check
37
+ */
38
+ has: (name: string) => boolean
39
+
40
+ /**
41
+ * Get a tool by name.
42
+ *
43
+ * @param name - The name of the tool to get
44
+ * @returns The tool if found, undefined otherwise
45
+ */
46
+ get: (name: string) => Tool | undefined
47
+
48
+ /**
49
+ * Whether this registry is frozen (immutable).
50
+ * Frozen registries don't allow add/remove operations.
51
+ */
52
+ readonly isFrozen: boolean
53
+ }
54
+
55
+ /**
56
+ * Create a mutable tool registry for dynamic tool scenarios.
57
+ *
58
+ * Tools can be added and removed during chat execution, and the
59
+ * changes will be reflected in subsequent agent loop iterations.
60
+ *
61
+ * @param initialTools - Optional initial set of tools
62
+ * @returns A mutable ToolRegistry
63
+ *
64
+ * @example
65
+ * ```typescript
66
+ * const registry = createToolRegistry([toolA, toolB])
67
+ *
68
+ * const stream = chat({
69
+ * adapter,
70
+ * messages,
71
+ * toolRegistry: registry,
72
+ * })
73
+ *
74
+ * // Later, during tool execution:
75
+ * registry.add(newTool) // Immediately available to LLM
76
+ * ```
77
+ */
78
+ export function createToolRegistry(
79
+ initialTools: Array<Tool> = [],
80
+ ): ToolRegistry {
81
+ const tools = new Map<string, Tool>()
82
+
83
+ for (const tool of initialTools) {
84
+ tools.set(tool.name, tool)
85
+ }
86
+
87
+ return {
88
+ getTools: () => Array.from(tools.values()),
89
+
90
+ add: (tool: Tool) => {
91
+ tools.set(tool.name, tool)
92
+ },
93
+
94
+ remove: (name: string) => {
95
+ return tools.delete(name)
96
+ },
97
+
98
+ has: (name: string) => {
99
+ return tools.has(name)
100
+ },
101
+
102
+ get: (name: string) => {
103
+ return tools.get(name)
104
+ },
105
+
106
+ isFrozen: false,
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Create a frozen (immutable) tool registry from a tools array.
112
+ *
113
+ * This is used internally to wrap static `tools` arrays for backward compatibility.
114
+ * Add and remove operations are no-ops on frozen registries.
115
+ *
116
+ * @param tools - The static array of tools
117
+ * @returns A frozen ToolRegistry
118
+ */
119
+ export function createFrozenRegistry(tools: Array<Tool> = []): ToolRegistry {
120
+ const toolMap = new Map<string, Tool>()
121
+
122
+ for (const tool of tools) {
123
+ toolMap.set(tool.name, tool)
124
+ }
125
+
126
+ const frozenTools = Object.freeze([...tools])
127
+
128
+ return {
129
+ getTools: () => frozenTools,
130
+
131
+ add: (_tool: Tool) => {
132
+ // No-op for frozen registry
133
+ },
134
+
135
+ remove: (_name: string) => {
136
+ // No-op for frozen registry
137
+ return false
138
+ },
139
+
140
+ has: (name: string) => {
141
+ return toolMap.has(name)
142
+ },
143
+
144
+ get: (name: string) => {
145
+ return toolMap.get(name)
146
+ },
147
+
148
+ isFrozen: true,
149
+ }
150
+ }