Data Flow & Guardrails¶
Data Ownership, Security Boundaries, and Conversation Flows
Last Updated: 2026-03-09 (v4.3.0 — SMS consent tracking added)
Data Ownership Model¶
VitaraVox maintains clear separation between its own data and clinic EMR data.
What VitaraVox Owns (Vitara Database)¶
| Data Type | Purpose | Retention | Protection |
|---|---|---|---|
| Clinic profiles | Name, address, timezone | Permanent | -- |
| OSCAR credentials | SOAP/OAuth credentials | Permanent | AES-256-GCM encrypted at rest |
| Working hours | Per-day open/close times | Permanent | -- |
| Holidays | Closure dates | Annual review | -- |
| Provider display names | Voice-friendly names | Permanent | -- |
| Waitlist entries | New patient queue | Until registered | DB-persisted (v3.2.1) |
| Call logs | Analytics, outcomes | Configurable (default 365 days) | Transcripts nulled after 90 days |
| SMS consent | Per-call patient preference | With call log | smsConsent boolean in CallLog |
| SMS config | Per-clinic sender, templates | Permanent | ClinicConfig SMS fields |
| User accounts | Admin dashboard access | Permanent | bcrypt password hashing |
| Audit logs | Admin action tracking | 7 years | Append-only (no DELETE) |
What OSCAR Owns (Clinic EMR)¶
| Data Type | VitaraVox Access | Purpose |
|---|---|---|
| Patient demographics | Read/Write | Lookup, registration |
| Appointments | Read/Write | Booking, rescheduling |
| Provider schedules | Read | Availability check |
| Medical records | NONE | Never accessed |
| Lab results | NONE | Never accessed |
| Prescriptions | NONE | Never accessed |
| Billing | NONE | Never accessed |
Data Flow Diagram¶
+-----------------------------------------------------------------------------+
| DATA FLOW OVERVIEW |
+-----------------------------------------------------------------------------+
| |
| INBOUND (Patient -> System) |
| --------------------------- |
| |
| Voice Input |
| | |
| v |
| +----------+ Transcript +----------+ Webhook +----------+ |
| | Vapi | --------------> | LLM | -----------> | Vitara | |
| | (STT) | | (GPT-4o) | |Middleware | |
| +----------+ +----------+ +----+-----+ |
| | |
| +---------------------------+ |
| | | |
| v v |
| +----------+ +----------+ |
| | Vitara | | OSCAR | |
| | DB | | EMR | |
| | | | | |
| | Config | | Patients | |
| | Logs | | Appts | |
| +----------+ +----------+ |
| |
| OUTBOUND (System -> Patient) |
| ---------------------------- |
| |
| +----------+ Response +----------+ Speech +----------+ |
| | OSCAR | -------------> | Vitara | -----------> | Vapi | |
| | EMR | |Middleware | | (TTS) | |
| +----------+ +----------+ +----+-----+ |
| | |
| v |
| Voice Output |
| |
+-----------------------------------------------------------------------------+
Mermaid: Data Flow Overview¶
flowchart TB
subgraph Inbound["INBOUND (Patient -> System)"]
direction LR
Voice1["Voice Input"]
VapiSTT["Vapi<br/>(STT)"]
LLM2["LLM<br/>(GPT-4o)"]
Middleware1["Vitara<br/>Middleware"]
Voice1 -->|"Audio"| VapiSTT
VapiSTT -->|"Transcript"| LLM2
LLM2 -->|"Webhook<br/>Tool Call"| Middleware1
end
subgraph Storage["Data Storage"]
VitaraDB2[("Vitara DB<br/>Config<br/>Logs<br/>Audit")]
OSCAR4[("OSCAR EMR<br/>Patients<br/>Appointments")]
end
subgraph Outbound["OUTBOUND (System -> Patient)"]
direction LR
OSCAR5[("OSCAR")]
Middleware2["Vitara<br/>Middleware"]
VapiTTS["Vapi<br/>(TTS)"]
Voice2["Voice Output"]
OSCAR5 -->|"Data"| Middleware2
Middleware2 -->|"Response"| VapiTTS
VapiTTS -->|"Speech"| Voice2
end
Middleware1 --> VitaraDB2
Middleware1 --> OSCAR4
style Inbound fill:#dbeafe,stroke:#2563eb
style Storage fill:#fef3c7,stroke:#d97706
style Outbound fill:#dcfce7,stroke:#16a34a
Data Flow Decisions Review¶
| Decision | Choice | Rationale | Alternatives Considered |
|---|---|---|---|
| Data Ownership | Split: Vitara owns config/logs, OSCAR owns PHI | Clear boundaries; PIPEDA compliance; clinic retains patient data | Single DB (compliance risk), Vitara caches PHI (liability), Sync copies (complexity) |
| PHI Access | Read/Write for demographics + appointments ONLY | Minimum necessary principle; no medical records ever accessed | Full EMR access (overkill), Read-only (can't book), No access (can't function) |
| Audit Retention | 7 years | PIPEDA requirement; provincial health regulation alignment | 1 year (non-compliant), Forever (storage cost), None (non-compliant) |
| Call Log Content | Metadata only, no PHI | Compliance; analytics without risk; patient names never logged | Full transcripts (PHI risk), No logging (no analytics), Encrypted PHI (key management) |
OSCAR EMR Connection -- Direct SOAP (v4.0)¶
As of v4.0 (2026-02-14), the Vitara Platform connects directly to OSCAR's built-in SOAP web services -- no REST Bridge middleman required. This is the production path, validated with 35/35 E2E tests passing.
Vitara Platform (OCI Toronto)
|
| SOAP/XML + WS-Security UsernameToken
| HTTPS port 8443
v
+--------------------------------------+
| OSCAR EMR (AWS Montreal) |
| |
| 3 SOAP Endpoints: |
| +--------------------------------+ |
| | /ws/ScheduleService?wsdl | | Schedules, appointments
| | /ws/DemographicService?wsdl | | Patient records
| | /ws/ProviderService?wsdl | | Doctor list
| +--------------------------------+ |
| |
| MariaDB 10.5 (oscar_mcmaster) |
+--------------------------------------+
| Capability | SOAP Method | Status |
|---|---|---|
| Search patients | searchDemographicByNameAsync |
Working |
| Get patient | getDemographicAsync |
Working |
| Get providers | getProviders2Async |
Working |
| Get schedule | getDayWorkScheduleAsync |
Working |
| Get appointments | getAppointmentsForDateRangeAndProvider2Async |
Working |
| Book appointment | addAppointmentAsync |
Working |
| Cancel/update | updateAppointmentAsync |
Working |
| Register patient | -- | NOT AVAILABLE via SOAP (use OSCAR web UI) |
REST Bridge (Legacy)
The OSCAR REST Bridge at 15.222.50.48:3000 is retained for dev/fallback only. It required a sidecar deployment on the same server as OSCAR, which is a compliance concern for customer instances (PHIPA/PIPA prohibit custom components on clinic EMRs). The SOAP adapter eliminates this requirement entirely.
Security Boundaries¶
Boundary 1: Vapi -> Vitara¶
| Control | Implementation |
|---|---|
| Transport | HTTPS (TLS 1.2+) |
| Authentication | Vapi server credential (managed by Vapi credentialId) + X-API-Key header on tool requests |
| PHI Redaction | redactPhi() strips patient names, DOB, phone, health cards from all logs |
| Rate limit | 100 req/min (general), 50 burst (webhook paths) |
| Caller ID | Server extracts real phone from Telnyx call.customer.number -- LLM input ignored |
| Response timeout | 5s Vapi tool-call timeout; server logs warnings for responses > 5s |
Boundary 2: Vitara -> OSCAR¶
| Control | Implementation |
|---|---|
| Transport | HTTPS (TLS 1.2+, port 8443) |
| Authentication | WS-Security UsernameToken (PasswordText, no Timestamp, no Nonce) |
| Credentials | Per-clinic, encrypted at rest (AES-256-GCM) |
| Scope | 3 SOAP services: Schedule, Demographic, Provider |
| Protection | Circuit breaker (opossum, 4s timeout, 50% error threshold) |
Boundary 3: Admin UI -> Vitara¶
| Control | Implementation |
|---|---|
| Transport | HTTPS (TLS 1.2+) |
| Authentication | JWT tokens |
| Authorization | Role-based (Clinic Admin, Super Admin) |
| Audit | All actions logged |
Conversation Flow: Booking (v3.0 -- 9-Agent Dual-Track)¶
This is the production call flow as of v4.0.1. The system has 9 agents organized in a dual-track (EN/ZH) architecture. The following diagram shows the English track; the Chinese track is identical in structure.
+-----------------------------------------------------------------------------+
| BOOKING FLOW (v3.0 -- 9-Agent Dual-Track) |
+-----------------------------------------------------------------------------+
| |
| 1. CALL CONNECTS -> ROUTER (AssemblyAI Universal STT) |
| ------------------------------------------------ |
| Vapi plays firstMessage: "Hi there, thanks for calling!" |
| Router's LLM calls get_clinic_info (MANDATORY first turn) |
| Patient speaks -> Router detects language: |
| - Default = ENGLISH unless caller says "Mandarin", "Chinese", |
| "speak Chinese", "speak Mandarin", or Chinese characters |
| Router: Warm greeting with clinic name from customGreeting field |
| e.g., "Welcome to [Clinic]! How can I help you today?" |
| Router: "Sure!" then silent handoff |
| Router: handoff_to_patient_id_en (or _zh) |
| |
| 2. PATIENT-ID AGENT (Deepgram nova-2 EN/ZH specific) |
| -------------------------------------------------- |
| Agent says "One moment while I look you up" |
| Agent calls search_patient_by_phone(phone: "0000000000") |
| NOTE: LLM sends dummy "0000000000". Server substitutes |
| real phone from call.customer.number (Telnyx metadata). |
| |
| +----------------------+ |
| | Patient found? | |
| +----------+-----------+ |
| +-----+-----+ |
| YES NO |
| | | |
| v v |
| "I have [Name] "I'm not finding a |
| on file -- file under this phone |
| is that you?" number. Are you a |
| | new patient?" |
| | -> handoff_to_registration_en |
| | |
| Analyze conversation history for intent: |
| "I'd like to book" -> BOOK |
| "reschedule" / "cancel" -> MODIFY |
| "check my appointment" -> CHECK |
| |
| Patient-ID: "I'll get you set up." |
| -> handoff_to_booking_en (silent) |
| |
| 3. BOOKING AGENT -- IMMEDIATE SLOT FINDING |
| ---------------------------------------- |
| Booking says "Let me find you an appointment" alongside tool call |
| Booking: find_earliest_appointment() -- no filters, any provider |
| Tool-level request-start plays: "Let me check what's available." |
| Booking: "I have [day], [date] at [time] with Dr. [name]. |
| Does that work?" |
| |
| 4. PATIENT ACCEPTS OR REFINES |
| ------------------------- |
| Patient: "Different doctor" -> call get_providers, then |
| find_earliest_appointment with different providerName |
| Patient: "Different time" -> find_earliest_appointment with filters |
| Patient: "That's good" -> proceed to step 5 |
| |
| 5. ASK REASON, THEN BOOK |
| -------------------- |
| Booking: "What is this visit for?" |
| Patient: "Just a checkup" |
| Booking maps to appointmentType: |
| B = general/checkup, 2 = follow-up, 3 = complaint, P = prescription |
| Booking: create_appointment(demographicId, providerId, startTime, |
| appointmentType, reason, language) |
| NEVER says "booked" without calling create_appointment first. |
| |
| 6. CONFIRM + LOG |
| ------------ |
| Booking: "All set! [Day], [date] at [time] with Dr. [name]. |
| You'll get a text confirmation. Arrive 10 minutes early with |
| your health card. Anything else?" |
| Booking: log_call_metadata(callOutcome=booked, language=en, ...) |
| |
| 7. CLOSE CALL |
| ---------- |
| Patient: "Thanks. Bye." |
| Booking: "Take care!" |
| |
+-----------------------------------------------------------------------------+
Mermaid: Booking Flow (v3.0)¶
flowchart TD
Start(["Call Connects"]) --> FirstMsg["Vapi plays: 'Hi there, thanks for calling!'"]
FirstMsg --> RouterClinic["Router calls get_clinic_info"]
RouterClinic --> RouterGreet["Router: 'Welcome to [Clinic]!'"]
RouterGreet --> LangDetect{"Language?"}
LangDetect -->|"English (default)"| PIDEN["handoff_to_patient_id_en"]
LangDetect -->|"Chinese keywords"| PIDZH["handoff_to_patient_id_zh"]
PIDEN --> PhoneLookup["search_patient_by_phone<br/>(server substitutes real phone)"]
PhoneLookup --> Found{Patient Found?}
Found -->|Yes| ConfirmName["'I have [Name] on file -- is that you?'"]
Found -->|No| NewPatient["'Are you a new patient?'"]
NewPatient -->|Yes| RegEN["handoff_to_registration_en"]
NewPatient -->|No| ManualSearch["search_patient by name+DOB"]
ConfirmName --> DetectIntent{"Detect Intent<br/>from conversation"}
DetectIntent -->|BOOK| HandoffBook["handoff_to_booking_en"]
DetectIntent -->|MODIFY/CHECK| HandoffMod["handoff_to_modification_en"]
DetectIntent -->|UNKNOWN| AskIntent["'How can I help you today?'"]
AskIntent --> DetectIntent
HandoffBook --> FindSlot["find_earliest_appointment<br/>(no filters)"]
FindSlot --> Offer["'I have [day] at [time] with Dr. [name].<br/>Does that work?'"]
Offer --> Accept{Patient Accepts?}
Accept -->|No| Refine["Ask preference, search again"]
Refine --> Offer
Accept -->|Yes| AskReason["'What is this visit for?'"]
AskReason --> MapType["Map to appointmentType<br/>B / 2 / 3 / P"]
MapType --> Book["create_appointment(...)"]
Book --> Confirm["'All set! [details].<br/>Arrive 10 min early.'"]
Confirm --> LogMeta["log_call_metadata<br/>(outcome=booked)"]
LogMeta --> End(["'Take care!'"])
style Start fill:#dbeafe,stroke:#2563eb
style End fill:#dcfce7,stroke:#16a34a
style Found fill:#fef3c7,stroke:#d97706
style Accept fill:#fef3c7,stroke:#d97706
v2.3.0 -- 6-Agent Squad (Legacy)¶
+-----------------------------------------------------------------------------+
| BOOKING FLOW (v2.3.0 -- 6-Agent Squad) |
+-----------------------------------------------------------------------------+
| |
| 1. CALL CONNECTS -> ROUTER AGENT |
| --------------------------------- |
| Router greets: "Welcome to the clinic! Let me look up your info." |
| Server: Extracts real phone from Telnyx call.customer.number |
| Server: search_patient_by_phone(real_phone) -- ignores LLM arg |
| |
| +----------------------+ |
| | Patient found? | |
| +----------+-----------+ |
| +-----+-----+ |
| YES NO |
| | | |
| v v |
| "Hello [Name], "I couldn't find your |
| how can I help?" records. Are you a |
| | new patient?" |
| | | |
| v v |
| |
| 2. INTENT -> SILENT HANDOFF TO BOOKING AGENT |
| ----------------------------------------- |
| Patient: "I'd like to book an appointment" |
| Router: [silently transfers to Booking agent] |
| |
| 3. BOOKING AGENT -- IMMEDIATE SLOT FINDING |
| ---------------------------------------- |
| Booking: "Let me find the earliest appointment for you." |
| Booking: find_earliest_appointment() -- NO filters, any provider |
| Booking: "Earliest is Thursday Feb 6 at 9:30 AM with Dr. Chen" |
| |
| 4. PATIENT ACCEPTS OR REFINES |
| -------------------------- |
| Patient: "That works!" -> proceed to step 5 |
| Patient: "Can I see Dr. Wong instead?" -> re-search with providerName |
| |
| 5. INLINE CONFIRMATION & BOOKING |
| ---------------------------- |
| Booking: "Shall I book that for you?" |
| Patient: "Yes" |
| Booking: create_appointment(...) + asks for visit reason |
| Booking: "Confirmed for Thursday at 9:30 AM with Dr. Chen." |
| |
| 6. CLOSE CALL |
| ---------- |
| Patient: "No, thank you" |
| Booking: "Have a great day! Goodbye." |
| |
+-----------------------------------------------------------------------------+
Booking Flow Decisions Review¶
| Decision | Choice | Rationale | Alternatives Considered |
|---|---|---|---|
| Patient Identification | Caller ID first, then name+DOB | Fast path for known patients; secure fallback | Always ask name (slow), Only caller ID (misses unlisted phones), PIN system (friction) |
| DOB Verification | Required for name-based lookup | PIPEDA identity verification; prevents PHI disclosure | Security question (hard to implement), Voice biometrics (accuracy concerns), None (non-compliant) |
| Appointment Type Mapping | Voice reason -> type code | Clinic-specific; LLM determines based on symptoms | Explicit type selection (confusing), Fixed durations (inflexible), Staff callback (defeats automation) |
| Provider Selection | "Any" by default, specific on request | Fastest booking; respects patient preferences | Always ask (slow), First available only (no choice), Require specific (delays) |
| Confirmation Read-back | Full date/time/provider | Prevents misunderstanding; audit trail | Just "confirmed" (ambiguous), SMS (not implemented), None (errors) |
Conversation Flow: New Patient Registration¶
In v3.0, Registration is handled by dedicated Registration agents (EN/ZH). The Patient-ID agent detects that the caller is not in the system and hands off to Registration.
+-----------------------------------------------------------------------------+
| REGISTRATION FLOW (v3.0 -- NEW PATIENT) |
+-----------------------------------------------------------------------------+
| |
| 1. PATIENT NOT FOUND (in Patient-ID agent) |
| ------------------------------------------ |
| Patient-ID: "I'm not finding a file under this phone number. |
| Are you a new patient?" |
| Patient: "Yes" |
| Patient-ID: handoff_to_registration_en (silent) |
| |
| 2. REGISTRATION AGENT OPENING |
| ---------------------------- |
| Registration: "Welcome! I'll help you register. This takes a few |
| minutes. Just so you know, this call is recorded for quality and |
| scheduling purposes. By continuing, you consent to the recording." |
| |
| 3. COLLECT INFORMATION (one question at a time, in order) |
| --------------------------------------------------- |
| | # | Field | EN Prompt | |
| |---|------------------|--------------------------------------------| |
| | 1 | Full name | "What is your full legal name?" | |
| | 2 | Gender | "What is your gender? Male, Female, Other?" | |
| | 3 | Date of birth | "What is your date of birth?" | |
| | 4 | Phone | "Best phone number to reach you?" | |
| | 5 | Address | "Address, including city and postal code?" | |
| | 6 | Health card | "Do you have a BC Services Card or PHN?" | |
| | | BC -> 10-digit PHN, healthCardType="BC" | |
| | | Out-of-province -> healthCardType="OUT_OF_PROVINCE" | |
| | | No card -> healthCardType="PRIVATE" | |
| | 7 | Email (optional) | "And finally, your email? Or we can skip." | |
| |
| 4. CONFIRM INFORMATION |
| ------------------- |
| Registration: "Let me confirm: [name], born [dob], phone [phone], |
| address [address]. Is everything correct?" |
| Patient: "Yes" |
| |
| 5. REGISTER |
| -------- |
| Registration: register_new_patient(firstName, lastName, dateOfBirth, |
| gender, phone, address, city, postalCode, healthCardType, language) |
| NEVER says "registered" without calling the tool first. |
| |
| WARNING: register_new_patient returns NOT_SUPPORTED via the SOAP |
| path. OSCAR DemographicService has no addDemographic method. The |
| voice agent collects info and either adds to waitlist or transfers |
| to staff for manual OSCAR entry. |
| |
| 6. POST-REGISTRATION |
| ---------------- |
| Success: |
| Registration: "Welcome [name]! You're registered. Would you like |
| to book your first appointment?" |
| Registration: log_call_metadata(callOutcome=registered) |
| Yes -> handoff_to_booking_en (silent) |
| Clinic not accepting: |
| Registration: "Sorry, we're not accepting new patients right now. |
| Would you like to join our waitlist?" |
| Yes -> add_to_waitlist(firstName, lastName, phone) |
| |
+-----------------------------------------------------------------------------+
Registration Flow Decisions Review¶
| Decision | Choice | Rationale | Alternatives Considered |
|---|---|---|---|
| Registration Gate | Check accepting_new_patients first |
Prevents wasted effort; clinic controls enrollment | Always collect info (wastes time), No gate (clinic can't control), Manual approval (delays) |
| Waitlist Option | Configurable per clinic | Some clinics want leads; others don't | Always offer (annoys some clinics), Never offer (loses leads), Clinic-wide only (inflexible) |
| BC Health Data | PHN required for BC patients | Provincial health insurance requirement; EMR integration | Health card scan (no voice), Self-reported only (EMR mismatch), Skip PHN (billing issues) |
| Field Collection | One at a time, voice-paced | Reduces errors; allows corrections; natural conversation | Form dump (overwhelming), All at once (error-prone), Transfer to staff (defeats purpose) |
| Confirmation | Abbreviated read-back required | Prevents data entry errors; verbal consent | Skip confirmation (errors), SMS confirmation (not implemented), None (compliance risk) |
| Error Recovery | Transfer to staff | Complex cases need human; maintains trust | Retry loop (frustrating), End call (poor experience), Queue callback (delay) |
Conversation Flow: Modification (Reschedule/Cancel)¶
In v3.0, Modification is handled by dedicated Modification agents (EN/ZH). The Patient-ID agent detects a reschedule, cancel, or appointment-check intent and hands off to Modification.
+-----------------------------------------------------------------------------+
| MODIFICATION FLOW (v3.0 -- Reschedule/Cancel) |
+-----------------------------------------------------------------------------+
| |
| 1. HANDOFF FROM PATIENT-ID |
| --------------------------- |
| Patient-ID detects intent: "reschedule", "cancel", "check appointment" |
| Patient-ID: "Let me pull that up." (or "I can help with that.") |
| -> handoff_to_modification_en (silent) |
| |
| 2. MODIFICATION AGENT -- FIRST TURN: FETCH APPOINTMENTS |
| ------------------------------------------------------- |
| Agent says "Let me pull up your appointments" alongside tool call |
| Agent: check_appointments(demographicId, startDate=today, |
| endDate=6 months out, findAvailable=false) |
| Tool-level request-start plays: "Let me look that up." |
| |
| +-------------------------+ |
| | Appointments found? | |
| +----------+--------------+ |
| +-----+------+ |
| NONE ONE OR MORE |
| | | |
| v v |
| "No upcoming If 1: "Your next appointment is [date] at [time] |
| appts. Want with Dr. [name]. Would you like to change or |
| to book one?" cancel it?" |
| -> handoff to If multiple: Read first 3, ask "Which one?" |
| booking |
| |
| 3. DETERMINE ACTION |
| ---------------- |
| "Would you like to reschedule or cancel?" |
| |
| +-----+-----+ |
| | | |
| RESCHEDULE CANCEL |
| | | |
| v v |
| |
| 3A. RESCHEDULE |
| ---------- |
| "When would work better?" |
| Agent: find_earliest_appointment(preferences) |
| "How about [day], [date] at [time] with Dr. [name]?" |
| |
| Patient agrees: |
| Agent: update_appointment(appointmentId, newStartTime, newProviderId) |
| NEVER says "moved" or "rescheduled" without calling tool first. |
| "Done! Moved to [date] at [time] with Dr. [name]. |
| You'll get a text confirmation. Anything else?" |
| Agent: log_call_metadata(callOutcome=rescheduled) |
| |
| 3B. CANCEL |
| ------ |
| "Just to confirm -- cancel your [date] appointment?" |
| Wait for "yes". |
| Agent: cancel_appointment(appointmentId, reason="patient requested") |
| NEVER says "cancelled" without calling cancel_appointment first. |
| "Cancelled. Want to book a different time?" |
| Agent: log_call_metadata(callOutcome=cancelled) |
| Yes -> handoff_to_booking_en. No -> "Take care!" |
| |
| WRONG INTENT REDIRECT |
| ---------------------- |
| Patient says "book a new appointment" -> "Of course" |
| -> handoff_to_booking_en |
| Any other unrelated intent -> handoff_to_router_v3 |
| |
+-----------------------------------------------------------------------------+
Modification Agent Tools¶
| Tool | Purpose |
|---|---|
check_appointments |
Fetch existing appointments for patient |
find_earliest_appointment |
Find new slots when rescheduling |
update_appointment |
Reschedule (move to new time/provider) |
cancel_appointment |
Cancel with reason |
create_appointment |
Available but rarely used (redirect to Booking preferred) |
get_providers |
List available providers for preference changes |
log_call_metadata |
Log call outcome (rescheduled/cancelled/no_action) |
transfer_call |
Transfer to staff on repeated failures |
Modification Error Handling¶
| Error | User Message | Action |
|---|---|---|
check_appointments fails |
"I'm having trouble looking up your appointments" | Ask patient for appointment details manually |
update_appointment fails |
"I'm sorry, there was an issue rescheduling. Let me try again." | Retry once; if still fails, transfer to staff |
cancel_appointment fails |
"I'm sorry, there was an issue cancelling. Let me try again." | Retry once |
| No slots for reschedule | "I'm not finding any openings in that range" | Offer different time period or doctor |
| Past date from patient | "That date has passed. Let me check from today forward." | Search from today |
End-of-Call Report & Call Metadata¶
Vapi sends tool calls and end-of-call-report as separate HTTP requests. The server bridges these using an in-memory callMetadataCache.
How Call Outcomes Are Tracked¶
During the call: After the call:
+---------------------+ +-----------------------+
| Agent calls | | Vapi sends |
| log_call_metadata | | end-of-call-report |
| with: | | with: |
| language | | transcript |
| callOutcome | in-memory | summary |
| demographicId |----> cache ------->| durationSeconds |
| appointmentId | (30 min TTL) | cost |
+---------------------+ | endedReason |
+-----------+-----------+
|
v
+------------------------+
| saveCallLog() merges: |
| cached metadata |
| + end-of-call data |
| -> Prisma callLog |
+------------------------+
Key details:
log_call_metadatais called by Booking, Modification, Registration, and Router agents before the call ends.create_appointmentandregister_new_patientalso cachelanguageanddemographicIdas a safety net iflog_call_metadatais never called.- Cache entries have a 30-minute TTL and are cleaned up probabilistically (1% chance per call).
saveCallLog()reads the cache entry for the call ID, merges it with the end-of-call-report data, and persists to the database.
Transfer-Destination-Request Flow¶
When an agent calls transfer_call, Vapi sends a transfer-destination-request webhook to the server:
Agent calls transfer_call(reason="frustrated")
|
v
Vapi sends transfer-destination-request webhook
|
v
Server: resolveClinicId(message) -> clinicId
Server: lookup clinicConfig.transferPhoneNumber
|
v
Response: { destination: { type: "number", number: "+16045551234" } }
|
v
Vapi initiates SIP transfer to clinic staff phone
If no transferPhoneNumber is configured for the clinic, the transfer silently fails and the call continues.
Clinic Resolution¶
When a webhook arrives, the server must determine which clinic the call belongs to. This is critical because the clinicId determines which OSCAR instance credentials to use.
Resolution Chain (Actual Implementation)¶
1. message.metadata.clinicId (explicit override, used in testing)
|
v (if not set)
2. message.call.phoneNumber.number (Vapi phone number that received the call)
|
v (if empty, common for Telnyx BYO numbers)
3. resolvePhoneFromVapiId(message.call.phoneNumberId)
| (1-hour cached lookup from Vapi API: GET /phone-number)
v
4. clinicService.findClinicByVapiPhone(phoneNumber)
| (SELECT id FROM clinic WHERE vapiPhone = $1)
v
clinicId
Telnyx BYO Numbers
For Telnyx BYO (Bring Your Own) phone numbers, call.phoneNumber.number is often empty in Vapi webhook payloads. The server maintains a 1-hour cached mapping from phoneNumberId to the actual E.164 number by calling the Vapi API (GET https://api.vapi.ai/phone-number). This cache is refreshed on first use after expiry.
Guardrails¶
Medical Safety¶
| Scenario | System Response |
|---|---|
| Emergency keywords (chest pain, can't breathe, etc.) | "This sounds like a medical emergency. Please hang up and call 911 immediately." End call. |
| Medical questions | "I handle scheduling only. Let me connect you with our staff." Transfer. |
| Medication questions | Transfer to staff |
| Test result interpretation | Transfer to staff |
Conversation Quality¶
| Scenario | System Response |
|---|---|
| Tool invocation | Tool-level request-start message plays (e.g., "Let me pull up your file.") |
| Patient not found (2 attempts) | Offer registration or transfer to staff |
| Frustrated caller | Transfer to staff via transfer_call (agents with this tool) |
| Unclear intent | Ask clarifying question |
| Off-topic request | Redirect to scheduling or transfer via handoff_to_router_v3 |
Data Protection¶
| Scenario | System Response |
|---|---|
| Caller asks for other patient's info | "I can only discuss your own appointments" |
| Request for medical records | Transfer to staff |
| PHI in logs | redactPhi() replaces patient names, DOB, phone, health cards with [REDACTED] |
| Credentials in logs | AES-256-GCM encrypted at rest; masked in admin UI (last 4 chars only) |
| Tool calls with PHI | Only tool name and clinicId logged; arguments and results redacted |
Error Handling Matrix¶
| Error Type | User Message | System Action |
|---|---|---|
| OSCAR unreachable | "Let me try again..." | Retry once, then transfer |
| Patient not found by phone | "I'm not finding a file under this phone number" | Offer name+DOB search or register |
| Patient not found by name | "Let me connect you with our staff" | Transfer with reason "record_not_found" |
| No appointments available | "Nothing in that range" | Offer alternative dates/providers |
| Booking failed (API error) | "I'm sorry, there was an issue" | Retry with find_earliest_appointment |
| Booking failed (slot taken) | "That slot was just taken" | find_earliest_appointment again |
| Registration failed | "I'm sorry, there was a technical issue" | Retry once; if still fails, transfer |
| Invalid health card number | "BC PHN numbers are 10 digits" | Ask to repeat |
| Past date from LLM | Silently clamped to today by server | find from today forward |
| Clinic not resolved | -- | Return 400 error to Vapi |
| Voice agent disabled for clinic | "The voice assistant is currently unavailable" | Return offline message |
Audit Trail¶
All calls generate audit records via the saveCallLog() function:
{
"clinicId": "uuid-here",
"vapiCallId": "call_abc123xyz",
"callerPhone": "+16045551234",
"language": "en",
"intent": "book",
"outcome": "booked",
"durationSeconds": 127,
"cost": 0.45,
"transcript": "[full transcript from Vapi]",
"summary": "[AI-generated summary from Vapi]",
"transferredToStaff": false,
"transferReason": null
}
Note: language and outcome are populated from the callMetadataCache (set by log_call_metadata during the call), with fallback to message.metadata fields from the end-of-call-report.
SMS Consent Data Flow¶
Source: patient-id-en.md:119-121, sms.service.ts:55-100, vapi-webhook.ts:259-305
SMS consent follows the opt-out model: consent is assumed unless the patient explicitly declines.
SMS CONSENT DATA FLOW
Patient-ID Agent
┌──────────────────────────────────────────────────────┐
│ "This call is recorded... We may send you a text │
│ confirmation. Let me know if you'd rather not │
│ receive texts." │
│ │
│ Patient says nothing / agrees → smsConsent = true │
│ Patient declines → smsConsent = false │
│ Patient changes mind later → use MOST RECENT │
└──────────────────────┬───────────────────────────────┘
│
smsConsent value
carried in conversation context
│
▼
Booking / Modification Agent
┌──────────────────────────────────────────────────────┐
│ Passes smsConsent param to tool call: │
│ create_appointment({ ..., smsConsent: true/false })│
│ update_appointment({ ..., smsConsent: true/false })│
│ cancel_appointment({ ..., smsConsent: true/false })│
└──────────────────────┬───────────────────────────────┘
│
▼
Server (vapi-webhook.ts)
┌──────────────────────────────────────────────────────┐
│ 1. Process tool call (book/reschedule/cancel) │
│ 2. Return result to Vapi immediately │
│ 3. setImmediate() → fireSmsBehindWebhook() │
│ │
│ SMS Guard Chain: │
│ TELNYX_API_KEY? → smsSenderNumber? → smsConsent? │
│ → valid phone? → smsEnabled? │
│ │
│ If all pass → Telnyx API → SMS sent │
│ Store: CallLog.smsConsent, CallLog.smsSentAt │
└──────────────────────────────────────────────────────┘
| Data Point | Storage | Source |
|---|---|---|
smsConsent (per-call) |
CallLog.smsConsent |
Voice agent tool call param |
smsSentAt (per-call) |
CallLog.smsSentAt |
Set after successful Telnyx API call |
smsSenderNumber (per-clinic) |
ClinicConfig.smsSenderNumber |
Admin UI SMS config |
smsEnabled (per-clinic) |
ClinicConfig.smsEnabled |
Admin UI SMS config |
smsTemplates (per-clinic) |
ClinicConfig.smsTemplates |
Admin UI custom templates (JSON) |
smsSent ≠ delivery
The smsSent response field and smsSentAt timestamp indicate the SMS was accepted by Telnyx, not that it was delivered to the patient's phone. Telnyx delivery webhooks are not yet implemented (planned for Structured Logging Tier 1).
See: SMS Integration