This page is for when your server tool works and you need more control. Each section stands alone.
The inputSchema checks the input right before the tool runs:
Malformed JSON arguments and failed input checks (Standard Schema or JSON Schema) become tool errors. The tool does not run. The agent loop can send the error back to the model, so the model can correct the call. This also applies to Chat Completions tools with no required input fields.
For OpenAI-compatible providers, an omitted .optional() field is absent when your tool runs. A .nullable() field keeps null.
Models often send a value with the wrong type, such as "5" for a number. Before the check fails the call, it repairs these values against the schema:
| 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 wrong for your tool. 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().
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, threadId } = await request.json();
const stream = chat({
adapter: openaiText("gpt-6.1-sol"),
messages,
threadId,
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, threadId } = await request.json();
const stream = chat({
adapter: openaiText("gpt-6.1-sol"),
messages,
threadId,
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 two times: once before it goes out, and once when it comes back. So your function can run two times for one call.
Do you already have JSON Schema definitions? Pass them as inputSchema and outputSchema:
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 };
});TypeScript cannot get types from a JSON Schema, so args is unknown. A Zod schema gives you the types too.
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".The second argument of a .server() function is the ToolExecutionContext:
Use emitCustomEvent to show progress:
import { toolDefinition } from "@tanstack/ai";
import { z } from "zod";
type ImportContext = {
db: {
read(source: string): Promise<unknown[]>;
write(rows: unknown[]): Promise<void>;
};
};
const importData = toolDefinition({
name: "import_data",
description: "Import data from a source",
inputSchema: z.object({ source: z.string() }),
outputSchema: z.object({ imported: z.number() }),
}).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 };
});Each emitCustomEvent call flushes through durability at once, so the client can show progress while the tool still runs. Pass { batch: true } only for a high-volume stream. To read the events on the client, see Custom Events.
Call context.get(capability) in a server tool. It returns the value that a middleware of the run provides, with the type of the capability.
import {
chat,
createCapability,
defineChatMiddleware,
toolDefinition,
} from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { z } from "zod";
const tenantCapability = createCapability<{ id: string }>()("tenant");
const withTenant = defineChatMiddleware({
name: "with-tenant",
provides: [tenantCapability],
setup(ctx) {
ctx.provide(tenantCapability, { id: "acme" });
},
});
const whoAmI = toolDefinition({
name: "who_am_i",
description: "Return the tenant of this run",
inputSchema: z.object({}),
}).server(async (_input, context) => {
// `tenant` has the type { id: string }
const tenant = context?.get?.(tenantCapability);
return tenant?.id ?? "unknown";
});
const stream = chat({
adapter: openaiText("gpt-6.1-sol"),
messages: [{ role: "user", content: "Who am I?" }],
threadId: "thread-1",
tools: [whoAmI],
middleware: [withTenant],
});To create and provide a capability, see Capabilities. A harness turn gives the same accessors to its tools.
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;
});Put the definitions in a shared file. Then the server and the client can import the same definitions:
// 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-6.1-sol"),
messages,
threadId: "thread-1",
tools: [getUserData, searchProducts],
});