sarthak bhardwaj.
0%
Skip to content
← work

ScreenAI · AI hiring platform · built solo, 2026

Designing an AI hiring pipeline that never loses a candidate

An internal platform that takes a candidate from application to a scored, reviewable video interview. I owned it alone — schema, API, video pipeline, prompts and on-call — across 170 merged pull requests in six months.

The problem

Good candidates were being lost in a queue, not rejected on merit

Recruiters were reading every résumé by hand and running every first-round interview live. At the volume the company was hiring, the bottleneck wasn’t judgement — it was time. Strong applicants waited days, and decisions differed from one reviewer to the next.

The goal was a system that screens consistently and explains itself: every score has written reasoning, every rejection points to a stated criterion, and a recruiter can always see — and override — what the machine did.

Architecture

One process, a durable queue in Postgres, and storage the browser talks to directly

I kept the deployment deliberately boring: a single Node process serves the React app and the API, and runs the background workers in-process. There’s no message broker — the job queue lives in Postgres as leases, so the database is the single source of truth for what’s running, what’s stuck and what finished.

ScreenAI high-level architectureCandidate browserrecorder · share linkRecruiter dashboardReact SPA · 26 pagesReverse proxyTLS · security headers · 500 MB bodiesshare token → session JWTJWT · RESTone Node process · Express + in-process workersREST API137 routes · Zod · rolesProcessing workerPostgres leases · heartbeatSweepers & schedulersstuck states · automationMedia slotFFmpeg · bounded · nice 19PostgreSQL34 models · leases · auditObject storagerésumés · recordings · remuxPrismaranged reads · uploadsresumable multipart upload, browser → storageApplicant tracking systemsigned webhook in · notes outLLMscoring · interview questionsSpeech-to-texttranscripts, verified for lengthTeam chateach alert sent exactly onceEmailinvites from rotating senders
High-level architecture. The dashed coral path is the one that matters most: recordings stream from the candidate's browser straight to object storage, never through the API.

Why no Redis or SQS?

The workload is tens of interviews an hour, not thousands a second. A lease table with a heartbeat gives crash recovery (an expired lease is simply re-claimed), visibility (it’s a query) and transactional consistency with the rows the job updates — without another system to run and monitor.

The interview pipeline

From a click on “Start” to a scored transcript

Video is the fragile part: long recordings, flaky home networks, tabs closed mid-answer. The pipeline is built so that each step either completes or leaves the session in a state the system knows how to recover from.

Video upload and processing sequenceBrowserAPIStorageWorkerinitiate · part URLsPUT parts ≥5 MB, 3 at a timeback up first chunk (WebM header)finalize: stamp submittedAt firstcomplete · ranged read: header ok?state → TRANSCRIBING · lease claimed
Upload and hand-off. The browser uploads parts directly; the API only coordinates, verifies and moves state.
  1. 1
    The candidate opens a share link, passes a passkey check (locked for 15 minutes after repeated failures) and gets a short-lived session token.
  2. 2
    Questions are generated when the candidate starts, from their résumé — so nothing can leak in the invite email, and questions reflect the job as it is today.
  3. 3
    The browser records with MediaRecorder and uploads 5 MB parts straight to storage through presigned URLs. A dropped connection costs one part, not the interview.
  4. 4
    On finalize, the API stamps submittedAt before anything fragile, completes the upload, and reads the first bytes back to verify the WebM header — repairing it from a server-side backup if needed.
  5. 5
    The worker claims a lease, extracts audio at 32 kbps with FFmpeg, transcribes it, and retries once if a long recording comes back suspiciously short.
  6. 6
    The transcript is scored against the prompt pinned to that interview. Completion is a conditional update, so a duplicate run is a no-op — and the team-chat alert goes out exactly once.

Every session is a state machine

PENDING→RECORDING→UPLOADING→TRANSCRIBING→SCORING→COMPLETEDany active state ↘ FAILED (kind + stage recorded)

Each transition is written to an audit log with what triggered it — an automatic retry, a candidate abandoning, an admin re-transcribing. Sweepers close the loop on the ones that stall: inactive for 3 minutes, stuck uploading for 30, stuck in the pipeline for 45. A tab closed mid-upload is salvaged from the parts that made it and marked as ended early, rather than lost.

Evaluation & prompts

Recruiters own the rubric; the system guarantees it's applied consistently

Scoring is driven by prompts recruiters write per job. That makes the system flexible, but it raises a hard question: what happens to a candidate scored last week when someone edits the prompt today?

  1. 1
    Gate, then score. Prompts can declare hard gates (must-have criteria) before weighted scoring. A failed gate produces no score and a stated reason, so a rejection is never a single opaque number.
  2. 2
    A small, strict contract. The server only requires an overall score and reasoning; everything else the prompt returns is kept and rendered as-is. A response without a score raises a visible contract error instead of saving a silent null.
  3. 3
    A playground before production. Drafts are tested against real candidates or generated sample résumés across fit bands, and model or prompt variants can be A/B-compared on the same inputs with token cost and latency logged.
  4. 4
    Everything kept for audit. The rendered prompt and raw model response are stored with each evaluation, so any score can be explained after the fact.

Reliability by design

Assume every step will run twice, time out, or crash halfway

Most of the engineering in ScreenAI isn’t features — it’s making sure partial failures land somewhere safe. A few of the patterns that run through the codebase:

  • Leases with heartbeats

    A job holds a 5-minute lease and renews it every third of that. If the process dies, the lease expires and another tick picks the job up.
  • Idempotency everywhere

    Webhook deliveries are deduplicated by token, enrolment is unique per candidate, and every outbound alert is stamped once it's sent.
  • Timeouts that match the work

    90 s for chat calls, 180 s for question generation, minutes for audio — instead of a library's 10-minute default that once stalled bulk jobs.
  • Bounded heavy work

    FFmpeg runs in a single slot at low priority with capped threads and a hard kill timeout, so one huge file can't starve everything else.
  • A failure taxonomy

    Every failure is tagged as code, prompt or candidate, with the stage it happened in — so it's obvious whether an engineer or a recruiter needs to act.
  • Fail closed

    If the webhook signing secret isn't configured, every delivery is rejected; signatures are compared in constant time.

Incidents & lessons

What broke in production, and what it taught me

Interviews that uploaded fine but couldn't be played

What broke
Roughly 1 in 50 recordings arrived as unplayable files. Every upload step had reported success; the first sign was a failed transcode long after the candidate had left.
Why
A WebM file's header lives only in the recorder's first chunk. A double-clicked Start replaced the live recorder and lost that chunk — without it, a large file isn't a damaged video, it isn't a video.
The fix
Made starting idempotent, tore down any previous recorder first, backed the header up server-side, and verified it on finalize with a repair path.

A 200 means the bytes arrived, not that they're right. Verify the artefact, not the request.

One candidate, five scores, five alerts

What broke
A single interview was scored and announced repeatedly.
Why
A long video encode held the only media slot; transcription waited past the lease expiry, so the worker kept re-claiming the same session.
The fix
Added lease heartbeats and conditional completion, took audio extraction off the shared slot, and capped the encode.

A lease without a heartbeat is a timer, not a lock.

Linking a job to the ATS duplicated every candidate

What broke
The first sync after linking a job re-inserted every manually uploaded candidate — dozens of duplicates in a day, each re-scored and re-announced.
Why
The sync only matched rows that already had an ATS ID, which manual uploads never do.
The fix
One candidate per (job, email) on every ingest path; sync and webhooks now adopt the matching manual row instead of inserting.

Deduplicate on the identity every path shares, not the one only some paths have.

Practices

The guardrails that kept a one-person codebase safe to change

  • Typed end to end

    TypeScript on client and server; request bodies validated with Zod; ownership filters typed per model so mixing them up is a compile error.
  • Migrations as the source of truth

    102 Prisma migrations, applied on boot, with backfill scripts for every data change that needed one.
  • Defence in depth

    Role-based access and team scoping, eight rate limiters, a strict content-security policy, HSTS and TLS 1.2+ at the proxy.
  • Observable by default

    Structured JSON logs with a correlation ID per request, shipped centrally, plus a worker-health view for the queue.

What I'd do next

Honest gaps

Shipping alone meant prioritising: the safety net was types, migrations, audit logs and production monitoring rather than a test suite. The next investments would be an automated test suite around the session state machine and scoring contract, real CI gates in front of deploys, and moving off a single instance once volume asks for it — the lease-based queue was designed so a second worker can join without code changes.

Built with

  • TypeScript
  • Node.js
  • PostgreSQL
  • AWS S3
  • FFmpeg
  • Whisper
  • REST