Skip to main content
ClaudeWave
Skill3.1k repo starsupdated today

ai-core/tool-calling

This Claude Code skill enables server-side and client-side tool calling in AI applications by providing utilities to define, implement, and coordinate tool execution across server and browser boundaries. Use it when building chat interfaces that need to execute backend functions (like database queries) or update frontend state based on AI model decisions, with full type safety through Zod schemas.

Install in Claude Code
Copy
git clone --depth 1 https://github.com/TanStack/ai /tmp/ai-core-tool-calling && cp -r /tmp/ai-core-tool-calling/packages/ai/skills/ai-core/tool-calling ~/.claude/skills/ai-core-tool-calling
Then start a new Claude Code session; the skill loads automatically.

SKILL.md

# 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.

```typescript
// 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() }),
})
```

```typescript
// 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 })),
  }
})
```

```typescript
// 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)
}
```

```typescript
// 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,
  });
  const { messages, sendMessage } = useChat(chatOptions);
  // InferChatMessages ties part types to the configured tools when needed:
  // type Messages = InferChatMessages<typeof chatOptions>

  return (
    <div>
      <span>Cart: {cartCount}</span>
      {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

### Generic middleware interrupts

Use `defineInterrupt()` when middleware needs typed data from the client. This
does not replace `needsApproval`. Tool approval asks whether a tool can run.
Generic interrupts ask for application data at a chat lifecycle boundary.

Define the interrupt once. Register it with both `chat({ interrupts })` and
`useChat({ interrupts })`. Emit it only from `onInterruptBoundary`, then read
the typed result in `onInterruptResolution`.

```typescript
import { defineInterrupt, type ChatMiddleware } from '@tanstack/ai'
import { z } from 'zod'

const reviewPlan = defineInterrupt({
  id: 'review-plan',
  payloadSchema: z.object({ title: z.string() }),
  responseSchema: z.object({ approved: z.boolean() }),
})

const reviewMiddleware: ChatMiddleware<unknown, typeof reviewPlan> = {
  onInterruptBoundary(ctx) {
    if (ctx.phase !== 'beforeTools') return
    return {
      interrupts: [
        reviewPlan.interrupt({
          key: 'release-plan',
          reason: 'review-required',
          message: 'Approve this plan?',
          payload: { title: 'Release plan' },
        }),
      ],
    }
  },
  onInterruptResolution(_ctx, resumedInterrupts) {
    for (const result of resumedInterrupts.for(reviewPlan)) {
      if (result.status === 'resolved' && !result.response.approved) {
        return { toolResume: 'stop' }
      }
    }
  },
}
```

Several middleware can request generic interrupts at one boundary. They share
one AG-UI interrupt batch with tool approvals. A continuation starts only after
the client resolves or cancels every bound item. `stop` is more restrictive than
`cancel`, which is more restrictive than `continue`.

Do not emit raw AG-UI interrupt events from middleware. Use the boundary hook
so the engine creates one terminal event and persistence records the batch.

### 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.

```typescript
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