pleach
Cookbook

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_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 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 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 — 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. 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

On this page