@tanstack/ai 0.10.0 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
@@ -731,6 +731,13 @@ class TextEngine<
731
731
  needsClientExecution: executionResult.needsClientExecution,
732
732
  })
733
733
 
734
+ // Build args lookup so buildToolResultChunks can emit TOOL_CALL_START +
735
+ // TOOL_CALL_ARGS before TOOL_CALL_END during continuation re-executions.
736
+ const argsMap = new Map<string, string>()
737
+ for (const tc of pendingToolCalls) {
738
+ argsMap.set(tc.id, tc.function.arguments)
739
+ }
740
+
734
741
  if (
735
742
  executionResult.needsApproval.length > 0 ||
736
743
  executionResult.needsClientExecution.length > 0
@@ -739,6 +746,7 @@ class TextEngine<
739
746
  for (const chunk of this.buildToolResultChunks(
740
747
  executionResult.results,
741
748
  finishEvent,
749
+ argsMap,
742
750
  )) {
743
751
  yield chunk
744
752
  }
@@ -765,6 +773,7 @@ class TextEngine<
765
773
  const toolResultChunks = this.buildToolResultChunks(
766
774
  executionResult.results,
767
775
  finishEvent,
776
+ argsMap,
768
777
  )
769
778
 
770
779
  for (const chunk of toolResultChunks) {
@@ -1080,12 +1089,35 @@ class TextEngine<
1080
1089
  private buildToolResultChunks(
1081
1090
  results: Array<ToolResult>,
1082
1091
  finishEvent: RunFinishedEvent,
1092
+ argsMap?: Map<string, string>,
1083
1093
  ): Array<StreamChunk> {
1084
1094
  const chunks: Array<StreamChunk> = []
1085
1095
 
1086
1096
  for (const result of results) {
1087
1097
  const content = JSON.stringify(result.result)
1088
1098
 
1099
+ // Emit TOOL_CALL_START + TOOL_CALL_ARGS before TOOL_CALL_END so that
1100
+ // the client can reconstruct the full tool call during continuations.
1101
+ if (argsMap) {
1102
+ chunks.push({
1103
+ type: 'TOOL_CALL_START',
1104
+ timestamp: Date.now(),
1105
+ model: finishEvent.model,
1106
+ toolCallId: result.toolCallId,
1107
+ toolName: result.toolName,
1108
+ })
1109
+
1110
+ const args = argsMap.get(result.toolCallId) ?? '{}'
1111
+ chunks.push({
1112
+ type: 'TOOL_CALL_ARGS',
1113
+ timestamp: Date.now(),
1114
+ model: finishEvent.model,
1115
+ toolCallId: result.toolCallId,
1116
+ delta: args,
1117
+ args,
1118
+ })
1119
+ }
1120
+
1089
1121
  chunks.push({
1090
1122
  type: 'TOOL_CALL_END',
1091
1123
  timestamp: Date.now(),