Overview
Panayam runs as two Cloud Run services (an API and a LiveKit agent worker) backed by Cloud SQL Postgres and Google Cloud Storage, with the AI work split across four external providers — Anthropic for scoring + interview turns, ElevenLabs for voice synthesis, Deepgram for English speech recognition, and Google for Tagalog speech recognition. Real-time audio/video runs through LiveKit Cloud.
Recruiters interact via the admin SPA at /admin/.
Candidates interact via the public join page at /web/
and the self-scheduling page. Maria (the AI interviewer) is a
persona implemented inside the agent worker; she joins LiveKit
rooms as a participant when dispatched.
Nine monitors watch every call. Five act in real time — connectivity, silence, abuse, second-voice/integrity, and question-coverage (a backstop that catches Maria re-asking a question she has already covered). The other four handle visual frame capture, recording, technical failures, and the health summary written on shutdown.
System architecture
Services, data stores, and external dependencies. Solid arrows are HTTP/REST; dashed arrows are async / event-driven.
admin/*.html"]:::client Cand["Candidate Web
web/index.html"]:::client SelfSched["Self-Schedule
web/schedule.html"]:::client HM["HM Portal
hm/align.html + signoff.html"]:::client Kiosk["Walk-in Kiosk
admin/walk-in.html"]:::client end subgraph "Google Cloud Run" API["Panayam API
FastAPI"]:::ours Agent["Panayam Agent Worker
LiveKit Worker (Python)"]:::ours end subgraph "Data Plane" DB[("Cloud SQL
PostgreSQL")]:::data GCS[("GCS Bucket
recordings + frames")]:::data SM[("Secret Manager
API keys + DB url")]:::data end subgraph "External Providers" LK["LiveKit Cloud
SFU + Egress"]:::ext Anth["Anthropic Claude
Haiku (English interview)
+ Sonnet (scoring, all roles)"]:::ext EL["ElevenLabs
TTS (English)"]:::ext DG["Deepgram
STT (English)"]:::ext GSTT["Google Cloud
STT chirp_2 + TTS fil-PH
+ Gemini 2.5 Flash (Tagalog)"]:::ext SMTP["Gmail SMTP
invitation emails"]:::ext end Admin -->|REST| API HM -->|REST| API Kiosk -->|REST| API Cand -->|REST| API SelfSched -->|REST| API Cand -.WebRTC.-> LK API --> DB API --> SM API -.dispatches.-> Agent Agent --> LK Agent -->|interview turns| Anth API -->|scoring| Anth Agent --> EL Agent --> DG Agent --> GSTT Agent --> DB LK -.recording.-> GCS API --> SMTP
Interview flow (with safety monitors)
What happens from "candidate clicks Join" to "scorecard generated". All six safety monitors are shown.
slice 8 sets technical_failure Note over Agent,Mon: Slice 9 writes health_summary on shutdown Agent->>DB: persist transcript + health_summary alt end_reason normal API->>Anth: Claude Sonnet scoring pipeline API->>DB: scores + ranking else end_reason reschedule API->>API: send reschedule email else end_reason abuse / integrity API->>API: log only (no scoring, no reschedule) end
Data model
The interview domain — 10 of the 25 tables. Omitted: auth and session plumbing (user_sessions, password_reset_tokens, user_totp_secrets), ops and fleet state (maintenance_mode, capacity_mode, ops_actions, activity_log), QA and feedback tables, and alembic_version.
Process maps by hiring type
Status: DRAFT for review.
Three distinct hiring journeys today: Non-mass (single-owner, resume-driven), Mass — invited (bulk SMS / WhatsApp invite, lightweight resume optional), Mass — walk-in (office kiosk, no resume). The diagrams below are the target operating model — implementation may lag in places (e.g. the wizard banner currently shows JD/resume steps for walk-in roles; fix shipped 2026-05-31 on dev).
1. Non-mass position (resume-driven, single recruiter owner)
Classic funnel: JD parsed → HM alignment → candidates added with resume → resume scored against JD → question bank per candidate → AI interview → scorecard. One owner_user_id on the role.
process_type=standard
is_mass_position=false] --> B[Upload + parse JD
Claude Sonnet 4.6] B --> C{JD ambiguous?
Claude decides} C -->|Yes| C1[HM clarifying questions
1-10 via email link] C -->|No| D C1 --> D D[Question bank + rubric ready] D --> S1[Recruiter sign-off
POST /signoff/recruiter] S1 --> S2[HM sign-off via email link
POST /hm/signoff/approve] S2 --> E[Add candidates: name + email + CV] E --> F[Resume parsed
Claude Sonnet 4.6] F --> G[Resume scored vs JD
match strength] G --> H[Per-candidate question bank
JD + resume + HM rubric] H --> I[Shortlist email sent
candidate self-schedules] I --> J[AI Interview — English stack
Deepgram + Claude Haiku + ElevenLabs
gated by fully_signed] J --> K[Scorecard generated
visual + transcript + cost] K --> L[Ranking on Candidates tab] classDef sign fill:#1a7f37,stroke:#6cc787,color:#fff; class S1,S2 sign;
2. Mass position — invited (bulk recruitment, may or may not have CV)
Same online journey as non-mass but: Tagalog interview stack, resume is optional (lots of candidates won't have one), multiple recruiter owners via role_collaborators. Candidates invited via bulk channel (SMS / WhatsApp / Darwinbox feed).
process_type=standard
is_mass_position=true] --> B[Upload + parse JD
Claude Sonnet 4.6] B --> C[Optional HM clarifying questions
only if JD ambiguous] C --> D[Author Custom Question bank
uniform per role, not per candidate] D --> S1[Recruiter sign-off] S1 --> S2[HM sign-off via email link] S2 --> E[Bulk import candidates
name + phone + optional CV] classDef sign fill:#1a7f37,stroke:#6cc787,color:#fff; class S1,S2 sign; E --> F{Has CV?} F -->|Yes| G[Resume parsed + scored vs JD] F -->|No| H[Skip resume scoring
use only question bank] G --> I[Bulk SMS / WhatsApp invite
self-schedule link] H --> I I --> J[AI Interview — Tagalog stack
Google STT chirp_2 + Gemini 2.5 Flash + ElevenLabs fil-PH] J --> K[Scorecard generated
scored against custom question rubric only when no CV] K --> L[Ranking on Candidates tab
shared across all role collaborators]
3. Mass position — walk-in (office kiosk, no resume ever)
Candidate physically walks into a HR office. No JD parse, no HM alignment, no resume. Recruiter at the kiosk registers them with name + a unique ID (govt ID, employee ref, or generated), and Maria interviews them on a fixed time cap (default 25 min) against the role's pre-authored question bank.
process_type=walk_in
is_mass_position=true] --> B[Author Custom Question bank
+ optional max_interview_duration_minutes] B --> S1[Recruiter sign-off] S1 --> S2[HM sign-off via email link
recruiter can override if HM unavailable] S2 --> C[Recruiter opens /admin/walk-in.html
kiosk mode at the office] classDef sign fill:#1a7f37,stroke:#6cc787,color:#fff; class S1,S2 sign; C --> D[Candidate walks in
shows ID] D --> E[Recruiter enters: name + external_id
+ optional phone] E --> F[POST /api/walk-in/start
creates candidate + interview + LiveKit room] F --> G[Kiosk hands candidate the join link
tablet / laptop opens it] G --> H[AI Interview — Tagalog stack
hard time-cap from role.max_interview_duration_minutes] H --> I[Scorecard generated
scored only against custom question bank rubric] I --> J[Recruiter sees ranking immediately
candidate can leave / sit for next steps] classDef key fill:#5e2a8a,stroke:#a464d8,color:#fff; class A,F,H key;
SIPOC — explicit ownership per step
SIPOC = Supplier · Inputs · Process · Outputs · Customers. The flowcharts above show the sequence; the tables below answer "who triggers what" for each step in each hiring type. Supplier is the actor who initiates the step (recruiter, HM, candidate, system); Customer is the downstream party who consumes that step's output.
SIPOC — Non-mass position
| Step | Supplier (triggers) | Inputs | Process | Outputs | Customer |
|---|---|---|---|---|---|
| 1. Create role | Recruiter | Title, dept, process_type, owner_user_id | POST /api/roles inserts the role row, scoped to owner | roles row, status=draft | Recruiter (their workspace) |
| 2. Parse JD | Recruiter | JD text (paste) | Claude Sonnet 4.6 extracts competencies, experience range, must-haves | roles.jd_parsed JSONB | HM (next step), AI scoring (rubric) |
| 3. HM clarifying questions (only if JD ambiguous) | Claude decides; Recruiter → HM if needed | Parsed JD, recruiter sends tokenized email link | HM answers async on public form; 0-10 questions; HM never tasked unless Claude flags ambiguity | roles.hm_notes | Question bank generation |
| 3a. Recruiter sign-off | Recruiter | Generated question bank + rubric | POST /signoff/recruiter — captures snapshot, stamps recruiter_signed_at; may override "proceed without HM" with mandatory comment | roles.recruiter_signed_at, signoff_snapshot | HM sign-off step / dispatcher gate |
| 3b. HM sign-off | HM via email link | Recruiter-approved snapshot | HM reviews on public page; can edit (resets recruiter sign-off) or approve; approval stamps hm_signed_at | roles.hm_signed_at, audit log | Dispatcher gate (Maria can now run interviews) |
| 4. Add candidate | Recruiter | Name, email, optional CV | POST /api/candidates inserts row; resume upload triggers parse | candidates row, candidates.resume_parsed | Resume scoring (next step) |
| 5. Resume-vs-JD match | Recruiter (clicks "Score") | Parsed JD + parsed resume | Claude Sonnet compares; returns 0-100 match + recommendation | candidates.resume_match JSONB | Recruiter (decides who advances) |
| 6. Question bank generation | Recruiter (clicks "Generate") | JD + HM rubric + resume_parsed | Claude Sonnet builds per-candidate question list (~8-12 Qs) | candidates.interview_questions JSONB | Maria (agent worker reads at session start) |
| 7. Shortlist email | Recruiter (clicks "Send invite") | Candidate email + role title | SMTP send with self-schedule link | Email delivered, candidates.status = invited | Candidate (picks slot) |
| 8. AI interview | Candidate (joins LiveKit room) | Question bank, JD, candidate consent | Maria interviews in English (Deepgram + Claude Haiku + ElevenLabs); safety monitors run | Recording, transcript, interviews.health_summary | Scoring pipeline |
| 9. Scoring + scorecard | System (on session shutdown) | Transcript + visual frames + JD rubric | Claude Sonnet scores each dimension + executive summary; visual presence scored separately | rankings row, scorecard JSON | Recruiter (Candidates tab), HM (shared link) |
SIPOC — Mass position (invited)
| Step | Supplier (triggers) | Inputs | Process | Outputs | Customer |
|---|---|---|---|---|---|
| 1. Create mass role | Recruiter (lead) | Title, is_mass_position=true, multiple owners via role_collaborators | Role inserted; visibility shared across all collaborator recruiters | roles row + collaborator rows | All collaborator recruiters |
| 2. Parse JD (lighter) | Recruiter | JD text | Same as non-mass; HM alignment is usually skipped or quick | roles.jd_parsed | Question bank, recruiter team |
| 3. Author Custom Questions | Recruiter | Recruiter writes 5-10 uniform questions for the role | POST /api/roles/{id}/custom-questions; same set used for every candidate | roles.custom_questions JSONB | Maria (per-candidate question bank == custom questions) |
| 4. Bulk import candidates | Recruiter | CSV / Darwinbox feed (name + phone + optional CV) | POST /api/candidates per row; resume parse only if CV present | N candidate rows | Bulk invite step |
| 5. Bulk SMS / WhatsApp invite | Recruiter | Candidate phones + self-schedule URL | External SMS gateway (not in tool today — recruiter triggers manually) | Invite sent | Candidate |
| 6. AI interview (Tagalog stack) | Candidate (joins) | Custom questions | Maria interviews in Filipino (Google chirp_2 STT + Gemini 2.5 Flash + ElevenLabs fil-PH) | Recording, transcript | Scoring |
| 7. Scoring (question-bank rubric) | System | Transcript + custom question rubric (no JD match weight) | Claude Sonnet scores against the role's custom questions only | Scorecard | All role collaborators (shared visibility) |
SIPOC — Mass position (walk-in kiosk)
| Step | Supplier (triggers) | Inputs | Process | Outputs | Customer |
|---|---|---|---|---|---|
| 1. Create walk-in role | Recruiter (lead) | Title, process_type=walk_in, max_interview_duration_minutes | Role inserted; wizard branch skips JD/HM/match | roles row with walk-in flag | All collaborator recruiters |
| 2. Author Custom Questions | Recruiter | Question list | Same as mass-invited step 3 | roles.custom_questions | Maria |
| 3. Open kiosk page | Recruiter (at office) | Browser tab on /admin/walk-in.html?role_id=… | Page lists today's queue + new-candidate form | Kiosk session | Recruiter (running the kiosk) |
| 4. Register walk-in | Recruiter (at kiosk) | Candidate name + external_id (govt ID / employee ref) + optional phone | POST /api/walk-in/start atomically: create candidate, create interview, create LiveKit room, dispatch agent | candidates+interviews+room provisioned | Kiosk hands candidate the join URL |
| 5. Candidate joins | Candidate (at tablet) | Join URL on a kiosk tablet | Standard /web/ join flow | Candidate in LiveKit room | Maria (agent) |
| 6. AI interview (time-capped) | Candidate | Custom questions + role's max_interview_duration_minutes | Tagalog stack; hard time-cap enforced by silence/connectivity + a duration timer | Recording, transcript | Scoring |
| 7. Scoring + immediate ranking | System (on shutdown) | Transcript + custom question rubric | Same as mass-invited step 7 | Scorecard visible on kiosk page within ~30 s | Recruiter (decides next step in person — second-round, walk-away, etc.) |
Decision matrix — which type fits which hire?
| Hire scenario | process_type | is_mass_position | Owner model | JD parse? | Resume? | Time cap |
|---|---|---|---|---|---|---|
| Single high-skill hire (PM, engineer) | standard | false | 1 owner | Yes | Required | ~35 min |
| Bulk recruitment with CV (sales, ops) | standard | true | Multiple owners | Yes (lighter) | Optional | ~25-30 min |
| Walk-in kiosk (field collections, retail) | walk_in | true | Multiple owners | No | None | Configurable, default 25 min |
To implement (post-review): wizard banner already auto-skips JD/HM/match for walk_in roles (shipped on dev). Still missing: a "Resume optional" toggle on standard + mass roles so candidates can be added without CV, and the per-role question_bank_only scoring path that ignores resume_match weights for the latter two types.
Two-stage sign-off on the interview question bank
Before Maria interviews any candidate for a role, BOTH the recruiter and the hiring manager must approve the question bank + rubric. Enforced server-side at the agent dispatcher — any path that creates an Interview row (recruiter scheduling, candidate self-schedule, walk-in kiosk) checks the gate.
Reset triggers — these wipe both signatures and reset state to unsigned:
editing the parsed JD, editing the HM notes, editing the custom questions.
Recruiter override "proceed without HM" requires a mandatory comment and is logged as proceed_without_hm in role_signoff_events.
Technology stack
| Layer | Technology | Purpose |
|---|---|---|
| Real-time transport | LiveKit Cloud | WebRTC rooms, SFU, recording (Egress to GCS) |
| Agent framework | livekit-agents 1.6.x (capped <1.9) | Joins room as AI participant; orchestrates STT / LLM / TTS pipeline |
| STT — English | Deepgram nova-3 | Low-latency English speech recognition |
| STT — Tagalog | Google STT chirp_2 | Filipino mass-position interviews (asia-southeast1, fil-PH) |
| LLM — interview turns | Claude Haiku 4.5 | Real-time Q&A reply generation (~1-3s TTFT) |
| LLM — scoring | Claude Sonnet 4.6 | Post-call transcript + visual evaluation against rubric |
| LLM — Tagalog | Gemini 2.5 Flash | Mass-position interview turns (Filipino) |
| TTS | ElevenLabs eleven_multilingual_v2 | Maria's voice on English roles |
| TTS — Tagalog | Google TTS fil-PH-Wavenet-B | Mass-position roles — Google has no fil-PH Chirp 3 HD voice yet |
| Backend | Python 3.11 + FastAPI | API server, webhook handlers, scoring pipeline |
| Frontend | Vanilla JS + HTML/CSS | Admin SPA + candidate join page (no build step) |
| Database | PostgreSQL 15 (Cloud SQL) | Roles, candidates, interviews, scores, rankings, analytics cache |
| Object storage | Google Cloud Storage | Video recordings, captured frames, exported scorecards |
| Gmail SMTP | Shortlist + invite + reschedule emails | |
| Hosting | Google Cloud Run (asia-southeast1) | Two services: Panayam API + Panayam Agent Worker |
| CI / CD | Cloud Build triggers on dev and main | Push to dev auto-deploys DEV; main auto-deploys PROD |
| Secrets | Google Secret Manager | API keys, DB URL, SMTP credentials |
Safety monitors (v0.8.0)
| # | Monitor | Source / cadence | Warn threshold | End threshold | Action |
|---|---|---|---|---|---|
| 1 | ConnectivityMonitor | LiveKit quality event; 2 s poll while degraded | 15 s sustained POOR / LOST | 60 s sustained | Maria speaks warning at 15 s; end + reschedule email at 60 s (end_reason=connectivity_interrupted). If quality recovers she says so and the warning re-arms. |
| 2 | SilenceMonitor | Poll loop, every 5 s | 50 s quiet → check-in #1 (60 s on mass roles) | 3 check-ins unanswered (~200 s; ~240 s on mass roles) | Each check-in is a spoken nudge; end + reschedule email after 3 (end_reason=prolonged_silence). Never interrupts while the candidate is speaking, and any activity resets the counter. |
| 3 | AbuseMonitor | Per user turn (event-driven); Haiku ~300-500 ms | LOW: 3 warnings · MED: 2 warnings · HIGH: 0 | Strike limit exceeded | HIGH: immediate end (no reschedule). MED/LOW: warning, then end on next breach. end_reason=abuse_<sev> |
| 4 | MultiSpeakerMonitor | Per user turn (event-driven); noise pre-filter, then Haiku | second-voice strikes 1-4 → warn | 5th strike → end | Ends without reschedule (end_reason=integrity_breach). Second-voice below 0.6 confidence is downgraded to ambient noise; ambient noise is counted separately and never penalised. |
| 5 | CoverageMonitor | Per agent turn (event-driven); pure string matching, no LLM | Maria re-asks a question already covered | n/a — never ends the call | Raises a verdict the interviewer turns into a corrective note injected into the next turn, escalating if she keeps looping. Added after interview 29efa923 (2026-08-03), where Maria asked the same question three times and the candidate withdrew. |
| 6 | Visual frame capture | Poll loop, every 60 s (configurable 10-600 s) | n/a (no real-time action) | n/a — used post-call only | Frames captured to GCS; scored as a separate Claude Sonnet visual-presence dimension after shutdown |
| 7 | Recording (LiveKit Egress) | Started on agent.candidate_joined, ends on session close | n/a | n/a | Full MP4 uploaded to voicescreen_recordings* bucket; URL surfaced on scorecard |
| 8 | Technical-failure handler | Hooked into LiveKit "error" / "unrecoverable_error" events | Any unrecoverable error | Immediate | Set end_reason=technical_failure, attempt apology TTS, send reschedule email |
| 9 | Health summary writer | Shutdown callback | n/a | n/a | Aggregates counters from the connectivity, silence, abuse and second-voice monitors into interviews.health_summary JSONB |
Process maps — when each monitor triggers
Sequence diagrams below show the actual control flow inside the agent worker for each safety monitor. Bold actor labels mark the trigger source; intervals on the arrows are the live values from the code (not aspirational).