Connecting a support agent to customer data

The highest-value support tickets are account-specific, and answering them is the riskiest thing a support AI can do. A primer on the least-privilege data architecture that makes it safe: per-flow field whitelists, category mapping, fail-closed errors, and why deterministic minimization beats PII detection.

Support automation’s economics look settled in 2026. AI resolutions cost $1 to $2 (Intercom’s Fin charges $0.99, Zendesk bills $1.50 to $2.00 per verified resolution); a human ticket costs $6 to $25 depending on complexity. Gartner projects agents autonomously resolving 80% of common issues by 2029. Fin reports a 76% average resolution rate across its customer base.

The resolution rates come disproportionately from account-specific questions. Order status, payout timing, account state, entitlements. Policy questions are a minority of volume; “where is my money” is the ticket. Which produces the tension this article is about:

  • The tickets most worth automating require customer data.
  • Giving a language model customer data is the riskiest thing a support deployment does.

The risk is not hypothetical, and the record is worth knowing before designing anything:

IncidentWhat happenedThe lesson
Moffatt v. Air Canada (2024)Chatbot invented a bereavement refund policy; tribunal rejected the claim that the bot was “a separate legal entity” and held the airline liableYou are liable for what the bot says
Cursor’s support bot (2025)AI support email invented a nonexistent licensing policy in response to a session bug; users cancelled over a fabricated ruleUngrounded answers about account state cost real revenue
EchoLeak, CVE-2025-32711Zero-click prompt injection exfiltrated data within a production assistant’s retrieval scopeWhatever the agent can reach, an attacker can try to reach through it

The previous article covered deploying and testing our Zendesk AI agent at a regulated fintech. This one covers the part that made it able to answer the valuable tickets: the data architecture between the bot and the warehouse. It is a working implementation of what the security frameworks recommend abstractly; OWASP’s current LLM Top 10 puts sensitive information disclosure at #2 and names excessive agency’s three causes as excessive functionality, permissions, and autonomy. Everything below is those three, bounded.

Why the obvious wirings fail

Three wirings suggest themselves, and each fails on the same analysis:

  1. Give the bot platform a database credential. An unbounded query surface. Whatever the model can be induced to ask for, by a confused prompt or a hostile one, it can receive. Simon Willison’s “lethal trifecta” names the pattern: private data, untrusted input, and an output channel in one system is an exfiltration engine. A support bot has untrusted input and an output channel by definition; the only leg you control is how much private data it can reach.
  2. Point a general-purpose connector (MCP-style) at the warehouse. Same surface, friendlier packaging. The 2026 security literature on MCP converges on the criticism: tool access mediated by natural-language interpretation at inference time, with static service credentials that hold “keys to the castle.” Even the bot vendors concede the problem; Zendesk’s own custom-actions docs warn that a connection “might have higher access privileges than your agents or end users.”
  3. Let the bot query through an NL-to-SQL layer. Right tool for analytics agents, wrong one here: a support conversation needs the same five lookups repeated millions of times, and NL-to-SQL trades that bounded need for an unbounded disclosure surface.

The design that survives the analysis inverts the direction: instead of the bot reaching into data, a narrow service decides, per conversation flow, exactly which fields exist. The bot can only ask questions the API has already agreed to answer.

The architecture: one flow, one field set

The bot reaches the API mid-conversation through the support platform’s custom actions. Each conversation flow (payouts, account status, offers, and two more) maps to one endpoint returning one fixed set of fields:

flowchart LR
  C["Customer in chat"] --> Z["Support agent (Zendesk AI)"]
  Z -->|"custom action, OAuth"| G["IP gate + token + scope check"]
  G --> B["Brand gates: 403 then 400"]
  B --> S["Source adapter (the only layer that knows SQL)"]
  S --> P["Policy choke point:
  whitelist, categories, eligibility"]
  P --> A["Audit row: field keys served, never values"]
  P -->|"whitelisted fields only"| Z

The last step before any response is a single pure module, no I/O, no framework imports, short enough for a compliance reviewer to read whole. It enforces three rules:

  1. A flow returns exactly its whitelisted fields.
  2. The account-closure reason never leaves raw, only as a routing category.
  3. No offer is surfaced to an account under regulatory eligibility restrictions.

The whitelist’s shape (names neutralized, structure real):

FIELD_WHITELIST: dict[Flow, frozenset[str]] = {
    Flow.PAYOUTS: frozenset({"pending", "holdHours", "isPriority", "isVerified", "dataFreshness"}),
    Flow.ACCOUNT: frozenset({"state", "closureCategory", "accessLimit", "restricted", "profile", "dataFreshness"}),
}

NESTED_WHITELIST: dict[str, frozenset[str]] = {
    "pending":     frozenset({"amount", "currency", "ageHours", "status", "method"}),
    "profile":     frozenset({"country", "acquisitionTag"}),  # no name, email, phone, DOB
}

apply_whitelist(flow, payload) drops any key off the flow’s list, including inside nested objects. The property this buys: a column that leaks from a source or a router bug cannot reach the bot, because the choke point does not care how a field entered the payload, only whether it may leave. The profile carries no identity fields at all; the support platform already knows who it is talking to, so the data API has no reason to repeat names and emails, and structurally cannot.

Reaches the bot
  • Pending payout: amount, currency, age
  • Hold hours and verification status
  • Closure routing category
  • Eligibility: yes or no, with the gate logged
  • Freshness: source, lag bound, degraded flag
Never leaves the service
  • Names, emails, phone numbers, DOB
  • Raw closure reasons
  • IBANs and payment details
  • Full customer records
  • SQL, table names, query errors

This is the difference between minimization and redaction, and it decides the primary control:

Field whitelistPII redaction
MechanismDeterministic: unlisted fields never fetchedProbabilistic: detector finds entities in text
Failure modeFails closed: over-blocking, visibleFails open: a missed entity is a leak
Right rolePrimary control on structured dataSecond layer on free text

GDPR’s data-minimization principle (Article 5(1)(c)) applies to what enters an LLM’s context, and European regulators’ AI guidance extends it to each step of an agent’s decision chain. A per-flow whitelist is that requirement implemented: the DPIA answer is a dictionary in a file, not a paragraph of intent.

Sensitive values leave as categories

Account closure is the clearest case of a field the bot must use and must never see. The back office records ten distinct closure reasons; several are things a support conversation is legally not allowed to state. The bot still has to route the customer, so the service maps the ten reasons to four routing categories:

def closure_category(raw_reason: str | None) -> ClosureCategory:
    """Unrecognized non-empty reason maps to RISK: route to a human
    rather than leak or under-classify."""
    if not raw_reason:
        return ClosureCategory.NONE
    return _REASON_TO_CATEGORY.get(raw_reason.strip().lower(), ClosureCategory.RISK)

When the back office adds an eleventh reason, this function has never seen it, and the failure directions are asymmetric: over-routing to a human costs one conversation; leaking a raw reason is a compliance incident. Unknown values fall toward the expensive-to-get-wrong side, and a named test pins it. (Intercom’s data connectors offer a lighter version of this idea as response transformation; the difference here is that the raw value never crosses the service boundary at all.)

The eligibility rule runs the same direction: restricted accounts never have offers surfaced, the gate runs before the whitelist, and the audit row records that the decision was gated. And because business rules drift from their descriptions, we validated them against ground truth:

  • The recurring credit offer turned out to be gated on a loyalty tier in production. Adding that gate per brand and checking it against a full week of actual grants removed 45% of the false “you are eligible” answers on one brand and 75% on another.
  • Answering “how much” with a computed estimate drifted 1 to 9% from actual granted amounts week to week. The API now returns the granted amount itself, validated to a median difference of one cent. If a table already holds the answer, serve the table; the bot should never do arithmetic the warehouse did.

The two failure modes that only appear live

Both of these reshaped the API, and neither was on my design list.

The safe default that sounds like an answer. The service is multi-brand, and authorization worked: a caller without a brand’s grant got a 403. Then a second brand approached go-live in a state nothing in the design represented: authorized in the caller’s grant, tens of thousands of accounts in the warehouse, zero validated reads. Every query matched no rows, every read returned its safe default, and the bot heard “account normal, nothing pending” as a fact about the customer. Nothing was down; nothing errored.

The 403 answers “may this caller ask.” Nothing answered “can this service truthfully answer.” Those became separate gates:

  • 403, authorization: may this caller read this brand?
  • 400, capability (brand not enabled on this service): has every flow for this brand been validated against the warehouse? Checked before any source call; the enabled list grows one brand at a time, after a per-flow validation pass. A flow a brand has not defined rules for gets the same treatment: a clear 400, so the bot escalates instead of applying another brand’s rules.

The same phase surfaced the sibling bug: the first validated flow read account state keyed on email, and the warehouse’s email column was null for the entire population. Zero rows matched; every account read “normal”. The fix was keying on the internal id. The lesson is the general one: a safe default is indistinguishable from a real answer unless something verifies that real answers are possible. A human agent looks at a screen of defaults and senses something is off. The bot converts whatever it receives into a confident sentence, which is precisely the Cursor failure shape with data instead of policy.

Silent green. When a source query failed transiently, the original behavior surfaced a raw 500, which the support platform rendered uselessly. The tempting fix is catching failures and returning an empty success so the conversation continues. The error boundary does the opposite, and its code comment is the policy: a failed account read must not read back as “account normal”; that would feed the bot fabricated state on a compliance-relevant field. Failures return a generic 502 with no query ids, table names, or stack detail (a test injects an exception with fake internals and asserts none of it reaches the response), the bot hands over to a human, and the cause goes to logs.

Freshness and composition are correctness

Two upgrades came from measuring wrong answers rather than errors:

  • Read hourly where the answer changes. The recurring credit lands Monday morning; Monday is the peak day for “where is my credit”; the daily table said “not credited” all day. Sampling recently active accounts showed daily-vs-hourly reads flipping eligibility answers in both directions. Flows now read hourly-first where it changes the answer, and every response carries dataFreshness: when the data was queried, the source’s declared maximum lag, which source served it, and whether the read was degraded. A declared bound beats a watermark, because a watermark looks fresh even when one customer’s event has not arrived.
  • Compose a live overlay for fields that cannot be stale. One compliance-relevant restriction flag reached the warehouse for only a sliver of the population. For exactly those fields, account reads compose a live stream source in front of the warehouse: the overlay serves the restriction flags, everything else stays on the warehouse, and overlay failure degrades softly to warehouse-only with degraded: true in the response and a structured fallback log. Before enabling it per brand, the overlay was cross-checked against two independent sources on sampled accounts. Composition is fine; silent composition is how a bot ends up confidently wrong about the one field with legal weight.

The warehouse behind all of this is itself migrating, and the API absorbs that with per-flow source config resolved most-specific-first: moving one flow to the new platform is one config entry, rollback is deleting it, and a flow pointed at a source that cannot serve it raises NotImplementedError on first call. After the failure modes above, a crash beats a guess: misconfigured sources that guess return defaults, and defaults sound like answers.

Tests that hold the guarantees

Around 185 tests, structural where possible, because guarantees survive contributors only when CI enforces them:

  • Every route provably gated. A test enumerates all routes from the OpenAPI schema and asserts each rejects unauthenticated requests. A new endpoint without auth fails the build.
  • Whitelist tests salt the payloads with leaked raw fields (emails, raw closure reasons, IBANs, names, DOBs) and assert only whitelisted keys survive, nested objects included.
  • Contract tests assert values, not keys. Earned in production: a composite endpoint returned profile: null on every call because a handler rebuilt its response by hand and dropped a section, and the contract test passed because it checked key presence. The bot operator caught it; the tests now check values.
  • Config fails fast. A typo’d per-brand rule refuses to start the process. A brand silently running another brand’s rules is the same class of bug as the safe default: wrongness the bot cannot detect.
  • The boundary is double-gated: an IP allowlist for the support platform’s published egress (trusted-proxy-hop pinning, so forwarded headers cannot spoof past it; an unparseable entry can only narrow access) in front of OAuth client-credentials.

Auditing mirrors the minimization: every request appends who asked, the flow, a hashed customer reference, the field keys served (keys, never values), the decision, and latency. It answers “what did the bot know and when” without becoming a second copy of customer data.

Key takeaways

  • The tickets most worth automating are account-specific, so the data architecture is the unlock for the advertised economics ($1-2 per AI resolution against $6-25 human), and the liability record (Air Canada, Cursor) prices getting it wrong.
  • Never wire a bot to a database, a generic connector, or NL-to-SQL for support: an agent’s reachable data is its blast radius, including under prompt injection. A per-flow field whitelist bounds disclosure architecturally.
  • Prefer deterministic minimization over detection: a whitelist fails closed, redaction fails open. Use redaction as the second layer, on text.
  • Return sensitive values as routing categories, with unknown values mapping to the most restrictive one; the asymmetry of failure costs decides the default.
  • Separate authorization from capability: a 403 says the caller may ask, a distinct gate says the service can truthfully answer. Without it, safe defaults become facts in customer conversations.
  • Fail closed and visibly; never convert failures into empty successes. Fabricated normality on a compliance-relevant field is the worst output a support system can produce.
  • Treat freshness as correctness: hourly reads where answers change, live overlays for fields that cannot be stale, and freshness plus degradation state declared on every response.
  • Make the guarantees structural: route-enumeration auth tests, salted whitelist tests, value-asserting contract tests, fail-fast config. The whole design doubles as your GDPR minimization evidence.

Common questions

How does an AI support agent access customer data safely?

Through a narrow, purpose-built read-only API, never through direct database access or a general-purpose connector. The bot asks for exactly the fields one conversation flow needs, the service enforces a per-flow whitelist in code, sensitive values are mapped to routing categories before they leave, and every request writes an audit row recording which field keys were served. The bot never sees SQL, a full customer record, or a field outside its flow's list.

Why not give the support bot database access or an MCP connector to the warehouse?

Because an agent's data access defines its blast radius, including under prompt injection. A database credential or generic connector is an unbounded query surface: whatever the model can be talked into asking for, it can receive. A per-flow field whitelist bounds disclosure architecturally, so even a fully successful injection can only extract the handful of fields that flow was ever allowed to see. OWASP lists excessive agency and sensitive information disclosure among the top LLM risks for exactly this reason.

What should happen when the agent's data lookup fails?

The request fails visibly and the bot escalates to a human. The tempting alternative, returning an empty or default response so the conversation continues, feeds the bot fabricated state: a failed account read comes back as 'account normal' and the bot says so to the customer. Support-AI incidents follow this shape: the bot states something with confidence that nothing verified. Fail closed, return a generic error with no internal detail, and log the cause.

Is PII redaction enough to protect customer data in AI conversations?

Redaction is detection-based, which means probabilistic, which means it fails open: an entity the detector misses is a leak. It is the right tool for scanning free text you do not control. For structured data you serve to an agent, a field whitelist is deterministic and fails closed: a field not on the list is never fetched, so there is nothing to miss. Use redaction as a second layer on the text channel, never as the primary control on the data channel.

Keep reading

Prasad Subrahmanya
Prasad Subrahmanya

Founder & CEO at Luminik. 3x technical founder. I turn expensive, repetitive work into products people pay for.

Back to all writing