Customer support agent
A multi-turn support agent with tool-backed lookups, human escalation, and a per-customer audit trail you can hand to a regulator without grep.
A customer support agent is the use case the audit ledger was
shaped for. Every turn is attributable to a customer, every tool
call is one row, and every escalation has a turn_id you can
search by.
This page walks the end-to-end shape: tool surface, the multi-turn session, the escalation handoff, and the per-customer rollup query. It assumes you've read Getting started and SessionRuntime.
Related shapes. Research agent if support questions fan out into multiple parallel investigations. Regulated-domain agent if the support corpus contains PHI or PII. Multi-tenant SaaS agent if one runtime serves many customer accounts.
What you're building
A single agent that answers customer questions over an existing support session. It can:
- Look up the customer's account and recent orders.
- Cite policy text from a knowledge base.
- Escalate to a human, attaching the full turn trail.
The session lives across many turns. The audit ledger carries one
row per LLM call and one row per tool call, all keyed by
sessionId and turnId.
Tool surface
Three tools, each with Zod-validated input. The runtime serializes the schema into the prompt and validates returns before the model sees them.
// lib/tools/supportTools.ts
import { defineTool } from "@pleach/core";
import { z } from "zod";
// Caller identity (userId) is NOT on the tool context — it lives on the
// SessionRuntime scope. Close over it in a per-request factory so each tool
// is bound to the authenticated customer.
export function buildSupportTools(scope: { userId: string }) {
const lookupCustomer = defineTool({
name: "lookup_customer",
description: "Fetch the current customer's profile and plan.",
input: z.object({}),
output: z.object({
customerId: z.string(),
plan: z.enum(["free", "pro", "enterprise"]),
createdAt: z.string(),
}),
async handler() {
return await db.customers.byUser(scope.userId);
},
});
const lookupOrders = defineTool({
name: "lookup_orders",
description: "List the customer's last 10 orders, newest first.",
input: z.object({ limit: z.number().int().min(1).max(10).default(10) }),
output: z.array(z.object({
id: z.string(),
status: z.enum(["pending", "shipped", "delivered", "cancelled"]),
total: z.number(),
placedAt: z.string(),
})),
async handler({ limit }) {
return await db.orders.recent({ userId: scope.userId, limit });
},
});
const escalateToHuman = defineTool({
name: "escalate_to_human",
description: "Page a human support agent. Use when the customer asks for one, or when policy requires it.",
input: z.object({
reason: z.string().min(8),
severity: z.enum(["low", "medium", "high"]),
}),
output: z.object({ ticketId: z.string() }),
async handler({ reason, severity }, ctx) {
const ticket = await pagerDuty.createTicket({
userId: scope.userId,
toolCallId: ctx.toolCallId,
reason,
severity,
});
return { ticketId: ticket.id };
},
});
return [lookupCustomer, lookupOrders, escalateToHuman];
}The caller's userId comes from the per-request scope the factory closes
over — it is not on the tool ctx, which carries only the invocation's
toolCallId and abort signal. The escalation tool stamps that
toolCallId on the human-facing ticket so the responding agent can pull the
full turn trail from the ledger.
Runtime construction
A per-request runtime, scoped to the authenticated customer.
Storage carries organization_id, the fingerprint carries
tenantId, and each row in the audit ledger gets both for free.
// lib/runtime.ts
import { SessionRuntime, AiSdkProvider, definePleachPlugin } from "@pleach/core";
import { SupabaseAdapter } from "@pleach/core/sessions";
import { SupabaseSaver } from "@pleach/core/checkpointing";
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
const openrouter = createOpenRouter({ apiKey: process.env.OPENROUTER_API_KEY! });
export function buildSupportRuntime(req: AuthedRequest) {
return new SessionRuntime({
storage: new SupabaseAdapter({ client: supabase }),
checkpointer: new SupabaseSaver({ client: supabase }),
provider: new AiSdkProvider({
model: openrouter("anthropic/claude-sonnet-4-5"),
maxSteps: 5,
}),
plugins: [definePleachPlugin("support-tools", {
tools: buildSupportTools({ userId: req.userId }),
safetyPolicies: [piiRedaction, escalationOnlyOnRequest],
})],
userId: req.userId,
organizationId: req.organizationId,
tenantId: req.tenantId,
});
}Running a turn
The frontend calls runtime.runTurn with the customer's message.
The runtime drives the tool loop, streams events to the channel,
and writes the audit rows on commit.
const events = runtime.runTurn({
sessionId,
message: req.body.message,
});
for await (const ev of events) {
if (ev.type === "text-delta") res.write(ev.delta);
if (ev.type === "tool-call") log.info({ tool: ev.name }, "tool fired");
if (ev.type === "tool-result") log.info({ tool: ev.name, ok: ev.ok }, "tool returned");
if (ev.type === "turn-complete") res.end();
}See Stream events for the full event taxonomy.
The per-customer rollup
Every row in harness_auditable_calls carries userId,
tenantId, sessionId, and turnId. One GROUP BY answers
"what did this customer cost us this month?"
select
user_id,
count(*) filter (where call_kind = 'llm') as llm_calls,
count(*) filter (where call_kind = 'tool') as tool_calls,
sum(input_tokens) as input_tokens,
sum(output_tokens) as output_tokens
from harness_auditable_calls
where tenant_id = $1
and created_at >= date_trunc('month', now())
group by user_id;For escalations specifically, join on payload->>'toolName' = 'escalate_to_human'
and you have a per-customer escalation rate broken down by reason.
The replay path
When a customer complains about an answer, the engineer doesn't guess. The turn is recorded; replay it against the current code and look at the diff.
import { createReplayRuntime } from "@pleach/replay";
const replayRuntime = createReplayRuntime({
sessionRuntime: runtime,
tenantId: req.tenantId,
});
const diff = await replayRuntime.replayTurn({
chatId: sessionId,
tenantId: req.tenantId,
messageId: turnId,
});
// `diff.state` is the reconstructed HydratedHarnessState (typed `unknown`) —
// the tools that fired this time plus the model's answer.
console.log(diff.state);runtimeMode gates this — record writes
the inputs that replay will replay. The split is a single
constructor flag, not a code path fork.
Safety policies you'll want
- PII redaction before the prompt hits the provider. The ledger stores the redacted form via scrubbers; the original never leaves your process.
- Escalation-only-on-request: the model isn't allowed to
call
escalate_to_humanunless the customer's message matched a trigger phrase. Enforced as a safety policy, not a prompt instruction. - Tool-scope-by-plan: free-plan users don't have access to
escalate_to_human. Drop it from the tool list at runtime construction; the model never sees it.
See Safety policies for the contribution shape.
Project layout
The baseline layout fits unchanged. What's load-bearing is the split of the tools directory — one file per integration — and the durable storage adapter the audit row needs to survive between agent runs and QA reads.
my-app/
src/
pleach/
runtime.ts # SessionRuntime + SupabaseAdapter + SupabaseSaver
tools/
helpdesk.ts # defineTool — your ticketing API
billing.ts # defineTool — your billing API
escalate.ts # defineTool — escalate_to_human, plan-gated
safety/
pii-redaction.ts # → /docs/scrubbers
escalation-gate.ts # → /docs/safety
app/
api/agents/[id]/route.ts # → /docs/api-routes
qa/
rollup.sql # the per-customer GROUP BY against the ledgerWhat changes from the baseline:
tools/splits by integration, not by capability. One file per upstream API means the BYOK story (per-tenant credentials) has a single place to wire and a single place to revoke.safety/is a real directory, not a placeholder. PII redaction and the escalation gate are safety policies — capability-subtracting, not prompt instructions. Lint-checkable as a result.- Storage must be durable. The
per-customer rollup and the
replay path both query the audit ledger
weeks after the turn ran.
MemoryProviderDecisionLedgerloses every row at restart — wrong default for this shape. qa/rollup.sqllives in the repo, not in a dashboard tool. When the column set onharness_auditable_callschanges, the query updates in the same PR.
Where to go next
Multi-tenant SaaS agent
One runtime per request, tenant-scoped storage, BYOK, and a billing rollup that's one GROUP BY against the ledger.
Audit ledger
The harness_auditable_calls schema behind the per-customer rollups.
Interrupts
Human-in-the-loop approval before a tool call commits.
Eval and replay
runtimeMode, recording, and the diff engine that powers the complaint-investigation path.
evalLab
Runtime + EvalSuite + (optional) ReplayClient trio wired for research reproducibility. The eval / research lab recipe — supply the model config, the eval suite cases, and a replay-cache backend, and re-derive the report months later without re-running the LLM.
Deep research agent
An anchor agent that dispatches bounded subagents, with depth-tracked fanout and a turn-rooted tree you can read top-down from the ledger.