Skip to Content
Tool Files

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.

FileWhat it’s for
routes.betteragent.tsHTTP 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.tsNext.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.tsBrowser-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];
FieldDescription
nameUnique identifier used in tool calls. camelCase recommended. Must be alphanumeric with underscores.
methodHTTP method: GET · POST · PUT · PATCH · DELETE.
pathPath appended to your project’s baseUrl setting. May contain {placeholder} segments — see below.
descriptionNatural-language description the agent uses to decide when to call this tool.
schemaZod 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:

FieldGoes toNotes
Named by a {placeholder} in pathThe URL pathRemoved from the query string / body.
Everything else, GETThe query stringArrays repeat the key (?tag=a&tag=b); objects are JSON-encoded.
Everything else, POST · PUT · PATCH · DELETEThe JSON bodyDELETE 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 generated AgentProvider uses import * 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.

Last updated on