CONTEXT
When the interviewer was a bot
I built this after a job interview where I never spoke to a person. Being interviewed by a bot was deeply frustrating β it was slow to answer, quick to talk over me, and cut my answers short before I could finish. It is the kind of AI-led first interview people joke about online, until it happens to you.
I had just been interviewed by an AI. So I built a better one to practice with.
Every frustration from that call became a design choice β I wanted mine to be everything that bot was not. It waits until you have truly finished before it replies, and it starts speaking the moment it has something to say, so a turn feels like a real conversation instead of a fight to be heard.
WHAT I BUILT
Three AIs, one interview
You upload your CV as a PDF and paste the job offer. From there, three agents hand the work to each other:
- Plan β a planner reads your CV and the offer, then designs the session: who the interviewer is, what the interview should probe, and 4β6 ordered milestones it must cover.
- Interview β a real-time voice agent conducts it in your browser. It transcribes as you speak, asks follow-ups grounded in your CV β which it can search mid-conversation β and checks milestones off as you cover them.
- Score β when it ends, an evaluator reads the full transcript against the plan and rules like a hiring committee: hired or not, a 0β100 score, and strengths and concerns, each backed by something you actually said.
ARCHITECTURE
A voice worker and an API around one database
The app is two processes sharing one PostgreSQL database. A FastAPI backend serves the REST API and the compiled TanStack Start SPA; a separate voice worker, built on LiveKit Agents, joins each interview room over WebRTC. When you upload a CV, the backend converts the PDF to markdown, embeds it with OpenAI's text-embedding-3-small, and indexes it in Qdrant; the planner then writes the interview plan and its milestones to Postgres, and the browser connects to the room where the interviewer is waiting.
Inside a turn: Silero VAD plus a turn-detection model decide that you have actually finished speaking β the one thing the bot that interviewed me got wrong β then AssemblyAI transcribes the turn, a LangGraph agent running on OpenAI models decides what to say, and Cartesia speaks it back, streaming, so the reply starts before it is fully written. The agent carries three tools: search your CV in Qdrant, mark a milestone complete, and end the interview.
How a single turn works
TRADE-OFFS
The hard choices, and what they cost
I stopped hand-rolling the voice plumbing. An early version tried to own the whole audio path β capture, silence rules, my own turn heuristics. It taught me a lot, and it was never going to feel human. Now WebRTC and LiveKit Agents handle echo, interruptions, and reconnection, and end-of-turn is decided by a purpose-built turn-detection model on top of Silero VAD β the exact part the bot that interviewed me got wrong. Cost: a hosted dependency I do not control. Gain: every hour not spent on audio plumbing went into the interview itself.
Two model tiers, on purpose. The interviewer sits in the latency path, so it runs on a small, fast OpenAI model with reasoning turned off β a shorter wait before it starts talking. The planner and the evaluator have no one waiting on them, so they run on a stronger model with reasoning effort set high. The interviewer can afford to be quick rather than brilliant because the planner already did the thinking: the persona, the focus areas, and the milestones are decided before the call starts.
The "LLM" is actually a graph. LiveKit expects a language model in its pipeline; I hand it a LangGraph agent disguised as one. Only text the graph explicitly streams is spoken β tool calls and their results never reach the voice, and a filter drops accidental JSON before it can be read aloud. The graph keeps no memory of its own: each turn, the milestone status is re-injected as context, because the voice framework rebuilds the conversation from the transcript. Cost: a stricter contract to respect. Gain: the interviewer can use tools mid-sentence without ever mumbling raw JSON at you.
OUTCOME
A verdict with evidence, not a vibe
Every session ends the way a real hiring loop does: with a decision. The evaluator reads the whole transcript against the plan and returns hired-or-not, a 0β100 score on an anchored rubric, and strengths and concerns that each point back to something you said β or failed to say. It is deliberately strict: milestones you never reached count against you, and leaving early is treated as missing evidence, not a pass.
It also cleans up after itself. The interview has hard limits β a session cap with a spoken warning near the end, an idle timeout, and detection of a closed tab β and whichever way it ends, the evaluation runs on its own. The moment scoring completes, your CV's chunks are deleted from the vector store, so the most personal document in the system does not outlive its one job.
This is the part I cared about most. The bot ignored what I said. This one is built around it. The report quotes you back to yourself β every point ties to a moment in your own interview β so you walk away feeling heard, not graded by a stranger. You can freeze, ramble, and start over with no one watching. That was the goal: do the awkward reps here, in private, so the real interview is not the first time you have had the conversation.
I have not taken it into a real interview yet β but I built it for exactly that moment, and I trust it to get me there ready.
REPORTWHAT I LEARNED
Three things I learned
- Don't rebuild solved problems. My hand-rolled audio pipeline taught me why WebRTC, VAD, and turn detection are their own discipline β and that my time was better spent on the agents than on the plumbing.
- How fast it feels beats how smart it is. Turning reasoning off on the interviewer and streaming its reply into speech did more for the conversation than any smarter model would have.
- Structured outputs turn LLM calls into functions. The planner and evaluator return validated schemas with retries, which is what lets both run unattended β no human checks the output before it ships to the screen.


