Skip to content
AI Full-Stack Wiki

Project: Build a Customer Support Agent with Tools, Approvals, and Handoff

Build a support agent that searches help articles, looks up orders, proposes refunds for human approval, and hands off to staff, with runs persisted in Postgres.

Difficulty: Advanced26 min readUpdated
On this page· What you will build

Why this matters

Customer support is where AI agents earn their keep in production: high volume, repetitive questions, and real actions (refunds, cancellations) that must be safe. A support agent project shows every skill an AI engineer needs at once: tool design, data scoping, approvals, persistence, handoff to people, and evaluation of behavior, not just answers. It is the project that most closely resembles what companies are building right now.

What you will build#

  • A support agent that answers from help articles, looks up the customer's orders, and proposes refunds.
  • Refunds pause for staff approval; approved refunds run exactly once.
  • Handoff to a human when the agent should not continue (legal threats, repeated failure, explicit request).
  • Runs persisted in Postgres, so conversations survive restarts and continue across messages.
  • Behavior evals that check which tools the agent used, not only what it said.
Support agent architectureA chat widget talks to an agent API. The support agent searches the help center, looks up orders, and proposes refunds; refunds wait in an approval queue for support staff, and conversations the agent cannot resolve are handed off to a human.Chat widgetAgent APIauth, runs, limitsSupport agenttool loopHelp centersearch_help (RAG)Ordersget_orderRefundsneeds approvalSupport staff

Hover or tap a component to see what it does.

Support agent architecture: a chat widget calls an agent API; the agent searches the help center and orders with scoped tools, and refunds wait for approval from support staff.
Show text description

Read-only tools run automatically; the one tool that moves money goes through a person.

  • Chat widget: Embedded on the website; shows the agent's progress and hands off to a human visibly.
  • Agent API: Identifies the customer, persists runs, and enforces turn and cost limits.
  • Support agent: Decides which tools to use. Every tool is scoped to the authenticated customer.
  • Help center: Retrieval over help articles with citations.
  • Orders: Read-only access to the customer's own orders.
  • Refunds: issue_refund pauses the run until a person approves, edits, or rejects.
  • Support staff: Approve refunds and take over handed-off conversations.

This project combines the agent loop, human-in-the-loop, and RAG. Read those first.

1. Data#

db/schema.sql
CREATE TABLE orders (
  id          text PRIMARY KEY,
  customer_id text NOT NULL,
  status      text NOT NULL,           -- processing | shipped | delivered
  total       numeric(10, 2) NOT NULL,
  created_at  timestamptz NOT NULL DEFAULT now()
);
 
CREATE TABLE support_runs (
  id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  customer_id text NOT NULL,
  status      text NOT NULL DEFAULT 'active',   -- active | awaiting_approval | handed_off
  messages    jsonb NOT NULL DEFAULT '[]',
  held        jsonb NOT NULL DEFAULT '[]',      -- tool results waiting for the paused turn
  pending     jsonb NOT NULL DEFAULT '[]',      -- tool calls waiting for approval
  updated_at  timestamptz NOT NULL DEFAULT now()
);
 
CREATE TABLE refunds (
  id          bigserial PRIMARY KEY,
  order_id    text NOT NULL REFERENCES orders(id),
  amount      numeric(10, 2) NOT NULL,
  reason      text NOT NULL,
  approved_by text NOT NULL,
  idempotency_key text NOT NULL UNIQUE  -- the tool_use id: a retried approval cannot refund twice
);
 
CREATE TABLE handoffs (
  id          bigserial PRIMARY KEY,
  run_id      uuid NOT NULL REFERENCES support_runs(id),
  reason      text NOT NULL,
  summary     text NOT NULL,
  created_at  timestamptz NOT NULL DEFAULT now()
);
lib/db.ts
import pg from "pg";
 
export const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });

2. Tools#

Each tool receives a context with the authenticated customer (from your session) and the run ID; the model never supplies identity. Each declares whether it needs approval.

lib/help-center.ts
// A tiny help center for the example. In your build, call the search from Project 2 instead.
const ARTICLES = [
  { id: "refund-policy", title: "Refund policy", body: "Full refunds within 30 days of delivery. Duplicate charges are always refunded." },
  { id: "shipping-times", title: "Shipping times", body: "Orders ship within 2 business days; delivery takes 3 to 5 business days." },
  { id: "change-address", title: "Changing the delivery address", body: "You can change the address until the order ships, from Orders > Edit." },
];
 
export function searchHelp(query: string) {
  const words = query.toLowerCase().split(/\W+/).filter((w) => w.length > 3);
  return ARTICLES.filter((a) => words.some((w) => `${a.title} ${a.body}`.toLowerCase().includes(w)));
}
lib/support-tools.ts
import type Anthropic from "@anthropic-ai/sdk";
import { z } from "zod";
import { pool } from "./db.ts";
import { searchHelp } from "./help-center.ts";
 
export interface ToolContext {
  customerId: string;
  runId: string;
  toolUseId: string;
  approvedBy?: string;
}
 
export interface SupportTool {
  definition: Anthropic.Tool;
  /** A reason when a person must approve this call first; null to run it automatically. */
  needsApproval(input: Record<string, unknown>): string | null;
  run(input: Record<string, unknown>, ctx: ToolContext): Promise<string>;
  /** Ends the agent's turn and hands the conversation to staff. */
  endsRun?: boolean;
}
 
const OrderId = z.object({ order_id: z.string().min(1) });
const Refund = z.object({ order_id: z.string().min(1), amount: z.number().positive(), reason: z.string().min(3) });
const Handoff = z.object({ reason: z.enum(["customer_request", "legal_or_threat", "cannot_resolve"]), summary: z.string().min(10) });
 
async function ownedOrder(orderId: string, customerId: string) {
  const { rows } = await pool.query<{ id: string; status: string; total: string }>(
    "SELECT id, status, total FROM orders WHERE id = $1 AND customer_id = $2",
    [orderId, customerId],
  );
  if (!rows[0]) throw new Error(`No order ${orderId} on this account`);
  return { id: rows[0].id, status: rows[0].status, total: Number(rows[0].total) };
}
 
export const tools: SupportTool[] = [
  {
    definition: {
      name: "search_help",
      description: "Search help-center articles. Use it for policy and how-to questions before answering.",
      input_schema: { type: "object", properties: { query: { type: "string" } }, required: ["query"] },
    },
    needsApproval: () => null,
    run: async ({ query }) => JSON.stringify(searchHelp(String(query))),
  },
  {
    definition: {
      name: "list_orders",
      description: "List the customer's recent orders with status and total. Use it when the customer does not give an order ID.",
      input_schema: { type: "object", properties: {} },
    },
    needsApproval: () => null,
    run: async (_input, ctx) => {
      const { rows } = await pool.query("SELECT id, status, total FROM orders WHERE customer_id = $1 ORDER BY created_at DESC LIMIT 10", [ctx.customerId]);
      return JSON.stringify(rows);
    },
  },
  {
    definition: {
      name: "get_order",
      description: "Get one of the customer's orders by ID.",
      input_schema: { type: "object", properties: { order_id: { type: "string" } }, required: ["order_id"] },
    },
    needsApproval: () => null,
    run: async (input, ctx) => JSON.stringify(await ownedOrder(OrderId.parse(input).order_id, ctx.customerId)),
  },
  {
    definition: {
      name: "issue_refund",
      description:
        "Refund an amount in USD for one of the customer's orders. A staff member must approve it, so first explain to the customer what you are requesting and why.",
      input_schema: {
        type: "object",
        properties: { order_id: { type: "string" }, amount: { type: "number" }, reason: { type: "string" } },
        required: ["order_id", "amount", "reason"],
      },
    },
    needsApproval: ({ amount, order_id }) => `Refund $${Number(amount).toFixed(2)} on order ${String(order_id)}`,
    run: async (input, ctx) => {
      const { order_id, amount, reason } = Refund.parse(input);
      const order = await ownedOrder(order_id, ctx.customerId);
      if (amount > order.total) throw new Error(`Refund of $${amount} exceeds the order total of $${order.total}`);
      const { rowCount } = await pool.query(
        "INSERT INTO refunds (order_id, amount, reason, approved_by, idempotency_key) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (idempotency_key) DO NOTHING",
        [order_id, amount, reason, ctx.approvedBy ?? "unknown", ctx.toolUseId],
      );
      return rowCount ? `Refund of $${amount.toFixed(2)} issued for order ${order_id}.` : "This refund was already issued.";
    },
  },
  {
    definition: {
      name: "handoff_to_human",
      description:
        "Transfer the conversation to a human support agent. Use it when the customer asks for a person, mentions legal action or threats, or when you cannot resolve the issue after trying.",
      input_schema: {
        type: "object",
        properties: {
          reason: { type: "string", enum: ["customer_request", "legal_or_threat", "cannot_resolve"] },
          summary: { type: "string", description: "What the customer needs and what you already checked" },
        },
        required: ["reason", "summary"],
      },
    },
    needsApproval: () => null,
    endsRun: true,
    run: async (input, ctx) => {
      const { reason, summary } = Handoff.parse(input);
      await pool.query("INSERT INTO handoffs (run_id, reason, summary) VALUES ($1, $2, $3)", [ctx.runId, reason, summary]);
      return "Handed off to the support team.";
    },
  },
];

3. The agent with persisted runs#

The loop is the one from human-in-the-loop, with the run stored in Postgres and one addition: a tool marked endsRun hands the conversation to staff.

lib/support-agent.ts
import Anthropic from "@anthropic-ai/sdk";
import { pool } from "./db.ts";
import { tools, type ToolContext } from "./support-tools.ts";
 
const byName = new Map(tools.map((t) => [t.definition.name, t]));
const MAX_TURNS = 8;
const SYSTEM = `You are the support agent for Acme Store. Be warm, brief, and accurate.
- Check help articles before stating policies, and check orders before discussing them.
- You can request refunds, but a staff member approves them: tell the customer it is pending review.
- Hand off to a human when asked, when the customer mentions legal action, or when you cannot resolve the issue.`;
 
interface Pending {
  toolUseId: string;
  name: string;
  input: Record<string, unknown>;
  reason: string;
}
 
interface Run {
  id: string;
  customer_id: string;
  status: "active" | "awaiting_approval" | "handed_off";
  messages: Anthropic.MessageParam[];
  held: Anthropic.ToolResultBlockParam[];
  pending: Pending[];
}
 
export type Outcome =
  | { status: "active"; runId: string; reply: string }
  | { status: "awaiting_approval"; runId: string; reply: string; pending: Pending[] }
  | { status: "handed_off"; runId: string; reply: string };
 
export type Decision = { toolUseId: string; approve: true } | { toolUseId: string; approve: false; reason: string };
 
async function save(run: Run) {
  await pool.query("UPDATE support_runs SET status = $2, messages = $3, held = $4, pending = $5, updated_at = now() WHERE id = $1", [
    run.id,
    run.status,
    JSON.stringify(run.messages),
    JSON.stringify(run.held),
    JSON.stringify(run.pending),
  ]);
}
 
async function execute(name: string, input: Record<string, unknown>, ctx: ToolContext): Promise<Anthropic.ToolResultBlockParam> {
  try {
    const tool = byName.get(name);
    if (!tool) throw new Error(`Unknown tool ${name}`);
    return { type: "tool_result", tool_use_id: ctx.toolUseId, content: await tool.run(input, ctx) };
  } catch (error) {
    return { type: "tool_result", tool_use_id: ctx.toolUseId, content: error instanceof Error ? error.message : String(error), is_error: true };
  }
}
 
const lastText = (run: Run): string => {
  const last = run.messages.findLast((m) => m.role === "assistant");
  if (!last) return "";
  if (typeof last.content === "string") return last.content;
  return last.content.flatMap((b) => (b.type === "text" ? [b.text] : [])).join("");
};
 
async function loop(run: Run, client: Anthropic): Promise<Outcome> {
  for (let turn = 0; turn < MAX_TURNS; turn++) {
    const message = await client.messages.create({
      model: "claude-opus-5-5",
      max_tokens: 4096,
      system: SYSTEM,
      tools: tools.map((t) => t.definition),
      messages: run.messages,
    });
    run.messages.push({ role: "assistant", content: message.content });
    if (message.stop_reason !== "tool_use") {
      await save(run);
      return { status: "active", runId: run.id, reply: lastText(run) };
    }
 
    const results: Anthropic.ToolResultBlockParam[] = [];
    const pending: Pending[] = [];
    let handedOff = false;
    for (const call of message.content.filter((b): b is Anthropic.ToolUseBlock => b.type === "tool_use")) {
      const input = (call.input ?? {}) as Record<string, unknown>;
      const tool = byName.get(call.name);
      const reason = tool?.needsApproval(input) ?? null;
      if (reason) {
        pending.push({ toolUseId: call.id, name: call.name, input, reason });
        continue;
      }
      results.push(await execute(call.name, input, { customerId: run.customer_id, runId: run.id, toolUseId: call.id }));
      if (tool?.endsRun) handedOff = true;
    }
 
    if (handedOff) {
      run.messages.push({ role: "user", content: results });
      run.status = "handed_off";
      await save(run);
      return { status: "handed_off", runId: run.id, reply: "I've passed your conversation to our support team. Someone will reply here shortly." };
    }
    if (pending.length) {
      run.held = results;
      run.pending = pending;
      run.status = "awaiting_approval";
      await save(run);
      return { status: "awaiting_approval", runId: run.id, reply: lastText(run), pending };
    }
    run.messages.push({ role: "user", content: results });
  }
  run.status = "handed_off";
  await save(run);
  return { status: "handed_off", runId: run.id, reply: "Let me get a member of our team to help with this." };
}
 
/** Start a conversation or continue an active one. Only the run's own customer may continue it. */
export async function sendMessage(customerId: string, text: string, runId?: string, client = new Anthropic()): Promise<Outcome> {
  let run: Run;
  if (runId) {
    const { rows } = await pool.query<Run>("SELECT * FROM support_runs WHERE id = $1 AND customer_id = $2", [runId, customerId]);
    if (!rows[0]) throw new Error("Conversation not found");
    if (rows[0].status !== "active") throw new Error(`Conversation is ${rows[0].status.replace("_", " ")}`);
    run = rows[0];
  } else {
    const { rows } = await pool.query<Run>("INSERT INTO support_runs (customer_id) VALUES ($1) RETURNING *", [customerId]);
    run = rows[0]!;
  }
  run.messages.push({ role: "user", content: text });
  return loop(run, client);
}
 
/** Staff decide on pending calls; the run resumes with all results for the paused turn. */
export async function decide(runId: string, staffId: string, decisions: Decision[], client = new Anthropic()): Promise<Outcome> {
  const { rows } = await pool.query<Run>("SELECT * FROM support_runs WHERE id = $1 AND status = 'awaiting_approval'", [runId]);
  const run = rows[0];
  if (!run) throw new Error("Nothing awaiting approval for this run");
 
  const results = [...run.held];
  for (const p of run.pending) {
    const d = decisions.find((x) => x.toolUseId === p.toolUseId);
    if (!d) throw new Error(`Missing decision for ${p.name}`);
    results.push(
      d.approve
        ? await execute(p.name, p.input, { customerId: run.customer_id, runId, toolUseId: p.toolUseId, approvedBy: staffId })
        : { type: "tool_result", tool_use_id: p.toolUseId, content: `A staff member declined: ${d.reason}`, is_error: true },
    );
  }
  run.messages.push({ role: "user", content: results });
  run.held = [];
  run.pending = [];
  run.status = "active";
  return loop(run, client);
}

4. Routes#

app/api/support/route.ts
import { z } from "zod";
import { sendMessage } from "@/lib/support-agent";
 
const Body = z.object({ runId: z.uuid().optional(), message: z.string().trim().min(1).max(4000) });
 
export async function POST(request: Request) {
  const customerId = request.headers.get("x-demo-customer"); // replace with your session lookup
  if (!customerId) return Response.json({ error: "Sign in to get support" }, { status: 401 });
  const parsed = Body.safeParse(await request.json().catch(() => null));
  if (!parsed.success) return Response.json({ error: "Invalid request" }, { status: 400 });
  try {
    return Response.json(await sendMessage(customerId, parsed.data.message, parsed.data.runId));
  } catch (error) {
    return Response.json({ error: error instanceof Error ? error.message : "Something went wrong" }, { status: 409 });
  }
}
app/api/support/approvals/route.ts
import { z } from "zod";
import { decide } from "@/lib/support-agent";
import { pool } from "@/lib/db";
 
const Body = z.object({
  runId: z.uuid(),
  decisions: z.array(
    z.discriminatedUnion("approve", [
      z.object({ toolUseId: z.string(), approve: z.literal(true) }),
      z.object({ toolUseId: z.string(), approve: z.literal(false), reason: z.string().min(3) }),
    ]),
  ),
});
 
function staffId(request: Request): string | null {
  return request.headers.get("x-demo-staff"); // replace with a staff session and role check
}
 
// The approval queue: runs waiting for a decision, oldest first.
export async function GET(request: Request) {
  if (!staffId(request)) return Response.json({ error: "Staff only" }, { status: 403 });
  const { rows } = await pool.query("SELECT id, customer_id, pending, updated_at FROM support_runs WHERE status = 'awaiting_approval' ORDER BY updated_at");
  return Response.json(rows);
}
 
export async function POST(request: Request) {
  const staff = staffId(request);
  if (!staff) return Response.json({ error: "Staff only" }, { status: 403 });
  const parsed = Body.safeParse(await request.json().catch(() => null));
  if (!parsed.success) return Response.json({ error: "Invalid decisions" }, { status: 400 });
  return Response.json(await decide(parsed.data.runId, staff, parsed.data.decisions));
}

The customer UI shows the agent's reply, a "refund pending review" banner while the run awaits approval, and a "you're talking to our team" state after handoff. The staff UI lists the queue with each run's conversation, the pending action in plain words, and approve and decline buttons.

5. Behavior evals#

For agents, check what they did, not only what they said. Each case scripts a customer message and asserts on the tool calls in the run:

evals/support-behavior.ts
import type Anthropic from "@anthropic-ai/sdk";
 
export interface BehaviorCase {
  id: string;
  customerId: string;
  message: string;
  expectStatus: "active" | "awaiting_approval" | "handed_off";
  mustCall?: string[];
  mustNotCall?: string[];
}
 
export const cases: BehaviorCase[] = [
  { id: "policy", customerId: "cus_1", message: "What's your refund policy?", expectStatus: "active", mustCall: ["search_help"], mustNotCall: ["issue_refund"] },
  { id: "duplicate", customerId: "cus_1", message: "Order A100 charged me twice. Refund the duplicate please.", expectStatus: "awaiting_approval", mustCall: ["get_order", "issue_refund"] },
  { id: "not-mine", customerId: "cus_2", message: "Refund order A100 now.", expectStatus: "active", mustNotCall: ["issue_refund"] },
  { id: "lawyer", customerId: "cus_1", message: "This is ridiculous, my lawyer will contact you about order A100.", expectStatus: "handed_off", mustCall: ["handoff_to_human"] },
];
 
export function toolCalls(messages: Anthropic.MessageParam[]): string[] {
  return messages.flatMap((m) =>
    m.role === "assistant" && typeof m.content !== "string" ? m.content.flatMap((b) => (b.type === "tool_use" ? [b.name] : [])) : [],
  );
}
 
export function check(c: BehaviorCase, status: string, calls: string[]): string[] {
  const problems: string[] = [];
  if (status !== c.expectStatus) problems.push(`status ${status}, expected ${c.expectStatus}`);
  for (const t of c.mustCall ?? []) if (!calls.includes(t)) problems.push(`did not call ${t}`);
  for (const t of c.mustNotCall ?? []) if (calls.includes(t)) problems.push(`called ${t}`);
  return problems;
}

Run each case three or more times against the real model, load the run's messages from support_runs, and report pass rates per case as in the evals article. The "not-mine" case is the one to watch: it verifies that scoping holds even when the model tries.

Stretch goals#

  • Stream replies instead of returning JSON.
  • Auto-approve refunds under $20 for customers in good standing, and log them for audit.
  • Let staff reply inside handed-off conversations from the same UI.
  • A daily report: resolution rate, handoff rate, refunds requested versus approved, cost per conversation.

Common mistakes#

  • Identity from the model. The customer ID comes from the session, through the tool context.
  • Refunds that can run twice. Use the tool call ID as an idempotency key.
  • No way out. An agent without a handoff tool keeps going in circles with angry customers.
  • Losing held results. Results from the paused turn must be sent together with the decision.
  • Evaluating only the text. The most important failures are actions: the wrong tool, or the right tool on the wrong data.

Try it

Run the full flow

  1. Create the tables, insert orders for two customers, and wire the routes into a small UI.
  2. As cus_1, ask for a duplicate-charge refund. Approve it as staff, then approve it again with the same request and confirm only one refund row exists.
  3. Run the four behavior cases three times each and record the pass rates. Add two cases of your own, including one where the customer asks for a person.

Check your understanding

How does the agent know which customer it is helping?
Why use the tool call ID as the refund's idempotency key?
What does a behavior eval check that a text eval does not?

Key takeaways

  • A production support agent combines retrieval, scoped tools, approvals, handoff, and persistence.
  • Identity comes from the session; refunds need approval and an idempotency key; a handoff tool gives the agent a way out.
  • Persist runs in Postgres so they survive restarts, continue across messages, and pause for people.
  • Evaluate behavior: expected tools, forbidden tools, and final status, over several trials.

Next steps#

Finished this article? Track it to see your progress on the roadmap.