Skip to main content

Ruby WhatsApp Assistant 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: An autopilot Ruby AI assistant that answers patients’ free-text WhatsApp messages (general info + their own next appointment), captures booking requests into the request→allot pipeline, and escalates/hands off clinical or sensitive chats to staff — gated to ssh & Associates. Architecture: One structured Ruby agent (callAIJson → DeepSeek v4-flash + Langfuse) invoked from the inbound webhook. A dispatch module owns guards (toggle, kill-switch, ruby-paused label, 15-min staff backoff, rate cap), request/lead creation, and the send. Slot-filling is transcript-driven (no state column). Config + knowledge ride in a clinic_modules row (whatsapp_assistant). No DB migration. Tech Stack: Cloudflare Workers (Hono), Drizzle/Neon Postgres, DeepSeek via server/src/lib/ai/client.ts, Langfuse-managed prompts, React + TanStack Query (UI). Spec: docs/superpowers/specs/2026-06-02-ruby-whatsapp-assistant-design.md Conventions to match (read first):
  • Agent shape: server/src/lib/ai/agents/recall-message.ts (uses callAIJson, PROMPTS, PROMPT_NAMES).
  • callAIJson signature: server/src/lib/ai/client.ts:159.
  • Prompt fallback pattern: server/src/lib/ai/prompts.ts (PROMPTS = fallback; Langfuse is canonical).
  • Module config read/write: server/src/routes/whatsapp-config.ts GET/PUT /config (JSON in clinic_modules.config).
  • Inbound hook point: server/src/routes/whatsapp-webhook.ts handleIncomingMessage, after the “Regular inbound message — log per clinic” block.
  • Send + event: sendTextMessage and writeWhatsappEvent in server/src/lib/whatsapp.ts.
  • requested appointment creation: server/src/routes/appointments.ts:374 (patients forced to status requested).
  • Bubble rendering: ui/src/components/chat-v2/thread/MessageBubble.tsx.
  • Ruby logo asset: ui/public/ruby-icon.webp.

Task 1: Assistant config helper + module row

Files:
  • Create: server/src/lib/whatsapp-assistant-config.ts
  • Test: server/src/lib/__tests__/whatsapp-assistant-config.test.ts
  • Step 1: Write the failing test
import { describe, it, expect, vi } from 'vitest';
import { parseAssistantConfig } from '../whatsapp-assistant-config';

describe('parseAssistantConfig', () => {
  it('returns disabled defaults when no row', () => {
    expect(parseAssistantConfig(null)).toEqual({ enabled: false, knowledge: '', disclosure: true });
  });
  it('reads isEnabled + config json', () => {
    const row = { isEnabled: true, config: JSON.stringify({ knowledge: 'We have parking.', disclosure: false }) };
    expect(parseAssistantConfig(row as any)).toEqual({ enabled: true, knowledge: 'We have parking.', disclosure: false });
  });
  it('tolerates malformed config json', () => {
    const row = { isEnabled: true, config: '{bad' };
    expect(parseAssistantConfig(row as any)).toEqual({ enabled: true, knowledge: '', disclosure: true });
  });
});
  • Step 2: Run test to verify it failscd server && npx vitest run src/lib/__tests__/whatsapp-assistant-config.test.ts → FAIL (module not found).
  • Step 3: Implement
// server/src/lib/whatsapp-assistant-config.ts
import { getReadDb, getWriteDb } from './db';
import { clinicModules } from '../schema';
import { and, eq } from 'drizzle-orm';

export const ASSISTANT_MODULE_KEY = 'whatsapp_assistant';

export interface AssistantConfig {
  enabled: boolean;
  knowledge: string;
  disclosure: boolean;
}

export function parseAssistantConfig(row: { isEnabled?: boolean; config?: string | null } | null): AssistantConfig {
  if (!row) return { enabled: false, knowledge: '', disclosure: true };
  let cfg: any = {};
  try { cfg = row.config ? JSON.parse(row.config) : {}; } catch { cfg = {}; }
  return {
    enabled: !!row.isEnabled,
    knowledge: typeof cfg.knowledge === 'string' ? cfg.knowledge : '',
    disclosure: cfg.disclosure !== false,
  };
}

export async function getAssistantConfig(clinicId: string): Promise<AssistantConfig> {
  const db = getReadDb();
  const [row] = await db.select().from(clinicModules)
    .where(and(eq(clinicModules.clinicId, clinicId), eq(clinicModules.moduleKey, ASSISTANT_MODULE_KEY)))
    .limit(1);
  return parseAssistantConfig(row ?? null);
}

export async function saveAssistantConfig(clinicId: string, patch: Partial<AssistantConfig>): Promise<AssistantConfig> {
  const db = getWriteDb();
  const current = await getAssistantConfig(clinicId);
  const next: AssistantConfig = { ...current, ...patch };
  // Idempotent upsert; matches the no-migration CREATE-on-write style used elsewhere.
  await db.execute(/* sql */`
    INSERT INTO app.clinic_modules (id, clinic_id, module_key, is_enabled, config, created_at, updated_at)
    VALUES (gen_random_uuid()::text, '${clinicId}', '${ASSISTANT_MODULE_KEY}', ${next.enabled}, $cfg$${JSON.stringify({ knowledge: next.knowledge, disclosure: next.disclosure })}$cfg$, now(), now())
    ON CONFLICT (clinic_id, module_key) DO UPDATE SET is_enabled = EXCLUDED.is_enabled, config = EXCLUDED.config, updated_at = now()
  ` as any);
  return next;
}
NOTE for implementer: verify the clinic_modules unique constraint name on (clinic_id, module_key) and the exact insert style against server/src/routes/whatsapp-config.ts PUT /config; reuse Drizzle insert/onConflict if that file uses it rather than raw SQL. Do NOT introduce a migration.
  • Step 4: Run test to verify it passes.
  • Step 5: Commitgit add server/src/lib/whatsapp-assistant-config.ts server/src/lib/__tests__/whatsapp-assistant-config.test.ts && git commit -m "feat(whatsapp): Ruby assistant config helper"

Task 2: Knowledge assembler

Files:
  • Create: server/src/lib/ai/whatsapp-knowledge.ts
  • Test: server/src/lib/ai/__tests__/whatsapp-knowledge.test.ts
Responsibility: build the plain-text knowledge block injected into the prompt — structured facts (clinic name/address/hours/services/doctors) + the freeform blob + (optional) the matched patient’s next appointment.
  • Step 1: Write the failing test
import { describe, it, expect } from 'vitest';
import { formatKnowledgeBlock } from '../whatsapp-knowledge';

describe('formatKnowledgeBlock', () => {
  it('includes clinic facts, blob, and patient appt', () => {
    const out = formatKnowledgeBlock({
      clinicName: 'ssh & Associates', address: '12 Main St',
      hours: [{ day: 'Monday', open: '13:00', close: '21:00', closed: false }],
      services: ['Cleaning', 'Braces'], doctors: ['Dr Ayesha Khan'],
      knowledge: 'Free parking at the rear.',
      patient: { firstName: 'Sara', nextAppointment: { date: '2026-06-10', time: '14:30', type: 'Cleaning', doctor: 'Dr Ayesha Khan' } },
    });
    expect(out).toContain('ssh & Associates');
    expect(out).toContain('Free parking');
    expect(out).toContain('Braces');
    expect(out).toContain('2026-06-10');
    expect(out).toContain('Sara');
  });
  it('omits patient block when none', () => {
    const out = formatKnowledgeBlock({ clinicName: 'X', address: '', hours: [], services: [], doctors: [], knowledge: '', patient: null });
    expect(out).not.toContain('PATIENT CONTEXT');
  });
});
  • Step 2: Run → FAIL.
  • Step 3: Implement
// server/src/lib/ai/whatsapp-knowledge.ts
import { getReadDb } from '../db';
import { clinics, clinicOperatingHours, procedures, users, doctorSchedules } from '../../schema';
import { and, eq } from 'drizzle-orm';
import { decryptPatientPHI } from '../encryption'; // match the helper used in the webhook
import { getAssistantConfig } from '../whatsapp-assistant-config';

export interface KnowledgeInput {
  clinicName: string;
  address: string;
  hours: Array<{ day: string; open?: string; close?: string; closed: boolean }>;
  services: string[];
  doctors: string[];
  knowledge: string;
  patient: { firstName: string; nextAppointment: { date: string; time: string; type: string; doctor: string } | null } | null;
}

export function formatKnowledgeBlock(k: KnowledgeInput): string {
  const lines: string[] = [];
  lines.push(`CLINIC: ${k.clinicName}`);
  if (k.address) lines.push(`ADDRESS: ${k.address}`);
  if (k.hours.length) {
    lines.push('HOURS:');
    for (const h of k.hours) lines.push(`  ${h.day}: ${h.closed ? 'Closed' : `${h.open}${h.close}`}`);
  }
  if (k.services.length) lines.push(`SERVICES: ${k.services.join(', ')}`);
  if (k.doctors.length) lines.push(`DOCTORS: ${k.doctors.join(', ')}`);
  if (k.knowledge.trim()) lines.push(`CLINIC NOTES:\n${k.knowledge.trim()}`);
  if (k.patient) {
    lines.push(`PATIENT CONTEXT: messaging patient is ${k.patient.firstName}.`);
    lines.push(k.patient.nextAppointment
      ? `  Next appointment: ${k.patient.nextAppointment.date} ${k.patient.nextAppointment.time}, ${k.patient.nextAppointment.type} with ${k.patient.nextAppointment.doctor}.`
      : `  No upcoming appointment on file.`);
  }
  return lines.join('\n');
}

// Assemble live data. `patient` is the single phone-matched patient (or null).
export async function buildKnowledge(clinicId: string, patient: { id: string; firstName: string } | null): Promise<KnowledgeInput> {
  const db = getReadDb();
  const [clinic] = await db.select().from(clinics).where(eq(clinics.id, clinicId)).limit(1);
  const cfg = await getAssistantConfig(clinicId);
  const hoursRows = await db.select().from(clinicOperatingHours).where(eq(clinicOperatingHours.clinicId, clinicId));
  const procRows = await db.select({ name: procedures.procedureName }).from(procedures).where(eq(procedures.clinicId, clinicId));
  const docRows = await db.select({ first: users.firstName, last: users.lastName }).from(users)
    .where(and(eq(users.clinicId, clinicId), eq(users.role, 'doctor')));

  let patientCtx: KnowledgeInput['patient'] = null;
  if (patient) {
    // next upcoming, non-cancelled appointment for this patient
    const appt = await fetchNextAppointment(db, clinicId, patient.id); // implement inline; see NOTE
    patientCtx = { firstName: patient.firstName, nextAppointment: appt };
  }

  return {
    clinicName: clinic?.name ?? 'the clinic',
    address: (clinic as any)?.address ?? '',
    hours: hoursRows.map((h: any) => ({ day: capitalize(h.dayOfWeek), open: h.openTime, close: h.closeTime, closed: !!h.isClosed })),
    services: procRows.map((p: any) => p.name).filter(Boolean),
    doctors: docRows.map((d: any) => `Dr ${d.first ?? ''} ${d.last ?? ''}`.trim()),
    knowledge: cfg.knowledge,
    patient: patientCtx,
  };
}

function capitalize(s: string) { return s ? s.charAt(0).toUpperCase() + s.slice(1) : s; }
NOTE for implementer: implement fetchNextAppointment(db, clinicId, patientId) returning {date,time,type,doctor}|null by querying appointments (status not in cancelled/requested, date >= today PKT, order by date/time, limit 1) joined to the doctor’s name — mirror the select in appointments.ts. Confirm clinics.address exists; if address is structured, format it. Confirm decryptPatientPHI import path matches the webhook’s import.
  • Step 4: Run → PASS.
  • Step 5: Commitfeat(whatsapp): Ruby knowledge assembler

Task 3: Ruby agent (structured output) + Langfuse prompt name + fallback

Files:
  • Create: server/src/lib/ai/agents/whatsapp-assistant.ts
  • Modify: server/src/lib/ai/prompts.ts (add PROMPT_NAMES.whatsappAssistant + PROMPTS.whatsappAssistant fallback)
  • Test: server/src/lib/ai/agents/__tests__/whatsapp-assistant.test.ts
  • Step 1: Write the failing test (mocks callAIJson)
import { describe, it, expect, vi } from 'vitest';
vi.mock('../../client', () => ({ callAIJson: vi.fn() }));
import { callAIJson } from '../../client';
import { askRubyWhatsApp } from '../whatsapp-assistant';

describe('askRubyWhatsApp', () => {
  it('passes knowledge + transcript and returns the structured result', async () => {
    (callAIJson as any).mockResolvedValue({ intent: 'general', action: 'reply', reply: 'We are open till 9pm.', category: 'hours' });
    const res = await askRubyWhatsApp({
      clinicId: 'c1', knowledgeBlock: 'CLINIC: X\nHOURS:\n  Monday: 13:00–21:00',
      transcript: [{ role: 'user', text: 'what time do you close?' }],
    });
    expect(res.action).toBe('reply');
    expect(res.reply).toContain('9pm');
    const arg = (callAIJson as any).mock.calls[0][0];
    expect(arg.promptName).toBeDefined();
    expect(JSON.stringify(arg)).toContain('what time do you close');
  });
});
  • Step 2: Run → FAIL.
  • Step 3: Implement agent
// server/src/lib/ai/agents/whatsapp-assistant.ts
import { callAIJson } from '../client';
import { PROMPTS, PROMPT_NAMES } from '../prompts';

export type RubyIntent = 'general' | 'appointment_lookup' | 'booking_request' | 'clinical' | 'escalate' | 'smalltalk' | 'unknown';
export type RubyAction = 'reply' | 'collect' | 'create_request' | 'handoff';

export interface RubyBooking {
  complete: boolean;
  preferredDate?: string; preferredTime?: string; reason?: string;
  name?: string; email?: string; missing?: string[];
}
export interface RubyResult {
  intent: RubyIntent;
  action: RubyAction;
  reply: string;
  category: string;
  booking?: RubyBooking;
  escalate?: { reason: string };
}

export interface AskRubyInput {
  clinicId: string;
  knowledgeBlock: string;
  transcript: Array<{ role: 'user' | 'assistant'; text: string }>; // last ~8 turns, oldest first
  patientMatched?: boolean;
}

export async function askRubyWhatsApp(input: AskRubyInput): Promise<RubyResult> {
  const userContent = [
    `KNOWLEDGE:\n${input.knowledgeBlock}`,
    `PATIENT_MATCHED: ${input.patientMatched ? 'yes' : 'no'}`,
    `CONVERSATION (oldest first):`,
    ...input.transcript.map(t => `${t.role === 'user' ? 'PATIENT' : 'CLINIC'}: ${t.text}`),
    `Respond with the JSON object now.`,
  ].join('\n');

  return callAIJson<RubyResult>({
    promptName: PROMPT_NAMES.whatsappAssistant,
    fallbackSystemPrompt: PROMPTS.whatsappAssistant,
    user: userContent,
    temperature: 0.3,
  });
}
NOTE: match the EXACT callAIJson option names from server/src/lib/ai/client.ts:159 (e.g. it may be prompt/messages/variables rather than user/fallbackSystemPrompt). Read that signature and a sibling agent (recall-message.ts) and conform — the structure above is the intent, not necessarily the exact keys.
  • Step 4: Add prompt name + fallback to prompts.ts
In PROMPT_NAMES add: whatsappAssistant: 'whatsapp-assistant', In PROMPTS add whatsappAssistant: \…`— the fallback system prompt. Author it three-tier (System → JSON schema → Rules) per the file header, covering: the §6 schema; intent routing; **clinical = hard handoff, never advise**; no hallucinated prices/policies; booking slot-filling (ask formissing[0], set create_request` when complete; for unmatched numbers require name+email+preferred time; matched needs preferred date/time+reason); escalation triggers (explicit human ask, frustration, refund, urgency words, 2 handoffs); first-reply disclosure line; short warm clinic voice; speak as the clinic, never name “Ruby”/“AI” to the patient. End with an explicit “Output the JSON object only.” directive.
This fallback is the safety net; the canonical prompt is uploaded to Langfuse in Task 9. Keep them in sync at author time.
  • Step 5: Run → PASS. Commitfeat(ai): Ruby WhatsApp assistant agent + prompt

Task 4: Dispatch — guards, rate cap, send, request/lead creation, escalation

Files:
  • Create: server/src/lib/whatsapp-assistant-dispatch.ts
  • Test: server/src/lib/__tests__/whatsapp-assistant-dispatch.test.ts
Responsibility: given an inbound text + matched patient(s) + conversation, decide whether Ruby should act, call the agent, and execute the action (reply / collect / create_request / handoff) with all side effects.
  • Step 1: Write failing tests for the pure guard shouldRubyEngage:
import { describe, it, expect } from 'vitest';
import { shouldRubyEngage } from '../whatsapp-assistant-dispatch';

const base = { enabled: true, labels: [] as string[], staffActiveWithinMin: 999, repliesLastHour: 0 };
describe('shouldRubyEngage', () => {
  it('engages when enabled, no pause, no recent staff, under cap', () => {
    expect(shouldRubyEngage(base).engage).toBe(true);
  });
  it('skips when disabled', () => { expect(shouldRubyEngage({ ...base, enabled: false }).engage).toBe(false); });
  it('skips when ruby-paused label present', () => { expect(shouldRubyEngage({ ...base, labels: ['ruby-paused'] }).engage).toBe(false); });
  it('skips when staff active in last 15 min', () => { expect(shouldRubyEngage({ ...base, staffActiveWithinMin: 5 }).engage).toBe(false); });
  it('skips when over hourly cap', () => { expect(shouldRubyEngage({ ...base, repliesLastHour: 5 }).engage).toBe(false); });
});
  • Step 2: Run → FAIL.
  • Step 3: Implement the guard + dispatch
// server/src/lib/whatsapp-assistant-dispatch.ts
export const RUBY_PAUSED_LABEL = 'ruby-paused';
const STAFF_BACKOFF_MIN = 15;
const HOURLY_CAP = 5;

export function shouldRubyEngage(s: { enabled: boolean; labels: string[]; staffActiveWithinMin: number; repliesLastHour: number }): { engage: boolean; reason?: string } {
  if (!s.enabled) return { engage: false, reason: 'disabled' };
  if (s.labels.includes(RUBY_PAUSED_LABEL)) return { engage: false, reason: 'handed-off' };
  if (s.staffActiveWithinMin < STAFF_BACKOFF_MIN) return { engage: false, reason: 'staff-active' };
  if (s.repliesLastHour >= HOURLY_CAP) return { engage: false, reason: 'rate-cap' };
  return { engage: true };
}
Then the orchestrator (best-effort; never throws to caller):
import { getReadDb } from './db';
import { sql } from 'drizzle-orm';
import { sendTextMessage } from './whatsapp';
import { getAssistantConfig } from './whatsapp-assistant-config';
import { buildKnowledge, formatKnowledgeBlock } from './ai/whatsapp-knowledge';
import { askRubyWhatsApp } from './ai/agents/whatsapp-assistant';
import { addRubyPausedLabel } from './whatsapp-assistant-state';
import { createNotificationForClinicUsers } from './notifications';

export async function runRubyForInbound(args: {
  clinicId: string;
  conversationId: string | null;
  patient: { id: string; clinicId: string; firstName: string; phone: string | null } | null; // single match or null
  matchCount: number;
  senderPhone: string;
  env?: any;
}): Promise<void> {
  try {
    const cfg = await getAssistantConfig(args.clinicId);
    const ctx = await gatherDispatchContext(args.clinicId, args.conversationId); // labels, staffActiveWithinMin, repliesLastHour, transcript, isFirstReply
    const decision = shouldRubyEngage({ enabled: cfg.enabled, ...ctx.guard });
    if (!decision.engage) return;

    const knowledge = await buildKnowledge(args.clinicId, args.matchCount === 1 ? args.patient : null);
    const res = await askRubyWhatsApp({
      clinicId: args.clinicId,
      knowledgeBlock: formatKnowledgeBlock(knowledge),
      transcript: ctx.transcript,
      patientMatched: args.matchCount === 1,
    });

    let replyText = res.reply;
    if (cfg.disclosure && ctx.isFirstReply) {
      replyText += `\n\n_(You're chatting with our automated assistant — a team member can jump in anytime.)_`;
    }

    if (res.action === 'create_request' && res.booking?.complete) {
      await createBookingArtifact(args, res.booking); // requested appt (matched) OR lead_submission (unmatched)
    }

    if (res.action === 'handoff') {
      await escalate(args, res.escalate?.reason ?? 'handoff');
    }

    await sendTextMessage({
      to: args.senderPhone,
      clinicId: args.clinicId,
      text: replyText,
      patientId: args.matchCount === 1 ? args.patient?.id : undefined,
      env: args.env,
    });
    // sendTextMessage writes the message row + outbound_message event; tag AI via metadata in a follow-up event write:
    await writeRubyEvent(args.clinicId, args.matchCount === 1 ? args.patient?.id ?? null : null, res.intent, res.category);
  } catch (err) {
    console.error('[Ruby] dispatch failed (non-fatal):', err);
  }
}
NOTE for implementer (fill these in as small private functions in the same file, each with a focused responsibility — keep the file cohesive):
  • gatherDispatchContext(clinicId, conversationId): read conversations.labels; compute staffActiveWithinMin from the latest staff (direction='outbound' AND sent_by IS NOT NULL) message time; repliesLastHour from whatsapp_events where metadata aiAssistant=true in this conversation/patient in the last hour; load last 8 messages as transcript (map inbound→user, outbound→assistant, body text, skip media/rich envelopes or summarize them); isFirstReply = no prior aiAssistant event in this conversation.
  • createBookingArtifact(args, booking): matched patient → insert appointments row status requested (clinicId, patientId, appointmentDate=preferredDate, appointmentTime=preferredTime, appointmentType=reason, doctorId=null) mirroring appointments.ts:374+insert; unmatched → insert a lead_submissions booking row (name, email, phone=senderPhone, preferred time, reason, source whatsapp_ruby, status new) — match the columns in server/src/schema/lead_submissions.ts.
  • escalate(args, reason): createNotificationForClinicUsers(...) high priority + addRubyPausedLabel(conversationId).
  • writeRubyEvent(...): writeWhatsappEvent({ type: 'outbound_message', metadata: { aiAssistant: true, intent, category } }). (Note: sendTextMessage already writes one outbound_message event; either pass metadata through it or write a distinct lightweight event — choose one and avoid double-counting in the rate cap. Simplest: extend sendTextMessage/logOutboundMessage to accept optional eventMetadata and set aiAssistant there.)
  • Step 4: Run guard test → PASS. Add focused tests for createBookingArtifact branch selection (matched vs unmatched) with a mocked db.
  • Step 5: Commitfeat(whatsapp): Ruby dispatch — guards, send, request/lead, escalation

Task 5: Conversation state helpers (ruby-paused label)

Files:
  • Create: server/src/lib/whatsapp-assistant-state.ts
  • Test: server/src/lib/__tests__/whatsapp-assistant-state.test.ts
  • Step 1: Failing test for label set/remove SQL builders (test the pure label math):
import { describe, it, expect } from 'vitest';
import { withLabel, withoutLabel } from '../whatsapp-assistant-state';
describe('label math', () => {
  it('adds without dupes', () => { expect(withLabel(['a'], 'ruby-paused')).toEqual(['a', 'ruby-paused']); expect(withLabel(['ruby-paused'], 'ruby-paused')).toEqual(['ruby-paused']); });
  it('removes', () => { expect(withoutLabel(['a', 'ruby-paused'], 'ruby-paused')).toEqual(['a']); });
});
  • Step 2: Run → FAIL.
  • Step 3: Implement withLabel/withoutLabel (pure) + addRubyPausedLabel(conversationId) / removeRubyPausedLabel(conversationId) (update conversations.labels via Drizzle).
  • Step 4: Run → PASS. Commitfeat(whatsapp): ruby-paused conversation label helpers

Task 6: Webhook hook + durable staff takeover

Files:
  • Modify: server/src/routes/whatsapp-webhook.ts (handleIncomingMessage, after the regular-inbound insert block)
  • Modify: the staff WhatsApp send path (chat-v2 send route) → add ruby-paused on staff outbound
  • Step 1: After the existing “Regular inbound message — log per clinic” loop (and only for message.type === 'text'), call:
// Best-effort Ruby autopilot. Runs after logging; never blocks the webhook.
await runRubyForInbound({
  clinicId,
  conversationId,                 // from the inbound insert above
  patient: matchingPatients.length === 1 ? matchingPatients[0] : null,
  matchCount: matchingPatients.length,
  senderPhone,
  env,
}).catch((e) => console.error('[Ruby] inbound hook failed:', e));
Place it so it does NOT run for cancel/reschedule intents (those already return earlier) and not for media/button/interactive.
  • Step 2: In the staff→patient WhatsApp send route (find via grep -rn "direction.*outbound" server/src/routes/conversations.ts and the chat send handler), after a staff member sends a message, call addRubyPausedLabel(conversationId) so Ruby durably backs off once a human engages.
  • Step 3: Manual verification note (no unit test for the wiring): cd server && npx tsc --noEmit 2>&1 | grep whatsapp-webhook → no new errors.
  • Step 4: Commitfeat(whatsapp): engage Ruby on inbound + durable staff takeover

Task 7: Config / preview / resume endpoints

Files:
  • Modify: server/src/routes/whatsapp.ts (mounted at /whatsapp-module)
  • Step 1: Add GET /assistant/config and PUT /assistant/config (admin/superadmin) using getAssistantConfig/saveAssistantConfig. Body: { enabled, knowledge, disclosure }.
  • Step 2: Add POST /assistant/preview (admin): body { question }buildKnowledge (no patient) → askRubyWhatsApp with a one-message transcript → return { reply, intent, category }. Never calls sendTextMessage.
  • Step 3: Add POST /conversations/:id/ruby-resume (staff) → removeRubyPausedLabel(id){ ok: true }.
  • Step 4: tsc --noEmit clean for whatsapp.ts. Commit — feat(whatsapp): assistant config, preview, resume endpoints
Update docs/api-reference.md with these endpoints (per API-doc discipline).

Task 8: Surface aiAssistant on the message DTO + ruby-accent bubble

Files:
  • Modify: server/src/lib/message-mapper.ts (carry aiAssistant from message metadata → DTO)
  • Modify: shared/src/chat-types.ts (MessageDto.aiAssistant?: boolean)
  • Modify: ui/src/components/chat-v2/thread/MessageBubble.tsx
  • Step 1: Add optional aiAssistant: z.boolean().optional() to MessageDto; have the mapper set it from the message row’s metadata (or from the linked event). If messages have no metadata column, derive it: the dispatch sets a recognizable subject (e.g. 'WhatsApp Message · Ruby') on Ruby sends and the mapper maps that → aiAssistant: true. Pick the lowest-friction signal that needs no migration; document the choice.
  • Step 2: In MessageBubble, when m.aiAssistant and outbound, render the ruby accent: above the bubble a chip <img src="/ruby-icon.webp" class="h-3.5 w-3.5"/> Ruby · auto-reply; on the bubble add border-l-[3px] border-l-[#9B1C2E] and a faint ruby tint background overlay. Keep text dark/legible — do NOT full-fill red. Must remain distinct from amber (internal notes) and rose (failed).
  • Step 3: cd ui && npm run build succeeds. Commit — feat(chat): ruby-accent bubble for Ruby auto-replies

Task 9: Settings UI — Ruby auto-reply card + preview; upload prompt to Langfuse

Files:
  • Modify: ui/src/components/settings/WhatsAppSettings.tsx
  • Modify: ui/src/lib/serverComm.ts (add getRubyConfig, saveRubyConfig, previewRuby)
  • Step 1: serverComm helpers wrapping the Task 7 endpoints.
  • Step 2: A “Ruby auto-reply” card under the existing WhatsApp settings (admin/superadmin only): enable toggle, disclosure toggle, knowledge <textarea>, Save button, and a preview box (<input> + “Ask Ruby” → shows {reply, intent} without sending). Use the existing card/styling patterns in the file.
  • Step 3: Author the canonical whatsapp-assistant prompt in Langfuse (hipaa.cloud.langfuse.com) matching the Task 3 fallback. (Manual step — document the prompt text in the plan output / spec; no code commit needed beyond the fallback.)
  • Step 4: npm run build succeeds. Commit — feat(settings): Ruby auto-reply card + preview

Task 10: End-to-end verification on ssh & Associates

  • Deploy server + UI (odontox-commit-deploy skill; force-promote canonical; stash any foreign uncommitted files first).
  • Enable whatsapp_assistant for ssh & Associates via the new toggle.
  • Preview-test in Settings: “what time are you open Friday?”, “do you do braces?”, “when is my appointment?” (no send).
  • Live test from a test phone → ssh & Associates WA number: a general Q (expect answer + first-reply disclosure), a clinical Q (“my tooth is killing me, what should I take?” → expect handoff holding line + staff notification + ruby-paused), a booking Q (expect slot-filling → requested/lead created → appears in reception). Verify the ruby-accent bubble renders.
  • Confirm kill-switch pause stops Ruby entirely.

Self-review notes

  • Spec coverage: §3 architecture→T3; §4 guards→T4; §5 knowledge→T2; §6 schema→T3; §7 booking→T4 (createBookingArtifact); §8 guardrails→T3 prompt; §9 escalation/takeover→T4/T5/T6; §10 send+bubble→T4/T8; §11 settings→T9; §12 config→T1; §13 prompt→T3/T9; §14 endpoints→T7; §15 observability→T4. All covered.
  • Open implementer confirmations (flagged inline, not placeholders): exact callAIJson option keys; clinics.address shape; clinic_modules unique-constraint/upsert style; the lowest-friction aiAssistant message signal (metadata vs subject). Each has a concrete fallback in the task text.