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 clientTool Call Received: Server receives a tool_call chunk from the LLM
Argument Parsing: The tool arguments (JSON string) are parsed and validated against the input schema
Execution: The tool's execute function is called with the parsed arguments
Result Processing: The result is:
Continuation: The chat continues with the tool result, allowing the LLM to generate a response based on the result
Automatic (Default):
Server tools with an execute function run automatically
Results are added to the conversation immediately
No client-side handling required
Approval-gated:
Tools marked needsApproval: true still execute automatically — but only after the user approves
The run pauses at the approval-requested state and resumes (executing the tool, or skipping it on denial) once the client sends an approval response
See Tool Approval Flow for the full pattern
import { toolDefinition } from "@tanstack/ai";
import { z } from "zod";
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";
// 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";
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.
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:
Receives the tool call from the model
Executes the tool's execute function
Adds the result to the conversation
Continues the chat with the tool result
You don't need to manually handle tool execution - it's automatic!
Tools should handle errors gracefully:
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 (error) {
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.
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";
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 };
});Note: JSON Schema tools skip runtime validation. Zod schemas are recommended for full type safety and validation.
Keep tools focused - Each tool should do one thing well
Validate inputs - Use Zod schemas to ensure type safety (JSON Schema skips validation)
Handle errors - Return meaningful error messages
Use descriptions - Clear descriptions help the model use tools correctly
Secure sensitive operations - Never expose API keys or secrets to the client