Skip to main content

Ruby Autopilot + WhatsApp Escalation Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: Make Ruby autopilot engage every WhatsApp thread (muted only by a durable human-handoff marker), route reschedule/booking through Ruby’s collect-and-escalate model, fire notification_alert.mp3 as the single notification tone on escalation, and tighten the prompt’s sensitive-data/on-topic guardrails. Architecture: Server change flips the per-thread gate in runRubyForInbound from “engage only if ruby-on” to “engage unless ruby-off”; the existing escalate() handoff sets that durable ruby-off marker. UI change unifies the notification tone on ui/public/sounds/notification_alert.mp3 and plays it in the already-existing Ruby-escalation branch of NotificationProvider. The reschedule slot-list flow stays gated off (shipped); reschedule falls through to Ruby. Tech Stack: TypeScript, Cloudflare Workers (Hono), Drizzle ORM, Vitest (server), React + Vite (UI), Web Audio / HTMLAudioElement.

File Structure

FileResponsibilityChange
server/src/lib/whatsapp-assistant-state.tsConversation label helpers + Ruby on/off persistenceAdd RUBY_OFF_LABEL, isRubyMuted, pure applyRubyToggle; setRubyOn writes the ruby-off mute marker
server/src/lib/__tests__/whatsapp-assistant-state.test.tsState unit testsAdd isRubyMuted + applyRubyToggle tests
server/src/lib/whatsapp-assistant-dispatch.tsRuby dispatch orchestrator + pure guardsGate on isRubyMuted not isRubyOn; update shouldRubyAutoSend semantics
server/src/lib/__tests__/whatsapp-assistant-dispatch.test.tsDispatch guard unit testsUpdate shouldRubyAutoSend cases
server/src/lib/ai/prompts.tsRuby system promptAdd sensitive-data + on-topic guardrail rules
server/src/lib/ai/__tests__/prompts.test.tsPrompt regression test (NEW)Assert guardrail clauses present
ui/src/lib/notification-sound.tsNotification tonePlay notification_alert.mp3; single tone
ui/src/components/providers/NotificationProvider.tsxApp-wide notification SSE handlerPlay alert tone on Ruby escalation

Task 1: ruby-off durable mute marker in state file

Files:
  • Modify: server/src/lib/whatsapp-assistant-state.ts
  • Test: server/src/lib/__tests__/whatsapp-assistant-state.test.ts
  • Step 1: Write the failing tests
Append to server/src/lib/__tests__/whatsapp-assistant-state.test.ts:
import { isRubyMuted, applyRubyToggle, RUBY_OFF_LABEL, RUBY_ON_LABEL } from '../whatsapp-assistant-state';

describe('ruby mute marker', () => {
  it('isRubyMuted true only when ruby-off present', () => {
    expect(isRubyMuted([])).toBe(false);
    expect(isRubyMuted([RUBY_ON_LABEL])).toBe(false);
    expect(isRubyMuted([RUBY_OFF_LABEL])).toBe(true);
  });

  it('applyRubyToggle(false) sets ruby-off and clears ruby-on', () => {
    expect(applyRubyToggle([RUBY_ON_LABEL], false)).toEqual([RUBY_OFF_LABEL]);
    expect(applyRubyToggle(['vip'], false)).toEqual(['vip', RUBY_OFF_LABEL]);
  });

  it('applyRubyToggle(true) clears ruby-off and adds ruby-on', () => {
    expect(applyRubyToggle([RUBY_OFF_LABEL], true)).toEqual([RUBY_ON_LABEL]);
    expect(applyRubyToggle(['vip', RUBY_OFF_LABEL], true)).toEqual(['vip', RUBY_ON_LABEL]);
  });

  it('applyRubyToggle is idempotent', () => {
    expect(applyRubyToggle(applyRubyToggle([], false), false)).toEqual([RUBY_OFF_LABEL]);
  });
});
  • Step 2: Run test to verify it fails
Run: cd server && npx vitest run src/lib/__tests__/whatsapp-assistant-state.test.ts Expected: FAIL — isRubyMuted, applyRubyToggle, RUBY_OFF_LABEL are not exported.
  • Step 3: Implement in whatsapp-assistant-state.ts
Replace the block from export const RUBY_ON_LABEL = 'ruby-on'; through the end of setRubyOn with:
export const RUBY_ON_LABEL = 'ruby-on';
export const RUBY_OFF_LABEL = 'ruby-off';

/** True when Ruby is explicitly turned on for this conversation. */
export function isRubyOn(labels: string[]): boolean {
  return labels.includes(RUBY_ON_LABEL);
}

/**
 * True when Ruby has been durably muted on this conversation — set when a human
 * takes over (model handoff via escalate(), or staff toggling Ruby off). In
 * autopilot mode Ruby engages every thread EXCEPT those carrying this marker.
 */
export function isRubyMuted(labels: string[]): boolean {
  return labels.includes(RUBY_OFF_LABEL);
}

/**
 * Pure label transition for a Ruby on/off toggle.
 *  on=true  → Ruby active: clear the mute, set the explicit on marker.
 *  on=false → Ruby muted (durable handoff): clear on marker, set the mute.
 */
export function applyRubyToggle(labels: string[], on: boolean): string[] {
  return on
    ? withLabel(withoutLabel(labels, RUBY_OFF_LABEL), RUBY_ON_LABEL)
    : withLabel(withoutLabel(labels, RUBY_ON_LABEL), RUBY_OFF_LABEL);
}

/** Add/remove the durable Ruby on/off markers on a conversation. */
export async function setRubyOn(conversationId: string, on: boolean): Promise<void> {
  const readDb = getReadDb();
  const [conv] = await readDb
    .select({ labels: conversations.labels })
    .from(conversations)
    .where(eq(conversations.id, conversationId));
  if (!conv) return;
  const updated = applyRubyToggle(conv.labels ?? [], on);
  const writeDb = getWriteDb();
  await writeDb.update(conversations).set({ labels: updated }).where(eq(conversations.id, conversationId));
}
  • Step 4: Run test to verify it passes
Run: cd server && npx vitest run src/lib/__tests__/whatsapp-assistant-state.test.ts Expected: PASS (all describe blocks green).
  • Step 5: Commit
git add server/src/lib/whatsapp-assistant-state.ts server/src/lib/__tests__/whatsapp-assistant-state.test.ts
git commit -m "feat(ruby): durable ruby-off mute marker + pure applyRubyToggle"

Task 2: Flip dispatch gate to engage-all-unless-muted

Files:
  • Modify: server/src/lib/whatsapp-assistant-dispatch.ts:16 (import), :50-52 (shouldRubyAutoSend), :298-299 (gate)
  • Test: server/src/lib/__tests__/whatsapp-assistant-dispatch.test.ts
  • Step 1: Update the failing tests
Replace the entire describe('shouldRubyAutoSend', ...) block in whatsapp-assistant-dispatch.test.ts with:
describe('shouldRubyAutoSend', () => {
  it('copilot mode never auto-sends', () => {
    expect(shouldRubyAutoSend('copilot', [])).toBe(false);
    expect(shouldRubyAutoSend('copilot', ['ruby-on'])).toBe(false);
  });
  it('autopilot engages a fresh (label-less) thread', () => {
    expect(shouldRubyAutoSend('autopilot', [])).toBe(true);
  });
  it('autopilot does NOT engage a muted (ruby-off) thread', () => {
    expect(shouldRubyAutoSend('autopilot', ['ruby-off'])).toBe(false);
  });
  it('off → never', () => {
    expect(shouldRubyAutoSend('off', [])).toBe(false);
  });
});
  • Step 2: Run test to verify it fails
Run: cd server && npx vitest run src/lib/__tests__/whatsapp-assistant-dispatch.test.ts Expected: FAIL — shouldRubyAutoSend('autopilot', []) currently returns false (still gated on ruby-on).
  • Step 3: Update the import (line 16)
Change:
import { isRubyOn, setRubyOn } from './whatsapp-assistant-state';
to:
import { isRubyMuted, setRubyOn } from './whatsapp-assistant-state';
  • Step 4: Update shouldRubyAutoSend (lines 49-52)
Replace:
/** Inline auto-send fires only in autopilot mode on an explicitly ruby-on thread. */
export function shouldRubyAutoSend(mode: AssistantMode, labels: string[]): boolean {
  return mode === 'autopilot' && isRubyOn(labels);
}
with:
/**
 * Inline auto-send fires in autopilot mode on every thread EXCEPT those a human
 * has durably taken over (ruby-off marker). copilot/off never auto-send.
 */
export function shouldRubyAutoSend(mode: AssistantMode, labels: string[]): boolean {
  return mode === 'autopilot' && !isRubyMuted(labels);
}
  • Step 5: Update the per-thread gate in runRubyForInbound (lines 298-299)
Replace:
    // 4. Per-thread gate — autopilot fires only on an explicitly ruby-on thread.
    if (!isRubyOn(ctx.labels)) { tag('skip', { reason: 'thread-off' }); return; }
with:
    // 4. Per-thread mute — in autopilot Ruby engages EVERY thread except those a
    //    human has taken over (durable ruby-off marker set by escalate()).
    if (isRubyMuted(ctx.labels)) { tag('skip', { reason: 'thread-muted' }); return; }
  • Step 6: Run tests to verify they pass
Run: cd server && npx vitest run src/lib/__tests__/whatsapp-assistant-dispatch.test.ts Expected: PASS. Then confirm no other references to the removed isRubyOn import remain in this file: Run: cd server && grep -n "isRubyOn" src/lib/whatsapp-assistant-dispatch.ts Expected: no output.
  • Step 7: Commit
git add server/src/lib/whatsapp-assistant-dispatch.ts server/src/lib/__tests__/whatsapp-assistant-dispatch.test.ts
git commit -m "feat(ruby): autopilot engages all threads unless durably muted"

Task 3: Verify reschedule routes through Ruby (no new code)

Files: none modified — verification only. The kill-switch (rescheduleAutoResponderEnabled, default off) shipped in commit 600c97128; with it off, the “reschedule” keyword falls through to inbound handling and (in autopilot) reaches Ruby.
  • Step 1: Confirm the kill-switch default is off
Run: cd server && grep -n "rescheduleAutoResponderEnabled === true" src/lib/whatsapp.ts Expected: one match (returns true only when explicitly set) — i.e. default off.
  • Step 2: Confirm both reschedule entry points are gated
Run: cd server && grep -n "isRescheduleAutoResponderEnabled" src/routes/whatsapp-webhook.ts Expected: two const rescheduleOn = await isRescheduleAutoResponderEnabled(...) guards, each returning only when rescheduleOn is true (otherwise falls through).
  • Step 3: Confirm fall-through reaches Ruby
Read src/routes/whatsapp-webhook.ts around the reschedule block and confirm that when rescheduleOn is false the handler does NOT return, so execution continues to the inbound-logging + runRubyForInbound dispatch near line 1001-1017. No code change. Note this in the commit body of the next task if no standalone commit is warranted.

Task 4: Prompt guardrails — sensitive data + on-topic

Files:
  • Modify: server/src/lib/ai/prompts.ts (the whatsappAssistant template, in the ## Rules section after ### Facts Not in KNOWLEDGE)
  • Create: server/src/lib/ai/__tests__/prompts.test.ts
  • Step 1: Write the failing regression test
Create server/src/lib/ai/__tests__/prompts.test.ts:
import { describe, it, expect } from 'vitest';
import { PROMPTS } from '../prompts';

describe('whatsappAssistant prompt guardrails', () => {
  const p = PROMPTS.whatsappAssistant;
  it('keeps the clinical no-advice handoff rule', () => {
    expect(p).toContain('NEVER give any medical advice');
  });
  it('keeps the never-confirm-slot rule', () => {
    expect(p).toContain('NEVER picks or confirms the final appointment slot');
  });
  it('has a sensitive-data handoff rule', () => {
    expect(p.toLowerCase()).toContain('sensitive');
    expect(p).toContain('sensitive-data');
  });
  it('has a stay-on-topic rule', () => {
    expect(p).toContain('Stay On Topic');
  });
});
  • Step 2: Run test to verify it fails
Run: cd server && npx vitest run src/lib/ai/__tests__/prompts.test.ts Expected: FAIL on the sensitive-data and stay-on-topic assertions.
  • Step 3: Add the guardrail rules to the prompt
In server/src/lib/ai/prompts.ts, inside the whatsappAssistant template, immediately after the ### Facts Not in KNOWLEDGE block (before ### Appointment Lookup), insert:
### Sensitive / Personal Data
→ If the patient sends sensitive personal data — national ID / CNIC, full card or bank details, or detailed medical history — do NOT process, store, or repeat it back to them. Acknowledge briefly and hand off: intent='escalate', action='handoff', escalate.reason='sensitive-data', reply="Thanks — for your security, a member of our team will follow up with you directly about this."

### Stay On Topic
→ You only handle dental-clinic reception topics: hours, location, services, pricing, appointments, and general clinic FAQ. For anything clearly off-topic, briefly steer back to how you can help with their dental visit. Do not engage with unrelated requests, opinions, or tasks.

  • Step 4: Run test to verify it passes
Run: cd server && npx vitest run src/lib/ai/__tests__/prompts.test.ts Expected: PASS (4/4).
  • Step 5: Commit
git add server/src/lib/ai/prompts.ts server/src/lib/ai/__tests__/prompts.test.ts
git commit -m "feat(ruby): prompt guardrails for sensitive data + stay on topic"
Langfuse note: PROMPT_NAMES.whatsappAssistant = 'whatsapp-assistant' — if a prompt with that name exists in Langfuse it OVERRIDES this hardcoded default at runtime. After deploy, mirror these two rules into the Langfuse whatsapp-assistant prompt (or the guardrails won’t apply for clinics hitting the Langfuse version). The langfuse skill / CLI can update it.

Task 5: Unify the notification tone on notification_alert.mp3

Files:
  • Modify: ui/src/lib/notification-sound.ts (replace the synthesized chime with the MP3)
  • Step 1: Replace primeNotificationSound and playNotificationSound
In ui/src/lib/notification-sound.ts, replace the entire body of primeNotificationSound (lines ~74-80) and playNotificationSound (lines ~82-128) — i.e. everything from export function primeNotificationSound(): void { to the end of the file — with:
const ALERT_SOUND_URL = '/sounds/notification_alert.mp3';
const ALERT_VOLUME = 0.5; // audible but not startling at a front desk

let alertAudio: HTMLAudioElement | null = null;

function getAlertAudio(): HTMLAudioElement | null {
  if (typeof window === 'undefined') return null;
  if (!alertAudio) {
    alertAudio = new Audio(ALERT_SOUND_URL);
    alertAudio.preload = 'auto';
    alertAudio.volume = ALERT_VOLUME;
  }
  return alertAudio;
}

/**
 * Warm the audio element on the first user gesture so the first real alert
 * plays without the browser autoplay delay. Safe to call repeatedly.
 */
export function primeNotificationSound(): void {
  const a = getAlertAudio();
  if (a) {
    try { a.load(); } catch { /* no-op */ }
  }
}

/**
 * Plays the single notification tone (notification_alert.mp3).
 *  - force:true → used for inbound WhatsApp messages; respects the per-inbox
 *    toggle (whatsappInboundSoundEnabled).
 *  - default    → used for escalation/system alerts; respects the global mute.
 */
export function playNotificationSound(options?: { force?: boolean }): void {
  if (options?.force) {
    if (!whatsappInboundSoundEnabled) return;
  } else if (muted) {
    return;
  }
  const a = getAlertAudio();
  if (!a) return;
  try {
    a.currentTime = 0;
    void a.play().catch(() => { /* autoplay blocked pre-gesture — ignore */ });
  } catch {
    /* best-effort; sound is never a correctness path */
  }
}
Then delete the now-unused Web Audio synth internals at the top of the file: the audioContext variable, the getContext() function, and any AudioContext-only code that is no longer referenced. Keep muted, whatsappInboundSoundEnabled, their storage keys, and the four setter/getter exports (setNotificationSoundMuted, isNotificationSoundMuted, setWhatsappInboundSoundEnabled, isWhatsappInboundSoundEnabled).
  • Step 2: Verify no remaining references to the deleted synth symbols
Run: cd ui && grep -n "getContext\|audioContext\|createOscillator\|FREQ_A" src/lib/notification-sound.ts Expected: no output.
  • Step 3: Type-check the UI
Run: cd ui && npx tsc -p tsconfig.app.json --noEmit 2>&1 | grep notification-sound Expected: no output (no type errors in this file).
  • Step 4: Commit
git add ui/src/lib/notification-sound.ts
git commit -m "feat(ui): use notification_alert.mp3 as the single notification tone"

Task 6: Play the alert tone on Ruby escalation (app-wide)

Files:
  • Modify: ui/src/components/providers/NotificationProvider.tsx (add import + one call in the existing isRubyEscalation branch at lines ~339-350)
  • Step 1: Add the import
Near the other @/lib imports at the top of NotificationProvider.tsx, add:
import { playNotificationSound } from '@/lib/notification-sound';
  • Step 2: Play the tone in the escalation branch
In the eventSource.onmessage handler, change the existing branch:
                                if (isRubyEscalation) {
                                    notify.rubyEscalation(notification.message || 'A WhatsApp conversation needs a human.', {
to:
                                if (isRubyEscalation) {
                                    // Mandatory audible alert so staff/doctor hear the handoff
                                    // anywhere in the app, not just the WhatsApp inbox.
                                    playNotificationSound();
                                    notify.rubyEscalation(notification.message || 'A WhatsApp conversation needs a human.', {
  • Step 3: Type-check the UI
Run: cd ui && npx tsc -p tsconfig.app.json --noEmit 2>&1 | grep NotificationProvider Expected: no output.
  • Step 4: Commit
git add ui/src/components/providers/NotificationProvider.tsx
git commit -m "feat(ui): sound alert on Ruby WhatsApp escalation"

Task 7: Full verification + deploy

Files: none — verification + deployment.
  • Step 1: Run the full server test suite
Run: cd server && npx vitest run src/lib/__tests__/whatsapp-assistant-state.test.ts src/lib/__tests__/whatsapp-assistant-dispatch.test.ts src/lib/ai/__tests__/prompts.test.ts Expected: all PASS.
  • Step 2: Build the UI (real type-check gate)
Run: cd ui && npm run build 2>&1 | tail -5 Expected: build succeeds; fresh dist/assets/*.js produced (confirm timestamps are from now).
  • Step 3: Manual sound check
In a logged-in browser tab (sound primed by a click), trigger a type:'system' notification titled WhatsApp needs a human (or have a test thread escalate). Confirm notification_alert.mp3 plays app-wide, exactly one tone, and that the global mute toggle suppresses it.
  • Step 4: Deploy via the odontox-commit-deploy skill
Use the odontox-commit-deploy skill (stash-foreign → build committed HEAD → deploy server with --env productionpages deploy UI → force-promote canonical → verify). Server changes (dispatch/state/prompts) AND UI changes (sound/provider) both ship.
  • Step 5: Verify on the test tenant FIRST, then watch Dental Square
After deploy, send a WhatsApp message to the ssh & Associates (test) clinic number and confirm Ruby auto-replies on a fresh thread (autopilot, no ruby-off). Trigger an escalation (e.g. “I want to speak to a person”) and confirm the handoff notification + sound fire and the thread gets a durable ruby-off (Ruby stops auto-replying there). Only then watch Dental Square’s live threads — its 69 label-less conversations will begin auto-engaging.

Self-Review Notes

  • Spec coverage: §1 autopilot-all → Tasks 1-2; §2 reschedule via Ruby → Task 3 (verification, no code, by design); §3 escalation sound → Tasks 5-6; §4 guardrails → Task 4. Testing matches the codebase (mocked agent → structural prompt test, not live-model contract tests). Rollout (§Rollout) → Task 7 Step 5.
  • No placeholders: every code step shows complete code/commands.
  • Type consistency: RUBY_OFF_LABEL, isRubyMuted, applyRubyToggle defined in Task 1 and consumed in Task 2; shouldRubyAutoSend(mode, labels) signature unchanged; playNotificationSound(options?) signature unchanged (Tasks 5-6).
  • Blast radius: only clinics already in autopilot (Dental Square + ssh & Associates test) auto-engage; copilot/off unaffected.