cd /news/artificial-intelligence/building-a-support-agent-that-refuse… · home topics artificial-intelligence article
[ARTICLE · art-90580] src=dev.to ↗ pub= topic=artificial-intelligence verified=true sentiment=· neutral

Building a support agent that refuses to make things up

An engineer building after-sales support agents for e-commerce shops has developed an architecture that prevents AI hallucination by routing every request to a closed set of intents, each mapped to a typed action with an explicit contract. The system uses a whitelist of facts and a natural-language layer that only phrases responses, ensuring the model cannot generate unverified information. Escalation is treated as a normal outcome with distinct reasons for hand-off to human agents.

read5 min views1 publishedAug 10, 2026

Most "AI customer service" demos fall apart the same way. You ask a question the

model can't answer from data, and instead of stopping, it produces something

plausible. In a chat toy that's a curiosity. In after-sales support it's a

promise your company now has to honour — a refund that was never approved, a

delivery date that doesn't exist, a return window that isn't your policy.

I build after-sales agents for e-commerce shops, and almost all the engineering

goes into that one problem: making the agent's honesty a property of the

architecture, not of the prompt. Here's how that's actually put together.

The naive design is one model, one big prompt, and a pile of documents in a

vector store. Ask "where is my order 41822?" and the retrieval layer returns the

three chunks that look most like the question. If none of them contain order

41822 — because it's a live database row, not a document — the model still gets

a context window full of order-shaped text. It will answer. It will be wrong.

The fix isn't a better prompt. It's removing the model's ability to answer that

class of question at all.

Every request the agent handles is routed to exactly one of a fixed set of

intents — order status, delivery delay, return, refund status, exchange, invoice,

product question, cancellation. That set is closed. There is no fallback intent

that means "answer anyway".

Each intent maps to a typed action with an explicit contract:

@dataclass(frozen=True)
class OrderStatus:
    """Reads the order system of record. Never generates a status."""
    order_ref: str

    def resolve(self, ctx: Ctx) -> Resolution:
        order = ctx.commerce.get_order(self.order_ref)   # Shopify / WooCommerce / API
        if order is None:
            return Resolution.escalate(
                reason=Reason.NOT_FOUND,
                say="I can't find that order number on this account.",
            )
        return Resolution.answer(
            template="order_status",
            facts=order.public_facts(),   # only whitelisted fields
        )

Two things matter here.

facts

is a whitelist, not the row.public_facts()

returns the carrier,

the tracking number, the shipped-at timestamp, the current state. It does not

return the margin, the internal notes, the customer's other orders, or the

fraud score. The model literally cannot leak a field it was never given.

The natural-language layer only phrases. The model receives the resolved

facts and a template intent, and its job is to write one or two sentences in the

shop's tone of voice. It is not asked what the status is. It is told, and asked

to say it nicely. Hallucination has no surface to attach to, because there is no

question left open at generation time.

Resolution.escalate

isn't an error path. It's a normal, expected outcome with

its own quality bar — arguably the most important one.

class Reason(Enum):
    NOT_FOUND       = auto()   # no matching record
    OUT_OF_SCOPE    = auto()   # intent not in the closed set
    POLICY_UNCLEAR  = auto()   # rule exists but doesn't cover this case
    LOW_CONFIDENCE  = auto()   # intent classification below threshold
    HUMAN_REQUESTED = auto()   # customer asked for a person
    EMOTIONAL       = auto()   # anger / distress detected

Each reason produces a different hand-off: a different message to the customer,

a different priority in the human queue, and a different summary attached to the

ticket so the agent who picks it up doesn't restart from zero.

The EMOTIONAL

branch is worth calling out. A furious customer is not a

retrieval failure — the system may have every fact it needs. It's routed to a

human anyway, because "technically resolvable" and "should be resolved by a

machine" are different questions. Getting that distinction wrong is how

automation projects lose the trust they were meant to build.

LOW_CONFIDENCE

deserves a real threshold, calibrated per shop, not a

hard-coded 0.7

. And the threshold should be asymmetric: the cost of a wrong

refund is not the cost of an unnecessary escalation.

Reading an order is safe. Issuing a refund is not. Those two live behind

different gates, and the gate is configuration owned by the merchant, not a

prompt:

actions:
  order_status:   { mode: auto }
  return_label:   { mode: auto, max_value_eur: 80 }
  refund:         { mode: propose }        # drafts it, a human clicks send
  cancel_order:   { mode: propose }
  address_change: { mode: auto, before_dispatch_only: true }

propose

mode is what makes the first month of a deployment survivable. The

agent does the work and produces the action; a human approves it. You watch the

approval rate. When a given action has been approved without edit often enough,

that's the evidence to flip it to auto

— not a vendor's claim, and not a

number in a slide.

I run each shop on its own instance, in France, on a French model

(Mistral). That reads like a marketing line, so here's the

engineering reason.

Support conversations are among the most sensitive data a shop holds. Not

because of the order numbers — because of what customers write around them.

Addresses. Health reasons for a return. Financial difficulty. Complaints about a

person. Once that transits through a shared multi-tenant pipeline in another

jurisdiction, "where is my data" stops being a question you can answer, and

becomes a question you forward to a vendor.

A dedicated instance also makes reversibility real rather than contractual. If

the merchant leaves, the export is a database and a config file, not a support

ticket asking for their data back.

The EU AI Act's transparency obligation (Article 50) pushes the same way: the

customer has to know they're talking to a machine. That's simpler to guarantee

when the disclosure is in your own message-composition layer than when it's a

setting in someone else's dashboard.

Honesty about the trade-off: this design resolves fewer conversations than a

model with a free hand. A closed intent set can't handle the long tail. Bounded

actions can't improvise. propose

mode needs a human in the loop for weeks.

That's the trade I'd make every time. The failure mode of the permissive design

isn't "slightly worse answers" — it's a confident wrong statement that a real

person acts on, in a channel where the shop is legally the one who said it.

An agent that says "I'll get a human on this" is a mildly disappointing

experience. An agent that invents a refund policy is an incident.

I'm Amine, founder of Bynevo Labs — we build sovereign after-sales AI agents for French e-commerce, hosted in France on an open-source stack. Happy to talk architecture in the comments.

── more in #artificial-intelligence 4 stories · sorted by recency
── more on @shopify 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/building-a-support-a…] indexed:0 read:5min 2026-08-10 ·