Agent SkillsTanStack/ai › ai-core/tool-calling

ai-core/tool-calling

GitHub

提供类型安全的工具调用系统,支持Zod Schema定义、服务端与客户端实现。新增useChat中工具调用部分的类型推断、解析后的输入字段填充,以及基于needsApproval的审批流程门控,提升开发体验与安全性。

packages/ai/skills/ai-core/tool-calling/SKILL.md TanStack/ai

触发场景

需要定义和使用AI工具进行函数调用 希望在React/Vue等前端框架中安全地处理工具调用状态 需要实现带有审批机制的工具执行流程

安装

npx skills add TanStack/ai --skill ai-core/tool-calling -g -y
更多选项

非标准路径

npx skills add https://github.com/TanStack/ai/tree/main/packages/ai/skills/ai-core/tool-calling -g -y

不安装直接使用

npx skills use TanStack/ai@ai-core/tool-calling

指定 Agent (Claude Code)

npx skills add TanStack/ai --skill ai-core/tool-calling -a claude-code -g -y

安装 repo 全部 skill

npx skills add TanStack/ai --all -g -y

预览 repo 内 skill

npx skills add TanStack/ai --list

SKILL.md

Frontmatter
{
    "name": "ai-core\/tool-calling",
    "type": "sub-skill",
    "library": "tanstack-ai",
    "sources": [
        "TanStack\/ai:docs\/tools\/tools.md",
        "TanStack\/ai:docs\/tools\/server-tools.md",
        "TanStack\/ai:docs\/tools\/client-tools.md",
        "TanStack\/ai:docs\/tools\/tool-approval.md",
        "TanStack\/ai:docs\/tools\/lazy-tool-discovery.md"
    ],
    "description": "Isomorphic tool system: toolDefinition() with Zod schemas, .server() and .client() implementations, passing tools to both chat() on server and useChat\/clientTools on client, tool approval flows with needsApproval and addToolApprovalResponse(), lazy tool discovery with lazy:true, rendering ToolCallPart and ToolResultPart in UI.\n",
    "library_version": "0.10.0"
}

Tool Calling

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.

// tools/definitions.ts
import { toolDefinition } from '@tanstack/ai'
import { z } from 'zod'

export const getProductsDef = toolDefinition({
  name: 'get_products',
  description: 'Search for products in the catalog',
  inputSchema: z.object({
    query: z.string().meta({ description: 'Search keyword' }),
    limit: z.number().optional().meta({ description: 'Max results' }),
  }),
  outputSchema: z.object({
    products: z.array(
      z.object({ id: z.string(), name: z.string(), price: z.number() }),
    ),
  }),
})

export const updateCartUIDef = toolDefinition({
  name: 'update_cart_ui',
  description: 'Update the shopping cart UI with item count',
  inputSchema: z.object({ itemCount: z.number(), message: z.string() }),
  outputSchema: z.object({ displayed: z.boolean() }),
})
// tools/server.ts
import { getProductsDef } from './definitions'

export const getProducts = getProductsDef.server(async ({ query, limit }) => {
  const results = await db.products.search(query, { limit: limit ?? 10 })
  return {
    products: results.map((p) => ({ id: p.id, name: p.name, price: p.price })),
  }
})
// api/chat/route.ts
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()
  const stream = chat({
    adapter: openaiText('gpt-5.5'),
    messages,
    tools: [getProducts, updateCartUIDef], // server tool + client definition
  })
  return toServerSentEventsResponse(stream)
}
// app/chat.tsx
import {
  useChat,
  fetchServerSentEvents,
  clientTools,
  createChatClientOptions,
  type InferChatMessages,
} from "@tanstack/ai-react";
import { updateCartUIDef } from "@/tools/definitions";
import { useState } from "react";

function ChatPage() {
  const [cartCount, setCartCount] = useState(0);

  const updateCartUI = updateCartUIDef.client((input) => {
    setCartCount(input.itemCount);
    return { displayed: true };
  });

  const tools = clientTools(updateCartUI);
  const chatOptions = createChatClientOptions({
    connection: fetchServerSentEvents("/api/chat"),
    tools,
  });
  type Messages = InferChatMessages<typeof chatOptions>;

  const { messages, sendMessage } = useChat(chatOptions);

  return (
    <div>
      <span>Cart: {cartCount}</span>
      {(messages as Messages).map((msg) => (
        <div key={msg.id}>
          {msg.parts.map((part) => {
            if (part.type === "text") return <p>{part.content}</p>;
            if (part.type === "tool-call") {
              return <div key={part.id}>Tool: {part.name} ({part.state})</div>;
            }
            return null;
          })}
        </div>
      ))}
    </div>
  );
}

Core Patterns

Pattern 1: Server-Only Tool

Define with toolDefinition(), implement with .server(), pass to chat({ tools }). The server executes it automatically. The client never runs code for this tool.

import { toolDefinition } from '@tanstack/ai'
import { z } from 'zod'

const getUserDataDef = toolDefinition({
  name: 'get_user_data',
  description: 'Look up user by ID',
  inputSchema: z.object({
    userId: z.string().meta({ description: "The user's ID" }),
  }),
  outputSchema: z.object({ name: z.string(), email: z.string() }),
})

const getUserData = getUserDataDef.server(async ({ userId }) => {
  const user = await db.users.findUnique({ where: { id: userId } })
  return { name: user.name, email: user.email }
})

// In your route handler:
const stream = chat({
  adapter: openaiText('gpt-5.5'),
  messages,
  tools: [getUserData],
})

Pattern 2: Client-Only Tool

Pass the bare definition (no .server()) to chat({ tools }) so the LLM knows about it. Pass the .client() implementation to useChat via clientTools().

import { toolDefinition } from '@tanstack/ai'
import { z } from 'zod'

export const showNotificationDef = toolDefinition({
  name: 'show_notification',
  description: 'Display a toast notification to the user',
  inputSchema: z.object({
    message: z.string(),
    type: z.enum(['success', 'error', 'info']),
  }),
  outputSchema: z.object({ shown: z.boolean() }),
})

Server -- pass definition only (no execute function):

const stream = chat({
  adapter: openaiText('gpt-5.5'),
  messages,
  tools: [showNotificationDef],
})

Client -- pass .client() implementation:

import {
  useChat,
  fetchServerSentEvents,
  clientTools,
  createChatClientOptions,
} from "@tanstack/ai-react";
import { showNotificationDef } from "@/tools/definitions";
import { useState } from "react";

function ChatPage() {
  const [toast, setToast] = useState<string | null>(null);

  const showNotification = showNotificationDef.client((input) => {
    setToast(input.message);
    setTimeout(() => setToast(null), 3000);
    return { shown: true };
  });

  const { messages, sendMessage } = useChat(
    createChatClientOptions({
      connection: fetchServerSentEvents("/api/chat"),
      tools: clientTools(showNotification),
    })
  );

  return (
    <div>
      {toast && <div className="toast">{toast}</div>}
      {messages.map((msg) => (
        <div key={msg.id}>
          {msg.parts.map((part) =>
            part.type === "text" ? <p>{part.content}</p> : null
          )}
        </div>
      ))}
    </div>
  );
}

Pattern 3: Tool with Approval Flow

Set needsApproval: true in the definition. Execution pauses until the client calls addToolApprovalResponse(). The part has state: "approval-requested" and an approval object with an id.

import { toolDefinition } from '@tanstack/ai'
import { z } from 'zod'

export const sendEmailDef = toolDefinition({
  name: 'send_email',
  description: 'Send an email to a recipient',
  inputSchema: z.object({
    to: z.string().email(),
    subject: z.string(),
    body: z.string(),
  }),
  outputSchema: z.object({ success: z.boolean(), messageId: z.string() }),
  needsApproval: true,
})

export const sendEmail = sendEmailDef.server(async ({ to, subject, body }) => {
  const result = await emailService.send({ to, subject, body })
  return { success: true, messageId: result.id }
})

Client -- render approval UI and respond:

import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";

function ChatPage() {
  const { messages, addToolApprovalResponse } = useChat({
    connection: fetchServerSentEvents("/api/chat"),
  });

  return (
    <div>
      {messages.map((msg) => (
        <div key={msg.id}>
          {msg.parts.map((part) => {
            if (part.type === "text") return <p>{part.content}</p>;
            if (
              part.type === "tool-call" &&
              part.state === "approval-requested" &&
              part.approval
            ) {
              return (
                <div key={part.id}>
                  <p>Approve "{part.name}"?</p>
                  {/* `part.input` is the parsed, typed object (populated once
                      the arguments are complete, as they are at approval
                      time); `part.arguments` remains the raw JSON string. */}
                  <pre>{JSON.stringify(part.input, null, 2)}</pre>
                  <button
                    onClick={() =>
                      addToolApprovalResponse({
                        id: part.approval!.id,
                        approved: true,
                      })
                    }
                  >
                    Approve
                  </button>
                  <button
                    onClick={() =>
                      addToolApprovalResponse({
                        id: part.approval!.id,
                        approved: false,
                      })
                    }
                  >
                    Deny
                  </button>
                </div>
              );
            }
            return null;
          })}
        </div>
      ))}
    </div>
  );
}

Type-safe approval: With typed tools, part.approval exists only on parts for tools defined with needsApproval: true. Tools without approval have no approval field (reading it is a compile error). For a tool-agnostic handler over a typed union, narrow with 'approval' in part (if (part.type === 'tool-call' && 'approval' in part && part.approval)), or type a shared component against the base ToolCallPart. An untyped useChat() keeps approval on every tool-call part, which is why the snippet above (no tools generic) reads it directly.

Pattern 4: Lazy Tool Discovery

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.

import {
  toolDefinition,
  chat,
  toServerSentEventsResponse,
  maxIterations,
} from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { z } from 'zod'

const getProductsDef = toolDefinition({
  name: 'getProducts',
  description: 'List all products',
  inputSchema: z.object({}),
  outputSchema: z.array(
    z.object({ id: z.number(), name: z.string(), price: z.number() }),
  ),
})
const getProducts = getProductsDef.server(async () => db.products.findMany())

const compareProductsDef = toolDefinition({
  name: 'compareProducts',
  description: 'Compare two or more products side by side',
  inputSchema: z.object({ productIds: z.array(z.number()).min(2) }),
  lazy: true, // not sent to LLM upfront
})
const compareProducts = compareProductsDef.server(async ({ productIds }) => {
  return db.products.findMany({ where: { id: { in: productIds } } })
})

export async function POST(request: Request) {
  const { messages } = await request.json()
  const stream = chat({
    adapter: openaiText('gpt-5.5'),
    messages,
    tools: [getProducts, compareProducts],
    agentLoopStrategy: maxIterations(20),
  })
  return toServerSentEventsResponse(stream)
}

The LLM sees getProducts and __lazy__tool__discovery__ upfront. To compare, it first calls __lazy__tool__discovery__({ toolNames: ["compareProducts"] }), gets the full schema, then calls compareProducts directly. Once discovered, a tool stays available for the conversation. When all lazy tools are discovered, the discovery tool is removed automatically.

Tuning the lazy catalog with lazyToolsConfig

By default the discovery-tool catalog lists only bare names ('none'). Pass lazyToolsConfig to chat() to include more context:

const stream = chat({
  adapter: openaiText('gpt-5.5'),
  messages,
  tools: [getProducts, compareProducts],
  agentLoopStrategy: maxIterations(20),
  lazyToolsConfig: { includeDescription: 'first-sentence' },
})

includeDescription values:

Value Catalog entry When to use
'none' (default) compareProducts Smallest prompt; model discovers by name
'first-sentence' compareProducts — Compare two or more products side by side. Helps the model decide whether to discover without extra tokens
'full' compareProducts — Compare two or more products side by side. Accepts productIds array. Use when descriptions are short or the model needs full context to route correctly

The post-discovery payload always returns the full description and schema regardless of this setting.

MCP Tools

@tanstack/ai-mcp lets a server-side chat() call discover and invoke tools hosted on any MCP server (Streamable HTTP, SSE, or stdio).

MCP tools and UI resources: When an MCP tool result carries a ui:// resource URI (via _meta.ui.resourceUri), TanStack AI surfaces it as a UIResourcePart on the assistant UIMessage in the client message list. UIResourcePart is a presentational-only part — it never enters model input. See the @tanstack/ai-mcp skill for the full MCP Apps API (createMcpAppCallHandler, createMcpAppBridge, MCPAppResource).

Basic usage — auto-discovery

// src/routes/api.chat.ts
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()

        // 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

Pass bare toolDefinition() instances (no .server()) to client.tools([...]). The MCP client supplies a callTool proxy as the execute function, while input/output validation and types come from the definitions' Zod schemas.

import { toolDefinition } from '@tanstack/ai'
import { createMCPClient } from '@tanstack/ai-mcp'
import { z } from 'zod'

const getWeather = toolDefinition({
  name: 'get_weather',
  description: 'Current weather for a city',
  inputSchema: z.object({ city: z.string() }),
  outputSchema: z.object({ temperature: z.number(), conditions: z.string() }),
})

const mcp = await createMCPClient({
  transport: { type: 'http', url: 'https://mcp.example.com/mcp' },
})

// Returns ServerTool[] typed to the definitions' input/output schemas.
// Throws MCPToolNotFoundError if the server does not expose a tool with that name.
const tools = await mcp.tools([getWeather])

const stream = chat({ adapter: openaiText('gpt-5.5'), messages, tools })

Multiple servers with createMCPClients

import { createMCPClients } from '@tanstack/ai-mcp'

// Each key becomes the default prefix for that server's tools.
await using pool = await createMCPClients({
  github: { transport: { type: 'http', url: 'https://mcp.github.com/mcp' } },
  linear: { transport: { type: 'http', url: 'https://mcp.linear.app/mcp' } },
})

// Tools auto-prefixed: 'github_search_repos', 'linear_create_issue', etc.
const tools = await pool.tools()

const stream = chat({ adapter: openaiText('gpt-5.5'), messages, tools })

Use pool.clients.<name> for typed per-server access (resources, prompts, typed tools([defs]) overload).

ToolExecutionContext.abortSignal — cancelling long-running tools

Every server tool's execute function now receives abortSignal in its context. When the chat run aborts (e.g. the client disconnects or calls the run's abortController), the signal fires and any in-flight callTool call is cancelled automatically.

You can also forward it from your own server tools:

const longRunningTool = myToolDef.server(async (args, ctx) => {
  // Forward to fetch, a DB query, or an MCP callTool call.
  const response = await fetch('https://slow.api/data', {
    signal: ctx?.abortSignal,
  })
  return response.json()
})

MCP tools wire this automatically — makeMcpExecute passes ctx?.abortSignal as the signal option to client.callTool(...), so MCP server calls cancel with the chat run without any extra code.

stdio transport (Node-only)

import { createMCPClient } from '@tanstack/ai-mcp'
import { stdioTransport } from '@tanstack/ai-mcp/stdio'

const mcp = await createMCPClient({
  transport: stdioTransport({ command: 'npx', args: ['-y', 'my-mcp-server'] }),
})

Import stdioTransport from the /stdio subpath only — it contains Node.js child_process imports and must not be bundled for edge runtimes.

chat({ mcp }) — discovery + lifecycle in one prop

Instead of manually calling client.tools() and managing close(), pass an mcp object and let chat() handle discovery and lifecycle.

// Prop shape (ChatMCPOptions):
// mcp: {
//   clients: Array<MCPClient | MCPClients>,
//   connection?: 'close' | 'keep-alive',  // default: 'close'
//   lazyTools?: boolean,
//   onDiscoveryError?: (error: unknown, source) => void,
// }
  • At run start, chat() calls .tools() on every entry in clients and merges the results — identical to spreading await client.tools() into tools: [...].
  • lazyTools: true is forwarded to tools({ lazy: true }).
  • onDiscoveryError: throw to fail-fast; return to skip that source.
  • connection: 'close' (default) closes each client when the run ends (after the agent loop completes and the stream is drained). With 'keep-alive', chat() never closes the clients — the caller owns their lifecycle (keep connections warm across requests).

When to use mcp vs. the tools spread:

Approach Use when
chat({ mcp: { clients: [...] } }) Convenience: discovery + lifecycle in one place; untyped tool args are acceptable
tools: [...await client.tools([toolDefinition(...)])] Fully-typed tool args/results via Zod schemas

Example:

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' },
        })

        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)
      },
    },
  },
})

Provider Skills

Not to be confused with @tanstack/ai-code-mode-skills, which are locally-generated TypeScript functions executed client-side. Provider Skills are hosted, provider-managed bundles that the model loads on demand and runs inside the provider's server-side sandbox.

Provider Skills are inert without an execution tool. The execution tool is what activates the sandbox; skills are additional capability bundles that run inside it:

  • Anthropic: skills require the code_execution tool (@tanstack/ai-anthropic/tools).
  • OpenAI: skills live inside the shell tool (@tanstack/ai-openai/tools) and are Responses API only.

Anthropic: codeExecutionTool with skills

Import from @tanstack/ai-anthropic/tools:

import { codeExecutionTool } from '@tanstack/ai-anthropic/tools'
import { chat, toServerSentEventsResponse } from '@tanstack/ai'
import { anthropicText } from '@tanstack/ai-anthropic'

export async function POST(request: Request) {
  const { messages } = await request.json()
  const stream = chat({
    adapter: anthropicText('claude-sonnet-4-5'),
    messages,
    tools: [
      codeExecutionTool(
        { type: 'code_execution_20250825', name: 'code_execution' },
        {
          skills: [{ type: 'anthropic', skill_id: 'pptx', version: 'latest' }],
        },
      ),
    ],
  })
  return toServerSentEventsResponse(stream)
}

AnthropicContainerSkill shape: { type: 'anthropic' | 'custom'; skill_id: string; version?: string }. Constraints: max 8 skills per request; skill_id must be 1–64 characters.

The adapter automatically:

  • Lifts the skills into the request's top-level container.skills param (the shape Anthropic's API requires).
  • Attaches the required beta headers (code-execution-2025-08-25 plus skills-2025-10-02 when skills are present). You do not set these manually.

Deprecation: Setting skills via modelOptions.container.skills is deprecated. Use codeExecutionTool(config, { skills }) instead — the legacy path bypasses the beta-header wiring.

OpenAI: shellTool with skills (Responses API only)

Import from @tanstack/ai-openai/tools:

import { shellTool } from '@tanstack/ai-openai/tools'
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.2'),
    messages,
    tools: [
      shellTool({
        environment: {
          type: 'container_auto',
          skills: [
            { type: 'skill_reference', skill_id: 'skill_abc', version: '2' },
          ],
        },
      }),
    ],
  })
  return toServerSentEventsResponse(stream)
}

SkillReference shape: { type: 'skill_reference'; skill_id: string; version?: string }. version is a string — use a positive integer as a string (e.g. '2') or 'latest'. This is Responses API only; Chat Completions does not support the shell tool.

Scope

Only hosted/managed-by-id skills (type: 'anthropic' / type: 'custom' for Anthropic; type: 'skill_reference' for OpenAI) are wired. Inline bundles, local-path, and upload-API skill creation are not handled by these factories.

Common Mistakes

a. HIGH: Not passing tool definitions to both server and client

Server tools need chat({ tools }). Client tools need their definition in chat({ tools }) AND their .client() in useChat({ tools: clientTools(...) }).

Wrong -- tool only on server, client cannot execute:

chat({ adapter, messages, tools: [myToolDef] })
useChat({ connection: fetchServerSentEvents('/api/chat') }) // no tools

Wrong -- tool only on client, LLM does not know about it:

chat({ adapter, messages }); // no tools
useChat({ ..., tools: clientTools(myToolDef.client(() => result)) });

Correct:

chat({ adapter, messages, tools: [myToolDef] });
useChat({ ..., tools: clientTools(myToolDef.client((input) => ({ success: true }))) });

Source: docs/tools/tools.md

Cross-References

  • See also: ai-core/chat-experience/SKILL.md -- Tools are used within chat
  • See also: @tanstack/ai-code-mode package skills -- Code Mode is an alternative to tools for complex multi-step operations

版本历史

  • 5fcaf90 当前 2026-07-11 18:44

    新增useChat中工具调用部分的类型推断(无需as const),填充ToolCallPart的parsed input字段,并引入基于needsApproval的审批流程门控机制。

  • 5deda27 2026-07-05 10:52

同 Skill 集合

.claude/skills/gap-analysis/SKILL.md
packages/ai-code-mode/skills/ai-code-mode/SKILL.md
packages/ai-mcp/skills/ai-mcp/SKILL.md
packages/ai/skills/ai-core/ag-ui-protocol/SKILL.md
packages/ai/skills/ai-core/chat-experience/SKILL.md
packages/ai/skills/ai-core/custom-backend-integration/SKILL.md
packages/ai/skills/ai-core/debug-logging/SKILL.md
packages/ai/skills/ai-core/middleware/SKILL.md
packages/ai/skills/ai-core/SKILL.md
.claude/skills/triage-github/SKILL.md
packages/ai-sandbox/skills/ai-sandbox/SKILL.md
packages/ai/skills/ai-core/adapter-configuration/SKILL.md
packages/ai/skills/ai-core/media-generation/SKILL.md
packages/ai/skills/ai-core/structured-outputs/SKILL.md

元信息

文件数
0
版本
5fcaf90
Hash
e04c8ece
收录时间
2026-07-05 10:52

首页 - Wiki
Copyright © 2011-2026 iteam. Current version is 2.155.2. UTC+08:00, 2026-07-13 21:11
浙ICP备14020137号-1 $访客地图$