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(usescallAIJson,PROMPTS,PROMPT_NAMES). callAIJsonsignature: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.tsGET/PUT/config(JSON inclinic_modules.config). - Inbound hook point:
server/src/routes/whatsapp-webhook.tshandleIncomingMessage, after the “Regular inbound message — log per clinic” block. - Send + event:
sendTextMessageandwriteWhatsappEventinserver/src/lib/whatsapp.ts. requestedappointment creation:server/src/routes/appointments.ts:374(patients forced to statusrequested).- 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
-
Step 2: Run test to verify it fails —
cd server && npx vitest run src/lib/__tests__/whatsapp-assistant-config.test.ts→ FAIL (module not found). - Step 3: Implement
NOTE for implementer: verify theclinic_modulesunique constraint name on(clinic_id, module_key)and the exact insert style againstserver/src/routes/whatsapp-config.tsPUT/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: Commit —
git 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
- Step 1: Write the failing test
- Step 2: Run → FAIL.
- Step 3: Implement
NOTE for implementer: implementfetchNextAppointment(db, clinicId, patientId)returning{date,time,type,doctor}|nullby queryingappointments(status not in cancelled/requested, date >= today PKT, order by date/time, limit 1) joined to the doctor’s name — mirror the select inappointments.ts. Confirmclinics.addressexists; if address is structured, format it. ConfirmdecryptPatientPHIimport path matches the webhook’s import.
- Step 4: Run → PASS.
- Step 5: Commit —
feat(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(addPROMPT_NAMES.whatsappAssistant+PROMPTS.whatsappAssistantfallback) -
Test:
server/src/lib/ai/agents/__tests__/whatsapp-assistant.test.ts -
Step 1: Write the failing test (mocks
callAIJson)
- Step 2: Run → FAIL.
- Step 3: Implement agent
NOTE: match the EXACTcallAIJsonoption names fromserver/src/lib/ai/client.ts:159(e.g. it may beprompt/messages/variablesrather thanuser/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
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. Commit —
feat(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
- Step 1: Write failing tests for the pure guard
shouldRubyEngage:
- Step 2: Run → FAIL.
- Step 3: Implement the guard + dispatch
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): readconversations.labels; computestaffActiveWithinMinfrom the latest staff (direction='outbound' AND sent_by IS NOT NULL) message time;repliesLastHourfromwhatsapp_eventswhere metadataaiAssistant=truein this conversation/patient in the last hour; load last 8 messages astranscript(map inbound→user, outbound→assistant, body text, skip media/rich envelopes or summarize them);isFirstReply= no prioraiAssistantevent in this conversation.createBookingArtifact(args, booking): matched patient → insertappointmentsrow statusrequested(clinicId, patientId, appointmentDate=preferredDate, appointmentTime=preferredTime, appointmentType=reason, doctorId=null) mirroringappointments.ts:374+insert; unmatched → insert alead_submissionsbooking row (name, email, phone=senderPhone, preferred time, reason, sourcewhatsapp_ruby, statusnew) — match the columns inserver/src/schema/lead_submissions.ts.escalate(args, reason):createNotificationForClinicUsers(...)high priority +addRubyPausedLabel(conversationId).writeRubyEvent(...):writeWhatsappEvent({ type: 'outbound_message', metadata: { aiAssistant: true, intent, category } }). (Note:sendTextMessagealready 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: extendsendTextMessage/logOutboundMessageto accept optionaleventMetadataand setaiAssistantthere.)
-
Step 4: Run guard test → PASS. Add focused tests for
createBookingArtifactbranch selection (matched vs unmatched) with a mocked db. -
Step 5: Commit —
feat(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):
- Step 2: Run → FAIL.
- Step 3: Implement
withLabel/withoutLabel(pure) +addRubyPausedLabel(conversationId)/removeRubyPausedLabel(conversationId)(updateconversations.labelsvia Drizzle). - Step 4: Run → PASS. Commit —
feat(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-pausedon staff outbound -
Step 1: After the existing “Regular inbound message — log per clinic” loop (and only for
message.type === 'text'), call:
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.tsand the chat send handler), after a staff member sends a message, calladdRubyPausedLabel(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: Commit —
feat(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/configandPUT /assistant/config(admin/superadmin) usinggetAssistantConfig/saveAssistantConfig. Body:{ enabled, knowledge, disclosure }. -
Step 2: Add
POST /assistant/preview(admin): body{ question }→buildKnowledge(no patient) →askRubyWhatsAppwith 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 --noEmitclean 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(carryaiAssistantfrom 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()toMessageDto; 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 recognizablesubject(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, whenm.aiAssistantand 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 addborder-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 buildsucceeds. 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(addgetRubyConfig,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-assistantprompt 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 buildsucceeds. 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_assistantfor 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
callAIJsonoption keys;clinics.addressshape;clinic_modulesunique-constraint/upsert style; the lowest-frictionaiAssistantmessage signal (metadata vs subject). Each has a concrete fallback in the task text.

