Designing Reliable Human Handoff for Customer-Service AI Agents
The short answer: handoff is a piece of persistent state, not a button.
In the WhatsApp Commerce Copilot, whether the AI or a human owns a conversation is a column on
the conversation row — is_ai_controlled — and every inbound message reads it before
anything else happens. Because the flag lives in the database rather than in process memory, a
restart, a second worker, or two messages arriving at once cannot resurrect a bot that a shop
owner has just switched off.
The problem: the bot is confidently unhelpful and a person is watching
Every customer-service agent eventually meets a conversation it should not be handling — an angry customer, a bespoke request, a question the catalogue cannot answer. The failure that matters is not the agent being wrong. It is the agent being wrong while a human is trying to take over, so the customer receives two contradictory replies and stops trusting either.
This is why "add a handoff button" is the easy half of the problem. The hard half is making the transfer atomic and durable.
The approach: control is a column, not a runtime variable
The copilot models the conversation itself as the unit of control. The
conversations table carries is_ai_controlled alongside the
conversation's status, its current product and variant, and a serialised context blob.
Ownership is therefore a fact about the conversation that any process can read, not a value
held in whichever worker happened to handle the last message.
That single decision buys several properties for free:
- A backend restart does not re-enable the AI on a conversation a human took over.
- Two messages arriving concurrently read the same flag, so they cannot disagree.
- The dashboard and the message pipeline share one source of truth rather than syncing.
- The state is inspectable — you can query which conversations a human currently owns.
In the dashboard, this surfaces as Human Mode: a one-click toggle that disables the AI so the owner can reply personally, and one click back when they are done.
Recording why, not just that
A separate human_handoffs table records each transfer with its
conversation_id, store_id, a reason, a
summary and a status. Two things follow from keeping this
separate from the boolean.
First, the reason is preserved. A toggle tells you the AI is off; a handoff row tells you it went off because confidence was low on a returns question — which is the input you need to decide what to fix.
Second, the summary means the human does not start cold. They inherit the conversation with context attached rather than scrolling to reconstruct it.
Handoff is triggered from two directions: the owner takes control from the dashboard, or the system escalates when confidence is low. The escalation path is the interesting one, because it means the agent has to know what it does not know.
Tradeoffs
- A read on every message. Checking the flag per inbound message costs a query. That is the price of correctness under concurrency, and at this scale it is immaterial — but it is a real cost, not a free lunch.
- Escalation thresholds are heuristic. Confidence here is produced by rule-based matching, not a calibrated probability. It works, and it is honest to say it is tuned by judgement rather than measured.
- Someone has to be there. Handing off to a human who is asleep is worse than a mediocre automated answer, because the customer now waits with no reply at all. A handoff design is incomplete without an answer to "and if nobody picks up?"
- Two tables instead of one. The boolean alone would have shipped faster. The audit trail is what makes the behaviour improvable.
Limitations
- There is no timeout or escalation ladder: a conversation stays in Human Mode until someone toggles it back. Nothing chases an unattended handoff.
- The dashboard has no real authentication in this MVP, so "which human took over" is not attributable to an individual account.
- No measurement exists of how often escalation fires, or how often it should have.
- Proposed, not built: a timeout that returns an unattended conversation to the AI with an apology message, per-agent attribution once authentication exists, and calibrating the confidence threshold against labelled conversations rather than judgement.
What I would take to the next system
Put the control flag where the data is. Almost every subtle bug in an AI agent that shares work with humans comes from ownership being tracked in one place and acted on in another — cached in a worker, held in a session, assumed from the last event. Making it a column turned a class of race conditions into a non-issue, and it cost one migration.
Related reading
- Building catalogue-grounded AI replies for WhatsApp commerce — how the agent decides what to say before it decides whether to say it.
- The full case study — the system this handoff design sits inside.
- Source code on GitHub — see
backend/app/services/conversation_controller.py.