# Customer support agent (/docs/customer-support-agent)



A customer support agent is the use case the [audit ledger](/docs/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](/docs/getting-started)
and [SessionRuntime](/docs/session-runtime).

**Related shapes.** [Research agent](/docs/research-agent) if
support questions fan out into multiple parallel investigations.
[Regulated-domain agent](/docs/regulated-domain-agent) if the
support corpus contains PHI or PII.
[Multi-tenant SaaS agent](/docs/multi-tenant-saas-agent) if one
runtime serves many customer accounts.

## What you're building [#what-youre-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 [#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.

```typescript
// 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 [#runtime-construction]

A per-request runtime, scoped to the authenticated customer.
[Storage](/docs/storage) carries `organization_id`, the [fingerprint](/docs/fingerprint) carries
`tenantId`, and each row in the audit ledger gets both for free.

```typescript
// 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 [#running-a-turn]

The frontend calls `runtime.runTurn` with the customer's message.
The runtime drives the tool loop, streams events to the [channel](/docs/channels),
and writes the audit rows on commit.

```typescript
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](/docs/stream-events) for the full event taxonomy.

## The per-customer rollup [#the-per-customer-rollup]

Every row in [`harness_auditable_calls`](/docs/auditable-call-row) carries `userId`,
`tenantId`, `sessionId`, and `turnId`. One `GROUP BY` answers
"what did this customer cost us this month?"

```sql
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 [#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.

```typescript
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`](/docs/eval-and-replay) 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 [#safety-policies-youll-want]

* **PII redaction** before the prompt hits the provider. The
  ledger stores the redacted form via [scrubbers](/docs/scrubbers); the original never leaves your
  process.
* **Escalation-only-on-request**: the model isn't allowed to
  call `escalate_to_human` unless 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](/docs/safety) for the contribution shape.

## Project layout [#project-layout]

The [baseline layout](/docs/project-layout#a-layout-that-works)
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 ledger
```

What 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](/docs/safety) — capability-subtracting, not
  prompt instructions. Lint-checkable as a result.
* **Storage must be durable.** The
  [per-customer rollup](#the-per-customer-rollup) and the
  [replay path](#the-replay-path) both query the audit ledger
  weeks after the turn ran. `MemoryProviderDecisionLedger` loses
  every row at restart — wrong default for this shape.
* **`qa/rollup.sql` lives in the repo**, not in a dashboard tool.
  When the column set on `harness_auditable_calls` changes, the
  query updates in the same PR.

## Where to go next [#where-to-go-next]

<Cards>
  <Card title="Multi-tenant SaaS agent" href="/docs/multi-tenant-saas-agent" description="One runtime per request, tenant-scoped storage, BYOK, and a billing rollup that's one GROUP BY against the ledger." />

  <Card title="Audit ledger" href="/docs/audit-ledger" description="The harness_auditable_calls schema behind the per-customer rollups." />

  <Card title="Interrupts" href="/docs/interrupts" description="Human-in-the-loop approval before a tool call commits." />

  <Card title="Eval and replay" href="/docs/eval-and-replay" description="runtimeMode, recording, and the diff engine that powers the complaint-investigation path." />
</Cards>
