Tools
Let agents take structured actions.
Tools let agents do work. Instead of guessing, the model calls your code through a structured contract with validated input.
A tool starts as a shared contract. You then materialize it as a server tool, a client tool, or a hosted provider tool.
Define a Tool
Use defineTool() to create a contract with a name, description, and input schema.
import { defineTool } from "@better-agent/core";
import { z } from "zod";
const getWeather = defineTool({
name: "get_weather",
description: "Get the current weather for a location",
schema: z.object({
location: z.string(),
unit: z.enum(["celsius", "fahrenheit"]),
}),
});Better Agent accepts any schema that follows the Standard Schema spec, as well as raw JSON Schema.
import { z } from "zod";
const tool = defineTool({
name: "get_weather",
schema: z.object({
location: z.string(),
}),
});import * as v from "valibot";
const tool = defineTool({
name: "get_weather",
schema: v.object({
location: v.string(),
}),
});import { type } from "arktype";
const tool = defineTool({
name: "get_weather",
schema: type({
location: "string",
}),
});const tool = defineTool({
name: "get_weather",
schema: {
type: "object",
properties: {
location: { type: "string" },
},
required: ["location"],
},
});Set strict: true on the contract when input validation should use strict mode where supported by the runtime.
Server Tools
Use .server() when the tool should run on the server. The handler receives validated input and a context with signal and emit.
const getWeatherTool = getWeather.server(async ({ location, unit }, { signal }) => {
const res = await fetch(`https://api.weather.example/${location}?unit=${unit}`, { signal });
return res.json();
});Then add it to an agent.
import { defineAgent } from "@better-agent/core";
import { openai } from "./openai";
const assistant = defineAgent({
name: "assistant",
model: openai.model("gpt-4o"),
tools: [getWeatherTool],
});Use emit to send custom events to the stream while the tool runs.
const analyzeTool = analyze.server(async ({ query }, { signal, emit }) => {
await emit({ type: "DATA_PART", data: { stage: "searching" } });
const results = await search(query, { signal });
await emit({ type: "DATA_PART", data: { stage: "analyzing" } });
return summarize(results);
});Client Tools
Use .client() when the tool should run on the user's device. The model calls it, Better Agent emits the call to the client, and the server waits for the client to return a result.
const showConfetti = defineTool({
name: "show_confetti",
description: "Shows a confetti animation in the UI",
schema: z.object({
color: z.string(),
}),
});
const showConfettiTool = showConfetti.client();const uiAgent = defineAgent({
name: "ui",
model: openai.model("gpt-4o"),
tools: [showConfettiTool],
});Client tools are model-callable like server tools. The difference is where the code runs. Better Agent does not execute client tools on the server. It waits for the client to submit a result.
Approval
Use approval when a tool should require human confirmation before it runs.
const deleteDatabase = defineTool({
name: "delete_database",
schema: z.object({ confirm: z.boolean() }),
approval: {
required: true,
timeoutMs: 300_000,
meta: { risk: "high" },
},
}).server(async ({ confirm }) => {
await db.delete();
return { deleted: true };
});Use resolve for dynamic approval based on context or input.
const refundTool = defineTool({
name: "refund_user",
schema: z.object({
amount: z.number(),
}),
approval: {
resolve: ({ input }) => ({
required: input.amount > 50,
timeoutMs: 60_000,
meta: { amount: input.amount },
}),
},
}).server(async ({ amount }) => {
return processRefund(amount);
});The resolver receives context, input, runId, toolCallId, toolName, and toolTarget.
See Human in the Loop for the full approval lifecycle, timeout precedence, and submitting approval decisions from the client.
Dynamic Tools
Tools can be created per run from agent context. Use this for permissions, feature flags, or tenant-specific logic.
const adminAgent = defineAgent({
name: "admin",
model: openai.model("gpt-4o"),
contextSchema: z.object({
role: z.enum(["admin", "user"]),
}),
tools: (context) => {
if (context.role === "admin") {
return [deleteUserTool, getProfileTool];
}
return [getProfileTool];
},
});Tool factories can be sync or async, and can return a single tool or an array.
Lazy Tools
Use lazyTools() when a tool source is expensive to create and should be reused across runs.
import { defineAgent, lazyTools } from "@better-agent/core";
import { convertMCPTools, createMCPClient } from "@better-agent/core/mcp";
const mcpTools = lazyTools(async () => {
const client = await createMCPClient({
transport: { type: "http", url: "https://mcp.context7.com/mcp" },
});
const listed = await client.listTools();
return {
tools: convertMCPTools(client, listed.tools, { prefix: "mcp" }),
dispose: async () => await client.close?.(),
};
});
const docsAgent = defineAgent({
name: "docs",
model: openai.model("gpt-4o"),
tools: mcpTools,
});It caches successful loads, shares concurrent resolves, and lets you call dispose() or reload(context) when needed.
runCleanup() does not dispose the cached lazy provider. Call await source.dispose() when your app should release it.
Error Handling
When a tool fails, the default behavior sends a structured error back to the model so it can retry or recover. Set toolErrorMode on a tool contract to change this.
const strictTool = defineTool({
name: "deploy",
schema: z.object({ env: z.string() }),
toolErrorMode: "throw",
}).server(async ({ env }) => {
return deploy(env);
});"tool_error"(default): sends the error to the model as a tool result."throw": stops the run immediately.
Use onToolError for custom recovery logic. The hook receives typed error context (parse, validation, or execution) and can return actions like send_to_model, throw, skip, repair, retry, or result.
const resilientTool = defineTool({
name: "fetch_data",
schema: z.object({ url: z.string() }),
onToolError: (error) => {
if (error.errorKind === "execution") {
return { action: "retry", maxAttempts: 2 };
}
return { action: "send_to_model" };
},
}).server(async ({ url }, { signal }) => {
const res = await fetch(url, { signal });
return res.json();
});toolErrorMode and onToolError can also be set at the agent level to apply across all tools. Tool-level settings take precedence.
Hosted Tools
Some providers expose native tools that run on the provider side. Include them without a local handler.
const assistant = defineAgent({
name: "assistant",
model: openai.text("gpt-4o"),
tools: [
openai.tools.fileSearch({
vector_store_ids: ["vs_abc123"],
}),
],
});Name Uniqueness
Tool names must be unique across the final merged list for a run. Use the as option to rename a tool when you need multiple materializations from the same contract.
const readProfile = defineTool({
name: "profile",
schema: z.object({ userId: z.string() }),
});
const serverProfile = readProfile.server(
async ({ userId }) => getProfile(userId),
{ as: "get_profile" },
);
const clientProfile = readProfile.client({
as: "open_profile",
});