One tool layer, the user's own permissions
The agent never gets a back door. It calls the same Ratebook API functions as the app's own pages, with the signed-in user's identity narrowed to each tool's scopes, so the API stays the single place that decides who can do what. What the agent adds is what an API alone doesn't have: a plan, a preview, a policy check, an approval, an undo and a trace.
Actions
- Every tool has a risk tier: read, write, bulk or destructive. Destructive tools are registered but never exposed, to the assistant or to MCP clients.
- Writes are dry runs that come back as a changeset the user sees and approves. The agent has no apply tool.
- Limits live in code: at most 50 records per change, an Owner for changes over 20%, rates between $1 and $10,000.
- Apply locks each row and checks its version, so a stale preview fails cleanly (409) and writes nothing.
- One changeset is one transaction, an idempotency key makes retries harmless, and it stores its own undo.
Knowledge
- How-to answers come from versioned help pages, with numbered citations and "Show me" links into the app.
- Record text (names, notes) goes to the model labelled as data. A note in the demo data tells the assistant to delete everything; it can't, and it says so.
- When the help center doesn't cover a question, the assistant says so instead of guessing.
Sessions and credentials
- A conversation belongs to one user in one org; switching person starts a new one.
- The agent holds no credentials. Each tool call gets a five-minute token for the user, narrowed to the tool's scopes, and the API re-reads the user's role on every call.
- Who the user is and which page they're on arrive as a system message each turn, separate from anything a record says.
- Per-org and per-visitor rate limits; demo visitors use Claude Haiku.
Traceability
- Every run records the request, model steps, tool calls with arguments, policy decisions, the approver and the writes.
- Activity rows say "Priya Shah via Assistant" and link to the run's trace.
- Spans are mirrored to OpenTelemetry when an OTLP endpoint is set.
- An eval set of tasks and prompt-injection attempts checks the resulting changesets, not just the wording.
The tool contract
Built first, because the assistant and the MCP server both rely on it. This is the real definition from ratebook-tools.ts:
export const bulkUpdateRatesTool = defineTool({
name: "bulk_update_rates",
description: "Prepare a preview that raises or lowers every rate in a folder ...",
scopes: ["rates:read", "rates:write"], // the per-call token is narrowed to these
needs: "rates:write", // checked before anything is planned
risk: "bulk", // read | write | bulk | destructive (never exposed)
input: z.object({ folder: folderArg, pct: z.number().min(-50).max(50), item_type: typeArg }),
async run(i, actor, ctx) {
// planBulkRate reads current values and versions; propose runs the policy engine
// and stores the changeset. No business data is written here.
return preview(actor, await planBulkRate(actor, { folder: i.folder, pct: i.pct }), ...);
},
});The other pieces: changesets.ts (propose, apply, undo), policy.ts, the agent loop, the MCP server and the eval set.