TanStack
Tools

Tools

Tools (also called "function calling") allow AI models to interact with external systems, APIs, or perform computations. TanStack AI provides an isomorphic tool system that enables type-safe, framework-agnostic tool definitions that work on both server and client.

Tools enable your AI application to:

  • Fetch data from APIs or databases
  • Perform calculations or data transformations
  • Interact with services like email, calendars, or payment systems
  • Execute client-side operations like updating UI or local storage
  • Create hybrid tools that execute in both server and client contexts

Looking for provider-native tools like Anthropic web search, OpenAI code interpreter, or Gemini URL context? See Provider Tools.

Framework Support

TanStack AI works with any JavaScript framework:

  • TanStack Start, Next.js, Express, Remix, Fastify, etc.
  • React, Vue, Solid, Svelte, vanilla JS, etc.

TanStack AI works with any JavaScript framework.

Isomorphic Tool Architecture

TanStack AI uses a two-step tool definition process:

  1. Define once with toolDefinition() - Creates a shared tool schema
  2. Implement with .server() or .client() - Add execution logic for each environment

This approach provides:

  • Type Safety: Full TypeScript inference from Zod schemas
  • Code Reuse: Define schemas once, use everywhere
  • Flexibility: Tools can execute on server, client, or both
  • Schema Options: Use Zod schemas or raw JSON Schema objects

Schema Options

TanStack AI supports two ways to define tool schemas:

Zod schemas provide full TypeScript type inference and runtime validation:

ts
import { z } from "zod";

const inputSchema = z.object({
  location: z.string().meta({ description: "City name" }),
  unit: z.enum(["celsius", "fahrenheit"]).optional(),
});

Note: For OpenAI-compatible providers, an omitted .optional() tool field is absent when your tool runs. A .nullable() field keeps null.

Option 2: JSON Schema Objects

For cases where you already have JSON Schema definitions or prefer not to use Zod, you can pass raw JSON Schema objects directly:

ts
import type { JSONSchema } from "@tanstack/ai";

const inputSchema: JSONSchema = {
  type: "object",
  properties: {
    location: {
      type: "string",
      description: "City name",
    },
    unit: {
      type: "string",
      enum: ["celsius", "fahrenheit"],
    },
  },
  required: ["location"],
};

Note: When using JSON Schema, TypeScript infers unknown for input/output types (it cannot derive types from a JSON Schema at compile time), so you must narrow or cast args before use. Zod schemas are recommended for full type safety.

Tip: Type safety from Zod schemas extends beyond tool execution. When you pass .client() tools to useChat, a check on part.name narrows part.input and part.output. See Type-safe tool call events.

Tool Definition

Tools are defined using toolDefinition() from @tanstack/ai:

ts
import { toolDefinition } from "@tanstack/ai";
import { z } from "zod";

// Step 1: Define the tool schema
const getWeatherDef = toolDefinition({
  name: "get_weather",
  description: "Get the current weather for a location",
  inputSchema: z.object({
    location: z.string().meta({ description: "The city and state, e.g. San Francisco, CA" }),
    unit: z.enum(["celsius", "fahrenheit"]).optional(),
  }),
  outputSchema: z.object({
    temperature: z.number(),
    conditions: z.string(),
    location: z.string(),
  }),
});

// Step 2: Create a server implementation
const getWeatherServer = getWeatherDef.server(async ({ location, unit }) => {
  const response = await fetch(
    `https://api.weather.com/v1/current?location=${location}&unit=${
      unit || "fahrenheit"
    }`
  );
  const data = await response.json();
  return {
    temperature: data.temperature,
    conditions: data.conditions,
    location: data.location,
  };
});

Using JSON Schema

If you prefer JSON Schema or have existing schema definitions:

ts
import { toolDefinition } from "@tanstack/ai";
import type { JSONSchema } from "@tanstack/ai";

// Define schemas using JSON Schema
const inputSchema: JSONSchema = {
  type: "object",
  properties: {
    location: {
      type: "string",
      description: "The city and state, e.g. San Francisco, CA",
    },
    unit: {
      type: "string",
      enum: ["celsius", "fahrenheit"],
    },
  },
  required: ["location"],
};

const outputSchema: JSONSchema = {
  type: "object",
  properties: {
    temperature: { type: "number" },
    conditions: { type: "string" },
    location: { type: "string" },
  },
  required: ["temperature", "conditions", "location"],
};

// Create the tool definition
const getWeatherDef = toolDefinition({
  name: "get_weather",
  description: "Get the current weather for a location",
  inputSchema,
  outputSchema,
});

// With a raw JSON Schema, `args` is `unknown` — narrow it before use
// (prefer a Zod schema for automatic typing).
const getWeatherServer = getWeatherDef.server(async (args) => {
  if (typeof args !== "object" || args === null || !("location" in args)) {
    throw new Error("Invalid input: expected a location");
  }
  const location = String(args.location);
  const unit = "unit" in args ? String(args.unit) : "fahrenheit";
  const response = await fetch(
    `https://api.weather.com/v1/current?location=${location}&unit=${unit}`
  );
  return await response.json();
});

Using Tools in Chat

Server-Side

ts
import { chat, toServerSentEventsResponse, toolDefinition } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { z } from "zod";

const getWeatherDef = toolDefinition({
  name: "get_weather",
  description: "Get the current weather for a location",
  inputSchema: z.object({
    location: z.string().meta({ description: "The city and state, e.g. San Francisco, CA" }),
    unit: z.enum(["celsius", "fahrenheit"]).optional(),
  }),
  outputSchema: z.object({
    temperature: z.number(),
    conditions: z.string(),
    location: z.string(),
  }),
});

export async function POST(request: Request) {
  const { messages } = await request.json();

  // Create server implementation
  const getWeather = getWeatherDef.server(async ({ location, unit }) => {
    const response = await fetch(`https://api.weather.com/v1/current?...`);
    return await response.json();
  });

  const stream = chat({
    adapter: openaiText("gpt-5.5"),
    messages,
    tools: [getWeather], // Pass server tools
  });

  return toServerSentEventsResponse(stream);
}

Client-Side with Type Safety

tsx
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
import { 
  createChatClientOptions, 
  type InferChatMessages 
} from "@tanstack/ai-client";
import { toolDefinition } from "@tanstack/ai";
import { z } from "zod";

const updateUIDef = toolDefinition({
  name: "updateUI",
  description: "Update the UI with a notification message",
  inputSchema: z.object({ message: z.string() }),
  outputSchema: z.object({ success: z.boolean() }),
});

const saveToStorageDef = toolDefinition({
  name: "saveToStorage",
  description: "Save data to storage",
  inputSchema: z.object({ key: z.string(), value: z.string() }),
  outputSchema: z.object({ saved: z.boolean() }),
});

// Create client implementations
const updateUI = updateUIDef.client((input) => {
  // Update UI state
  console.log(input.message);
  return { success: true };
});

const saveToStorage = saveToStorageDef.client((input) => {
  localStorage.setItem(input.key, input.value);
  return { saved: true };
});

// Create typed tools array (no 'as const' needed!)
const tools = [updateUI, saveToStorage];

const textOptions = createChatClientOptions({
  connection: fetchServerSentEvents("/api/chat"),
  tools,
});

// Infer message types for full type safety
type ChatMessages = InferChatMessages<typeof textOptions>;

function ChatComponent() {
  const { messages } = useChat(textOptions);
  
  // messages is now fully typed with tool names and outputs!
  return (
    <div>
      {messages.map((m) => (
        <div key={m.id}>{m.role}</div>
      ))}
    </div>
  );
}

Hybrid Tools

Tools can be implemented for both server and client, enabling flexible execution patterns:

ts
import { toolDefinition, chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { z } from "zod";
import { db } from "./db";

// Define once
const addToCartDef = toolDefinition({
  name: "add_to_cart",
  description: "Add item to shopping cart",
  inputSchema: z.object({
    itemId: z.string(),
    quantity: z.number(),
  }),
  outputSchema: z.object({
    success: z.boolean(),
    cartId: z.string(),
  }),
  needsApproval: true,
});

// Server implementation - Store in database
const addToCartServer = addToCartDef.server(async (input) => {
  const cart = await db.carts.create({
    data: { itemId: input.itemId, quantity: input.quantity },
  });
  return { success: true, cartId: cart.id };
});

// Client implementation - Update local wishlist
const addToCartClient = addToCartDef.client((input) => {
  const wishlist = JSON.parse(localStorage.getItem("wishlist") || "[]");
  wishlist.push(input.itemId);
  localStorage.setItem("wishlist", JSON.stringify(wishlist));
  return { success: true, cartId: "local" };
});

On the server, pass either the definition (for client execution) or the server implementation — in separate chat() calls:

ts
const messages = [{ role: 'user' as const, content: 'Add item abc to my cart' }]

// Pass the definition: the client will execute the tool
chat({
  adapter: openaiText("gpt-5.5"),
  messages,
  tools: [addToCartDef],
});

// Or pass the server implementation: the server will execute the tool
chat({
  adapter: openaiText("gpt-5.5"),
  messages,
  tools: [addToCartServer],
});

Type Safety Benefits

The isomorphic architecture provides complete type safety:

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

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

  // In your React component
  uiMessages.forEach((message) => {
    message.parts.forEach((part) => {
      if (part.type === 'tool-call' && part.name === 'add_to_cart') {
        // ✅ TypeScript knows part.name is literally 'add_to_cart'
        // ✅ part.input is typed as { itemId: string, quantity: number }
        // ✅ part.output is typed as { success: boolean, cartId: string } | undefined
        
        if (part.output) {
          console.log(part.output.cartId); // ✅ Fully typed!
        }
      }
    });
  });

  return null;
}

Tool Execution Flow

  1. Model decides to call a tool - Based on user input and tool descriptions
  2. Tool is identified - Server or client implementation
  3. Tool executes - Automatically on server or client
  4. Result is returned - To the model as a tool result message
  5. Model continues - Uses the result to generate a response

Progress Events and Runtime Context

A server tool's .server() implementation receives a second argument, the ToolExecutionContext{ context, toolCallId, emitCustomEvent }. Use emitCustomEvent to stream typed progress to the client while the tool runs, and context to read request-scoped dependencies (auth, DB clients, etc.):

ts
import { toolDefinition } from "@tanstack/ai";
import { z } from "zod";

type ImportContext = {
  db: {
    read(source: string): Promise<unknown[]>;
    write(rows: unknown[]): Promise<void>;
  };
};

const importDataDef = toolDefinition({
  name: "import_data",
  description: "Import data from a source",
  inputSchema: z.object({ source: z.string() }),
  outputSchema: z.object({ imported: z.number() }),
});

const importData = importDataDef.server<ImportContext>(async (input, { context, emitCustomEvent }) => {
  emitCustomEvent("progress", { step: 1, total: 3 });
  const rows = await context.db.read(input.source);

  emitCustomEvent("progress", { step: 2, total: 3 });
  await context.db.write(rows);

  emitCustomEvent("progress", { step: 3, total: 3 });
  return { imported: rows.length };
});

See Server Tools for the full runtime-context pattern.

Tool States

Tools go through different states during execution:

  • awaiting-input - Tool call received, waiting for arguments
  • input-streaming - Partial arguments being streamed
  • input-complete - All arguments received
  • approval-requested - Tool requires user approval (if needsApproval: true)
  • approval-responded - User has approved/denied

Once arguments (and approval, if required) are in, the result appears as part.output on the tool-call part and as a separate sibling tool-result part whose state is complete or error. See Tool Architecture for the full state model.

Tip: If your use case involves calling multiple tools with complex logic (filtering, aggregation, parallel calls), consider Code Mode — it lets the LLM write a TypeScript program that orchestrates tools in a single execution instead of one tool call at a time.

Next Steps