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

AgentPortHITL Gate
Planner Agent7777Yes — plan approval before scheduling
Interviewer Agent7778No — runs for duration of live session
Assessor Agent7779Yes — 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:

bash
# Option 1: X-API-Key header
X-API-Key: <A2A_API_KEY>

# Option 2: Bearer token
Authorization: Bearer <A2A_API_KEY>
These endpoints are for internal NestJS → Agent communication only. They must not be exposed on any public ingress. Use network policies or a private service mesh to restrict access.

Run State Machine

Every run transitions through a shared state machine regardless of agent type:

text
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
StatusDescription
queuedRun created, background task starting
runningActive — LLM generation or live interview in progress
pending_hitlWaiting for recruiter action via /continue
completedTerminal — result available in run.result
failedTerminal — error message in run.error
cancelledTerminal — 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.

Request
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"
  }
}
FieldTypeRequiredDescription
positionstringYesJob position title
levelstringYesJUNIOR | MID | SENIOR | LEAD
skillsstring[]YesSkills to assess (min 1)
durationintegerYesInterview length in minutes (15–180)
company_namestringNoCompany name shown in Maya's greeting script
company_descriptionstringNoBrief company overview for contextualising questions
job_descriptionstringNoFull job description — improves question relevance
Response 202
{
  "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:

FieldUsed byDescription
greeting_scriptInterviewer AgentMaya's exact spoken opening — introduces the company, role, and invites the candidate to introduce themselves
inmail_draftNestJS (on approval)LinkedIn InMail template with {{CANDIDATE_FIRST_NAME}} and {{INTERVIEW_LINK}} placeholders substituted at approval time
Kafka: interview.plan.created
{
  "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.

Response — pending_hitl
{
  "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.

Approve plan
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"
  }
}
Reject plan
{
  "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_draft from 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.approved webhook to the external system
Kafka: interview.approved
{
  "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"
}
NestJS approve API response (POST /a2a/interview/:runId/approve)
{
  "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"
  }
}
The 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.

Request
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"
  }
}
Response 201
{
  "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.

bash
POST http://interviewer-agent:7778/session/{session_id}/greet?tenant_id={tenant_id}
Response 200 — greeting sent
{ "status": "ok", "session_id": "session-uuid" }
Response 200 — already sent
{ "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.

Kafka: interview.session.ended
{
  "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.

Request
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"
  }
}
Response 202
{
  "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.

Kafka: interview.assessment.ready
{
  "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.

Approve assessment
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"
  }
}
Reject assessment
{
  "data": {
    "approved": false,
    "verdictBy": "recruiter-user-id",
    "verdictReason": "Assessment underscores communication skills"
  }
}
FieldTypeRequiredDescription
approvedbooleanYesTrue to approve, false to reject
verdictBystringYesRecruiter user ID for audit trail
verdictReasonstringNoReason, required if approved=false
recruiter_scoresobjectNoPer sub-dimension score overrides (0–4)
evidence_confirmationsobjectNoEvidence type confirmations per competency
bias_annotationsstringNoFree-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

text
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
Was this page helpful?