Skip to content
ONTO

AI Customer Support That Doesn't Hallucinate Refunds

How to ship a customer support agent that remembers each customer, proposes refunds and escalations through Plan Mode, and auto-approves only when policy says so.

· 9 min read · ONTO team

The standard customer support AI pattern fails in two specific ways. First, the agent forgets the customer between sessions. Second, when it does take action — like issuing a refund — it does so autonomously, and sometimes wrongly.

This post walks through a customer support agent architecture that fixes both with ONTO’s runtime primitives. It’s based on the customer-support example in the framework repo.

The two failure modes

Forgetting the customer. Conversation buffer memory dies with the session. Vector retrieval finds “similar past conversations” but not “what this customer’s tier is right now.” Result: the agent re-derives the customer’s account state from scratch each time, badly.

Autonomous refunds. A customer asks for a refund. The agent looks at the case, decides “yes, refund $500,” and calls the refund tool. There was no policy check. Maybe the customer asked too many times this quarter. Maybe their account is flagged. Maybe the model just hallucinated the right thing to do. By the time anyone notices, the refund is processed.

Both failures share a root cause: the agent has authority it shouldn’t have. The fix is structural — separate “agent proposes” from “system decides.”

The agent loop, sketched

import { Onto, Models, BuiltinTools } from "@onto/core";

const onto = new Onto({
  model: Models.GPT_OSS_20B,
  builtinTools: BuiltinTools.STANDARD,
});

async function handleMessage(tenantId: string, accountId: string, message: string) {
  // 1. Load the account's durable UPO. Extract any new facts from the message.
  await onto.run(message, {
    userId: accountId,
    tenantId,
    consentScopes: ["memory:write", "support:read"],
    extract: true,
  });

  // 2. Plan: propose actions (refund, escalation, knowledge base reply, ...).
  const plan = await onto.run("Plan response to this customer.", {
    userId: accountId,
    tenantId,
    mode: "plan_only",
    consentScopes: ["tasks:write", "support:read"],
  });

  // 3. Apply policy. The LLM doesn't decide; the policy function does.
  const account = await loadAccount(accountId);
  const decision = policy.classify(plan, account);

  if (decision === "auto-approve") {
    await onto.run("Execute the approved plan.", {
      userId: accountId,
      tenantId,
      consentScopes: ["support:refund", "support:notify"],
    });
  } else {
    await escalateToHuman(plan, account);
  }
}

The memory layer

Each account has a UPO partition (tenant_id + user_id). At conversation start, the agent loads the account’s typed facts:

account.tier          = "gold"
account.signup_year   = 2023
account.escalation_history = ["billing_2025_03", "shipping_2025_11"]
account.preferred_channel = "email"
account.refunds_ytd   = 1

These are typed assertions, not chat history. They’re queryable with one Postgres lookup. The agent’s prompt is a few hundred tokens — not 50k of conversation buffer.

When the customer says something new (“I never received the package”), extract=True writes a typed assertion: (account_42, claimed_non_delivery, package_X44, confidence=0.85). Next session, this is part of the account state.

The policy function

Policy lives outside the LLM. It’s plain TypeScript:

function classify(plan: Plan, account: Account): "auto-approve" | "human-review" {
  const refund = plan.tasks.find(t => t.tool === "issue_refund");
  if (!refund) return "auto-approve";   // Nothing to gate.

  // Auto-approval gate: gold tier, low value, not already refunded this quarter.
  const value = refund.args.amount;
  const recentRefunds = account.refunds_ytd;

  if (account.tier === "gold" && value < 50 && recentRefunds < 3) {
    return "auto-approve";
  }
  if (account.tier !== "gold" && value < 10 && recentRefunds < 1) {
    return "auto-approve";
  }
  return "human-review";
}

The model doesn’t know this logic. The model proposes; the policy decides. Two consequences:

  1. You can change the policy without retraining or re-prompting the model.
  2. The audit trail records the policy decision separately from the model proposal.

When something goes wrong, you know whether it was a bad proposal or a bad policy — they’re different fixes.

The multi-tenant story

In SaaS support, the worst-case failure is cross-tenant data leak. The agent talks to a Tenant A user; the model’s context somehow includes a snippet from Tenant B. Catastrophic.

ONTO’s structural fix: every UPO read and write carries tenant_id. Cross-tenant reads refuse with a structured error — no silent fall-through. The Policy Guard checks tenant_id on every tool call.

For shared resources (like a knowledge base that all tenants can read), declare a tool with required_consent=["kb:read"] and grant it broadly. For per-tenant resources, the tenant_id must match.

This means you can audit “did any cross-tenant read happen this month” with a simple query against the runtime’s denial log. The answer is “no” by construction unless an explicit grant was made.

The cost meter

Multi-tenant SaaS bills the customer for the usage. The vendor bill — OpenAI / Anthropic / Ollama Cloud — is gross. You need to attribute it per tenant.

const cost = await onto.cost({ tenantId: "acme-corp" });
// {
//   foreground: { tokens: 1_234_567, dollars: 4.92 },
//   background: { tokens: 8_456_021, dollars: 1.08 },  // cheaper model
// }

Foreground vs background split matters: foreground is the user-facing turn; background is summarization, conflict detection, and consolidation. They can use different models. They’re billed separately.

This is how you turn an AI vendor bill into a tenant invoice.

What still requires human review

Three categories where Plan Mode should never auto-approve:

  1. High-dollar refunds. Threshold depends on your business; choose conservatively.
  2. Account terminations. Always human-reviewed.
  3. Anything legal-adjacent. Disputes, complaints, regulatory matters — escalate.

For these, Plan Mode produces tasks; the human review UI shows them; an authorized agent approves. The audit log records the human approval.

What ships for free

Building this from scratch on a generic LLM API would take months. With ONTO:

  • Multi-tenant memory: built in
  • Plan Mode with typed tasks: built in
  • Policy Guard with consent scopes: built in
  • Cost meter with foreground/background split: built in
  • Audit log: built in
  • Background summarization on a cheaper model: built in

You write the policy function and the human review UI. Everything else is configuration.

A summary you can paste in a design doc

The support agent runs in two modes. In Plan Mode, it loads typed per-account memory, extracts new facts from the customer’s message, and proposes a list of tasks (reply, refund, escalation). A policy function — outside the LLM — classifies each task as auto-approve or human-review. Auto-approve tasks fire in Execute Mode with appropriate consent scopes. Human-review tasks go to a queue. The agent has no authority to take action without classification; it has authority to propose actions for classification.

That’s the architecture. It generalizes to every “agent acts on the world” use case in support.


See the customer support industry page for a deeper writeup or the multi-tenant memory article for the partitioning details.

Frequently asked questions

How do you prevent the agent from issuing refunds the company didn't authorize?

Plan Mode produces a typed task list. Your business logic — outside the LLM, in plain code — decides which tasks auto-approve and which escalate. The agent never has authority to issue refunds; it has authority to *propose* refunds.

Doesn't this slow everything down with human review?

Only for the tasks that need review. Auto-approval under policy (gold tier + under $50 + not previously refunded this month → fire) is instant. Plan Mode is opt-in per task class, not a global brake.

How does memory scope across tenants?

ONTO partitions UPO assertions by tenant_id. Cross-tenant reads refuse with a structured error. The same agent code runs for every tenant with full isolation.

Build with ONTO

The agent SDK where humans drive the state.

Plan Mode, typed memory, per-call consent scopes, and open-weight defaults. Open source under MIT or Apache-2.0.