Skip to main content

Ruby Autopilot + WhatsApp Escalation — Design

Date: 2026-06-10 Status: Approved (design); pending implementation plan Author: ssh + Claude

Problem

Two linked WhatsApp/Ruby defects surfaced on the live Dental Square tenant:
  1. Broken reschedule auto-responder. A deterministic, keyword-triggered flow (handleRescheduleRequest in server/src/routes/whatsapp-webhook.ts) sent patients an interactive list of bookable times. For doctorless appointments (~83% of Dental Square’s bookings) it widened to the clinic open window (13:00–22:00) and offered times when no doctor is actually working (e.g. 2:30 PM, when both scheduled doctors start at 17:00/20:00). Patients also hit a “Sorry, that slot just got taken — reply reschedule” loop and never rebooked. Already mitigated by a per-clinic kill-switch (rescheduleAutoResponderEnabled, default off — commit 600c97128).
  2. Ruby autopilot never engages. Ruby (the AI WhatsApp assistant) has a per-clinic mode off | copilot | autopilot. Even with a clinic set to autopilot, Ruby stays silent because runRubyForInbound (server/src/lib/whatsapp-assistant-dispatch.ts:299) requires the individual conversation to carry a ruby-on label — and new conversations are never created with it. Live check: Dental Square = autopilot, 0 of 69 conversations have ruby-on. So Ruby skips every thread with reason thread-off.

Goals

  • In autopilot, Ruby engages every thread automatically.
  • Reschedule/booking is handled conversationally by Ruby (collect details → escalate / create request); a human confirms the slot. The deterministic slot-list flow is retired permanently.
  • When Ruby escalates, the team (staff and doctor) gets an audible alert using exactly one tone: ui/public/sounds/notification_alert.mp3.
  • Ruby stays on-topic with hard guardrails; sensitive info / clinical / urgent cases escalate to a human.

Non-goals (YAGNI)

  • Doctor-intersection slot availability — we are not offering patient-facing bookable slots anymore, so the slot-math fix is moot.
  • Fixing the “slot just got taken” re-validation loop — that code path (handleRescheduleSelection) is retired.
  • A Settings UI toggle for rescheduleAutoResponderEnabled — it stays off permanently; no UI needed.

Existing behavior we are KEEPING (already correct)

The Ruby prompt (server/src/lib/ai/prompts.ts:737 whatsappAssistant) and agent (server/src/lib/ai/agents/whatsapp-assistant.ts) already:
  • Escalate via action:'handoff' to ['admin','receptionist','doctor'] at priority:'high' (whatsapp-assistant-dispatch.ts:227 notifyStaffNeedsHuman, :252 escalate) on: explicit request for a person, frustration / complaint / refund, emergency words (bleeding, swelling, severe pain, pus), or 3+ consecutive unanswered turns.
  • Never give clinical advice — symptoms/diagnosis/medication → handoff with a warm holding line.
  • Never invent hours/prices/policies; escalate on repeated inability to answer.
  • Never pick or confirm a final appointment slot — booking/reschedule → action:'collect' (gather fields one at a time) then action:'create_request' (booking artifact for staff to confirm). This is exactly the desired model.
Also kept untouched: the 15-minute staff-active suppression and hourly reply rate cap in shouldRubyEngage — sane anti-spam / don’t-talk-over-staff guards.

Design

1. Autopilot engages all threads, with durable handoff preserved

Change the per-thread gate in runRubyForInbound (whatsapp-assistant-dispatch.ts:298-299):
  • From: engage only if the ruby-on label is present (isRubyOn).
  • To: engage unless the thread is explicitly muted by a durable ruby-off marker.
Rationale + safety:
  • New / label-less threads now engage automatically in autopilot.
  • The existing escalate() already calls setRubyOn(conversationId, false) on a genuine handoff. We repurpose that into a durable ruby-off signal so that once a human takes over (model handoff, or staff manually mute), Ruby will not re-fire on that thread even though autopilot is “engage all”. Without this, “engage all” would stomp every handoff — this is the critical invariant.
  • copilot and off modes are unchanged (still return at the mode gate).
  • The per-thread ruby-on/ruby-off toggle endpoint (POST /conversations/:id/ruby-toggle) keeps working; in autopilot it now functions as a per-thread mute, not an opt-in.
State model: a thread is Ruby-active in autopilot when mode===‘autopilot’ AND the thread has no ruby-off marker AND shouldRubyEngage passes.

2. Reschedule/booking → Ruby collects + escalates (retire slot-list flow)

  • The kill-switch (rescheduleAutoResponderEnabled, default off) stays off permanently; the deterministic handleRescheduleRequest / handleRescheduleSelection slot-list path remains gated out. The “reschedule” keyword/button falls through to normal inbound handling → Ruby (when autopilot is on).
  • Ruby then drives reschedule via its existing booking_request intent: collect (date/time/reason, one field at a time) → create_request (artifact for staff) or handoff (escalate). Ruby never offers or confirms a slot. No new agent behavior required — we simply let it run.
  • Cancel/confirm deterministic flows: out of scope for this change; leave as-is unless they also send slots (they do not).

3. Escalation sound alert (mandatory)

  • Escalation already produces a priority:'high' notification to staff+doctor with an SSE broadcast.
  • Gap: a sound only plays for inbound messages while the user is in the inbox. The active inbox sound is a synthesized Web Audio chime (ui/src/lib/notification-sound.ts), not an MP3. The /sounds/notification.mp3 references live only in dead *.old.tsx / communication_backup files.
  • Wire the global notification SSE listener (the app-wide notifications provider / bell) to play notification_alert.mp3 when a WhatsApp escalation notification arrives — so staff/doctor hear it anywhere in the app, not just the inbox.
  • One tone everywhere: replace the synthesized inbox chime with notification_alert.mp3 too, so there is exactly one notification tone in the product. Respect the existing mute preference (chatV2.notificationSoundMuted) and AudioContext gesture-priming (already handled in WhatsAppModuleV2).

4. Guardrails (light prompt tightening)

Add to whatsappAssistant prompt (prompts.ts:737):
  • Sensitive data rule: if a patient sends sensitive info (national ID / CNIC, card/payment details, detailed medical history), Ruby acknowledges briefly and hands off rather than processing or repeating it back; never echo sensitive data.
  • Reaffirm: stay strictly on dental-clinic-reception topics; off-topic → brief redirect or handoff. Replies 1–3 sentences, warm, as the clinic.
(Everything else in the prompt — clinical handoff, no fabrication, no slot confirmation, existing escalation triggers — is unchanged.)

Components touched

AreaFileChange
Dispatch gateserver/src/lib/whatsapp-assistant-dispatch.tsruby-on-required → ruby-off-muted; ensure handoff sets durable ruby-off
Thread stateserver/src/lib/whatsapp-assistant-state.tsruby-off marker helpers / semantics
Promptserver/src/lib/ai/prompts.tssensitive-data + on-topic guardrails
Sound libui/src/lib/notification-sound.tsplay notification_alert.mp3; single tone
Global notificationsapp-wide notifications SSE provider/bell (UI)play alert on WhatsApp escalation notifications

Testing

  • Dispatch unit tests: autopilot engages a fresh label-less thread; does NOT re-engage after ruby-off; still suppressed by staff-active and rate cap; handoff sets ruby-off.
  • Prompt contract tests: clinical→handoff, emergency→handoff, sensitive-PII→handoff, person-request→handoff, booking→collect/create_request (never slot-pick), off-topic→redirect/handoff.
  • Sound (manual): simulated escalation fires notification_alert.mp3 app-wide; mute toggle respected; exactly one tone in the product.

Rollout

  • Ship behind existing mode setting — only clinics already in autopilot (Dental Square, ssh & Associates test) start auto-engaging. Verify on the test tenant first, then watch Dental Square.
  • Reschedule kill-switch stays off for all clinics; no re-enable.

References

  • Kill-switch commit 600c97128; memory project_wa_reschedule_killswitch_2026_06_10.
  • Ruby copilot pivot context: memory project_ruby_copilot_2026_06_03.