Skip to content

Mr. Hire — Voice Interviews & AI Calling

This document is the canonical developer reference for the AI voice-calling surface of the MAS / Mr. Mentor backend. It covers three distinct but related calling systems that all live in this Node/Express backend: (1) candidate AI voice interviews for Mr. Hire recruitment (VAPI / OpenAI+Twilio / ElevenLabs single calls, scored into the screening pipeline), (2) Aarya batch calling campaigns for the Sales CRM (ElevenLabs Conversational-AI batch dispatch to raw leads, reconciled by a sync worker), and (3) Miss Ozone mentorship check-in calls (LiveKit-driven outbound calls a batch lead places to a student, with an AI-generated summary fed back into the Students-at-Risk surface). It also covers the shared AI call-prompt library and per-HR call-provider configuration.

Status: documented from source on this branch.


Overview

The backend never talks to a phone network directly. Instead it orchestrates external voice-agent platforms and persists their results:

System Persona External engine Who triggers it Who consumes results
Candidate voice interview AI screening interviewer mr-hire-backend → VAPI / OpenAI+Twilio / ElevenLabs Recruitment pipeline (auto, after resume+quiz stages) HR / Admin in the Mr. Hire ATS
Aarya batch calling "Aarya" (admissions caller) ElevenLabs Conversational-AI Batch Calling Admin / Sales Head from the CRM Aarya tab Sales CRM lead timeline + Aarya campaign history
Miss Ozone "Miss Ozone" (warm mentor) LiveKit voice agent (mas-voice-agent) Batch Lead (BL) from the Students-at-Risk surface Student dashboard + BL + Students-at-Risk risk row

Personas / roles that interact with this domain:

  • Candidate — receives an outbound AI screening call; never authenticates here.
  • HR / external HR / Admin — configure interview prompts, view interview transcripts/scores, set their call provider.
  • Admin / Sales Head — launch and monitor Aarya batch campaigns to leads.
  • Batch Lead (BL) — trigger / schedule Miss Ozone calls to their batch students.
  • Student — sees Miss Ozone call status in real time and the post-call summary + action items.
  • Service callers (mr-hire-backend, LiveKit, ElevenLabs) — hit unauthenticated webhook endpoints validated by shared secrets.

Where it sits: this is the calling layer of the Mr. Hire product plus a CRM/LMS add-on. Candidate interviews feed the recruitment screening pipeline (see Mr. Hire — Screening Pipeline); Aarya plugs into the Sales CRM; Miss Ozone plugs into Student Engagement & Gamification. All three reuse the shared AICallPrompt template store.


Key concepts & entities

Glossary

  • callId — the external provider call identifier for a candidate interview (VoiceInterview.callId, unique). Used to correlate webhook events and ScreeningResult.callId.
  • Provider — the engine that placed a candidate call: vapi, openai_twilio, elevenlabs, nvidia_twilio.
  • Dynamic variables — snake_case key/value pairs ElevenLabs interpolates into the Aarya agent prompt per recipient (student_name, session_year, phone, plus opt-in extras).
  • Batch (Aarya) — one ElevenLabs batch-calling dispatch to many leads, mirrored locally in aarya_call_batches so history survives ElevenLabs retention.
  • Room (Miss Ozone) — the LiveKit room name returned by POST /call; acts as the Miss Ozone call ID.
  • Action items — per-call follow-ups extracted by the Miss Ozone analyzer; the student can check them off.
  • Prompt template — an AICallPrompt whose body contains {{variable}} placeholders rendered at call time via AICallPromptService.renderPrompt.

TypeORM entities owned by this domain

Entity File Table Notes
VoiceInterview src/entities/VoiceInterview.entity.ts voice_interviews One candidate interview; FK application_idJobApplication.
VoiceInterviewMessage src/entities/VoiceInterviewMessage.entity.ts voice_interview_messages Ordered conversation turns (sequenceOrder), cascade-deleted with the interview.
AICallPrompt src/entities/AICallPrompt.entity.ts ai_call_prompts Reusable prompt template; belongsToId null = global, set = user-specific; optional platform.
HrCallConfig src/entities/HrCallConfig.ts hr_call_configs Per-HR allowed providers + default provider (+ optional credentials JSON).
AaryaCallBatch src/entities/AaryaCallBatch.ts mas_crm.aarya_call_batches Durable copy of an ElevenLabs batch dispatch (status, counts, filters, owner).
MissOzoneCall src/entities/MissOzoneCall.entity.ts miss_ozone_calls One BL-to-student mentorship call; LiveKit room, evaluation JSON, action-item state.

Related but owned elsewhere: ScreeningResult (recruitment scoring), JobApplication, LeadCallLog (mas_crm.lead_call_logs, where Aarya per-recipient results land), RawLead, User, StudentRiskScore, PipelineCostLedger.


Architecture

flowchart TD
  subgraph FE["Frontends"]
    ATS["Mr. Hire ATS UI"]
    CRM["Sales CRM Aarya tab"]
    BLUI["Batch Lead - Students at Risk"]
    STU["Student dashboard"]
  end

  subgraph API["mr-mentor-backend routes"]
    VIR["/api/voice-interviews/*"]
    AAR["/api/admin/aarya/*"]
    MOZ["/api/miss-ozone/*"]
    HRC["/api/admin/hr-call-configs + /api/hr/my-call-config"]
    PRM["/api/mr-hire/call-prompts + /api/mr-hire/call"]
  end

  subgraph SVC["Services"]
    VIS["VoiceInterviewService"]
    ELS["ElevenLabsBatchService"]
    ACS["AaryaCallSyncService"]
    MOS["MissOzoneService"]
    HCS["HrCallConfigService"]
    APS["AICallPromptService"]
  end

  subgraph WRK["BullMQ workers"]
    AW["aaryaSync.worker"]
    MW["missOzone.worker"]
    RW["resumeAnalysis.worker"]
  end

  subgraph DB["PostgreSQL"]
    VITbl["voice_interviews + messages"]
    AB["mas_crm.aarya_call_batches"]
    LCL["mas_crm.lead_call_logs"]
    MOTbl["miss_ozone_calls"]
    PROMPTS["ai_call_prompts"]
    HRCFG["hr_call_configs"]
    SCR["screening_results"]
  end

  subgraph EXT["External voice platforms"]
    MRHIRE["mr-hire-backend (call engine)"]
    EL["ElevenLabs ConvAI"]
    LK["LiveKit voice agent"]
  end

  ATS --> VIR
  ATS --> PRM
  CRM --> AAR
  BLUI --> MOZ
  STU --> MOZ
  ATS --> HRC

  VIR --> VIS
  AAR --> ELS
  MOZ --> MOS
  HRC --> HCS
  PRM --> APS

  VIS --> VITbl
  VIS --> MRHIRE
  ELS --> EL
  ELS --> AB
  ELS --> LCL
  ACS --> EL
  ACS --> LCL
  ACS --> AB
  MOS --> LK
  MOS --> MOTbl
  MOS --> PROMPTS
  HCS --> HRCFG
  APS --> PROMPTS

  AW --> ACS
  MW --> MOS
  RW --> SCR

  MRHIRE -.webhook.-> VIR
  LK -.webhook.-> MOZ
  MRHIRE --> SCR

Data model

erDiagram
  VOICE_INTERVIEW ||--o{ VOICE_INTERVIEW_MESSAGE : "has turns"
  JOB_APPLICATION ||--o{ VOICE_INTERVIEW : "interviewed in"
  SCREENING_RESULT }o--|| JOB_APPLICATION : "scores"
  AARYA_CALL_BATCH ||--o{ LEAD_CALL_LOG : "dispatched as"
  RAW_LEAD ||--o{ LEAD_CALL_LOG : "called"
  USER ||--o{ AARYA_CALL_BATCH : "submitted"
  USER ||--o{ MISS_OZONE_CALL : "is student"
  USER ||--o{ AI_CALL_PROMPT : "owns"
  USER ||--|| HR_CALL_CONFIG : "configures"

  VOICE_INTERVIEW {
    uuid id PK
    varchar callId UK
    varchar status
    varchar provider
    varchar phoneNumber
    varchar candidateName
    float duration
    float cost
    text transcript
    text recordingUrl
    text summary
    uuid application_id FK
    uuid belongsTo
  }
  VOICE_INTERVIEW_MESSAGE {
    uuid id PK
    uuid voice_interview_id FK
    varchar role
    text message
    int sequenceOrder
  }
  AI_CALL_PROMPT {
    uuid id PK
    varchar name
    text systemPrompt
    varchar platform
    uuid belongsToId
    boolean isActive
  }
  HR_CALL_CONFIG {
    uuid id PK
    uuid userId UK
    jsonb allowedProviders
    varchar defaultProvider
    jsonb credentials
    boolean isActive
  }
  AARYA_CALL_BATCH {
    uuid id PK
    varchar elevenlabs_batch_id UK
    varchar name
    varchar agent_id
    varchar phone_number_id
    varchar status
    int recipient_count
    bigint scheduled_time_unix
    jsonb filters
    uuid created_by FK
    uuid owning_sales_head_id
    timestamp last_synced_at
  }
  MISS_OZONE_CALL {
    uuid id PK
    uuid studentId FK
    uuid batchLeadId FK
    uuid batchId
    uuid triggerBatchId
    varchar state
    varchar room
    timestamp scheduledFor
    text promptText
    jsonb questionsAsked
    jsonb rawEvaluation
    jsonb rawConversation
    jsonb actionItemsState
    timestamp archivedAt
    text archiveS3Key
  }

Notable status / enum fields

  • VoiceInterview.status (VoiceInterviewStatus): queuedringingin-progressended | failed. Forward-only (see STATUS_ORDER in VoiceInterviewService).
  • MissOzoneCall.state (MissOzoneCallState): scheduled, dialing, completed, summary_ready, failed, cancelled.
  • AaryaCallBatch.status: free-text mirror of ElevenLabs (pending, in_progress, completed, cancelled, failed) plus our sentinel expired (ElevenLabs purged it).
  • LeadCallLog.outcome Aarya values: aarya_dispatched, aarya_completed, aarya_failed; LeadCallLog.source = aarya.

API surface

Mount prefixes are from src/routes/index.ts. Aarya, HR-call-config and call-prompt routers mount at /api (full paths shown).

Candidate voice interviews — src/routes/voiceInterview.routes.ts (mounted at /api/voice-interviews)

Method Path Auth/role Purpose
POST /api/voice-interviews/webhook none (x-webhook-secret) Upsert interview from mr-hire-backend; on ended/failed triggers scoring/retry.
GET /api/voice-interviews/recording-proxy?url= auth Proxy a Twilio/VAPI recording (auth-injecting, CORS-safe).
GET /api/voice-interviews/stats auth Aggregate stats (by status/provider, avg duration, total cost); belongsTo scoped.
GET /api/voice-interviews/ auth Paginated list; filters: status, provider, dateFrom/dateTo, belongsTo. Triggers background auto-sync.
GET /api/voice-interviews/:id auth One interview with ordered messages.
POST /api/voice-interviews/sync auth + admin Force a pull from mr-hire-backend.
DELETE /api/voice-interviews/:id auth + admin Delete an interview.

Aarya batch calling — src/routes/aarya.routes.ts (mounted at /api)

All require auth + (Admin or Sales Head) + requireHeadPagePermission('crm').

Method Path Purpose
GET /api/admin/aarya/config Resolve locked-in agent + outbound number labels.
GET /api/admin/aarya/agents List Aarya agents (filtered to Aarya only).
GET /api/admin/aarya/phone-numbers List ElevenLabs phone numbers.
GET /api/admin/aarya/batches Campaign history (durable rows overlaid with live status).
GET /api/admin/aarya/batches/:id Batch detail with recipients enriched by internal lead id.
GET /api/admin/aarya/conversations/:id Single conversation transcript JSON.
GET /api/admin/aarya/conversations/:id/audio Proxy the conversation recording audio.
POST /api/admin/aarya/batches/:id/cancel Cancel a batch at ElevenLabs.
POST /api/admin/aarya/batches/preview Resolve recipients + show "already called" badges (no dispatch).
POST /api/admin/aarya/batches Submit a batch campaign to ElevenLabs.

Miss Ozone — src/routes/missOzone.routes.ts (mounted at /api/miss-ozone)

Method Path Auth/role Purpose
POST /api/miss-ozone/livekit-webhook none (x-webhook-secret) LiveKit call lifecycle events.
GET /api/miss-ozone/preview-context?studentIds= auth + batchLead Pre-call context snapshots for confirm modal.
POST /api/miss-ozone/trigger auth + batchLead Create + enqueue call(s) for students (immediate or scheduled).
GET /api/miss-ozone/calls auth + batchLead List the BL's calls.
POST /api/miss-ozone/calls/:id/cancel auth + batchLead Cancel a still-scheduled call.
PATCH /api/miss-ozone/calls/:id/reschedule auth + batchLead Edit time/reason/questions of a scheduled call.
GET /api/miss-ozone/me/calls auth Student lists their own calls.
GET /api/miss-ozone/me/calls/:id auth Student reads one of their calls.
PATCH /api/miss-ozone/me/calls/:id/action-items/:itemKey auth Student checks off an action item.

HR call-provider config — src/routes/hrCallConfig.routes.ts (mounted at /api)

Method Path Auth/role Purpose
GET /api/hr-call-config/:userId none (public, credentials included) mr-hire-backend reads provider resolution for an HR.
GET /api/system-config/enabled-call-providers none Enabled provider list (declared in systemConfig.routes.ts).
GET /api/hr/my-call-config auth HR reads own config.
PUT /api/hr/my-call-config auth HR sets own default provider (must be in allowed list).
GET /api/admin/hr-call-configs auth List all HR configs.
POST /api/admin/hr-call-configs auth Upsert config (allowed providers + default).
POST /api/admin/hr-call-configs/create-default auth Create default (["vapi"]) for a new HR.
GET /api/admin/hr-call-configs/:userId auth Get one HR config.
DELETE /api/admin/hr-call-configs/:userId auth Remove config (falls back to global default).

AI call prompts + trigger — src/routes/mrHire.routes.ts (mounted at /api)

All require auth + hrRoleMiddleware.

Method Path Purpose
POST /api/mr-hire/call Trigger a candidate call (renders active prompt, proxies to mr-hire-backend).
GET /api/mr-hire/call-prompts/active Resolve the active prompt for this user + platform.
GET /api/mr-hire/call-prompts List prompts (user-specific + global).
POST /api/mr-hire/call-prompts Create a prompt template.
PUT /api/mr-hire/call-prompts/:id Update a prompt.
DELETE /api/mr-hire/call-prompts/:id Delete a prompt.
GET / PUT / POST /api/mr-hire/interview-prompt[/reset] Get/update/reset the single active interview prompt.

Note: the literal (req) recording proxy and mr-hire/call paths above are quoted verbatim from the route files; the candidate call engine itself (provider selection, dialing) lives in mr-hire-backend, not this repo.


User journeys

Journey 1 — Configure an HR call provider and an interview prompt

An admin assigns which engines an HR may use; the HR (or admin) authors the AI interview prompt that every candidate call renders.

sequenceDiagram
  participant Admin as Admin
  participant API as mr-mentor-backend
  participant HCS as HrCallConfigService
  participant APS as AICallPromptService
  participant DB as PostgreSQL
  participant MH as mr-hire-backend

  Admin->>API: POST /api/admin/hr-call-configs with userId and allowedProviders
  API->>HCS: upsert allowed providers and default
  HCS->>DB: save hr_call_configs row
  API->>MH: POST /call-provider/refresh best effort
  API-->>Admin: saved config

  Admin->>API: POST /api/mr-hire/call-prompts with name and systemPrompt
  API->>APS: create prompt template
  APS->>DB: insert ai_call_prompts row isActive true
  API-->>Admin: created prompt

  Note over MH: When mr-hire places a call it reads /api/hr-call-config/userId to pick the provider

Provider resolution chain (documented in HrCallConfig.ts): HR's defaultProvider → global CALL_PROVIDER from system_configs → fallback vapi. Prompt resolution priority (AICallPromptService.getActivePrompt): user+platform (score 4) → user+any-platform (3) → global+platform (2) → global+any-platform (1).

Journey 2 — Launch a candidate voice interview and persist conversation turns

The pipeline (or HR) triggers a screening call. mr-hire-backend dials the candidate via the configured provider, then pushes lifecycle webhooks back. Each webhook upserts the VoiceInterview and replaces its VoiceInterviewMessage turns.

sequenceDiagram
  participant HR as HR or Pipeline
  participant API as mr-mentor-backend
  participant APS as AICallPromptService
  participant MH as mr-hire-backend
  participant Prov as VAPI or Twilio or ElevenLabs
  participant VIS as VoiceInterviewService
  participant DB as PostgreSQL

  HR->>API: POST /api/mr-hire/call with candidate and role
  API->>APS: getActivePrompt then renderPrompt with candidate vars
  API->>MH: POST /call with rendered systemPrompt
  MH->>Prov: place outbound call
  MH-->>API: call accepted with callId

  Prov->>MH: call ringing then answered then ended
  MH->>API: POST /api/voice-interviews/webhook x-webhook-secret status ringing
  API->>VIS: upsertFromWebhook
  VIS->>DB: create voice_interviews row status ringing

  MH->>API: POST /api/voice-interviews/webhook status ended with transcript and messages
  API->>VIS: upsertFromWebhook forward-only status guard
  VIS->>DB: update interview then delete and re-insert messages ordered
  API-->>MH: ok with interview id
  Note over API: on ended or failed it kicks handleCallResult in background

Message handling detail: empty or whitespace-only turns are filtered out (providers send silent / tool-call turns), and on an update the message set is fully replaced, not appended (VoiceInterviewService.upsertFromWebhook).

Journey 3 — Score the interview, retry on no-answer, finalize the candidate

When a call ends, the webhook fires handleCallResult (in voiceInterview.controller.ts). It decides: score it, retry it, or exhaust attempts and score zero.

sequenceDiagram
  participant API as VoiceInterviewController
  participant DB as PostgreSQL
  participant MH as mr-hire-backend analyzer
  participant Q as resumeAnalysisQueue
  participant Ledger as PipelineCostService

  API->>DB: find ScreeningResult by callId
  alt already processed
    API-->>API: skip duplicate webhook
  else real interview ended and duration at least 60s and transcript over 50 chars
    API->>MH: POST /api/v1/screening/analyze-call with transcript
    MH-->>API: scores and extracted candidate info
    API->>DB: save communication and technical and overall scores
    API->>Ledger: record call cost and transcript analysis cost
    API->>DB: calculateFinalScore then set shortlisted or rejected or scoring_complete
  else call failed or too short and attempts under 3
    API->>DB: set callStatus retry_scheduled and clear callId
    API->>Q: addResumeAnalysisJob type ai-call with delay 4h then 8h
    Note over API: delay nudged into IST 9am to 10pm business hours
  else attempts exhausted
    API->>DB: score 0 with failure reason and compute final score
  end

Constants live in the webhook handler: MIN_CALL_DURATION = 60s, MAX_CALL_ATTEMPTS = 3, retry delays [4h, 8h] adjusted to IST business hours via adjustDelayForBusinessHours.

Journey 4 — Auto-sync stale interviews from mr-hire-backend

VAPI sometimes sends a webhook before the transcript is final, or a webhook is missed. The list endpoint self-heals by pulling fresh state from mr-hire-backend on a cooldown.

sequenceDiagram
  participant FE as ATS UI
  participant API as VoiceInterviewController
  participant VIS as VoiceInterviewService
  participant MH as mr-hire-backend
  participant DB as PostgreSQL

  FE->>API: GET /api/voice-interviews
  API->>VIS: getAll then triggerAutoSync non blocking
  VIS->>DB: query interviews and return page immediately
  API-->>FE: current page of interviews
  par background sync
    VIS->>MH: GET /calls limit 100
    MH-->>VIS: call list
    VIS->>DB: find existing callIds and statuses
    Note over VIS: skip terminal ended or failed and skip when remote has not progressed
    loop missing or stale calls
      VIS->>MH: GET /call/callId
      MH-->>VIS: full detail
      VIS->>DB: upsertFromWebhook
    end
  end

Cooldown: 60s normally, 15s when stale non-terminal records exist (SYNC_COOLDOWN_MS, hasStaleRecords). Single-flight via syncInProgress.

Journey 5 — Launch an Aarya batch-calling campaign

An admin or sales head filters leads, previews the recipient list, then dispatches the campaign to ElevenLabs. Phones are normalized to E.164 (+91 default); per-recipient call logs and a durable batch row are written.

sequenceDiagram
  participant Admin as Admin or Sales Head
  participant API as AaryaController
  participant Scope as SalesScopeService
  participant ELS as ElevenLabsBatchService
  participant EL as ElevenLabs
  participant DB as PostgreSQL

  Admin->>API: POST /api/admin/aarya/batches/preview with filters
  API->>Scope: getActiveHeadScope to lock owningSalesHeadId
  API->>ELS: previewBatch resolves leads and builds recipients
  ELS->>DB: fetch prior Aarya history for already-called badges
  API-->>Admin: recipient preview capped at 500

  Admin->>API: POST /api/admin/aarya/batches with callName
  API->>ELS: submitBatch
  ELS->>ELS: resolveLeads then buildRecipients normalize phones
  alt no valid recipients
    ELS-->>API: 400 refine your filters
  else dispatch
    ELS->>EL: POST /v1/convai/batch-calling/submit
    EL-->>ELS: batch id and status
    ELS->>DB: insert LeadCallLog per recipient outcome aarya_dispatched
    ELS->>DB: bump RawLead lastContactedAt and call_count
    ELS->>DB: insert aarya_call_batches durable row
    API-->>Admin: 201 submitted N calls
  end

Guardrails (ElevenLabsBatchService.submitBatch): Aarya-only agent (rejects forged agentId), recipient hard cap (resolveLeads takes at most 2000), 503 if ELEVENLABS_API_KEY / AARYA_AGENT_ID / AARYA_PHONE_NUMBER_ID unset.

Journey 6 — Reconcile Aarya batch results (sync worker)

ElevenLabs only knows the conversation_id and duration after the call runs. The aaryaSync.worker polls every 15 minutes to backfill LeadCallLog rows and refresh batch status.

sequenceDiagram
  participant Cron as BullMQ repeat 15m
  participant W as aaryaSync.worker
  participant ACS as AaryaCallSyncService
  participant EL as ElevenLabs
  participant DB as PostgreSQL
  participant WF as Workflow engine

  Cron->>W: syncAaryaCalls job
  W->>ACS: syncPending
  ACS->>DB: find Aarya logs missing conversation id
  loop each batch
    ACS->>EL: GET batch-calling batchId
    alt 404 expired
      ACS->>DB: mark matching logs aarya_failed
    else ok
      ACS->>DB: write conversation_id by matching E164 phone
    end
  end
  ACS->>DB: find logs with conversation id but null durationSec
  loop each conversation
    ACS->>EL: GET conversations id
    ACS->>DB: write durationSec and outcome completed or failed
  end
  ACS->>WF: wake workflow enrollments waiting on completed call logs
  ACS->>DB: reconcile aarya_call_batches status and counts mark expired on 404
  W-->>Cron: counts convoIds durations batches

Per-pass caps: 20 batches, 200 conversations. Duration buckets (bucketFromDuration): hot ≥30s, warm ≥10s, cold ≥0s, else no_engagement — surfaced as a badge in the CRM timeline.

Journey 7 — Miss Ozone: trigger a mentorship check-in call

A batch lead previews a student's context, then triggers (or schedules) a Miss Ozone call. The call row is created synchronously; dispatch happens asynchronously through the worker.

sequenceDiagram
  participant BL as Batch Lead
  participant API as MissOzoneController
  participant MOS as MissOzoneService
  participant DB as PostgreSQL
  participant Q as missOzoneQueue

  BL->>API: GET /api/miss-ozone/preview-context studentIds
  API->>MOS: assertBatchLeadOwnsStudents then buildContextSnapshot
  MOS->>DB: gather activity warnings tests placement AI profile nudges
  API-->>BL: context snapshots for confirm modal

  BL->>API: POST /api/miss-ozone/trigger studentIds reason questions scheduledFor
  API->>MOS: trigger with ownership and dedupe checks
  alt active or recent duplicate call
    MOS-->>API: skipped with reason
  else created
    MOS->>DB: insert miss_ozone_calls state scheduled
  end
  API->>Q: add trigger-call delay immediate stagger 8s or scheduled offset
  API-->>BL: created and skipped lists

Dedupe: any existing scheduled/dialing/completed call for the student blocks a new one; an immediate re-trigger by the same BL within 60s is also skipped. Bulk cap 50 students; scheduled time capped at 30 days out.

Journey 8 — Miss Ozone: dispatch, live status, summary

The worker builds the prompt, calls LiveKit, then LiveKit webhooks and a poller drive the call to summary_ready. Each transition is pushed to the student and BL over Socket.IO.

sequenceDiagram
  participant W as missOzone.worker
  participant MOS as MissOzoneService
  participant APS as AICallPromptService
  participant LK as LiveKit voice agent
  participant DB as PostgreSQL
  participant Sock as Socket.IO bus
  participant Risk as StudentRiskService

  W->>MOS: trigger-call dispatchToLivekit
  MOS->>DB: load call and rebuild snapshot
  alt student has no phone
    MOS->>DB: state failed reason no phone
    MOS->>Sock: emit miss-ozone state to student and BL
  else dispatch
    MOS->>APS: getActivePrompt miss_ozone then renderPrompt
    MOS->>LK: POST /call with phone persona context fields
    LK-->>MOS: room name
    MOS->>DB: save room phoneSnapshot state stays scheduled
    MOS->>Sock: emit dispatched waiting for ringing
  end

  LK->>MOS: POST /api/miss-ozone/livekit-webhook call.dialing
  MOS->>DB: state dialing
  MOS->>Sock: phone ringing pick up
  LK->>MOS: webhook call.ended
  MOS->>DB: state completed
  W->>MOS: fetch-evaluation retry with backoff
  MOS->>LK: GET /calls/room
  alt not_picked_up
    MOS->>DB: state failed
  else analysis ready
    MOS->>DB: rawEvaluation summary action_items state summary_ready
    MOS->>Risk: attachCallSummary onto risk row
    MOS->>Sock: summary ready student notified
  end

fetch-evaluation retries with delays [10s,20s,30s,60s,120s]; a 60s poll-active-calls reconciler also re-pulls any call stuck in scheduled/dialing/completed with a room set in the last 24h. The student then checks off action items via PATCH /api/miss-ozone/me/calls/:id/action-items/:itemKey.


Background jobs & async

Queue Worker Job types Schedule Source
aaryaSyncQueue src/workers/aaryaSync.worker.ts syncAaryaCalls / aaryaCallSync repeat every 15 min (QueueService.scheduleAaryaSync) reconcile Aarya conversation ids, durations, batch status
missOzoneQueue src/workers/missOzone.worker.ts trigger-call on demand (staggered 8s, or delayed to scheduledFor) dispatch one call to LiveKit
missOzoneQueue same fetch-evaluation self-rescheduling backoff up to 6 attempts pull summary from LiveKit
missOzoneQueue same poll-active-calls repeat every 60s (scheduleMissOzonePolling) reconcile stuck calls
missOzoneQueue same prune-old-calls daily 30 3 * * * (scheduleMissOzonePrune) null transcripts at 7d, archive heavy payload to S3 at 30d
resumeAnalysisQueue resumeAnalysis.worker.ts ai-call delayed retry (4h/8h) re-place a failed candidate call

missOzoneQueue worker concurrency is 4; aaryaSyncQueue is single-flight (concurrency 1) to avoid hammering ElevenLabs.

Webhooks (inbound, secret-validated, no JWT)

  • POST /api/voice-interviews/webhook — header x-webhook-secret must equal MR_HIRE_WEBHOOK_SECRET.
  • POST /api/miss-ozone/livekit-webhook — header x-webhook-secret must equal LIVEKIT_WEBHOOK_SECRET.

Socket.IO events (Miss Ozone, via src/socket/bus emitToUser)

  • To student: miss-ozone:state (every transition) plus miss-ozone:dialing, miss-ozone:answered, miss-ozone:completed, miss-ozone:summary-ready.
  • To batch lead: miss-ozone:bl:update.

External integrations

System Used by Base URL env Auth Failure / fallback
mr-hire-backend (call engine + analyzer) VoiceInterviewService, MrHireController MR_HIRE_BACKEND_URL (default http://localhost:8001) INTERNAL_SERVICE_TOKEN bearer for analyze-call Sync skipped if URL unset; analyzer failure scores call 0 and continues.
ElevenLabs ConvAI Batch Calling ElevenLabsBatchService, AaryaCallSyncService https://api.elevenlabs.io header xi-api-key = ELEVENLABS_API_KEY Missing key → 503; live list falls back to stored rows; 404 batch → marked expired.
LiveKit voice agent (Miss Ozone) MissOzoneService LIVEKIT_API_BASE (default https://livekit-tasa.onrender.com) webhook secret LIVEKIT_WEBHOOK_SECRET /call failure → call failed with captured reason; webhook disabled if PUBLIC_BASE_URL unset.
Twilio / VAPI recordings recording proxy route TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN Basic auth injected for Twilio 500 if Twilio creds missing; only twilio/vapi hosts allowed.
AWS S3 (Glacier) MissOzoneService.pruneOldCalls MISS_OZONE_ARCHIVE_BUCKET (fallback AWS_S3_BUCKET_NAME) AWS SDK creds Per-row try/catch; archive failures retried next prune.

Key env vars

MR_HIRE_BACKEND_URL          # mr-hire-backend base
MR_HIRE_WEBHOOK_SECRET       # validates the voice-interview webhook
INTERNAL_SERVICE_TOKEN       # bearer for analyze-call + agent-config service reads
ELEVENLABS_API_KEY           # Aarya batch calling (503 if unset)
AARYA_AGENT_ID               # locks Aarya to one agent (else name match "aarya")
AARYA_PHONE_NUMBER_ID        # Aarya outbound number
LIVEKIT_API_BASE             # Miss Ozone voice agent
LIVEKIT_WEBHOOK_SECRET       # validates the livekit webhook
PUBLIC_BASE_URL              # Miss Ozone webhook callback base (disables webhook if unset)
MISS_OZONE_ARCHIVE_BUCKET    # Glacier bucket for pruned call payloads
TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN  # recording proxy

Feature-flag behavior — each integration disables itself gracefully when its key/URL is absent: Aarya endpoints throw a 503 with a clear message; voice-interview sync logs a warning and no-ops; Miss Ozone logs the webhook as disabled at boot.


Status lifecycles

VoiceInterview (voice_interviews.status)

stateDiagram-v2
  [*] --> queued
  queued --> ringing
  ringing --> in_progress
  in_progress --> ended
  queued --> failed
  ringing --> failed
  in_progress --> failed
  ended --> [*]
  failed --> [*]
  note right of ended
    forward-only via STATUS_ORDER
    ended and failed are terminal
  end note

MissOzoneCall (miss_ozone_calls.state)

stateDiagram-v2
  [*] --> scheduled
  scheduled --> cancelled: BL cancels before dispatch
  scheduled --> failed: no phone or livekit error
  scheduled --> dialing: call.dialing webhook
  dialing --> completed: call.ended webhook
  dialing --> failed: not_picked_up
  completed --> summary_ready: analyzer result fetched
  completed --> failed: not_picked_up
  summary_ready --> [*]
  failed --> [*]
  cancelled --> [*]
  note right of summary_ready
    attachCallSummary feeds
    Students-at-Risk row
  end note

AaryaCallBatch (aarya_call_batches.status)

stateDiagram-v2
  [*] --> pending: scheduled future dispatch
  [*] --> in_progress: immediate dispatch
  pending --> in_progress
  in_progress --> completed
  in_progress --> cancelled
  in_progress --> failed
  in_progress --> expired: 404 purged by ElevenLabs
  completed --> [*]
  cancelled --> [*]
  failed --> [*]
  expired --> [*]

Edge cases, limits & gotchas

  • Three independent calling systems, one repo. They share only AICallPrompt + the prompt renderer. Candidate interviews route through mr-hire-backend; Aarya hits ElevenLabs directly; Miss Ozone hits LiveKit directly. Don't assume a shared provider abstraction.
  • Webhooks are unauthenticated except for a shared secret. Both webhook handlers 401 on mismatch and (importantly) also 401 when the expected secret env is unset — so a missing MR_HIRE_WEBHOOK_SECRET / LIVEKIT_WEBHOOK_SECRET silently breaks ingestion.
  • Forward-only interview status. upsertFromWebhook ignores a webhook that would move status "backwards" (STATUS_ORDER). A late in-progress after ended is dropped.
  • Message replacement, not append. Every webhook with messages deletes and re-inserts all VoiceInterviewMessage rows; empty/whitespace turns are filtered. Don't rely on incremental turn IDs.
  • Idempotent scoring guard. ScreeningResult.callProcessedAt blocks duplicate webhook handling. Retries clear callId so a fresh call gets a new id; without that the new call would collide.
  • Retry windows respect IST business hours. adjustDelayForBusinessHours pushes retries into 09:00–22:00 IST, so a "4h" retry can land much later.
  • Aarya is locked to one agent. If AARYA_AGENT_ID is set, only that agent is allowed and listed; otherwise it falls back to a case-insensitive name match on "aarya". A forged agentId in the body is rejected (400).
  • Phone normalization must stay in sync. ElevenLabsBatchService.normalizePhone and AaryaCallSyncService.normalizePhone are duplicated on purpose; the conversation-id join in step 1 matches on E.164, so any change must be mirrored or results stop linking.
  • Durable Aarya history. ElevenLabs purges old batches (404). aarya_call_batches is our permanent copy; the sync worker marks purged batches expired rather than deleting, and listBatches overlays live status on stored rows.
  • Sales-head data isolation. Aarya recipient resolution is hard-scoped to the caller's active sales-head pool (owningSalesHeadId set in the controller from SalesScopeService, never from the request body).
  • Miss Ozone ownership + dedupe. assertBatchLeadOwnsStudents blocks calling students outside the BL's batches; active/recent-call dedupe prevents double-dialing. A BL can only cancel/reschedule while state = scheduled and room is null (post-dispatch is refused).
  • Miss Ozone prompt mirrors the confirm modal exactly. The blocks rendered into the LiveKit prompt are built to match what the BL saw — same caps and wording. If you change the modal, change the prompt builder, and vice-versa. A DEFAULT_MISS_OZONE_PROMPT is used when no miss_ozone-platform prompt is active.
  • not_picked_up is terminal. Without that branch in fetchEvaluation, the worker would poll forever and the student dedupe would stay stuck.
  • Retention. Miss Ozone transcripts (rawConversation) are nulled at 7 days; the full heavy payload is archived to S3 Glacier at 30 days (including failed calls) — lightweight audit fields (who/when/state/room) stay in Postgres forever.
  • Multi-tenant. These flows are not x-platform-branched in code; provider/agent selection is per-HR (HrCallConfig) or env-locked (Aarya), not per platform header.