Server tools execute automatically when called by the LLM. They have full access to server resources like databases, APIs, and environment variables.
sequenceDiagram
participant LLM Service
participant Server
participant Tool
participant Database/API
LLM Service->>Server: tool_call chunk<br/>{name: "getUserData", args: {...}}
Server->>Server: Parse tool call<br/>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 clientAutomatic (Default):
Approval-gated:
import { toolDefinition } from "@tanstack/ai";
import { z } from "zod";
import { db } from "./db";
const getUserDataDef = toolDefinition({
name: "get_user_data",
description: "Get user information from the database",
inputSchema: z.object({
userId: z.string().meta({ description: "The user ID to look up" }),
}),
outputSchema: z.object({
name: z.string(),
email: z.string().email(),
createdAt: z.string(),
}),
});
const getUserData = getUserDataDef.server(async ({ userId }) => {
// This runs on the server - secure access to database
const user = await db.users.findUnique({ where: { id: userId } });
return {
name: user.name,
email: user.email,
createdAt: user.createdAt.toISOString(),
};
});Server tools use the isomorphic toolDefinition() API with the .server() method:
import { toolDefinition } from "@tanstack/ai";
import { z } from "zod";
import { db } from "./db";
// Step 1: Define the tool schema
const getUserDataDef = toolDefinition({
name: "get_user_data",
description: "Get user information from the database",
inputSchema: z.object({
userId: z.string().meta({ description: "The user ID to look up" }),
}),
outputSchema: z.object({
name: z.string(),
email: z.string().email(),
createdAt: z.string(),
}),
});
// Step 2: Create server implementation
const getUserData = getUserDataDef.server(async ({ userId }) => {
// This runs on the server - can access database, APIs, etc.
const user = await db.users.findUnique({ where: { id: userId } });
return {
name: user.name,
email: user.email,
createdAt: user.createdAt.toISOString(),
};
});
// Example: API call tool
const searchProductsDef = toolDefinition({
name: "search_products",
description: "Search for products in the catalog",
inputSchema: z.object({
query: z.string().meta({ description: "Search query" }),
limit: z.number().optional().meta({ description: "Maximum number of results" }),
}),
});
const searchProducts = searchProductsDef.server(async ({ query, limit = 10 }) => {
const response = await fetch(
`https://api.example.com/products?q=${query}&limit=${limit}`,
{
headers: {
Authorization: `Bearer ${process.env.API_KEY}`, // Server-only access
},
}
);
return await response.json();
});Pass tools to the chat function:
import { chat, toServerSentEventsResponse } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { getUserData, searchProducts } from "./tools";
export async function POST(request: Request) {
const { messages } = await request.json();
const stream = chat({
adapter: openaiText("gpt-5.5"),
messages,
tools: [getUserData, searchProducts],
});
return toServerSentEventsResponse(stream);
}Server tools can receive typed runtime context as their second argument. Use this for request-scoped dependencies like authenticated users, database clients, tenant IDs, or audit loggers.
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 } = await request.json();
// `session` and `db` come from your own app setup (auth middleware,
// a DB client, etc.) — they are not provided by TanStack AI.
const session = await getSession(request);
const db = getDb();
const stream = chat({
adapter: openaiText("gpt-5.5"),
messages,
tools: [getCurrentUser],
context: {
userId: session.user.id,
db,
},
});
return toServerSentEventsResponse(stream);
}If a server tool declares a context generic, chat() requires a compatible context value. Untyped tools keep working and receive unknown context.
For middleware and client-to-server handoff patterns, see Runtime Context.
Some hosts run chat() for a long session. Such a host can restart a run after a crash, or move a slow tool call to the background. Two fields let your tool work with that host. chat() alone does not use them.
import { toolDefinition } from "@tanstack/ai";
import { z } from "zod";
declare function runBuild(target: string): Promise<string>;
const build = toolDefinition({
name: "build",
description: "Build a target",
inputSchema: z.object({ target: z.string() }),
replay: "safe",
}).server(async ({ target }, ctx) => {
const work = runBuild(target);
return ctx?.detach ? Promise.race([work, ctx.detach(work)]) : work;
});For better organization, define tool schemas and implementations separately:
// tools/definitions.ts
import { toolDefinition } from "@tanstack/ai";
import { z } from "zod";
export const getUserDataDef = toolDefinition({
name: "get_user_data",
description: "Get user information",
inputSchema: z.object({
userId: z.string(),
}),
outputSchema: z.object({
name: z.string(),
email: z.string(),
}),
});
export const searchProductsDef = toolDefinition({
name: "search_products",
description: "Search products",
inputSchema: z.object({
query: z.string(),
}),
});
// tools/server.ts
import { getUserDataDef, searchProductsDef } from "./definitions";
import { db } from "@/lib/db";
export const getUserData = getUserDataDef.server(async ({ userId }) => {
const user = await db.users.findUnique({ where: { id: userId } });
return { name: user.name, email: user.email };
});
export const searchProducts = searchProductsDef.server(async ({ query }) => {
const products = await db.products.search(query);
return products;
});
// api/chat/route.ts
import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { getUserData, searchProducts } from "@/tools/server";
const stream = chat({
adapter: openaiText("gpt-5.5"),
messages,
tools: [getUserData, searchProducts],
});Server tools are automatically executed when the model calls them. The SDK:
You don't need to manually handle tool execution - it's automatic!
Tools should handle errors gracefully:
import { toolDefinition } from "@tanstack/ai";
import { z } from "zod";
import { db } from "./db";
const getUserDataDef = 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(),
}),
});
const getUserData = getUserDataDef.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" };
}
});Throwing vs. returning an error: if your .server() function throws, the SDK catches it and surfaces it as a tool-result error (the model sees the failure but you lose control over the message). Returning a structured { error } shape keeps the model in control of how to recover and is usually preferable. Either way, when an outputSchema is defined the returned value is validated against it (Zod) before being added to the conversation — so include the error field in your outputSchema if you return it.
Malformed JSON arguments and input validation failures (Standard Schema or JSON Schema) are also surfaced as tool-result errors. The tool implementation is not called, and the agent loop can return the error to the model so it can repair the tool call. This also applies to Chat Completions tools with no required input fields.
The inputSchema checks the input just before the tool runs:
Models often send a value with the wrong type, such as "5" for a number. The check repairs these against the schema before it fails the call:
| The model sends | The schema wants | The tool gets |
|---|---|---|
| "5" | number or integer | 5 |
| "true" | boolean | true |
| 5 | string | "5" |
| null for an optional field | a type with no null | no field |
| null for a required field | string, number, or boolean | "", 0, or false |
Sometimes the built-in repair is the wrong move. Maybe your tool moves money, and a null must never turn into 0. Or maybe your model makes the same odd mistake every time, and you know the fix. Pass repairToolInput to chat() and take control.
Turn the repair off, so a bad input always goes back to the model as an error:
import { chat, toServerSentEventsResponse } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { transferFunds } from "./tools";
export async function POST(request: Request) {
const { messages } = await request.json();
const stream = chat({
adapter: openaiText("gpt-6.1-sol"),
messages,
tools: [transferFunds],
repairToolInput: false,
});
return toServerSentEventsResponse(stream);
}Or write your own fix. Say the model keeps sending "limit": "all" to search_products. Map it to a number:
import { chat, toServerSentEventsResponse } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { searchProducts } from "./tools";
export async function POST(request: Request) {
const { messages } = await request.json();
const stream = chat({
adapter: openaiText("gpt-6.1-sol"),
messages,
tools: [searchProducts],
repairToolInput: ({ tool, input }) => {
if (
tool.name === "search_products" &&
typeof input === "object" &&
input !== null &&
"limit" in input &&
input.limit === "all"
) {
return { ...input, limit: 100 };
}
return undefined;
},
});
return toServerSentEventsResponse(stream);
}How your function works:
The option works the same for Standard Schema tools, JSON Schema tools, and MCP tools.
A client tool, or a tool that waits for approval, is checked twice: once before it goes out, and once when it comes back. So your function can run twice for one call.
If you have existing JSON Schema definitions or prefer not to use Zod, you can define tool schemas using raw JSON Schema objects:
import { toolDefinition } from "@tanstack/ai";
import type { JSONSchema } from "@tanstack/ai";
import { db } from "./db";
const inputSchema: JSONSchema = {
type: "object",
properties: {
userId: {
type: "string",
description: "The user ID to look up",
},
},
required: ["userId"],
};
const outputSchema: JSONSchema = {
type: "object",
properties: {
name: { type: "string" },
email: { type: "string" },
},
required: ["name", "email"],
};
const getUserDataDef = toolDefinition({
name: "get_user_data",
description: "Get user information from the database",
inputSchema,
outputSchema,
});
// With a raw JSON Schema, args is typed as `unknown` — narrow it before use
const getUserData = getUserDataDef.server(async (args) => {
if (typeof args !== "object" || args === null || !("userId" in args)) {
throw new Error("Invalid input: expected a userId");
}
const user = await db.users.findUnique({ where: { id: String(args.userId) } });
return { name: user.name, email: user.email };
});The JSON Schema checks each call before the tool runs. MCP tools get the same check. If the input does not fit, the call gets a tool error, and the error names each failed field:
Input validation failed for tool get_user_data: #: Instance does not have required property "userId".Tip: When you pass .client() tools to useChat, a check on part.name narrows part.input and part.output. See Type-safe tool call events.