Architecture
Agent A2A API
Internal machine-to-machine API used by NestJS to drive the Planner, Interviewer, and Assessor agents.
Each Agno agent exposes a shared /api/v1/runs interface over HTTP. The NestJS API Gateway calls these endpoints internally — they are not exposed to external clients. All three agents share the same run lifecycle and authentication contract.
Agent Ports
| Agent | Port | HITL Gate |
|---|---|---|
| Planner Agent | 7777 | Yes — plan approval before scheduling |
| Interviewer Agent | 7778 | No — runs for duration of live session |
| Assessor Agent | 7779 | Yes — recruiter final verdict before sending externally |
Authentication
All run endpoints require a shared machine-to-machine API key configured via A2A_API_KEY env var. Two schemes are accepted:
# Option 1: X-API-Key header
X-API-Key: <A2A_API_KEY>
# Option 2: Bearer token
Authorization: Bearer <A2A_API_KEY>Run State Machine
Every run transitions through a shared state machine regardless of agent type:
queued
│
▼
running
│
├── (planner / assessor only)
│ ▼
│ pending_hitl ←── HITL gate: waiting for recruiter action
│ │
│ ▼ (POST /{run_id}/continue)
│ running (briefly, publishing outcome event)
│
▼
completed ←── terminal: result stored in Redis
failed ←── terminal: error stored in Redis
cancelled ←── terminal: cancelled via POST /{run_id}/cancel| Status | Description |
|---|---|
| queued | Run created, background task starting |
| running | Active — LLM generation or live interview in progress |
| pending_hitl | Waiting for recruiter action via /continue |
| completed | Terminal — result available in run.result |
| failed | Terminal — error message in run.error |
| cancelled | Terminal — cancelled by NestJS |
Planner Agent — POST /api/v1/runs
Creates a plan-generation run. Returns 202 Accepted immediately; the LLM plan is generated asynchronously. The run pauses at a HITL gate until the recruiter approves or rejects the plan.
POST http://planner-agent:7777/api/v1/runs
X-API-Key: <A2A_API_KEY>
Content-Type: application/json
{
"agent_type": "planner",
"interview_id": "interview-uuid",
"input": {
"position": "Senior Software Engineer",
"level": "SENIOR",
"skills": ["TypeScript", "React", "Node.js", "PostgreSQL"],
"duration": 60,
"company_name": "Acme Corp",
"company_description": "A leading SaaS platform for enterprise workflows.",
"job_description": "Join our platform team to build scalable microservices..."
},
"context": {
"tenantId": "tenant-uuid"
}
}| Field | Type | Required | Description |
|---|---|---|---|
| position | string | Yes | Job position title |
| level | string | Yes | JUNIOR | MID | SENIOR | LEAD |
| skills | string[] | Yes | Skills to assess (min 1) |
| duration | integer | Yes | Interview length in minutes (15–180) |
| company_name | string | No | Company name shown in Maya's greeting script |
| company_description | string | No | Brief company overview for contextualising questions |
| job_description | string | No | Full job description — improves question relevance |
{
"run_id": "planner-run-uuid",
"status": "queued",
"agent_type": "planner",
"interview_id": "interview-uuid"
}After the plan is generated, the run enters pending_hitl and publishes interview.plan.created to Kafka. NestJS listens to this event, saves the plan, and notifies the recruiter via the approval UI.
The generated plan stored in the DB includes two AI-generated fields used downstream:
| Field | Used by | Description |
|---|---|---|
| greeting_script | Interviewer Agent | Maya's exact spoken opening — introduces the company, role, and invites the candidate to introduce themselves |
| inmail_draft | NestJS (on approval) | LinkedIn InMail template with {{CANDIDATE_FIRST_NAME}} and {{INTERVIEW_LINK}} placeholders substituted at approval time |
{
"plan_id": "plan-uuid",
"interview_id": "interview-uuid",
"tenant_id": "tenant-uuid",
"position": "Senior Software Engineer",
"level": "senior",
"skills": ["TypeScript", "React", "Node.js", "PostgreSQL"],
"run_id": "planner-run-uuid"
}Planner Agent — GET /api/v1/runs/{run_id}
Poll run status. When status is pending_hitl, the active_requirement field contains the full plan for the recruiter to review.
{
"run_id": "planner-run-uuid",
"status": "pending_hitl",
"agent_type": "planner",
"interview_id": "interview-uuid",
"active_requirement": {
"type": "plan_approval",
"description": "Interview plan requires recruiter approval before scheduling.",
"data": {
"plan_id": "plan-uuid",
"position": "Senior Software Engineer",
"level": "senior",
"total_duration": 60,
"questions_count": 8,
"skills_coverage": {
"TypeScript": ["questions"],
"React": ["questions"]
},
"plan": { "...full plan object..." }
}
}
}Planner Agent — POST /api/v1/runs/{run_id}/continue
Resume a pending_hitl run with the recruiter's approval or rejection. The background task unblocks, publishes the outcome Kafka event, and transitions to completed or failed.
POST http://planner-agent:7777/api/v1/runs/{run_id}/continue
X-API-Key: <A2A_API_KEY>
Content-Type: application/json
{
"data": {
"approved": true,
"approvedBy": "recruiter-user-id"
}
}{
"data": {
"approved": false,
"approvedBy": "recruiter-user-id",
"rejectionReason": "Add more system design questions"
}
}On approval, the Planner agent publishes interview.approved to Kafka. NestJS then:
- Generates the candidate interview link (signed join token)
- Reads the
inmail_draftfrom the plan stored in the DB - Substitutes
{{INTERVIEW_LINK}}and{{CANDIDATE_FIRST_NAME}}placeholders - Returns both in the approve API response and fires the
interview.approvedwebhook to the external system
{
"run_id": "planner-run-uuid",
"plan_id": "plan-uuid",
"interview_id": "interview-uuid",
"tenant_id": "tenant-uuid",
"approved_by": "recruiter-user-id",
"approved_at": "2026-02-24T10:00:00Z"
}{
"message": "Interview plan approved. Candidate link generated.",
"workflowState": "APPROVED",
"interviewLink": "https://app.ai-interview.com/interview/join/abc123token",
"inmailDraft": {
"subject": "Senior Software Engineer Opportunity at Acme Corp — Interview Invitation",
"body": "Hi Jane,\n\nI came across your profile and was very impressed by your TypeScript and React expertise. At Acme Corp, we build developer tools used by over 50,000 engineers...\n\nPlease click the link below to start your interview:\nhttps://app.ai-interview.com/interview/join/abc123token\n\nBest regards,\nThe Acme Corp Recruiting Team"
}
}inmailDraft is generated by the Planner LLM and personalised using the company name, role, and top required skills. The recruiter can send it directly as a LinkedIn InMail to invite the candidate. It is only present on approval — rejected responses do not include it.On rejection, the run transitions to failed and NestJS triggers plan regeneration via Kafka.
Interviewer Agent — POST /api/v1/runs
Initializes a live interview session in Redis pre-seeded with the approved plan. Returns 201 Created and immediately sets status to running. There is no HITL gate — the run stays running for the duration of the live session.
POST http://interviewer-agent:7778/api/v1/runs
X-API-Key: <A2A_API_KEY>
Content-Type: application/json
{
"agent_type": "interviewer",
"interview_id": "interview-uuid",
"input": {
"session_id": "session-uuid",
"interview_id": "interview-uuid",
"candidate_id": "candidate-uuid",
"plan": {
"questions": [...],
"skills_coverage": { "TypeScript": [...], "React": [...] }
}
},
"context": {
"tenant_id": "tenant-uuid"
}
}{
"run_id": "interviewer-run-uuid",
"status": "running",
"agent_type": "interviewer",
"interview_id": "interview-uuid",
"session_id": "session-uuid"
}Publishes interview.session.started to Kafka. The Kafka audio processor (kafka_processor.py) then drives the live STT → LLM → TTS loop for the session.
Interviewer Agent — POST /session/{session_id}/greet
Triggers Maya's opening greeting TTS for the candidate. Called by the browser (via Vert.x proxy) immediately after WebSocket connect. Idempotent — a greeting_sent Redis flag prevents duplicate greetings.
POST http://interviewer-agent:7778/session/{session_id}/greet?tenant_id={tenant_id}{ "status": "ok", "session_id": "session-uuid" }{ "status": "skipped", "reason": "greeting already sent or session not found" }Interviewer Agent — POST /api/v1/runs/{run_id}/cancel
Ends the interview session. Marks ended_at on the Redis session and publishes interview.session.ended. NestJS listens to this and triggers the Assessor Agent.
{
"run_id": "interviewer-run-uuid",
"session_id": "session-uuid",
"interview_id": "interview-uuid",
"tenant_id": "tenant-uuid",
"ended_at": "2026-02-24T11:00:00Z"
}/continue returns 409 for the Interviewer — it has no HITL gate. The session ends only via /cancel.Assessor Agent — POST /api/v1/runs
Starts an async assessment run. Returns 202 Accepted. The agent reads the session transcript from Redis, runs LLM scoring, then pauses at a HITL gate for the recruiter's final verdict before the result is sent externally.
POST http://assessor-agent:7779/api/v1/runs
X-API-Key: <A2A_API_KEY>
Content-Type: application/json
{
"agent_type": "assessor",
"interview_id": "interview-uuid",
"input": {
"session_id": "session-uuid"
},
"context": {
"tenant_id": "tenant-uuid"
}
}{
"run_id": "assessor-run-uuid",
"status": "queued",
"agent_type": "assessor",
"interview_id": "interview-uuid"
}Once the assessment is generated, the run enters pending_hitl and publishes interview.assessment.ready. NestJS saves the assessment and shows it in the HITL Approvals UI for recruiter review.
{
"run_id": "assessor-run-uuid",
"assessment_id": "assessment-uuid",
"interview_id": "interview-uuid",
"tenant_id": "tenant-uuid",
"session_id": "session-uuid",
"overall_score": 82,
"recommendation": "STRONG_HIRE",
"competency_scores": [
{
"competency": "TypeScript",
"score": 4,
"sub_dimensions": { "proficiency": 4, "problem_solving": 4 }
}
],
"question_scores": [
{ "question_id": "q1", "score": 4, "evidence": "Explained closures correctly" }
],
"strengths": ["Strong system design", "Clear communication"],
"weaknesses": ["Limited distributed tracing experience"],
"summary": "Jane demonstrated strong TypeScript skills..."
}Assessor Agent — POST /api/v1/runs/{run_id}/continue
Resume with the recruiter's final verdict. Supports optional score overrides and bias annotations for audit compliance.
POST http://assessor-agent:7779/api/v1/runs/{run_id}/continue
X-API-Key: <A2A_API_KEY>
Content-Type: application/json
{
"data": {
"approved": true,
"verdictBy": "recruiter-user-id",
"verdictReason": "Assessment is accurate",
"recruiter_scores": {
"TypeScript": { "proficiency": 5 }
},
"evidence_confirmations": {
"TypeScript": { "proficiency": { "code_example": true } }
},
"bias_annotations": "No bias detected"
}
}{
"data": {
"approved": false,
"verdictBy": "recruiter-user-id",
"verdictReason": "Assessment underscores communication skills"
}
}| Field | Type | Required | Description |
|---|---|---|---|
| approved | boolean | Yes | True to approve, false to reject |
| verdictBy | string | Yes | Recruiter user ID for audit trail |
| verdictReason | string | No | Reason, required if approved=false |
| recruiter_scores | object | No | Per sub-dimension score overrides (0–4) |
| evidence_confirmations | object | No | Evidence type confirmations per competency |
| bias_annotations | string | No | Free-text bias notes for compliance audit |
On approval, publishes interview.assessment.completed. On rejection, publishes interview.assessment.rejected and the run transitions to failed.
HITL Crash Recovery
If an agent process crashes while a run is in pending_hitl, it automatically recovers on restart. Both the Planner and Assessor agents call recover_pending_hitl_runs() at startup, which re-attaches background polling tasks for any orphaned runs stored in Redis. A SET NX Redis lock ensures only one coroutine handles each run, preventing double-processing if the original task and recovery task overlap.
Complete Flow
NestJS Planner (7777) Interviewer (7778) Assessor (7779)
| | | |
|-- POST /runs -------->| | |
|<-- 202 {run_id} ------| | |
| |-- LLM generates plan | |
| |-- set pending_hitl | |
| |-- Kafka: plan.created | |
|-- GET /runs/{id} ---->| (poll) | |
|<-- pending_hitl ------| | |
| | | |
| [Recruiter approves in UI] | |
| | | |
|-- POST /runs/{id}/continue ------------------>| |
|<-- {status: running} -| | |
| |-- Kafka: interview.approved |
| | | |
| [Candidate joins, session starts] | |
| | | |
|-- POST /runs -------->|--- POST /runs -------->| |
| | {running, session_id} |
| | Kafka: session.started |
| | | |
| | [Live audio: STT→LLM→TTS loop] |
| | | |
|-- POST /runs/{id}/cancel ---------------------->| |
| | Kafka: session.ended |
| | | |
|-- POST /runs -------->|--- POST /runs -------->|-- POST /runs ------>|
| | | LLM scores session |
| | | set pending_hitl |
| | | Kafka: assessment.ready
|-- GET /runs/{id} ---->|--- GET /runs/{id} ---->|-- (poll) ---------->|
|<-- pending_hitl ------ |
| | | |
| [Recruiter reviews assessment in UI] |
| | | |
|-- POST /runs/{id}/continue ----------------------------------------->|
| | | Kafka: assessment.completed