The hard part of putting an AI assistant into a booking platform is not the model call. It is the handoff — the moment the AI recognises it is out of its depth and gets a human into the conversation without the guest feeling dropped. Get that wrong and you have automated a worse experience. Here is how I designed it.
Model the conversation as a state machine
An assistant that can escalate is not a chat loop — it is a state machine with explicit, auditable transitions. Encoding it as a union of states (rather than a pile of booleans) makes the illegal states unrepresentable.
type ConversationState =
| { status: "ai_active"; turns: number }
| { status: "escalating"; reason: EscalationReason; since: number }
| { status: "human_active"; agentId: string }
| { status: "resolved"; by: "ai" | "human" };
type EscalationReason =
| "low_confidence"
| "explicit_request" // guest asked for a human
| "sensitive_topic" // refunds, complaints, disputes
| "repeated_failure"; // AI gave the same non-answer twiceDecide when to step aside
The assistant should escalate on signal, not vibes. I used four concrete triggers, each cheap to evaluate on every turn. The cheapest and most important one is the explicit request — if a guest asks for a human, that is not a classification problem, it is a hard rule.
function shouldEscalate(turn: AiTurn, history: Turn[]): EscalationReason | null {
if (turn.userAskedForHuman) return "explicit_request";
if (SENSITIVE.test(turn.userText)) return "sensitive_topic";
if (turn.confidence < 0.55) return "low_confidence";
// Same non-answer twice in a row = we are stuck.
const lastTwo = history.slice(-2).filter((t) => t.role === "assistant");
if (lastTwo.length === 2 && similar(lastTwo[0].text, lastTwo[1].text)) {
return "repeated_failure";
}
return null;
}Never let the model decide alone whether to hand off a refund or a complaint. Route sensitive topics to a human by rule, in code, regardless of how confident the model claims to be. Confidence scores are not a safety mechanism.
Stream the AI, but make the seam invisible
Guests expect token-by-token streaming now. The trick is that the escalation decision runs on the server as the stream completes, so the transition to a human is part of the same event stream — the UI never has to poll or hard-refresh to discover a human joined.
export async function* handleTurn(ctx: Ctx, userText: string) {
const stream = await llm.stream({ messages: ctx.messages, userText });
let full = "";
for await (const token of stream) {
full += token;
yield { type: "token", token };
}
const reason = shouldEscalate(
{ userText, confidence: stream.confidence, userAskedForHuman: ctx.flagged },
ctx.history,
);
if (reason) {
const agent = await queue.assign(ctx.conversationId, reason);
yield { type: "handoff", reason, agent: agent.displayName };
} else {
yield { type: "done", text: full };
}
}Carry the context across the seam
The worst handoff makes the guest repeat themselves. When a human joins, hand them a machine-written summary of what the AI already tried, so the agent opens the conversation already knowing the situation.
type HandoffPacket = {
conversationId: string;
reason: EscalationReason;
guest: { name: string; bookingRef?: string };
summary: string; // LLM-generated recap of the exchange so far
attemptedResolutions: string[];
transcript: Turn[]; // full history, for the agent to scroll if needed
};Guardrails that made it safe to ship
- Hard rules in code for refunds, cancellations and complaints — the model advises, it never acts on money.
- A per-conversation turn cap: after N unresolved AI turns, escalate regardless of confidence.
- Every tool the AI can call is schema-validated and side-effect-free in preview; booking mutations require an explicit confirmed action.
- Full audit log of every state transition, so a disputed interaction can always be reconstructed.
The metric that mattered was not 'AI resolution rate'. It was guest satisfaction on escalated conversations — proof that stepping aside gracefully is itself a feature, not a failure.
An AI agent in front of paying customers earns trust by knowing its limits. The engineering that matters is not the prompt — it is the state machine, the escalation rules, and the context you carry across the seam when the model steps back and a person steps in.