TK
All writing
AI

Designing a clean AI → human handoff in a booking platform

Shipping an AI assistant into a live booking product means designing the moment it steps aside. Here is the state machine, the streaming architecture, and the guardrails that make an AI agent safe to put in front of paying guests.

Tanveer Khan10 min read

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.

conversation-state.ts
ts
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 twice

Decide 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.

escalation.ts
ts
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.

turn-handler.ts
ts
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.

handoff-packet.ts
ts
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.