Skip to content
Tools

Server Tools

A server tool runs on your server. Use it for a database row, a secret, or an environment variable. That code never runs in the browser.

Write the function with .server(), and chat() runs it each time the model calls it.

Add a server tool

1. Define the tool

This tool looks up an order. The Map stands in for your database:

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

const orders = new Map([
  ["1001", { status: "shipped", eta: "2026-10-24" }],
  ["1002", { status: "processing", eta: "2026-10-28" }],
]);

export const getOrderStatus = toolDefinition({
  name: "get_order_status",
  description: "Get the shipping status of an order by its order number",
  inputSchema: z.object({
    orderId: z.string().meta({ description: "The order number, for example 1001" }),
  }),
  outputSchema: z.object({
    found: z.boolean(),
    status: z.string().optional(),
    eta: z.string().optional(),
  }),
}).server(async ({ orderId }) => {
  // This runs on your server. Query your database here.
  const order = orders.get(orderId);
  if (!order) {
    return { found: false };
  }
  return { found: true, ...order };
});

2. Pass the tool to chat()

ts
// api/chat.ts
import {
  chat,
  chatParamsFromRequest,
  toServerSentEventsResponse,
} from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { getOrderStatus } from "./tools";

export async function POST(request: Request) {
  const { messages, threadId, runId } = await chatParamsFromRequest(request);

  const stream = chat({
    adapter: openaiText("gpt-6.1-sol"),
    messages,
    threadId,
    runId,
    tools: [getOrderStatus],
  });

  return toServerSentEventsResponse(stream);
}

3. Try it

Ask your chat "Where is order 1001?" The model calls get_order_status and answers that the order shipped and arrives on 2026-10-24.

You did not change the client. Server tools need no client code.

How it works

mermaid
sequenceDiagram
    participant LLM Service
    participant Server
    participant Tool
    participant Database/API

    LLM Service->>Server: tool_call chunk<br/>{name: "get_order_status", args: {...}}
    Server->>Server: Parse and check<br/>the arguments
    Server->>Tool: execute(parsedArgs)
    Tool->>Database/API: Query/Fetch data
    Database/API-->>Tool: Return data
    Tool-->>Server: Return result
    Server->>Server: Create tool_result<br/>message
    Server->>LLM Service: Continue chat with<br/>tool_result in history

    Note over LLM Service: Model uses result<br/>to generate response

    LLM Service-->>Server: Stream content chunks
    Server-->>Server: Stream to client
  1. The model sends a tool call. The arguments are a JSON string.
  2. chat() parses the arguments and checks them against inputSchema.
  3. chat() runs your .server() function with the parsed input.
  4. If you set an outputSchema, chat() checks the result against it. Then it adds the result to the conversation.
  5. The model reads the result and continues.

If the tool has needsApproval: true, the run pauses before step 3 until the user says yes. See Tool Approval Flow.

Use the signed-in user

A tool often needs data from the request, such as the user ID or a database client. Pass it as context on chat(). The tool gets it as the second argument:

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

type AppContext = {
  userId: string;
  db: {
    users: {
      findUnique(args: { where: { id: string } }): Promise<{ name: string } | null>;
    };
  };
};

const getCurrentUser = toolDefinition({
  name: "get_current_user",
  description: "Get the current authenticated user",
  inputSchema: z.object({}),
  outputSchema: z.object({
    name: z.string().nullable(),
  }),
}).server<AppContext>(async (_input, ctx) => {
  const user = await ctx.context.db.users.findUnique({
    where: { id: ctx.context.userId },
  });

  return { name: user?.name ?? null };
});

export async function POST(request: Request) {
  const { messages, threadId } = await request.json();
  // `getSession` and `getDb` are your own app code, not part of TanStack AI.
  const session = await getSession(request);
  const db = getDb();

  const stream = chat({
    adapter: openaiText("gpt-6.1-sol"),
    messages,
    threadId,
    tools: [getCurrentUser],
    context: {
      userId: session.user.id,
      db,
    },
  });

  return toServerSentEventsResponse(stream);
}
  • If a tool sets a context type, such as .server<AppContext>(), chat() requires a context that matches it.
  • A tool with no context type gets unknown context.

For middleware and values that come from the client, see Runtime Context.

Return an error the model can use

If a lookup fails, return the error as data. The model can then tell the user or try again:

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

const getUserData = toolDefinition({
  name: "get_user_data",
  description: "Get user information",
  inputSchema: z.object({
    userId: z.string(),
  }),
  outputSchema: z.object({
    name: z.string().optional(),
    email: z.string().optional(),
    error: z.string().optional(),
  }),
}).server(async ({ userId }) => {
  try {
    const user = await db.users.findUnique({ where: { id: userId } });
    if (!user) {
      return { error: "User not found" };
    }
    return { name: user.name, email: user.email };
  } catch {
    return { error: "Failed to fetch user data" };
  }
});
  • Return { error }: you write the message that the model sees. Put the error field in outputSchema, because chat() checks the result against it.
  • Throw: chat() catches the error and sends a tool error to the model. You do not control the message.

Advanced

For input checks and repair, JSON Schema tools, progress events, long-running hosts, and file layout, see Server Tools: Advanced.