{"slug": "from-ai-support-copilot-to-agentic-customer-service", "title": "From AI support copilot to agentic customer service", "summary": "A developer's AI support system had three data leak paths, not one, and the model output was the least dangerous. The return path from internal systems was unguarded, allowing personal data to enter the model context. The developer used Microsoft Presidio and spaCy to build a deterministic detection layer that runs before the model sees any text.", "body_md": "**TL;DR**\n\nI thought there was one place personal data could leak out of my AI support system.\n\nThere are three. The model wasn't the leak.\n\nIn Part 2 I listed the tests we had added, including this assertion:\n\nno PII leaks into hypothesis text\n\nI watched it go green in CI and filed the problem as handled. It wasn't. That assertion watches the model's *output*, which is one door out of three, and the least dangerous of them.\n\nPart 3 ended with productionization mostly done and a promise: that this post would cover the quiet failures, the behaviors we did not predict, and the feedback loops that changed the second iteration. That eval is the perfect example of all three, because nothing about it ever failed. It passed, every time, while watching the wrong door.\n\nTwo months later, here is the honest version. The system changed in kind, not in size, going from two runtime containers to twenty-seven services, and almost none of that came from features we planned.\n\nWhat I had by Part 3 was a **copilot**: something that sits next to a support agent and hands them evidence. Re-reading that post, I claimed more than I had proved. I had made one ingest work for one stage of the process. That is not the same as running customer service.\n\nA copilot can ignore where the customer's personal data ends up, because a human reads everything anyway. It does not need a work queue, because one person is looking at one card. It never needs to know what was actually sent, because it never claimed to have handled anything.\n\nA customer-service system cannot ignore any of them. And each one arrived the same way the eval did: nothing threw, no alert fired, every check was green.\n\nA ticket arrives. Evidence agents run in parallel: one asks the CRM who this customer is, one asks telemetry what fired, one checks provisioning. Each result becomes model context.\n\nNow trace the personal data:\n\n```\ncustomer email ──► [strip] ──► \"{a3f1...} reports a crash\"  ──┐\n                                                              ├──► model context\nCRM lookup ──────────────────► {\"name\": \"Jan Bakker\",       ──┘\n                                \"email\": \"jan@acme.nl\",\n                                \"phone\": \"010-1234567\"}\n```\n\nBoth arrive in the same context window. Only one of them went through the stripper.\n\nIntake tokenization never touched that second path. You cleaned the front door while the side door handed the same data straight back in.\n\nSo there are three boundaries, not one:\n\n```\n                        ┌────────────────────┐\n   customer mail ──1──► │                    │\n                        │   model context    │ ──3──► reply to customer\n internal systems ──2──►│                    │\n                        └────────────────────┘\n\n  1  ingress        inbound content            best effort, cannot refuse\n  2  return path    tool results you asked for  deterministic, fail-closed\n  3  emission       what the model writes       detects, cannot prevent\n```\n\nThree doors, and they do not have equal guarantees. I had instrumented door 3 and partly covered door 1. The return path I had not considered at all. It is also the hardest of the three: the data comes from a system you trust, in a shape you do not control, in response to a query you deliberately made.\n\nIt has the least prior art too. There is plenty of guidance on scrubbing user input and filtering model output. For the return path I could not find a specification that treats it as a boundary at all.\n\nIf a model has to see raw personal data before deciding whether it is personal data, you have already lost. Detection has to finish before the model sees the text at all.\n\nThat rules out the obvious approach and leaves a deterministic one, split two ways. **Microsoft Presidio** supplies the recognizer registry, the context scoring and the replacement step. **spaCy** supplies the per-locale NER that tags the fuzzy things, people and organizations and locations, one model per supported language.\n\nspaCy is good at \"Maria from accounting\" and useless on a nine-digit national ID, because a structured identifier does not look like an entity. It looks like a number.\n\nThat is also where the thing is extensible. Adding an identifier type is a pattern plus a validator, and the validator does the real work. The Dutch BSN carries a checksum, so a loose match at a deliberately low score gets confirmed or discarded by arithmetic:\n\n``` python\nclass NlBsnRecognizer(PatternRecognizer):\n    def __init__(self):\n        super().__init__(\n            supported_entity=\"NL_BSN\",\n            patterns=[Pattern(\"nl_bsn\", r\"\\b\\d{8,9}\\b\", 0.3)],\n            supported_language=\"nl\",\n        )\n\n    def validate_result(self, pattern_text):\n        return nl_bsn.is_valid(pattern_text)  # python-stdnum, 11-proof\n```\n\nThe regex on its own would tokenize every 8 to 9 digit number in the message: order numbers, timestamps, version codes. The checksum is what makes it precise. Every further identifier, a tax number or an IBAN or a postcode, is the same twenty lines.\n\nNER over dense business text in a non-English language is noisy. An ordinary street address survived un-redacted until we added a locale-specific recognizer for it. The default phone recognizer's region list did not include ours. Over-redaction costs a little context. A surviving secret costs everything.\n\nOne exception proves the bias cannot be blind: a .NET namespace in a stack trace reads exactly like a person's name. Inside a hard-marked technical region, person and location spans get dropped.\n\nSpans from two different mechanisms overlap partially:\n\n```\n\"contact Jan Bakker at jan.bakker@acme.nl\"\n         └─ PERSON ─┘\n                       └────── EMAIL ──────┘\n              overlap ─┘\n\nkeep PERSON, drop EMAIL  ->  \"contact {token} at bakker@acme.nl\"   leaked\nunion the two spans      ->  \"contact {token}\"                     covered\n```\n\nKeep one and drop the other, and the tail of the dropped span stays in the message as cleartext. Merge overlapping spans into their union and tokenize the whole substring, and every detected character is covered.\n\nBlacking values out is the obvious move. It is also wrong.\n\nThe point of the system is to send an answer to a real person at a real address. Destroy the data on the way in and you cannot send anything back out.\n\nSo values get replaced with placeholders, and the originals are stored encrypted in a scoped vault, resolved back at the human review step. The model stays a text-to-text function that never holds a key and never invokes the gate. The gate is deterministic middleware in the data path, so it cannot be prompt-injected away or skipped.\n\nTwo details worth stealing.\n\n**The token must reveal nothing.**\n\n```\n{PERSON_1} mailed {PERSON_2} about {COMPANY_1}   two people, one company, all typed\n{a3f1c8de-...} mailed {7b02e914-...} about {c5d9...}   nothing\n```\n\nA counter tells the reader how many distinct people are in the message. A category tells them what each value was. That is the metadata you were trying not to hand over, so the token is a random UUID and nothing else.\n\n**The matcher must be a closed allowlist**, not \"anything in braces\":\n\n```\n_OPEN   = re.compile(r\"\\{[^}]+\\}\")            # matches {SOMETHING_1} too\n_CLOSED = re.compile(r\"\\{[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-\"\n                     r\"[0-9a-f]{4}-[0-9a-f]{12}\\}\")\n```\n\nWith the open pattern, an attacker writes `{SOMETHING_1}`\n\ninto a support email, your masking step treats it as already handled, and skips the span. You have given them a way to switch your detector off one region at a time.\n\nUnder GDPR, a customer can ask you to delete their personal data and you have to actually do it. A copilot dodges this, because it reads from systems that already own the record. The moment you ingest and store customer email yourself, you control that copy, and \"delete everything about this person\" becomes a query you have to be able to run.\n\nThat forces the question you have been avoiding: where did all of this end up?\n\nFurther than I thought. One erasure has to reach interactions, cases, the audit log, operator notes, attachment blobs, the knowledge store, and the vault at multiple scopes. Miss one and you have not complied. You have only reported that you did.\n\nThe gap that taught me the most was a table I had stopped thinking about. The mailbox poller keeps a ledger of every message it has seen, for deduplication. It stores the raw body. It was designed as a dedup ledger, so it never got a lifecycle path: no retention, no expiry. Raw customer mail, kept forever, in a table nobody thought of as storage.\n\nThe fix is more interesting than the bug, because the obvious repair is wrong:\n\n```\n-- wrong: the dedup key goes with it, so the next delta sync\n-- happily re-ingests the mail you just erased\nDELETE FROM inbox_entries WHERE lower(sender) = :email;\n\n-- right: destroy the content, keep the key\nUPDATE inbox_entries\n   SET body = :redacted, sender = :redacted, subject = :redacted\n WHERE lower(sender) = :email\n   AND body <> :redacted;          -- idempotent, safe to re-run\n```\n\n**You cannot delete the rows.** An erasure that breaks idempotency re-creates the data it erased. Note the `lower()`\n\nas well: the stored sender is raw text from the mail API, and a case mismatch would silently under-redact while still reporting success.\n\nEvery table where data just accumulates is a retention decision you made by not making it.\n\nPart 3 claimed the chat bot was only an ingest, and that any channel could feed the same endpoint. That part held. A mailbox poller now does exactly that, with no orchestrator changes.\n\nBut I was describing a smaller achievement than I thought. Real customer service is not a stage. It is intake, triage, ownership, drafting, review, sending, and knowing afterwards what happened.\n\nIt is not one channel either. The unit is the **case**, not the message, because the same customer problem can arrive by email, continue in a live chat, and get followed up by phone:\n\n```\ncase\n ├── thread  EMAIL    \"printing fails after update\"\n ├── thread  CHAT     follow-up two days later\n └── thread  VOICE    callback, resolved\n```\n\nVoice and chat are not built yet. Modelling them now is what stops the next channel from being a rewrite.\n\nThe chat channel only ever covered the middle of that process anyway. A reviewer working a stream of cases needs a worklist: filter, search, claim, reassign, approve. An adaptive card is a fine surface for one result and a terrible surface for a queue, so the operator experience left chat and became its own application.\n\nNone of that is AI work. All of it was required before the AI work could be used by more than one person, which is the real difference between a demo and a service.\n\nDeployment turned out to have the same problem as that eval in Part 2. Every check was green and watching the wrong thing.\n\nThe most instructive failure of the two months: a pipeline that went green while shipping a **stale image**. Build succeeded. Deploy succeeded. Health checks passed. The running revision was the previous code.\n\nEvery signal said yes.\n\nThat is why deployment stopped being a step and became a protocol:\n\n```\nbuild ─► push ─► deploy green ─► verify replicas ─► flip traffic ─► watch\n                                       │ fail\n                                       └─► roll back, blue keeps serving\n```\n\nBlue-green instead of in-place update, replica verification before the flip, automatic rollback when the cutover fails. Add to that a production outage caused by a missing dependency in one image, present everywhere it was tested and absent where it ran.\n\nThe deploy pipeline is itself a system that can lie to you.\n\nThe biggest single piece of engineering in this period was a knowledge and reliability subsystem. The most *important* thing we shipped was much smaller: the ability to attach the email an operator actually sent back to the case it answered.\n\nThat sounds like admin. It is the keystone.\n\nThe system drafts an answer. A human edits it and sends it, often from their own mail client. At that moment the system knows what it *proposed* and has no idea what was *said*.\n\nNo recorded link between a case and the reply a human actually sent means no ground truth. And without ground truth, every piece of learning machinery upstream of it is theater.\n\nIt brings a second signal along nearly for free. Compare the draft to what was actually sent:\n\n```\nphi = SequenceMatcher(None, draft, sent).ratio()\n\nif   phi >= 0.98: credit = 1.0   # sent as drafted\nelif phi >= 0.70: credit = 0.3   # edited on the way out\nelse:             credit = 0.0   # rewritten: the draft earned nothing\n```\n\nA human rewriting your output is not a success case, whatever the model's confidence said about it. A system that scores it as one will learn its way into confident nonsense.\n\n**Intake is not fail-closed, and cannot be.** The three doors have different guarantees. Inbound content cannot be refused, so detection there is best-effort. The return path is deterministic and fail-closed. Emission can be inspected but not re-derived, so a check there detects a failure rather than preventing one. Underneath all of it: a control that fails open is not a control.\n\n**No detection recall number.** Measuring it needs a labeled corpus of real customer mail, which is the thing I am trying not to accumulate. Structured identifiers are checksum-confirmed. For names in free text I have an over-redaction bias and no honest percentage. Anyone quoting you a PII recall figure for free text should be asked how they measured it.\n\n**No accuracy figure.** There is no customer confirmation and no reopen signal joined to outcomes yet. Time-to-send is not time-to-resolution.\n\nOne thing is measured precisely, because it is a property of the runtime rather than a judgment about the world. Over 48 hours, a fully evidenced triage ran about 21 model calls and 88,000 tokens, roughly $0.40.\n\nI am not going to turn that into a savings figure. What it replaces is the context assembly that opened Part 1: a quarter of an hour of tedious hunting across five systems, worth considerably more than forty cents of an engineer's time, and work nobody enjoys. The ratio is obviously lopsided. Making it precise would mean picking a salary, an hours-per-year and a minutes-per-ticket, multiplying them, and presenting the result as though it had been measured. A human still approves every answer, so none of this removes a person. It removes the fetching.\n\nThe contrast is the point. The easy thing to measure I can quote precisely. The things that matter most I cannot. A dashboard full of the former will quietly convince you that you have handled the latter.\n\nThe reliability subsystem is built and deliberately switched off. It grades whether an answer type actually worked, and an autonomous evaluator can demote or quarantine a pattern but is structurally incapable of promoting one. The action vocabulary it gets contains no promotion. Moving closer to sending without a human requires a human.\n\nIt stays dark until real outcomes exist to feed it, which is what Lesson 18 was about. The loop is not closed. The piece that lets it start is in place.\n\nEvery part of this series turned out to be the same move at a different altitude.\n\nPart 1 drew the **reasoning** boundaries: parallel evidence, structured outputs, a human approves anything that leaves the building. Part 2 drew the **runtime** boundaries, where the lesson was that runtime semantics matter more than diagrams. Part 3 drew the **platform** boundaries: async handoff, presentation limits, permissions that look granted and are not. This one drew the boundaries around the **data, the process and the people**. Those only show up once the thing stops being a demo and a customer is on the other end of it.\n\nFind something implicit, make it explicit, make it checkable. Every time. None of the failures were clever. They were assumptions nobody wrote down, which is exactly why nothing threw when they turned out to be wrong.\n\nWhich brings me to the title. This series is called \"I built a multi-agent AI support copilot,\" and I would not call it that today. A copilot is judged on whether its suggestion was useful. A customer-service system is judged on whether the problem went away, whether the customer's data was handled properly on the way through, and whether the person who pressed send could see what they were signing. Those are not harder versions of the same question. They are different questions, and answering them is the whole distance between the two names.\n\nIf you take one thing from four posts, take this: the check that passes while watching the wrong door is more dangerous than the check that fails. A failing check gets fixed on Tuesday. A passing one buys you months of unearned confidence. The eval in Part 2 wasn't wrong. It was answering a smaller question than I thought, and it took two months of production to notice.\n\nThe loop is not closed. But the system now records enough about its own behavior to eventually tell me whether it works, which is further than it could claim when this series started. When it closes, that will be worth writing about.\n\nThanks for reading.", "url": "https://wpnews.pro/news/from-ai-support-copilot-to-agentic-customer-service", "canonical_source": "https://dev.to/eelcolos/from-ai-support-copilot-to-agentic-customer-service-4eb4", "published_at": "2026-07-31 14:12:27+00:00", "updated_at": "2026-07-31 14:35:31.350979+00:00", "lang": "en", "topics": ["artificial-intelligence", "ai-safety", "ai-infrastructure", "developer-tools"], "entities": ["Microsoft Presidio", "spaCy"], "alternates": {"html": "https://wpnews.pro/news/from-ai-support-copilot-to-agentic-customer-service", "markdown": "https://wpnews.pro/news/from-ai-support-copilot-to-agentic-customer-service.md", "text": "https://wpnews.pro/news/from-ai-support-copilot-to-agentic-customer-service.txt", "jsonld": "https://wpnews.pro/news/from-ai-support-copilot-to-agentic-customer-service.jsonld"}}