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 andScreeningResult.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_batchesso 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
AICallPromptwhose body contains{{variable}}placeholders rendered at call time viaAICallPromptService.renderPrompt.
TypeORM entities owned by this domain
| Entity | File | Table | Notes |
|---|---|---|---|
VoiceInterview |
src/entities/VoiceInterview.entity.ts |
voice_interviews |
One candidate interview; FK application_id → JobApplication. |
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):queued→ringing→in-progress→ended|failed. Forward-only (seeSTATUS_ORDERinVoiceInterviewService).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 sentinelexpired(ElevenLabs purged it).LeadCallLog.outcomeAarya 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 andmr-hire/callpaths above are quoted verbatim from the route files; the candidate call engine itself (provider selection, dialing) lives inmr-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— headerx-webhook-secretmust equalMR_HIRE_WEBHOOK_SECRET.POST /api/miss-ozone/livekit-webhook— headerx-webhook-secretmust equalLIVEKIT_WEBHOOK_SECRET.
Socket.IO events (Miss Ozone, via src/socket/bus emitToUser)
- To student:
miss-ozone:state(every transition) plusmiss-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 throughmr-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_SECRETsilently breaks ingestion. - Forward-only interview status.
upsertFromWebhookignores a webhook that would move status "backwards" (STATUS_ORDER). A latein-progressafterendedis dropped. - Message replacement, not append. Every webhook with messages deletes and re-inserts all
VoiceInterviewMessagerows; empty/whitespace turns are filtered. Don't rely on incremental turn IDs. - Idempotent scoring guard.
ScreeningResult.callProcessedAtblocks duplicate webhook handling. Retries clearcallIdso a fresh call gets a new id; without that the new call would collide. - Retry windows respect IST business hours.
adjustDelayForBusinessHourspushes retries into 09:00–22:00 IST, so a "4h" retry can land much later. - Aarya is locked to one agent. If
AARYA_AGENT_IDis set, only that agent is allowed and listed; otherwise it falls back to a case-insensitive name match on"aarya". A forgedagentIdin the body is rejected (400). - Phone normalization must stay in sync.
ElevenLabsBatchService.normalizePhoneandAaryaCallSyncService.normalizePhoneare 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_batchesis our permanent copy; the sync worker marks purged batchesexpiredrather than deleting, andlistBatchesoverlays live status on stored rows. - Sales-head data isolation. Aarya recipient resolution is hard-scoped to the caller's active sales-head pool (
owningSalesHeadIdset in the controller fromSalesScopeService, never from the request body). - Miss Ozone ownership + dedupe.
assertBatchLeadOwnsStudentsblocks calling students outside the BL's batches; active/recent-call dedupe prevents double-dialing. A BL can only cancel/reschedule whilestate = scheduledandroomis 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_PROMPTis used when nomiss_ozone-platform prompt is active. not_picked_upis terminal. Without that branch infetchEvaluation, 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.