DEV Community

Amine
Amine

Posted on

Building a support agent that refuses to make things up

Most "AI customer service" demos fail the same way. You ask something the model
can't answer from data, and instead of stopping, it produces a plausible answer.
In a chat toy, that is a curiosity. In after-sales support it becomes a promise
the company has to honour: a refund nobody approved, or a delivery date that
never existed.

I build these agents for e-commerce shops, for the pre-sale questions that decide
whether someone buys and the after-sale ones that decide whether they come back.
Almost all of the engineering goes into one problem: making the agent's honesty a
property of the architecture rather than the prompt. The examples here lean on
after-sales, where a wrong answer costs the most, but the same design carries
pre-purchase questions just as well. This post walks through how.

The mistake: treating the model as the source of truth

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. None of them contain order 41822,
because it is a live database row rather than a document, so the model gets a
context window full of order-shaped text and answers anyway. The answer is wrong.

A better prompt does not fix this. What fixes it is removing the model's ability
to answer that class of question at all.

Bounded actions instead of free-form generation

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
        )
Enter fullscreen mode Exit fullscreen mode

Two details do the real work here. The first is that facts is a whitelist.
public_facts() returns the carrier, the tracking number, the shipped-at
timestamp and the current state. It does not return the margin, the internal
notes, the customer's other orders or the fraud score. The model can't leak a
field it was never handed.

The second is that the natural-language layer only phrases. The model receives
the resolved facts plus a template intent, and writes one or two sentences in
the shop's tone of voice. It never decides what the status is; it is handed the
status and asked to say it well. There is no open question left at generation
time, so hallucination has nothing to attach to.

Making refusal a first-class outcome

Resolution.escalate is not a failure path. It is an expected outcome with its
own quality bar, and the one that matters most.

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
Enter fullscreen mode Exit fullscreen mode

Each reason produces a different hand-off: a specific message to the customer, a
priority in the human queue, and a summary attached to the ticket so whoever
picks it up does not start from zero.

The EMOTIONAL branch matters more than it looks. A furious customer is not a
retrieval failure; the system may hold every fact it needs. It still goes to a
human, because "technically resolvable" and "should be handled by a machine" are
different questions. Getting that wrong is how automation loses the trust it was
supposed to earn.

LOW_CONFIDENCE needs a real threshold, calibrated per shop rather than a
hard-coded 0.7, and it should be asymmetric: a wrong refund costs far more than
an unnecessary escalation.

Writes are gated separately from reads

Reading an order is safe; issuing a refund is not. The two sit behind different
gates, and the gate is configuration the merchant owns rather than 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 }
Enter fullscreen mode Exit fullscreen mode

propose mode is what makes the first month of a deployment survivable. The
agent does the work and drafts the action; a human approves it, and you watch
the approval rate. Once an action clears without edits often enough, that history
is what justifies flipping it to auto: a decision earned from your own data
instead of a vendor's promise.

Why the hosting boundary is an architecture decision

I run each shop on its own instance, in France, on a French model
(Mistral). That sounds like a marketing line, so here is
the engineering behind it.

Support conversations are among the most sensitive data a shop holds, less for
the order numbers than for what customers write around them: addresses, health
reasons for a return, money troubles, complaints about a named employee. Once
that runs through a shared multi-tenant pipeline in another jurisdiction, "where
is my data" stops being a question you can answer and becomes one you forward to
a vendor.

A dedicated instance also makes reversibility real instead of contractual. When
a merchant leaves, the export is a database dump and a config file, handed over
without a support ticket in sight.

The EU AI Act's transparency obligation (Article 50) points the same way: the
customer has to know they are talking to a machine. That is easier to guarantee
when the disclosure lives in your own message-composition layer than when it is
a toggle in someone else's dashboard.

What this costs you

The trade-off is real: this design resolves fewer conversations than a model
with a free hand. A closed intent set will not cover the long tail, bounded
actions cannot improvise, and propose mode keeps a human in the loop for weeks.

I would make that trade every time. The permissive design has a worse failure
mode. It does not produce a slightly worse answer; it produces 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 "let me get a human on this" is a minor disappointment. An
agent that invents a refund policy is an incident the shop then has to clean up.


I'm Amine, founder of Bynevo Labs. We build
sovereign AI support agents
for French e-commerce, answering customer questions before the sale and after it,
hosted in France on an open-source stack. Happy to talk architecture in the
comments.

Top comments (0)