Add an AI agent to an existing SaaS without rewriting it
You do not need to rebuild your product to ship an AI agent inside it. The trick is to expose the service functions you already have — search records, create an order, fetch a customer — as tools, then run a small server-side agent loop that the model uses to orchestrate them. This tutorial wraps an existing service layer as tools, scopes every call to the authenticated user, separates safe read tools from gated write tools, exposes the agent as one authenticated endpoint, and deploys that endpoint to Totalum. Your database, auth, and business logic stay untouched.
On this page
Teams stall on "add AI to the product" because they imagine a rewrite. You do not need one. An agent is a model that calls functions, and you already have functions: the service layer behind your existing endpoints. This tutorial bolts an agent onto a typical SaaS by exposing that service layer as tools, keeping every line of business logic where it is.
We will use a support-style example — an app with customers, orders, and refunds — but the pattern maps onto any CRUD product.
Prerequisites
- An existing server-side service layer (functions that read and write your database).
- Node.js 20+ and an Anthropic API key (
ANTHROPIC_API_KEY). - An authenticated context: you can identify the current user on the server.
- You have read the agent-loop basics, or built the loop in build-first-ai-agent-from-scratch.
Expected outcome: a single authenticated POST /api/agent route that accepts a user message, runs a tool-using agent scoped to the current user, gates destructive actions behind confirmation, and returns the answer — deployed and reachable on Totalum.
Inventory the functions you already have
Before writing any AI code, list the service functions worth exposing. You are not building new capabilities; you are surfacing existing ones. A typical inventory:
// services/support.ts — functions that ALREADY exist in your app.
export async function searchOrders(userId: string, q: string): Promise;
export async function getOrder(userId: string, orderId: string): Promise;
export async function refundOrder(userId: string, orderId: string): Promise;
export async function listInvoices(userId: string): Promise;
Split them mentally into two tiers: read (searchOrders, getOrder, listInvoices) and write (refundOrder). That split drives every safety decision later.
Wrap each function as a tool adapter
A tool adapter is a thin layer that maps JSON arguments to a real function call and back to a string. Crucially, the adapter — not the model — injects the authenticated userId.
// agent/tools.ts
import * as support from "../services/support.js";
export function buildTools(userId: string) {
return {
search_orders: async ({ query }: { query: string }) =>
JSON.stringify(await support.searchOrders(userId, query)),
get_order: async ({ order_id }: { order_id: string }) =>
JSON.stringify(await support.getOrder(userId, order_id)),
list_invoices: async () =>
JSON.stringify(await support.listInvoices(userId)),
};
}
> Heads up: bind userId from the server session, never from a tool argument. If the model can pass userId, a prompt-injected message can read another tenant's data. Tenancy must live in code the model cannot reach.
Describe the tools to the model
Advertise only what the model should use. Notice there is no userId field anywhere — the model never sees it.
import type Anthropic from "@anthropic-ai/sdk";
export const toolSchemas: Anthropic.Tool[] = [
{
name: "search_orders",
description: "Search the current user's orders by free-text query. Use for 'where is my order' questions.",
input_schema: { type: "object", properties: { query: { type: "string" } }, required: ["query"] },
},
{
name: "get_order",
description: "Fetch full details for one of the current user's orders by id.",
input_schema: { type: "object", properties: { order_id: { type: "string" } }, required: ["order_id"] },
},
{
name: "list_invoices",
description: "List the current user's invoices. Takes no arguments.",
input_schema: { type: "object", properties: {} },
},
];
Gate write actions behind confirmation
Read tools can run automatically. Write tools should not. Instead of executing a refund, the refund_order tool returns a proposed action that your UI confirms. Add it as a special-cased tool:
export const refundSchema: Anthropic.Tool = {
name: "propose_refund",
description: "Propose a refund for an order. This does NOT execute; it returns an action for the user to confirm.",
input_schema: {
type: "object",
properties: { order_id: { type: "string" }, reason: { type: "string" } },
required: ["order_id", "reason"],
},
};
// Adapter returns a structured proposal instead of performing the refund.
async function propose_refund(input: { order_id: string; reason: string }) {
return JSON.stringify({ action: "refund", ...input, requires_confirmation: true });
}
> Heads up: do not enforce "ask before refunding" in the system prompt alone. Prompts are advisory; a jailbreak removes them. The guarantee comes from the tool simply not having the power to execute — it can only propose.
Assemble the server-side agent loop
Now combine the pieces into a loop that is identical in shape to a from-scratch agent, but wired to your real services. Tenancy is captured in the registry closure.
import Anthropic from "@anthropic-ai/sdk";
import { buildTools } from "./tools.js";
import { toolSchemas, refundSchema } from "./schemas.js";
const client = new Anthropic();
const MODEL = "claude-sonnet-4-6";
export async function runSupportAgent(userId: string, message: string): Promise {
const registry: Record Promise> = {
...buildTools(userId),
propose_refund: async (i) => JSON.stringify({ action: "refund", ...i, requires_confirmation: true }),
};
const tools = [...toolSchemas, refundSchema];
const messages: Anthropic.MessageParam[] = [{ role: "user", content: message }];
for (let turn = 0; turn < 8; turn++) {
const res = await client.messages.create({ model: MODEL, max_tokens: 1024, tools, messages });
messages.push({ role: "assistant", content: res.content });
if (res.stop_reason !== "tool_use") {
const t = res.content.find((b) => b.type === "text");
return t && t.type === "text" ? t.text : "(no answer)";
}
const results: Anthropic.ToolResultBlockParam[] = [];
for (const b of res.content) {
if (b.type !== "tool_use") continue;
console.log(`[support-agent] user=${userId} -> ${b.name}`);
let out: string;
try { out = await registry[b.name](b.input); }
catch (err) { console.error(`[support-agent] ${b.name} failed`, err); out = `Error: ${(err as Error).message}`; }
results.push({ type: "tool_result", tool_use_id: b.id, content: out });
}
messages.push({ role: "user", content: results });
}
return "Stopped: reached the turn limit.";
}
Expose it as one authenticated endpoint
The frontend never talks to the model. It talks to a single route that resolves the user, runs the agent, and returns { ok: true, data }.
// app/api/agent/route.ts (Next.js App Router)
import { NextResponse } from "next/server";
import { runSupportAgent } from "@/agent/loop";
import { getSessionUser } from "@/lib/auth";
export async function POST(req: Request) {
try {
const user = await getSessionUser();
if (!user) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const { message } = (await req.json()) as { message: string };
const answer = await runSupportAgent(user.id, message);
return NextResponse.json({ ok: true, data: { answer } });
} catch (err) {
console.error("[api/agent] failed", err);
return NextResponse.json({ ok: false, error: (err as Error).message }, { status: 500 });
}
}
> Heads up: keep ANTHROPIC_API_KEY strictly server-side. If it ever appears in a client bundle, rotate it immediately — anyone can drain your account with a leaked key.
Deploy the agent to Totalum
Because the agent is just a server route, you deploy it like any other endpoint in your Totalum project — no separate inference service to operate. Add the model key as a project secret and ship.
# Set the model key as a Totalum environment secret (never commit it).
# Totalum dashboard -> Project -> Environment variables:
# ANTHROPIC_API_KEY = sk-ant-...
# Then publish the project from the Totalum dashboard.
# Your route is now live at:
# https://.totalum.app/api/agent
From the client, call it through your existing fetch helper so the response shape stays consistent:
import { api } from "@/lib/api";
const res = await api.post("/api/agent", { message: "Where is my last order?" });
if (res.ok) console.log(res.data.answer);
Because Totalum runs your server code with its built-in environment and database, the same userId scoping and secrets you set up locally apply in production unchanged.
Verify your install
Log in as a test user and send a read request, then a write request, and confirm the write only proposes.
curl -X POST https://.totalum.app/api/agent \
-H "Content-Type: application/json" \
-H "Cookie: " \
-d '{"message":"Refund my last order, it arrived broken."}'
Expected: the server logs show search_orders then propose_refund, and the response contains a refund proposal with requires_confirmation: true — not an executed refund. If a refund actually runs, your write tool is doing work it should only propose; move the execution behind a separate confirmed endpoint.
Limitations and open questions
- Latency stacks up. Each tool round-trip adds a model call. Multi-step support answers can take several seconds; stream partial output to keep the UI responsive.
- The model can still be wrong about which order. Tool scoping protects data access, but the model may summarize the wrong record. Show the user the underlying records it used.
- Confirmation UX is unsolved here. We return a proposal; wiring the confirm-and-execute step (and preventing double execution) is product-specific work.
- Cost scales with tool verbosity. Returning whole records as JSON inflates context. Trim tool outputs to the fields the model actually needs.
The open question for most teams is how much autonomy to grant write tools over time. Start with propose-and-confirm for everything, then graduate individual low-risk actions to automatic once your evals show the agent is reliable on them.
Sources
- Anthropic, "Tool use with the Messages API" — official documentation, 2025.
- Anthropic, "Building effective agents" — patterns for tool design and human-in-the-loop, 2024.
- OWASP, "Top 10 for Large Language Model Applications" — prompt injection and excessive agency, 2025.
Written by
Ren OkabeRen builds agent infrastructure and writes copy-paste tutorials for engineers shipping LLM tool-use systems.
Frequently asked questions
Do I need to rewrite my app to add an agent?
No. The fastest path is to treat your existing service functions as tools. You wrap the functions you already have (list invoices, refund order, search docs) in thin tool adapters and let the model call them. Your business logic, auth, and database access stay exactly where they are.
Where should the agent run — client or server?
Always server-side. The agent needs your API keys and must enforce the same authorization your normal endpoints do. Expose it as a single authenticated route the frontend calls, never as client-side code holding model credentials.
How do I stop the agent from taking destructive actions?
Split tools into read and write tiers. Read tools run automatically; write tools (refunds, deletes, emails) return a proposed action that a human confirms, or are gated behind per-user permission checks inside the tool adapter itself — not in the prompt.
How do I scope the agent to the current user's data?
Pass the authenticated user's id into the tool layer as a closure or context, and filter every query by it inside the adapter. Never rely on the model to remember 'only this user' — enforce tenancy in code that the model cannot bypass.
Related tutorials
Build your first AI agent from scratch in 30 minutes
An AI agent is just a loop: you call a model, the model asks to run a tool, you run it, you feed the result back, and you repeat until the model is done. In this tutorial you build that loop yourself in plain TypeScript against the Anthropic Messages API — no framework. You will wire up two tools (read a file, run a calculation), let the model orchestrate them, add a turn cap and basic guardrails, then verify the whole thing end to end. The result is a small research agent you fully understand and can extend with your own tools.
Agent eval methodology: 5 metrics that actually catch regressions
Agents fail quietly: a prompt tweak that fixes one task often breaks three others, and manual spot-checks never re-test what used to work. The fix is a frozen eval set scored on every change. This tutorial builds that harness and tracks five metrics that actually catch regressions — task success rate, tool-call accuracy, step efficiency, cost per task, and a safety/guardrail rate. You will assemble an eval set, write a runner that scores each metric, and turn the before/after diff into a regression gate so a change only ships when the numbers hold or improve.