All articles

From chatbot to a real person: how the handoff to live chat works

The site has an AI-based assistant that answers questions about services, projects and tools. It works well for frequent questions, but sometimes the person writing wants to talk to a human: they have a specific project in mind, or a question a language model shouldn't answer by guessing. Before, the only way was the contact form. Now, while you type in the chat, an invitation may appear: "Want to talk to Gent directly?". If you say yes, I get an email, open a link and join the same conversation, in real time.

In this article I explain how it works, with the code (Angular, NestJS, Socket.IO, MongoDB) and the bugs that never showed up locally. The excerpts are simplified compared to the ones in production, but the logic is the same.

The use case

A visitor asks the assistant how much a custom management system might cost. They get a general answer, ask another question, then start describing their case. While they type, the banner appears. They click "Yes, contact him" and read "Waiting for Gent…".

I receive an email with the subject "🔴 Un visitatore vuole parlare con te ora" ("A visitor wants to talk to you now"), the latest messages of the conversation and a button. I can open it from my phone too, join the chat, and the visitor sees "Gent is joining…", then "You're talking to Gent". From that moment their messages reach me, not the AI.

If I don't answer within 15 minutes, the request expires and the visitor is told: "Gent isn't available right now, but he's received your request". They can carry on with the assistant, and nobody is left waiting in front of a blinking cursor.

The flow, from start to finish

Visitor (Angular)                  Backend (NestJS)                     Gent
─────────────────                  ────────────────                     ────
types... (debounce 1.2 s)
→ banner "Talk to Gent?"
clicks "Yes"
  POST /chatbot/:id/live-handoff → saves request   [requested]
                                 → sends email ─────────────────────────→ 📧
  io('/live-chat')
  join_session ─────────────────→ room live-handoff:<id>                [notified]
                                                         ←── admin_join (JWT)
  ←── agent_joined, status: live                                        [live]
  visitor_message ══════════════ chat_message (both ways) ══════════════ admin_message

The core choice: the request goes through REST, the conversation through WebSocket. The request is a single event to validate, rate-limit and record; the conversation is a two-way stream where latency matters.

1. The banner: asking at the right moment

A popup that appears as soon as the page opens is annoying. This one only appears when three conditions are true:

  • there has already been at least one exchange with the assistant;
  • the visitor is typing: every keystroke feeds an RxJS stream with a 1.2-second debounceTime, so the banner shows up during a pause, not on every letter;
  • they haven't already declined it in the last 10 minutes.
// chatbot.component.ts: every keystroke feeds a stream, debounced
private readonly typing$ = new Subject<void>();

ngOnInit(): void {
  this.typing$.pipe(debounceTime(1200)).subscribe(() => {
    const sessionId = this.chatbot.currentSessionId;
    if (sessionId && this.flags.liveHandoffEnabled()) {
      this.liveHandoff.notifyTyping(sessionId, this.chatbot.hasMessages);
    }
  });
}

onInputChange(): void {
  if (this.liveState === 'idle' || this.liveState === 'prompt_shown') {
    this.typing$.next();
  }
}

// live-handoff.service.ts
notifyTyping(sessionId: string, hasExchangedMessages: boolean): void {
  if (!hasExchangedMessages) return;              // never on the very first message
  if (this.state !== 'idle') return;              // already asked, or already live
  if (this.isDismissedRecently(sessionId)) return; // "no" means no, for 10 minutes

  this._state.next('prompt_shown');
  this.armAutoMinimize();                         // shrinks to a pill after 8 s
}

After 8 seconds without an answer the banner shrinks to a "Talk to Gent" pill: still available, but not covering the conversation. The refusal is remembered in sessionStorage, so it applies to the open tab and doesn't follow the visitor forever. And a switch in the dashboard turns the whole feature off when I can't answer.

2. The request: REST, with limits

// live-handoff.controller.ts
@Post(':sessionId/live-handoff')
@RequireFeature('liveHandoffEnabled')              // the switch in the dashboard
@Throttle({ default: { limit: 3, ttl: 600_000 } }) // 3 requests every 10 minutes
create(
  @Req() req: Request,
  @Param('sessionId', ParseUUIDPipe) sessionId: string,
  @Body() dto: CreateLiveHandoffDto,
) {
  return this.liveHandoffService.createRequest(sessionId, dto, req.ip ?? '');
}

// live-handoff.service.ts
async createRequest(sessionId: string, dto: CreateLiveHandoffDto, ip: string) {
  // Idempotent: a double click returns the request that is already open
  const existing = await this.model.findOne({ sessionId, status: { $in: ACTIVE_STATUSES } });
  if (existing) return this.toStatusDto(existing);

  const todayCount = await this.model.countDocuments({ createdAt: { $gte: startOfToday() } });
  if (todayCount >= this.config.get<number>('LIVE_HANDOFF_MAX_PER_DAY', 20)) {
    throw new ConflictException('Live handoff request limit reached for today.');
  }

  const doc = await this.model.create({
    sessionId,
    status: 'requested',
    lastUserMessage: dto.lastUserMessage?.slice(0, 500),
    locale: dto.locale,
    // Never the raw IP: a truncated hash is enough to spot repeated abuse
    ipHash: createHash('sha256').update(ip).digest('hex').slice(0, 16),
    expiresAt: new Date(Date.now() + 15 * 60_000),
  });

  // Fire-and-forget: a slow mail provider must not keep the visitor waiting
  this.notifyGent(doc).catch((err) => this.logger.error('Live handoff email failed', err));

  return this.toStatusDto(doc);
}

Every line has a reason:

  • Idempotency: a double click, or a second attempt after a network error, returns the request that's already open instead of creating another one and sending me two emails.
  • Three requests every ten minutes and a daily cap: a public endpoint that sends emails is an invitation to abuse.
  • The IP isn't stored: I only keep a truncated hash, useful for spotting repeated abuse, useless for identifying anyone.
  • The email is sent without await: the visitor gets an immediate response, and if sending fails it shows up in the logs.

3. The email: all the context at a glance

When I open the email I need to understand in a few seconds who is looking for me and why. That's why it contains the last six messages of the conversation, the visitor's language, the expiry time and a direct link to the admin page of that chat:

private async notifyGent(doc: LiveHandoffRequestDocument): Promise<void> {
  const session = await this.chatbotService.getSession(doc.sessionId).catch(() => null);

  await this.mailService.sendLiveHandoffRequest({
    sessionId: doc.sessionId,
    lastUserMessage: doc.lastUserMessage,
    locale: doc.locale,
    recentMessages: session?.messages?.slice(-6) ?? [], // the context at a glance
    expiresAt: doc.expiresAt,
  });

  doc.status = 'notified';
  doc.notifiedAt = new Date();
  await doc.save();
  this.gateway.emitStatusChanged(doc.sessionId, doc.status); // the visitor's UI follows
}

// mail.service.ts: the button in the email
const joinUrl = `${frontendUrl}/dashboard/live-chat/${opts.sessionId}`;

The link leads to a protected page, reachable only as an administrator. And email isn't the only channel: the request also shows up in the dashboard, with a real-time notification.

4. The WebSocket: rooms and roles

The Socket.IO gateway lives in a dedicated namespace, /live-chat. Each conversation is a room: visitor and administrator join the same one, and every message is first saved and then broadcast to the whole room.

@WebSocketGateway({ namespace: '/live-chat', cors: { origin: corsOriginValidator } })
export class LiveHandoffGateway {
  @WebSocketServer() server: Server;

  private room(sessionId: string): string {
    return `live-handoff:${sessionId}`;
  }

  @SubscribeMessage('join_session')
  async onJoinSession(@ConnectedSocket() client: Socket, @MessageBody() body: { sessionId?: string }) {
    if (body?.sessionId) await client.join(this.room(body.sessionId));
  }

  @SubscribeMessage('visitor_message')
  async onVisitorMessage(
    @ConnectedSocket() client: Socket,
    @MessageBody() body: { sessionId?: string; text?: string },
  ) {
    const text = body?.text?.trim().slice(0, 1000);
    if (!body?.sessionId || !text) return;

    const { status } = await this.liveHandoffService.getStatus(body.sessionId);
    if (!CHATTABLE_STATUSES.includes(status)) return;

    // Stored in the same session as the AI chat (roles: user, assistant, agent)
    const message = await this.chatbotService.appendLiveMessage(body.sessionId, 'user', text);
    await this.liveHandoffService.touchActivity(body.sessionId);
    this.server.to(this.room(body.sessionId)).emit('chat_message', {
      from: 'visitor',
      text,
      sentAt: message.timestamp,
    });
  }
}

My messages end up in the same session as the AI chat, with a third role next to user and assistant: agent. I extended the existing schema instead of creating a new one, so the conversation stays a single one.

The visitor has no account: they join the room with the session identifier, a random UUID known only to their browser. That's their permission. The administrator, on the other hand, authenticates when the socket opens, with a token:

async handleConnection(client: Socket): Promise<void> {
  const token = client.handshake.auth?.token;
  if (typeof token === 'string' && (await this.isAdminToken(token))) {
    client.data.isAdmin = true;
    await client.join(ADMINS_ROOM); // also receives the dashboard notifications
  }
  // Visitors connect without a token: their sessionId is their capability
}

private async isAdminToken(token: string): Promise<boolean> {
  let sub: string | undefined;
  try {
    sub = this.jwtService.verify<{ sub?: string }>(token).sub;
  } catch {
    return false;
  }
  if (!sub) return false;
  // A valid signature is not enough: read the role from the database,
  // so a demoted admin loses access immediately
  const user = await this.usersService.findById(sub);
  return user?.role === 'admin';
}

In the first version any valid JWT was enough. With registration open to everyone, a regular user could have joined a chat posing as "Gent", written in my place and closed it. Now the role is read from the database on every connection: a demoted administrator loses access immediately, without waiting for the token to expire.

5. A state machine, with deadlines

requested ──→ notified ──→ agent_joining ──→ live ──→ closed
    │             │                            │
    └─────────────┴──→ expired                 └──→ closed  (5 min without messages,
         (15 min without an answer)                          or 2 h at most)

States are moved forward by events, but also by time. That's why there are three scheduled jobs and a MongoDB TTL index:

// Every minute: unanswered requests past their deadline become 'expired'
@Cron(CronExpression.EVERY_MINUTE)
async expireStaleRequests(): Promise<number> {
  const stale = await this.model.find({
    status: { $in: ['requested', 'notified'] },
    expiresAt: { $lt: new Date() },
  });
  for (const doc of stale) {
    doc.status = 'expired';
    await doc.save();
    this.gateway.emitStatusChanged(doc.sessionId, doc.status); // the visitor is told
  }
  return stale.length;
}

// Same pattern for the other two jobs:
// - every minute:     'live' with no messages for 5 minutes  → 'closed'
// - every 10 minutes: open for more than 2 hours (tab closed) → 'closed'

// Data retention: each request is deleted 24 h after creation, whatever its outcome
LiveHandoffRequestSchema.index({ createdAt: 1 }, { expireAfterSeconds: 60 * 60 * 24 });

Without the inactivity close, two tabs left open without writing to each other would stay "live" for hours, taking up the dashboard as if it were an ongoing conversation.

6. The safety net: polling

A WebSocket can fail to connect for many reasons: a corporate proxy, a flaky mobile network, a wrong configuration. The frontend defends itself in two ways:

private connectSocket(sessionId: string): void {
  // No `transports: ['websocket']`: start with HTTP long polling and
  // upgrade to WebSocket when possible, instead of failing outright
  this.socket = this.io(`${this.wsOrigin}/live-chat`);

  this.socket.on('connect', () => this.socket?.emit('join_session', { sessionId }));
  this.socket.on('handoff_status_changed', ({ status }) => this.applyStatus(status));
  this.socket.on('chat_message', (msg) => this.appendMessage(msg));

  // Independent safety net: even if the socket never connects,
  // a REST poll every 4 s still finds out when Gent joins
  this.pollTimer = setInterval(() => {
    this.http
      .get<{ status: string }>(`${this.apiUrl}/${sessionId}/live-handoff/status`)
      .subscribe({ next: (res) => this.applyStatus(res.status), error: () => undefined });
  }, 4000);
}

First, it doesn't force the websocket transport: Socket.IO starts with HTTP long polling and switches to WebSocket if it can, so if the WebSocket is blocked the messages still get through. Second, if Socket.IO can't connect at all, the REST poll keeps detecting status changes: the visitor still finds out that I've joined the chat, or that the request has expired, instead of waiting forever.

The bugs that never showed up locally

The admin stuck on "Joining the chat…"

On the first day, opening the link from the email, I'd stay stuck on "Joining the chat…" forever. The reason: if the request had already expired, the service threw an exception, but the gateway didn't catch it. No event came back to the client, neither success nor error. And an expired request was the most common case: all it takes is reading the email a quarter of an hour late.

// Before (simplified): an expired request threw, and nobody caught it
@SubscribeMessage('admin_join')
async onAdminJoin(@ConnectedSocket() client: Socket, @MessageBody() body: { sessionId: string }) {
  const status = await this.liveHandoffService.markAgentJoining(body.sessionId);
  // ConflictException on 'expired' → no event ever reached the client
}

// After: every failure becomes an event the UI can show
let status: LiveHandoffStatusDto;
try {
  status = await this.liveHandoffService.markAgentJoining(body.sessionId);
} catch (err) {
  client.emit('error', {
    message: err instanceof Error ? err.message : 'Impossibile entrare in questa chat.',
  });
  return;
}

The fix has three parts. An expired request can now be reopened: if the visitor still has the page open, the conversation picks up again. The gateway turns every exception into a readable error event. And a message sent to a chat that's no longer active produces an error instead of vanishing. The lesson: in a feature with deadlines, the case to test isn't the request you just created. It's the one from fifteen minutes ago.

The socket that wouldn't connect in production

Locally everything worked; in production the admin page stayed on "Connecting…". I got the diagnosis wrong twice. First I thought it was CORS, but the headers were correct. Then the proxy of the cloud service that hosted the backend back then: a WebSocket handshake made with curl returned 400. But that was curl negotiating HTTP/2, where that upgrade doesn't exist; a real browser, against the same host, connected without any problem.

The real cause was the Content Security Policy. connect-src listed the backend as https://, and for CSP the scheme matters: https://host doesn't authorize wss://host. The browser blocked the socket and left just one line in the console.

# Before: the WebSocket was silently blocked
Header always set Content-Security-Policy "... connect-src 'self' https://api.example.com"

# After: the same host, listed once per scheme
Header always set Content-Security-Policy "... connect-src 'self' https://api.example.com wss://api.example.com"

I proved it by listening for violations in the browser, on the real page, instead of relying on tools that don't behave like a browser:

// Paste it in the console of the real page, then open the chat
document.addEventListener('securitypolicyviolation', (e) => {
  console.warn(e.violatedDirective, e.blockedURI);
});
// → connect-src wss://api.example.com

There's now an automated test that reads the CSP and fails if the wss:// entry is missing. And even now that the backend sits on the same domain as the site, the rule stays explicit: the site's own wss:// next to 'self', because not every browser considers it already included.

The message dropped silently

The third bug was the sneakiest: I'd reply, and the visitor received nothing. No error, anywhere. A session can have several requests (an expired one and a new one, for example), and the function that moves the status to live looked up the document without sorting: sometimes it updated the old one. The most recent one stayed put, the gateway considered it inactive and dropped my messages.

async markLive(sessionId: string): Promise<void> {
  // Must target the SAME document markAgentJoining updated: the most recent one.
  // Without sort(), findOne could pick an older, expired request.
  const doc = await this.model.findOne({ sessionId }).sort({ createdAt: -1 });
  if (!doc || !['agent_joining', 'notified'].includes(doc.status)) return;

  doc.status = 'live';
  doc.lastActivityAt = new Date(); // the inactivity clock starts from here
  await doc.save();
  this.gateway.emitStatusChanged(doc.sessionId, doc.status);
}

Since then every rejected message produces an explicit error. A message that disappears silently is the hardest bug to diagnose: it leaves no clues.

Privacy and limits

  • No IP in clear text, only a truncated hash.
  • Requests delete themselves after 24 hours, thanks to the TTL index.
  • Limits per client and per day, and a switch to turn everything off.
  • A stated limit: on the other side there's me, not a customer service team available around the clock. That's why the deadline is short and the message announcing it is clear.

In short

Going from a chatbot to a person takes little code but many decisions: when to ask, how to notify, how to authenticate whoever answers and what to do when nobody does. REST for the request, WebSocket for the conversation, polling as a safety net and an explicit deadline for every state. I collected more notes on cookies, cache and WebSockets in this guide. If you want to try it, open the site's chat: if I'm available, I'll answer myself. And if your project needs something similar, get in touch.

💬 Reader notes

0 notes

Write a note

Share your opinion, a suggestion or a compliment

Latest notes

No notes yet. Be the first to comment!