An AI support agent is only safe to run if a customer can reach a person when the AI is the wrong tool. In LayBuild, a handoff happens for a small number of explicit reasons, it flips the conversation to a HANDOFF status, stops the AI from replying, and alerts your team. It does not summarize the chat and it does not try to read the customer's mood. This post describes exactly what happens, where we think the design is still thin, and how to set up escalation rules you can defend.

What triggers a handoff

There are four paths into a handoff, and they behave slightly differently.

The first is an explicit request in the web widget or API. Before retrieval runs, every customer message is checked against a pattern that looks for phrases such as "talk to a human", "speak to an agent", "real person", "escalate" or "customer service". A match marks the turn as a handoff request. Whether the handoff actually happens depends on an organization setting, ALLOW_HUMAN_HANDOFF, which is off by default and can be switched on from the admin settings page.

The second is the model itself. The built-in system prompts tell the model that when a customer asks for a human, or the issue needs an action it cannot take (refund approval, account changes, disputes, legal or safety concerns), it should reply with one fixed sentence: "I'm connecting you with a human support specialist right now." While the reply streams, a watcher looks for that sentence and, when it appears, stops generation and marks the turn as a handoff request. This path is gated by the same ALLOW_HUMAN_HANDOFF setting.

The third is a turn limit. Once a conversation has 25 AI replies, the next customer message gets a fixed notice that the automated turn limit is reached, and the conversation is handed off. This check does not look at ALLOW_HUMAN_HANDOFF. It exists to stop a customer and a bot from going round in circles indefinitely.

The fourth is WhatsApp, which has its own keyword list per channel. If you have not set one, the defaults are "agent", "human", "support", "help" and "representative", matched anywhere in the message, plus the "talk to agent" buttons. This path also does not consult ALLOW_HUMAN_HANDOFF. The default list is broad: "Can you help me track my order?" contains "help" and will go to a human. If you run WhatsApp, replace the defaults with phrases you actually want to escalate on.

Staff can also escalate manually from the dashboard, which is covered below.

What happens once a handoff fires

All four paths end in the same function in our distribution service. It does five things.

text
customer message (widget, API, WhatsApp)
        |
        v
message queue -> LangGraph run
  preflight (request pattern) -> generate (fixed-sentence watcher) -> handoff node
        |                                         (checks ALLOW_HUMAN_HANDOFF)
        v
distribution service
  |-- Postgres: conversation.status = HANDOFF, SYSTEM message added
  |-- audit log: conversation.handoff_requested (with reason)
  |-- outbound webhook: CONVERSATION_HANDOFF
  |-- Socket.io: conversation:handoff event to the org's agents room
  '-- optional: assign to the least-loaded ADMIN / AGENT / OWNER

The conversation's status changes to HANDOFF and a system message is written into the transcript. An audit log entry records the reason (ai_unable, ai_turn_limit_reached, whatsapp_keyword_trigger or manual_escalation). A CONVERSATION_HANDOFF webhook goes to any endpoint you subscribed. Everyone connected to the dashboard for that organization gets a real-time conversation:handoff event over Socket.io, which is what makes the chat appear in the handoff queue without a refresh.

From that point the AI stays quiet. The reply worker only runs for conversations that are OPEN and have no assigned agent, so further customer messages wait for a person. In the dashboard, agents can filter for unassigned handoffs or their own tickets, claim a conversation and reply in the same thread. Claiming is transactional: if a colleague already has the conversation, the claim is refused with their name unless the agent explicitly forces it, which avoids two people answering the same customer.

Automatic handoffs land unassigned. Auto-assignment exists for manual escalations from the dashboard: it picks the ADMIN, AGENT or OWNER in the organization with the fewest OPEN or HANDOFF conversations currently assigned to them. It does not know who is online, on lunch or off shift, so treat it as a tiebreaker, not a rota.

What we have not built

We would rather you hear the gaps from us than discover them during an incident.

There is no handoff summary. The agent who claims the chat reads the transcript. For short conversations that is fine. For a long one that hit the 25-turn limit, it is a lot of scrolling, and a summary generated by the same model that just failed the customer would need its own guardrails before we would trust it.

There is no sentiment or frustration detection. A customer who writes "this is useless" three times, without asking for a person, stays with the AI until one of the four triggers fires. We have not added a frustration classifier because the cheap ones are noisy in both directions: they flag sarcasm and ordinary complaints, and they miss polite customers who are quietly giving up. We would rather make the explicit request easy and honor it every time.

There is no availability check on the web widget path. A handoff at 3 a.m. puts the conversation in the queue and tells the customer a person is coming, whether or not anyone is on shift. WhatsApp channels can send an out-of-office message to new conversations outside configured business hours; the web widget has no equivalent today.

There is no escalation timer on unclaimed handoffs. Nothing re-alerts anyone if a handed-off conversation sits unclaimed for an hour. The only timer is the general one: any OPEN or HANDOFF conversation with no activity for 24 hours is closed automatically, with a message telling the customer they can reopen it. An unclaimed handoff that nobody touches for a day ends that way, which is tidy for the queue and bad for the customer. If you need a shorter escalation, build it on the webhook (example below).

Webhook deliveries are logged but not retried. If your endpoint is down when a handoff fires, that notification is gone, although the conversation is still in the dashboard queue.

One configuration trap is worth calling out. If ALLOW_HUMAN_HANDOFF is off, the built-in prompt still tells the model to say it is connecting the customer to a person. The customer can be told help is coming when no handoff took place. Either switch the setting on, or edit your agent's prompt so it tells customers how to reach you instead.

How to design escalation rules customers trust

Whatever tool you use, a few rules hold up well.

Always honor an explicit request. A customer who asks for a person has already decided the bot is not helping. Making them repeat themselves, or asking "are you sure?", costs trust you will not get back. This is why we match on the customer's own words rather than asking the model whether a handoff is warranted.

Escalate on the kind of request, not just on failure. Refund approvals, account ownership changes, billing disputes, legal threats and safety issues should go to a person even when the knowledge base contains a policy that looks relevant. The AI can quote the refund policy; it should not be the one deciding an exception. Put those categories in your prompt and keep the list short enough that the model applies it consistently.

Treat keyword lists as a precision and recall trade-off. Broad words like "help" and "support" catch everyone, including customers the AI could have answered. Narrow phrases miss people who phrase things differently. Start narrow, then read a week of transcripts where customers asked for a person in words your list missed, and add those.

Use a turn limit as a loop breaker, not a quality metric. Twenty-five AI replies is a long conversation for a support question. If many conversations reach it, the problem is usually a documentation gap or a question the AI should never have tried to answer, and the fix belongs in your content, not in a lower limit.

Be honest about the wait. If nobody is on shift, say so and give an expected response time. Silence after "connecting you now" is worse than a clear "our team replies within one business day".

Setting it up in LayBuild

  • First, switch on ALLOW_HUMAN_HANDOFF in the admin settings if you want customers in the widget and API to be able to ask for a person. If you leave it off, edit the prompt as described above.
  • Next, if you use WhatsApp, replace the default handoff keywords on the channel with phrases you want to escalate on.
  • Then make sure the people who should answer handoffs have the AGENT, ADMIN or OWNER role in your organization, and that someone keeps the dashboard open during your support hours so the real-time alert reaches a person.
  • Finally, subscribe a webhook to CONVERSATION_HANDOFF if you want alerts outside the dashboard, such as a message in your team chat or a page to whoever is on call.

A minimal receiver looks like this. It verifies the signature, then forwards the event to an alerting endpoint of your choice. The signature is an HMAC-SHA256 of the timestamp header, a dot, and the raw request body, sent as sha256=<hex>.

ts
import { createHmac, timingSafeEqual } from 'node:crypto';

const WEBHOOK_SECRET = process.env.LAYBUILD_WEBHOOK_SECRET ?? '';
const ALERT_URL = process.env.ALERT_URL ?? '';

function isValidSignature(rawBody: string, timestamp: string, header: string): boolean {
  const expected = `sha256=${createHmac('sha256', WEBHOOK_SECRET).update(`${timestamp}.${rawBody}`).digest('hex')}`;
  const a = Buffer.from(expected);
  const b = Buffer.from(header);
  // Constant-time compare so the check does not leak how many characters matched.
  return a.length === b.length && timingSafeEqual(a, b);
}

export async function handleWebhook(req: Request): Promise<Response> {
  const rawBody = await req.text();
  const timestamp = req.headers.get('x-webhook-timestamp') ?? '';
  const signature = req.headers.get('x-hub-signature-256') ?? '';
  if (!isValidSignature(rawBody, timestamp, signature)) {
    return new Response('invalid signature', { status: 401 });
  }
  const envelope = JSON.parse(rawBody) as {
    event: string;
    id: string;
    timestamp: string;
    data: { id: string; reason?: string; assignedAgent?: { name: string } | null };
  };
  if (envelope.event === 'CONVERSATION_HANDOFF' && !envelope.data.assignedAgent) {
    // Only alert on unassigned handoffs; a claimed conversation already has an owner.
    await fetch(ALERT_URL, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        text: `Chat ${envelope.data.id} needs a person (reason: ${envelope.data.reason ?? 'unknown'})`,
      }),
    });
  }
  return new Response('ok');
}

Store the delivery id (envelope.id) if you want to ignore duplicates, and reject timestamps that are far from your server's clock if replayed requests worry you. Because we do not retry failed deliveries, keep the handler fast and move slow work to a queue on your side.

Related reading