Ai Core/Tool Calling logo

Ai Core/Tool Calling

OrganizationPopular
TanStack
ai-core/tool-calling

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 bound interrupts (resolveInterrupt), generic middleware interrupts with defineInterrupt(), lazy tool discovery with lazy:true, rendering ToolCallPart and ToolResultPart in UI.

Overview

PublisherTanStack
Repositoryai
Skill nameai-core/tool-calling
Stars
3.1K
Forks
330
Bundled files
Instructions only
LicenseMIT
Links
  • Markdown instructions

    A SKILL.md file the model loads on demand, so it only costs tokens when a request actually matches.

  • Works with any LLM

    AI skills are plain Markdown, not provider-specific code, so this works with GPT, Claude, Gemini, Grok, or a local model.

  • Self-contained

    Everything the model needs lives in the instructions — no extra files to sync.

  • Open source

    Published by TanStack on GitHub. Read the source before you install it.

Installation

Install the Ai Core/Tool Calling AI skill in TypingMind to use it with any LLM, or drop it into another agent that reads SKILL.md.

1

Install in TypingMind

TypingMind installs a skill straight from its GitHub folder — it reads SKILL.md, bundles the resource files, and stores the result locally.

  1. Open the app and go to Plugins → Skills.
  2. Choose "Install from GitHub".
  3. Paste the skill folder URL below and confirm.
  4. Enable the skill in any chat where you want it available.
Plugins → Skills → Add skill → From GitHub URL, then paste the folder URL and press Continue.
2

Install in another agent

Any agent that reads the Agent Skills format can use this skill — copy the folder into that agent's skills directory.

Claude Code — .claude/skills
git clone --depth 1 https://github.com/TanStack/ai.git /tmp/ai
mkdir -p .claude/skills
cp -r /tmp/ai/packages/ai/skills/ai-core/tool-calling .claude/skills/tanstack-ai-core-tool-calling
Restart Claude Code after copying so it picks up the new skill.

Use it in TypingMind

Enable Ai Core/Tool Calling in any TypingMind chat and the model takes it from there. Its name and description sit in the system prompt, and the moment a request matches, the model loads the full instructions itself — you never invoke it by hand, and it costs no tokens until it is actually used.

The model loads Ai Core/Tool Calling on its own as soon as a request matches it.

Works with any AI model

AI skills are plain Markdown instructions rather than provider-specific code, so Ai Core/Tool Calling is not tied to the model it was written for. Install it once in TypingMind and use it with GPT-5, Claude, Gemini, Grok, DeepSeek, Mistral, Llama, or a local model you run yourself — all on your own API keys.

  • Loaded only when it is needed

    The system prompt carries just the name and description. The instructions are fetched on the first matching request, so an idle skill costs nothing.

  • Switch models mid-chat

    Because the skill is instructions rather than code, changing model does not break it — the next model reads the same SKILL.md.

Skill instructions

This is the SKILL.md content the model loads. Read it before installing — a skill is instructions your model will follow.

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. The four files below share one scope, so later files use the earlier exports directly.

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 (uses getProductsDef from tools/definitions.ts)
import { db } from './db'

export const getProducts = getProductsDef.server(async ({ query, limit }) => {
  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 (uses getProducts and updateCartUIDef from 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.5'),
    messages,
    tools: [getProducts, updateCartUIDef], // server tool + client definition
  })
  return toServerSentEventsResponse(stream)
}
tsx
// app/chat.tsx (uses updateCartUIDef from tools/definitions.ts)
import {
  useChat,
  fetchServerSentEvents,
  createChatClientOptions,
  type InferChatMessages,
} from '@tanstack/ai-react'
import { clientTools } from '@tanstack/ai-client'
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 { 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',
  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:
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

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

typescript
// tools/definitions.ts
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):

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

tsx
// app/chat.tsx (uses showNotificationDef from tools/definitions.ts)
import {
  useChat,
  fetchServerSentEvents,
  createChatClientOptions,
} from '@tanstack/ai-react'
import { clientTools } from '@tanstack/ai-client'
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 with RUN_FINISHED.outcome.type === 'interrupt'. The primary client API is bound interrupts + resolveInterrupt / resolveInterrupts / cancel. addToolApprovalResponse and pendingInterrupts remain as deprecated compatibility shims during migration.

typescript
// tools/email.ts
import { toolDefinition } from '@tanstack/ai'
import { z } from 'zod'
import { emailService } from './email-service'

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

Server route must forward resume / parentRunId (via chatParamsFromRequest or equivalent). Client -- render bound interrupts:

tsx
// 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'),
    tools: [sendEmailDef],
  })

  return (
    <div>
      {interrupts.map((interrupt) => {
        if (interrupt.kind !== 'tool-approval') return null
        return (
          <div key={interrupt.id}>
            <p>Approve "{interrupt.toolName}"?</p>
            <pre>{JSON.stringify(interrupt.originalArgs, null, 2)}</pre>
            <button onClick={() => interrupt.resolveInterrupt(true)}>
              Approve
            </button>
            <button onClick={() => interrupt.resolveInterrupt(false)}>
              Deny
            </button>
            <button onClick={() => interrupt.cancel()}>Cancel</button>
          </div>
        )
      })}
      {messages.map((msg) => (
        <div key={msg.id}>
          {msg.parts.map((part) =>
            part.type === 'text' ? (
              <p key={part.content}>{part.content}</p>
            ) : null,
          )}
        </div>
      ))}
    </div>
  )
}

Batch all pending approvals with resolveInterrupts (void — submission is async; watch resuming / interruptErrors):

tsx
function ApproveAllButton() {
  const { resolveInterrupts, resuming } = useChat({
    connection: fetchServerSentEvents('/api/chat'),
    tools: [sendEmailDef],
  })

  // Payloadless tool-approvals only
  const approveAll = () => resolveInterrupts(true)

  // Or per-item:
  const approveEach = () =>
    resolveInterrupts((interrupt) => {
      if (interrupt.kind === 'tool-approval') {
        interrupt.resolveInterrupt(true)
      }
    })

  return (
    <>
      <button disabled={resuming} onClick={approveAll}>
        Approve all
      </button>
      <button disabled={resuming} onClick={approveEach}>
        Approve each
      </button>
    </>
  )
}

Migration: pendingInterrupts aliases interrupts; addToolApprovalResponse forwards to the matching bound approval when present. Prefer the bound methods above for new code. See docs/interrupts/.

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.

typescript
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',
  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],
    // maxIterations bounds model turns, not tool calls. For tool budgets,
    // use middleware onBeforeToolCall + onShouldContinue (see agentic-cycle docs).
    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:

typescript
// Same tools as the route above, with a richer discovery catalog:
export function chatWithCatalog(messages: Array<ModelMessage>) {
  return chat({
    adapter: openaiText('gpt-5.5'),
    messages,
    tools: [getProducts, compareProducts],
    agentLoopStrategy: maxIterations(20),
    lazyToolsConfig: { includeDescription: 'first-sentence' },
  })
}

includeDescription values:

ValueCatalog entryWhen to use
'none' (default)compareProductsSmallest 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

typescript
// api/chat/route.ts
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()

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

typescript
import { chat, toolDefinition } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
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 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.
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 messages = [{ role: 'user' as const, content: 'Open an issue for #42' }]
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:

typescript
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/reports/${reportId}`, {
    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)

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

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

ApproachUse 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:

typescript
// api/chat/route.ts
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 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

Not to be confused with @tanstack/ai-code-mode-snippets, whose snippets are TypeScript functions your application generates and runs in its own Code Mode sandbox (a local JS isolate). 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:

typescript
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-6'),
    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:

typescript
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.5'),
    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:

tsx
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] })
// client
function ChatServerOnly() {
  useChat({ connection: fetchServerSentEvents('/api/chat') }) // no tools
  return null
}

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

tsx
// server
chat({ adapter, messages }) // no tools
// client
function ChatClientOnly() {
  useChat({
    connection: fetchServerSentEvents('/api/chat'),
    tools: clientTools(myToolDef.client(() => ({ success: true }))),
  })
  return null
}

Correct:

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

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

Frequently asked questions

What does the Ai Core/Tool Calling AI skill do?

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 bound interrupts (resolveInterrupt), generic middleware interrupts with defineInterrupt(), lazy tool discovery with lazy:true, rendering ToolCallPart and ToolResultPart in UI.

Why use Ai Core/Tool Calling on TypingMind?

Because you install it once and use it with any model. Ai Core/Tool Calling is plain Markdown rather than provider-specific code, so the same skill runs on GPT-5, Claude, Gemini, Grok, or a local model — and you can switch model mid-chat without it breaking. TypingMind runs on your own API keys, so you pay providers directly instead of a per-seat subscription, and your skills and chats stay in your own storage.

How do I install Ai Core/Tool Calling in TypingMind?

Open Plugins → Skills → Install from GitHub in TypingMind and paste https://github.com/TanStack/ai/tree/main/packages/ai/skills/ai-core/tool-calling. TypingMind reads its SKILL.md and installs it as a skill you can enable per chat.

Which AI models can use Ai Core/Tool Calling?

Any model you connect in TypingMind. AI skills are plain Markdown instructions rather than provider-specific code, so GPT, Claude, Gemini, Grok, and local models can all load this skill when a request matches it.

How many AI models can I use with Ai Core/Tool Calling?

As many as you like. As long as a model supports skills, you can use Ai Core/Tool Calling with it — GPT, Claude, Gemini, Grok, DeepSeek, Mistral, Llama and more — all on TypingMind with your own API keys.

Is the Ai Core/Tool Calling AI skill free?

Yes. It is published on GitHub by TanStack under the MIT license. You only pay your own AI provider for the tokens you use.

What are AI skills?

An AI skill is a reusable instruction bundle that teaches an AI model how to do one specific task. It follows the open Agent Skills format: a SKILL.md file with a name and description, plus any scripts, templates or reference files the model may need. The model reads the instructions only when your request matches the skill, so an installed skill costs nothing until it is used.

How are AI skills different from plugins or MCP servers?

A plugin or MCP server gives a model new tools to call — code that runs somewhere and returns a result. An AI skill gives the model knowledge and process instead: how to approach a task, which steps to follow, what good output looks like. Skills are plain Markdown, so they need no server, no API key and no runtime, and they work with any model.

View all

Set up your own AI workspace now

Get notified about new features and future giveaways by subscribing to our newsletter 👇