Tool files
Tool files are how the agent learns what it can do in your app. Define HTTP routes, server actions, and browser-side effects — then sync once. The agent calls them automatically.
What are tool files?
Tool files declare what capabilities your agent has. Without them, the agent can only respond in text — it can’t actually do anything in your app.
| File | What it’s for |
|---|---|
routes.betteragent.ts | HTTP endpoints the agent calls server-to-server via fetch. Good for REST APIs, data fetching, and mutations that already have route handlers. |
server-actions.betteragent.ts | Next.js Server Actions the agent calls through your provider. The SDK dispatches them from the browser, but they execute on the server with full session context — good for form mutations, database writes, and authenticated work. |
actions.betteragent.ts | Browser-side effects the agent triggers on the client — opening modals, navigating, refreshing state. These run in the user’s browser, not the server. |
Discovery vs. manual authoring. Run betteragent discover to scaffold the
three files with one entry per selected handler and empty Zod schemas. Fill in
the descriptions and schemas by hand, or write tools from scratch for things
discovery can’t detect (e.g. third-party API calls). Both approaches produce
the same file format — there’s no lock-in to either.
routes.betteragent.ts
Use defineRoute from betteragent-next to expose any HTTP endpoint as a tool.
The chat engine performs a server-to-server request using the baseUrl you
configured in your project settings.
import { defineRoute } from "betteragent-next";
import { z } from "zod";
// GET with optional query params
export const searchProducts = defineRoute({
name: "searchProducts",
method: "GET",
path: "/api/products",
description:
"Search the product catalogue. Use when the user asks to find, " +
"list, or browse products.",
schema: z.object({
q: z.string().optional().describe("Search query"),
category: z.string().optional().describe("Filter by category slug"),
limit: z.number().int().min(1).max(50).optional(),
}),
});
// POST with a body
export const createOrder = defineRoute({
name: "createOrder",
method: "POST",
path: "/api/orders",
description:
"Place a new order for the current user. Only call this after " +
"explicitly confirming the items and total with the user.",
schema: z.object({
items: z.array(
z.object({
productId: z.string(),
quantity: z.number().int().min(1),
}),
),
}),
});
// Path parameters: {placeholders} are filled from the schema
export const getProduct = defineRoute({
name: "getProduct",
method: "GET",
path: "/api/products/{productId}",
description: "Fetch one product by id.",
schema: z.object({
productId: z.string().describe("The product's id"),
includeVariants: z.boolean().optional(),
}),
});
export const routes = [searchProducts, createOrder, getProduct];| Field | Description |
|---|---|
name | Unique identifier used in tool calls. camelCase recommended. Must be alphanumeric with underscores. |
method | HTTP method: GET · POST · PUT · PATCH · DELETE. |
path | Path appended to your project’s baseUrl setting. May contain {placeholder} segments — see below. |
description | Natural-language description the agent uses to decide when to call this tool. |
schema | Zod schema (or a plain JSON Schema object) for the input. See the placement rules below. |
Where each field ends up
Every field in schema is sent to your endpoint in exactly one place:
| Field | Goes to | Notes |
|---|---|---|
Named by a {placeholder} in path | The URL path | Removed from the query string / body. |
Everything else, GET | The query string | Arrays repeat the key (?tag=a&tag=b); objects are JSON-encoded. |
Everything else, POST · PUT · PATCH · DELETE | The JSON body | DELETE sends a body, not a query string. |
{...} placeholders must be required and primitive (string or number).
defineRoute throws at import time otherwise, rather than letting a broken URL
reach your API:
// ❌ throws: "{productId}" must be required
schema: z.object({ productId: z.string().optional() })
// ❌ throws: "{productId}" has no matching property in the schema
schema: z.object({ id: z.string() })Values are URL-encoded on the way in, and a value containing /, \, or ..
is rejected — a tool argument can’t walk out of its endpoint.
A path with no placeholders behaves exactly as it always has.
Keep results small
Tool results are capped at 8 KB (MAX_TOOL_RESULT_BYTES); anything longer is
truncated before the agent sees it, so a list endpoint that returns full records
will silently lose the tail. Design around it: expose a narrower projection, or
default to a smaller page size.
export const searchProducts = defineRoute({
name: "searchProducts",
method: "GET",
path: "/api/products",
schema: z.object({
q: z.string().optional(),
// Cap it here so the agent can't ask for 500 rows.
limit: z.number().int().min(1).max(20).default(10),
}),
});If the agent needs detail, give it a second tool that fetches one record by id rather than widening the list response.
How route tools authenticate
This is the part that most often surprises people, so to be explicit:
Route tools are called server-to-server from BetterAgent’s backend, not from the user’s browser. Your app’s session cookie is not attached to those requests. A route tool that relies on a session cookie will see an unauthenticated request.
The mechanism is the authToken prop on the provider. Whatever you pass is
forwarded verbatim as headers on every route-tool call:
// A short-lived, scoped token minted server-side — the recommended shape.
<AgentProvider
clientKey={process.env.NEXT_PUBLIC_BETTERAGENT_CLIENT_KEY!}
endUserId={user.id}
authToken={{ Authorization: `Bearer ${await mintAgentToken(user.id)}` }}
>Pass a string to send Authorization: Bearer <token>, an object to send
custom headers verbatim, or a function (Client Components only) to refresh
the value per request. Scope the token to what the agent is allowed to do and
give it a short lifetime — it leaves your infrastructure.
If your API genuinely can’t issue a token and only accepts a session cookie, there is an opt-in escape hatch. Enable Forward end-user cookies in project settings (it requires an https base URL), then:
import { buildCookieHeader } from "betteragent-next";
<AgentProvider
clientKey={process.env.NEXT_PUBLIC_BETTERAGENT_CLIENT_KEY!}
endUserId={user.id}
authToken={await buildCookieHeader(["session"])}
>Understand what this does before enabling it: it sends a live session credential to BetterAgent, which forwards it to your backend on every tool call. The credential is only sent over HTTPS and redirects are not followed, but it is still a real session cookie leaving your app. Prefer a short-lived scoped token wherever your backend can mint one.
Server actions and client actions are unaffected — they run in your own app, with the user’s session intact.
server-actions.betteragent.ts
Use defineServerAction to expose a Next.js Server Action as a tool. The React
SDK dispatches calls from the browser — the handler runs on the server via the
normal server-action mechanism, so session context and auth are available as
usual.
"use server";
// ^ Required. This file is a "use server" module — Next.js compiles
// each exported async function as a callable server action.
import { defineServerAction } from "betteragent-next";
import { z } from "zod";
// Import handlers from your own "use server" files.
import { updateProfile } from "@/app/actions/profile";
import { sendInvoice } from "@/app/actions/billing";
// Export each action individually — no array export.
// The generated AgentProvider imports this file automatically.
export const updateDisplayName = defineServerAction({
name: "updateDisplayName",
description:
"Update the user's display name. Use when the user explicitly " +
"asks to change their name.",
schema: z.object({
name: z.string().min(1).max(100),
}),
handler: updateProfile,
});
export const sendUserInvoice = defineServerAction({
name: "sendUserInvoice",
description:
"Re-send an invoice to the user's email address. " +
"Only call this when explicitly requested.",
schema: z.object({
invoiceId: z.string(),
}),
handler: sendInvoice,
});"use server"required — the file must start with"use server". This makes each exported async function a real Next.js server action reference, callable from the browser.handler— must be imported from one of your own"use server"files, not defined inline. Input is Zod-validated before the handler is called.- No array export — export each action individually.
"use server"files cannot export arrays; the generatedAgentProviderusesimport *to pick them all up automatically. - Return value — whatever the handler returns is serialised and sent back to the agent as the tool result. Keep it concise — the agent uses it to decide the next step. Results are truncated past 8 KB; see Limits & Billing for the full list of runtime caps.
actions.betteragent.ts
Use defineAction to declare pure client-side effects — opening modals,
navigating, refreshing UI state. The agent emits an action_call event; the
React SDK dispatches it locally in the user’s browser.
// actions.betteragent.ts
import { defineAction } from "betteragent-next";
import { z } from "zod";
export const openModal = defineAction({
name: "openModal",
description:
"Open a dialog or modal panel. Use when the user " +
"asks to edit or view something in a dialog.",
schema: z.object({
name: z.enum(["settings", "profile", "billing"]),
}),
});
export const navigate = defineAction({
name: "navigate",
description:
"Navigate to a different page in the app. Only use " +
"for navigation the user explicitly requests.",
schema: z.object({
path: z.string().describe("App path, e.g. /dashboard/projects"),
}),
});
export const actions = [openModal, navigate];Register handlers by adding an actions prop to the generated AgentProvider
in components/betteragent-provider.tsx — a map from action name to handler
function.
// components/betteragent-provider.tsx
"use client";
import { BetterAgentProvider } from "betteragent-react";
import * as serverActions from "@/server-actions.betteragent";
import { useRouter } from "next/navigation";
import { useState } from "react";
export function AgentProvider({ children, ...props }) {
const router = useRouter();
const [dialog, setDialog] = useState<string | null>(null);
return (
<BetterAgentProvider
{...props}
serverActions={serverActions}
actions={{
openModal: ({ name }) => setDialog(name),
navigate: ({ path }) => router.push(path),
}}
>
{children}
<SettingsDialog open={dialog === "settings"} />
</BetterAgentProvider>
);
}Best practices for descriptions
The agent picks tools based on their descriptions. Vague descriptions lead to wrong tool calls or no tool calls at all.
Avoid — too vague:
description: "Get projects"
description: "Update user"
description: "Open modal"Better — says when to call it:
description: "List the current user's projects. Use when they ask to see, find, or browse their projects."
description: "Update the user's profile name and bio. Only call after they explicitly ask to change their name."
description: "Open the settings dialog. Use when the user asks to change their account settings or preferences."- Be specific about when — include “Use when the user asks to…” This directly maps to intent.
- Add safety guardrails — for destructive actions, add “Only call after explicit user confirmation.”
- Describe parameters too — use
.describe()on Zod fields to give the agent context about each param.
Keep tools in sync
Run betteragent sync whenever your tool files change. The CLI diffs against
what’s already on the backend and reports added, updated, removed, and unchanged
tools.
# Push changes
npx betteragent sync
→ Loading tool files from .
✓ routes.betteragent.ts (3 tools)
✓ server-actions.betteragent.ts (4 tools)
✓ Synced. +2 added · ~1 updated · -0 removed · =4 unchanged.# Validate without pushing
npx betteragent sync --dry-run
→ Dry run — no changes will be made.
✓ routes.betteragent.ts (3 tools)
✓ server-actions.betteragent.ts (4 tools)
Would push: +2 added · ~1 updated · -0 removed · =4 unchanged.When to sync:
- After adding or removing a tool
- After editing a description or schema
- Before deploying to production
- After renaming a route handler or action
CI/CD tip. Add betteragent sync to your deploy pipeline so tools stay in
sync on every production deploy. Set BETTERAGENT_API_URL for
environment-specific URLs.
Full CLI reference: CLI Reference.