Human Handoff for AI Chat Agents: A Practical Design Guide
The first customer-facing agent I shipped answered questions beautifully. Then a customer wrote "perfect, book it for Thursday", and the agent replied "Done! See you Thursday." There was no booking tool. There was no Thursday slot. The model had just finished the conversation the way conversations usually end. That was the day I stopped thinking of AI agent human handoff as a fallback feature and started treating it as the core of the design.
Most chatbot tutorials stop at "the model answers questions." In production, on a messaging app or a web chat widget, answering is the easy part. The hard part is deciding three things up front: what the agent may do on its own, what it may only prepare for a person to approve, and when it must step aside and hand the conversation to a human without making the customer repeat themselves.
This is the design I now use for every customer-facing agent I build. It isn't tied to a framework. I've built versions of it with the Vercel AI SDK, with LangGraph, and with my own Dart agent runtime, Akashi. The ideas carry over.
Inform, Act, Commit: Why "Just Answer" Isn't Enough
Every message a customer-facing agent sends falls into one of three categories:
- Inform. "We open at 9." "That model comes in three sizes." Low risk if grounded in real data. If it's wrong, it's embarrassing, but nothing happened in the world.
- Act. The agent changes state: creates a lead, adds a note, schedules a reminder, looks up an order. Something happened, but it can usually be undone.
- Commit. The agent makes a promise on behalf of the business: a price, a delivery date, a refund, a reservation. The customer will hold you to it, sometimes legally.
The failure mode I opened with is a model producing a commit message with no act behind it. LLMs are trained to be helpful and to close loops, so "I've booked it" is a very likely continuation even when no booking happened. You can't prompt that away reliably. You have to design it away: commits only come from code paths a person controls, and the agent's text gets checked against what its tools actually did.
That last point deserves its own post, and it has one: evaluate what your agent does, not what it says.
The Act / Propose / Hand Off Matrix
Once you accept the three categories, the next step is to sort every tool the agent has. I score each one on two axes:
- Reversibility. Can I undo this cleanly, without the customer noticing?
- Business risk. If the agent gets it wrong, does someone lose money, time or trust?
The answer maps each tool to one of three tiers:
| Tool (generic example) | Reversible? | Business risk | Tier |
|---|---|---|---|
| Search catalog / FAQ | n/a (read-only) | Low | Act |
| Check order status | n/a (read-only) | Low | Act |
| Create or update a lead record | Yes | Low | Act |
| Schedule a follow-up reminder | Yes | Low | Act |
| Build a quote with line items | Yes (it's a draft) | High if sent | Propose |
| Reserve a time slot | Partly | Medium | Propose |
| Apply a discount | No, once seen | High | Propose |
| Issue a refund | No | High | Hand off |
| Complaint, legal or safety topic | n/a | High | Hand off |
| Anything outside the agent's scope | n/a | Unknown | Hand off |
- Act: the agent calls the tool and tells the customer the result.
- Propose: the agent prepares the artifact (a quote, an order, a booking) and a person approves it before the customer sees any binding number or date.
- Hand off: the agent stops, tells the customer a person will follow up, and routes the conversation with a summary.
The matrix is boring on purpose. It lives next to the tool definitions and gets reviewed like code. If you separate the conversational layer from the action layer, as I describe in separate talking from acting, the tier is just a property of the action, enforced outside the model.
Draft, Then Approve: Letting the Agent Prepare Without Promising
The "propose" tier is where most of the value is. Customers want fast answers, and the slowest part of a sales or service conversation is usually someone assembling a quote. The agent is great at that: it asks the right follow-up questions, collects quantities and preferences, and builds a structured draft.
What it must not do is send a price or a date to the customer on its own. So the flow becomes:
A few rules I've settled on:
- The draft is data, not text. The agent fills a structured object (line items, quantities, customer notes, open questions). The approved message to the customer is rendered from that object by code, not written by the model. That's how you guarantee the number the customer sees is the number a person approved.
- No prices in agent text, ever. The system prompt says it, and an output check enforces it. If a currency pattern shows up in an agent message for a propose-tier flow, the message is blocked and the turn is retried or escalated.
- The agent tells the truth about the wait. "A teammate confirms prices shortly" is honest and sets expectations. "Your total is..." is not something the agent says.
- Approval is one tap. If the reviewer has to open a dashboard, log in and find the conversation, approvals pile up. A notification with the draft and an approve/edit link is the minimum.
This is the same human-in-the-loop interrupt pattern you see in agent frameworks. LangGraph models it as an interrupt that pauses the graph until a human resumes it. My public langgraph-ts-agents lab has an HITL approval example if you want to see the shape in TypeScript.
Handoff Triggers: When the Agent Must Step Aside
A handoff is any point where the conversation's owner changes from the agent to a person. I use five triggers, each with its own detection:
- Explicit request. "Can I talk to a person?" This one is easy and non-negotiable. Detect it with a small classifier or a tool the model can call (
request_human). Never make the customer argue with the bot. - Hand-off-tier topic. Refunds, complaints, anything legal or safety-related. The router flags these before the agent writes a reply, not after.
- Repeated failure. The same question asked twice, the customer saying "that's not what I asked", or a tool failing twice in a row. I count these per conversation and hand off at a low threshold. A frustrated customer talking to a confused agent gets worse with every turn.
- Low confidence on a grounded answer. If retrieval returns nothing relevant, the agent should say it doesn't know and offer a person, not improvise. (Getting retrieval confidence right is its own topic; see RAG over your own docs with pgvector.)
- Out of scope. The agent sells furniture; the customer asks for tax advice. Polite decline, offer a human if the business wants one.
The important design choice: triggers are evaluated by code around the agent, not only by the agent itself. The model can call request_human, but the system also checks failure counters and topic flags independently. If the model gets talked out of escalating (by a confused customer or an adversarial one), the outer checks still fire. Treat "convince the bot not to escalate" as one of your prompt injection test cases.
Passing Context: The Handoff Summary
The worst handoff is the one where a human jumps in with "Hi! How can I help you?" after the customer spent ten minutes explaining. Every handoff carries a summary, generated by the agent and rendered into a fixed template:
# Handoff summary (illustrative shape)
reason: explicit_request | topic_refund | repeated_failure | low_confidence | out_of_scope
customer:
name: "if given"
channel: messaging
language: es
intent: "Wants a quote for 3 units, black, delivery next week"
facts_collected:
- quantity: 3
- color: black
- delivery_window: "next week, weekdays"
open_questions:
- "Delivery address not confirmed"
agent_actions:
- "search_catalog -> 2 matching items"
- "draft_quote -> draft #pending (not sent)"
promises_made: [] # must be empty for propose/hand-off flows
sentiment: neutral | frustrated | urgent
suggested_next_step: "Confirm address, approve draft quote"
transcript_link: "<link to full conversation>"
Two fields matter more than the rest. promises_made should be empty; if it isn't, the reviewer needs to see that first. And agent_actions comes from the tool log, not from the model's memory of what it did. The summary prose is LLM-written, but the facts about actions are pulled from the system of record.
Conversation Hygiene: Bursts, Locks, Dedupe and Loops
Messaging channels have a property web chat demos don't: people send three short messages instead of one long one. "hi" / "do you have this in black" / "and how much". If your webhook triggers an agent turn per message, you get three overlapping replies, sometimes contradicting each other. Here's the hygiene layer I put in front of every channel agent:
- Burst window. Buffer inbound messages per conversation for a short, configurable window (a few seconds) and reset the timer on each new message. When it expires, run one agent turn over everything buffered.
- Per-conversation lock. Only one agent turn runs per conversation at a time. Messages that arrive mid-turn go into the next buffer. A distributed lock with a TTL is enough; just make sure the TTL is longer than your slowest turn.
- Idempotent ingestion. Channel providers retry webhooks. Store the provider's message ID and drop duplicates before they reach the buffer.
- Anti-loop rules. Agents can loop with other bots (auto-replies, out-of-office messages) or with themselves through tool retries. I cap agent turns per conversation per time window, ignore messages that look automated, and hand off when the cap trips.
- Channel rules are constraints, not suggestions. Some business messaging platforms only allow free-form replies within a time window after the customer's last message; after that you need pre-approved templates. A handoff that waits too long for a human can silently become a message you're not allowed to send. Your approval queue has to know about that clock.
None of this is AI. All of it decides whether the AI looks competent.
Sticky Ownership: Who Owns the Conversation Right Now?
Once a human gets involved, the agent has to know to stay quiet. I model conversation ownership as a small state machine, stored with the conversation:
The rules behind it:
- Agent: the agent replies normally.
- PendingHuman: the agent may acknowledge new messages ("a teammate has your request and will reply soon") but makes no further tool calls in act or propose tiers. The pending conversation shows up flagged in whatever inbox the team uses.
- Human: the agent is silent. Sticky means sticky: if a person replied, the next customer message goes to the person, even if it's a simple question the agent could answer. Customers notice when the voice changes mid-conversation, and they don't like it.
- Back to Agent: only on an explicit release by the teammate, or after an idle timeout the business chooses. When the agent resumes, it reads what the human said first, so it doesn't contradict a promise a person made.
The most common bug I've seen here is detection: how does the system know a human replied? If teammates answer from the same business number through a different app, your backend may never see their message. Solve that before launch, either by routing human replies through your own system or by listening to the provider's outbound message events.
Notifying the Team Without a Babysitter
Nobody wants to watch a dashboard all day waiting for the bot to need help. The notification layer should push, not wait to be checked:
- Send the handoff summary to where the team already is: email, a team chat channel, or a push notification. Include the approve/claim link.
- Show a pending badge count wherever conversations are listed. A pending item that nobody sees is worse than no agent at all.
- Escalate stale items. If a pending handoff isn't claimed within the business's target time, notify a second person.
- Tell the customer the truth when it's after hours: "Our team replies from 9am. I've passed along everything you told me."
AI Agent Human Handoff Launch Checklist
Before a channel agent talks to real customers, I go through this list:
Scope and tiers
- Every tool is in the act / propose / hand off matrix, reviewed by someone from the business
- Propose-tier outputs are structured drafts, rendered by code after approval
- An output check blocks prices, dates or commitments in agent-written text
- The agent can say "I don't know" and offer a person
Handoff
- Explicit "talk to a human" works in every language you support
- Hand-off-tier topics are flagged before the agent replies
- Repeated-failure and tool-failure counters trigger a handoff
- The handoff summary template is filled from the tool log, with
promises_madechecked - Notifications reach a person without anyone polling a dashboard
- Stale handoffs escalate to a second person
Hygiene
- Burst window buffers split messages into one turn
- Per-conversation lock prevents overlapping turns
- Webhook retries are deduplicated by message ID
- Turn caps and bot-detection stop loops
- Channel reply windows (where the platform has them) are tracked per conversation
Ownership
- Ownership state is stored per conversation and respected on every inbound message
- Human replies are detected even when sent outside your app
- Release back to the agent is explicit or time-based, and the agent reads human messages first
Evidence
- Eval scenarios cover handoff triggers, including customers trying to talk the agent out of escalating (an eval gym makes this repeatable)
Skip any one of these and a good model ends up making a bad promise.
Where to Start
If you already have an agent in production and none of this in place, don't try to do it all at once. In order:
- Write the matrix. One table, every tool, one tier each. This takes an afternoon and changes how everyone talks about the agent.
- Add the explicit handoff and the notification. Customers who ask for a person get one, and someone hears about it.
- Add the burst window and the lock. It's the cheapest fix for the most visible weirdness.
- Move one high-risk flow to draft-then-approve. Quotes are usually the right first candidate.
- Add ownership state once humans are regularly in the loop.
- Write evals for each trigger so the next prompt change doesn't quietly break escalation.
If you're designing the agent from scratch, I'd go further and keep the conversation flow itself deterministic, with the LLM only at the edges. I wrote about that approach in LLMs at the edges.
Conclusion
A customer-facing agent is only as trustworthy as its worst promise. The model will always be tempted to close the loop with "done!", so the system around it has to decide which loops it's allowed to close. Sort every action into act, propose or hand off. Let the agent draft and a person commit. Hand off with a summary built from what actually happened. Keep the conversation clean with burst windows, locks and sticky ownership.
None of it is glamorous. All of it is why customers trust the agent instead of screenshotting it.
Putting an agent in front of customers on a messaging app or web chat? Reach out. I'm always happy to compare notes on handoff design.