An agent here is a model in a tool loop: it answers by calling typed tools (your module functions) until it has what it needs, bounded by a step limit. The whole feature lives in src/modules/agents/ and is driven by one idea — agents are data in a registry, not bespoke wiring.
The registry
src/modules/agents/registry.ts defines every agent as a plain object: instructions, a createTools(context) factory that binds tools to the calling user, a step limit, and optionally a list of tools that require human approval. Two agents ship: the account assistant (reads your balance and ledger) and the knowledge assistant (searches your RAG documents and can save notes).
Add an entry and everything downstream picks it up automatically: the picker on /dashboard/agents, the streaming API route, persistence, and billing. A new agent is ~20 lines:
{
id: "support",
name: "Support assistant",
description: "Answers from your docs.",
placeholder: "How do I rotate an API key?",
instructions: "Answer using the tools. Be concise.",
createTools: ({ userId }) => ({
searchDocs: tool({
description: "Search the user's documents.",
inputSchema: z.object({ query: z.string() }),
execute: async ({ query }) => retrieve(userId, query),
}),
}),
maxSteps: 4,
}One billable route for every agent
/api/agents/[agentId] is the billable-endpoint recipe applied once for all agents: session (401) → rate limit (429) → balance pre-check (402) → input-size cap (413) → stream the tool loop → charge credits from aggregated usage in onEnd. Tools that spend on the side (the knowledge assistant's saveNote embeds text) report their tokens through context.addTokens(), so one charge covers the whole run.
Streaming tool calls
The run streams as UI-message parts, so the chat renders each tool call live: name, input, then result — no "thinking…" black box. The client is src/modules/agents/components/agent-chat.tsx, a useChat over the agent route.
Human-in-the-loop approval
Tools listed in toolsRequiringApproval pause the run before executing: the stream stops, the UI shows the exact input with Approve / Deny buttons, and the answer is sent back with the conversation. Approved calls execute and the loop resumes; denied calls never run. Use it for anything with side effects — writes, sends, spends.
Saved conversations
Threads and messages persist in Postgres (agent_threads, agent_messages — see src/modules/agents/schema.ts). Messages store the UI parts as JSON, so tool calls and approval decisions survive reloads exactly as they were rendered. The first user message becomes the thread title.
Scheduled agents
Agents don't need a chat UI. /api/cron/agent-report runs a headless ops-report agent every Monday (see vercel.json): it pulls admin stats through tools and emails a short report to everyone in ADMIN_EMAILS. The pattern is in src/modules/agents/report.ts — swap the tools and instructions for any recurring job that benefits from a model in the loop.