Skip to content

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_metadata is called by Booking, Modification, Registration, and Router agents before the call ends.
  • create_appointment and register_new_patient also cache language and demographicId as a safety net if log_call_metadata is 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.


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


Next Steps