Ruby Autopilot + WhatsApp Escalation — Design
Date: 2026-06-10 Status: Approved (design); pending implementation plan Author: ssh + ClaudeProblem
Two linked WhatsApp/Ruby defects surfaced on the live Dental Square tenant:-
Broken reschedule auto-responder. A deterministic, keyword-triggered flow
(
handleRescheduleRequestinserver/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 — commit600c97128). -
Ruby autopilot never engages. Ruby (the AI WhatsApp assistant) has a
per-clinic mode
off | copilot | autopilot. Even with a clinic set toautopilot, Ruby stays silent becauserunRubyForInbound(server/src/lib/whatsapp-assistant-dispatch.ts:299) requires the individual conversation to carry aruby-onlabel — and new conversations are never created with it. Live check: Dental Square =autopilot, 0 of 69 conversations haveruby-on. So Ruby skips every thread with reasonthread-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']atpriority:'high'(whatsapp-assistant-dispatch.ts:227notifyStaffNeedsHuman,:252escalate) 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 →
handoffwith 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) thenaction:'create_request'(booking artifact for staff to confirm). This is exactly the desired model.
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 inrunRubyForInbound
(whatsapp-assistant-dispatch.ts:298-299):
- From: engage only if the
ruby-onlabel is present (isRubyOn). - To: engage unless the thread is explicitly muted by a durable
ruby-offmarker.
- New / label-less threads now engage automatically in autopilot.
- The existing
escalate()already callssetRubyOn(conversationId, false)on a genuine handoff. We repurpose that into a durableruby-offsignal so that once a human takes over (modelhandoff, 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. copilotandoffmodes are unchanged (still return at the mode gate).- The per-thread
ruby-on/ruby-offtoggle endpoint (POST /conversations/:id/ruby-toggle) keeps working; in autopilot it now functions as a per-thread mute, not an opt-in.
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 deterministichandleRescheduleRequest/handleRescheduleSelectionslot-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_requestintent:collect(date/time/reason, one field at a time) →create_request(artifact for staff) orhandoff(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.mp3references live only in dead*.old.tsx/communication_backupfiles. - Wire the global notification SSE listener (the app-wide notifications
provider / bell) to play
notification_alert.mp3when 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.mp3too, so there is exactly one notification tone in the product. Respect the existing mute preference (chatV2.notificationSoundMuted) and AudioContext gesture-priming (already handled inWhatsAppModuleV2).
4. Guardrails (light prompt tightening)
Add towhatsappAssistant 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.
Components touched
| Area | File | Change |
|---|---|---|
| Dispatch gate | server/src/lib/whatsapp-assistant-dispatch.ts | ruby-on-required → ruby-off-muted; ensure handoff sets durable ruby-off |
| Thread state | server/src/lib/whatsapp-assistant-state.ts | ruby-off marker helpers / semantics |
| Prompt | server/src/lib/ai/prompts.ts | sensitive-data + on-topic guardrails |
| Sound lib | ui/src/lib/notification-sound.ts | play notification_alert.mp3; single tone |
| Global notifications | app-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 setsruby-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.mp3app-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; memoryproject_wa_reschedule_killswitch_2026_06_10. - Ruby copilot pivot context: memory
project_ruby_copilot_2026_06_03.

