Add a chat tool
Give the AI agent a new tool it can call mid-conversation — the AgentTool shape, the ToolContext it receives, and how to register it, following the built-in create_item / generate_image pattern.
Add a chat tool
The built-in AI chat is more than a chatbot: the agent can call tools mid-conversation to take real actions on the user's behalf — create a record, generate an image, schedule a follow-up. Adding a tool means writing one object and registering it. The template ships three as the reference (create_item, generate_image, schedule_followup) in apps/realtime/src/tools.ts; copy their shape for your own.
The agent runs in the apps/realtime Worker — see AI chat for the feature and Realtime Worker (and the dev wiki at /admin/docs/agents) for the architecture.
How tools work
Every chat turn, the agent passes its tool definitions (name, description, input_schema) to Anthropic. When the model decides to call one, the chat loop dispatches the tool_use block to that tool's handler, feeds the result back to the model, and lets it continue. A handler that throws is surfaced to the model as an error result, so it can recover gracefully and explain to the user — handlers should throw on failure rather than swallow it.
The agent itself never touches the database. Tools act through the web app's API routes, which keeps all persistence and authorization in one place. Two transports are available on the context:
callAsUser(env, cookie, path, init)— forwards the user's session cookie, so the call runs as the user (e.g.create_item→POST /api/items). Requires an interactive turn (a cookie).callInternal(...)— an internal-secret call for work with no cookie (e.g. a scheduled wake). The web route verifies ownership before writing.
The tool shape
A tool is an AgentTool (apps/realtime/src/types.ts):
export interface AgentTool {
name: string;
description: string;
input_schema: Record<string, unknown>; // Anthropic tool input schema (JSON Schema)
handler: (input: Record<string, unknown>, ctx: ToolContext) => Promise<unknown>;
}The handler receives the model's parsed input and a ToolContext:
export interface ToolContext {
userId: string; // == the validated Better Auth user id
cookie?: string; // present when the turn came from a live socket
env: Env; // Worker bindings + secrets
conversationId?: string; // the thread this turn belongs to
}Write the tool
Follow createItemTool in apps/realtime/src/tools.ts. A tool that acts on the user's data requires a cookie and calls a web API route through callAsUser:
import type { AgentTool } from "./types";
import { callAsUser } from "./web-api";
export const logMealTool: AgentTool = {
name: "log_meal",
description:
"Record a meal the user describes. Use when the user says they ate something.",
input_schema: {
type: "object",
properties: {
name: { type: "string", description: "What the user ate (required)." },
calories: { type: "number", description: "Optional calorie estimate." },
},
required: ["name"],
},
handler: async (input, ctx) => {
// Acting AS the user requires an interactive session cookie.
if (!ctx.cookie) {
throw new Error("log_meal requires an interactive session (no cookie on this turn).");
}
const res = await callAsUser(ctx.env, ctx.cookie, "/api/meals", {
method: "POST",
body: JSON.stringify({ name: input.name, calories: input.calories }),
});
if (!res.ok) {
const detail = await res.text().catch(() => "");
throw new Error(`Failed to log meal (${res.status}): ${detail}`);
}
const { meal } = (await res.json()) as { meal: { id: string } };
return { logged: true, meal };
},
};Notes drawn from the built-in tools:
- Write a precise
description— it's the model's only cue for when to call the tool. Say what it does and when to use it. - Keep
input_schematight — mark required fields, describe each property. The model fills it from the conversation. - Guard
ctx.cookiefor user-acting tools, exactly ascreate_itemandgenerate_imagedo — a scheduled wake has no cookie and must not act as the user. - Return a small JSON result the model can read back to the user. Don't return huge blobs.
- The corresponding
/api/mealsroute is a normal scoped endpoint — build it per Add an API route. BecausecallAsUserforwards the cookie, the route's owngetSessioncheck enforces ownership; the tool inherits that for free.
Register the tool
AppAgent.getTools() returns the built-ins (create_item, generate_image, schedule_followup) plus anything registered. The template's extension surface is registerTool() — call it from a subclass so you never edit the core agent file:
this.registerTool(logMealTool);Re-registering the same name replaces the prior definition. (Alternatively, for a tool you consider part of the baseline, you can add it to the built-in list returned by getTools() in apps/realtime/src/index.ts, next to createItemTool — but registerTool is the intended path for downstream tools.)
Tools that close over the agent (to schedule wake-ups, publish proactively, etc.) are built per-instance — see
makeScheduleFollowupTool(agent)intools.ts, which takes the agent so its handler can callscheduleProactive.
Deploy
The agent runs in apps/realtime, which deploys independently of the web app. Ship tool changes with a manual deploy:
npm run deploy:realtimeRelated
- AI chat — the chat feature and its configuration (model, system prompt).
- Realtime Worker — the agent architecture, subclassing, and deploy.
- Add an API route — build the endpoint a tool calls.
- Add a realtime channel — how the agent delivers proactive messages.
Add a realtime channel
Push live events to clients — publish() on the server, useRealtime() on the client, and the single authorizeChannel seam you extend to authorize a new channel type.
Protect a route with API keys
Opt an API route into sk_… API-key auth with authenticateRequest() and requireApiPermission() — a flag-gated, session-first model where an invalid key never falls back to the session.