Think Aloud — Voice System-Design Interview Practice

Summary

Think Aloud runs a mock system design interview out loud. The candidate talks to a voice interviewer and sketches on a shared canvas. The interviewer reads the canvas while they draw and follows their reasoning instead of a script. A few minutes after the interview, a Report grades what they showed against a published bar for their level, and every point quotes their own words.

It is my own product, in private beta at https://thinkaloud.dev. I built it in under three weeks, in September and October 2026, mostly by directing Claude Code agents. The section "How it was built" covers how that worked.

Most of the difficulty was not in making it talk. The hard parts were four. A voice model that decides for itself when to speak has to lead an interview whose plan it does not hold. The interview has to survive a server that can vanish mid-session. The interviewer has to read a diagram it cannot see. And up to 45 minutes of speech has to be graded the same way from one run to the next.

Context and problem

In a system design interview, the code was never the point. What gets tested is whether you can scope a problem, estimate it, weigh trade-offs, and explain a design out loud while someone pushes on the weak part. The only way to practise that is with another engineer willing to spend an hour on you, and most candidates get one or two such sessions before the real thing.

An interviewer that is any use has to do three things at once. It has to listen to what you say and ask about the part you skipped. It has to look at what you drew and ask about the box you just added. And it has to keep the interview moving through its parts inside a fixed time. A language model handed all three drifts. A speech-to-speech model makes it harder still, because it decides for itself when to speak, and nothing it has been told can be taken back.

My role and ownership

Personal product, designed and built end-to-end: the interview engine, the voice and canvas integration, the Report, the scripted test candidate, accounts, credits and payments, and the landing site. I wrote the specs, made the product and architecture decisions, and ran the live interviews that judged each design. Claude Code agents wrote most of the code under my direction (252 of 256 commits carry a Claude co-author line).

This case study describes the problems and the shape of the solutions. Prompts, judgment wordings, tuning values and interview content are left out.

The problems, and what I did about them

Leading an interview through a model that decides when to speak

The interviewer's voice is OpenAI's speech-to-speech model. It has no silence threshold, no eagerness setting and no turn-taking control. It decides when to speak from its instructions alone. Anything sent to it can make it speak, nothing in its context can be deleted, and nothing reports when it was cut off. The server, meanwhile, reads a transcript that runs a second or two behind the audio.

So the job is split. The voice model owns tone, timing and which question to ask next. A server-side Director owns what may be asked. The Director never speaks and never writes a sentence for the interviewer. It hands the model a Card: the current part of the interview, at most four open questions with no ranking, the questions already covered, the facts still to state, and the time left. A newer Card supersedes every earlier one, since the old ones cannot be removed.

When the Card arrives matters as much as what it says, and that was measured rather than guessed. Across the first nine live interviews on this design, a Card sent into the candidate's speech made the interviewer cut in 13 times out of 34. One sent into the model's own speech did so once in 35. The Card now goes only while the interviewer is already talking, or as it hands over the floor. A separate experiment found the opposite risk: a Card sent at a breath drew new speech 56 times in 61, against 33 in 58 for a same-length message with no Card.

Orders are treated the same way. A live interview showed why: an instruction to close a question landed 0.8 seconds into the interviewer's reply, the model stopped mid-sentence and never spoke again, and the candidate left at 1:28. Instructions now wait until the interviewer is quiet. An acknowledgement only proves an instruction arrived, so a hand-over or a move to the next part counts as done only once the interviewer's transcript says it, with a bounded number of retries.

How the control layer got here

The design above is the third attempt at the control layer in fifteen days, and each change followed a live interview that broke the one before.

The first version was a human sending every instruction by hand from a console, to learn what the model would and would not do. It showed the model could ground itself on a text description of the canvas, and produced one lasting rule: when the candidate points at a box, that news has to skip the usual debounce, or the interviewer answers "this box" about the previous one.

The second put the server in charge of choosing each question and the moment to ask it. It worked until the take where the interviewer never led. Over seven minutes it asked three questions and otherwise said "mhm", while one unvoiced question stayed in flight and blocked every other. The instructions held about eight lines telling the model to wait or listen and none telling it to take the floor. An interview the interviewer does not lead is not a product.

Before rebuilding, I tested the assumption the rebuild would rest on: whether the voice model follows a list of open questions at all. A throwaway prototype and a small harness, with a simulated candidate and a scripted director, showed that it did, in order, and also showed its failure modes. It closed the interview on its own, repeated covered questions when the list ran empty, and re-asked the opener a minute after the candidate had answered it. The third design was built around those failures. It shipped in three days with no feature flag and no dual path, because there were no users yet and the old code was disposable.

An interview that survives its server

Each interview lives in one Cloudflare Durable Object. A 45-minute session has to assume the object will be evicted, and a deploy restarts every live interview. The voice provider's acknowledgements can also arrive after memory has been wiped.

The interview state is never stored directly. It is a fold over an append-only Ledger of events, and the decision core, decide(state, tick), is a pure function with no I/O, no clock and no randomness. The host appends a decision's events before it performs any of them, so a restart replays to the same place and never sends anything twice. Every answer from a judgment model is its own event, written before any threshold is applied, so replaying an interview never calls a model.

Three things fall out of that. The core runs under plain unit tests: one runs five hundred seeded random sessions against the invariants (one question asked at a time, nothing sent mid-utterance, no attempt sent twice), and another refolds every prefix of a recorded Ledger and checks it reaches the same decision. A recorded production interview can be re-run through today's code. On the three takes that log enough state, the replay sends the same Cards within 250 ms of the originals, 20 of 20, 7 of 7 and 13 of 13. And the work after an interview ends (charging, writing the evidence, queuing the Report) is three durable flags in a fixed order, each idempotent on its own side and retried by the next alarm until it lands.

The session host is also its own Worker, separate from the rest of the app, so that only a change to the interview code restarts live interviews.

Judging live only what steers the interview

Many things an interviewer notices need a model's judgment: whether a thought is finished, which question an answer belongs to, whether the candidate asked for a key figure or was given it. A live judgment costs latency, and a wrong one changes what the interviewer says next.

The rule is that a judgment runs live only when its answer steers what the interviewer says next, or when. Live judgments are typed questions to a fast judgment model, each returning a probability read against a threshold. They answer in a median 267 ms. Everything else runs after the interview over the whole transcript, where it can see what came later and cannot change the conversation. Whether the candidate asked for the key figures moved there after measurement: the live record agreed with hand labels on 14 of 20 windows, the after-interview reader on 60 of 60.

A canvas the interviewer can read and point at

The voice model takes no images, and the candidate's design lives on a free-form Excalidraw canvas. When the candidate says "this box", the interviewer has to know which box. When the interviewer talks about one, the candidate should see which.

A deterministic renderer turns the canvas into a typed graph and then into a short description in constrained English, sized to fit the model's limits, with duplicate labels numbered and every guess marked as one. A focus line says what the candidate is pointing at. A separate judgment slots the candidate's own label into a question, so the interviewer says "your record store" rather than "that store". In the browser, an interviewer cursor moves to a box when the interviewer's transcript names it, matched locally with no server hop.

This is the least finished part. In a text-mode test, the model resolved "this box" correctly 78.9% of the time with the focus line and 15.8% without it, against a 95% target. In one recent take, 12 of 51 replies answered from a canvas that had already moved on. Both are open.

Coaching without contaminating the interview

A stuck candidate can pause, get taught the part they are missing, and go back. The interviewer must not then know what the coach said, and since nothing can be deleted from the voice model's context, the only guarantee is to replace it. Resuming closes the voice session and starts a fresh one, seeded with the minute before the pause, the questions already closed and the facts already given, and no coaching text. The interview clock keeps running, pauses are capped at a fifth of the session, and the Report does not credit words that only repeat what the coach said. Before this design, the interviewer greeted a resumed candidate by restating the coach.

Grading against a bar, and measuring the grader

The Report grades a 30 or 45 minute interview, plus its final canvas, against twelve competencies at four levels. The grade has to be stable from run to run and fair to terse and verbose candidates alike. Every claim has to quote the candidate, and before launch there is almost no real data to calibrate on.

The bars are public on the landing site. Each level is two to four observable clauses, and each level is the one below plus one clause, word for word, which the compiler checks. One model call reads the transcript and returns, per clause, whether it was met and up to three quotes. Everything after that is code. Code derives the level from the verdicts, checks every quote is the candidate's own words, drops an estimate quoted before the interviewer gave the figures, ignores anything said after the interview closed, and refuses to judge fewer than 250 words. A second model then words the result and is not allowed to judge.

The working rule that came out of it: every lasting gain came from moving a decision into code. A prompt rule that a weakness implies a lower level marked strong candidates down. A second call to settle disagreements fixed four levels, broke two and added two flips.

Changes are measured on a small fixture set with gold labels: two real interviews, synthetic ones, and traps written to trigger one known failure each, some held out. A change counts as a regression if it buys a gain with a contradiction, a mark-down on something never asked, or a level flip. The current baseline gets the level exactly right on 90% of fixtures. That is in-sample, on a dozen sessions, and its recall and precision on weaknesses (68% and 70%) are still below the 75% I set. A blind set of hand-labelled beta interviews is the next measurement.

Testing a voice product without paying for every test

Every real test costs voice-model minutes and needs a microphone, a browser, a canvas and someone talking. The bugs show up as a silence or a mistimed sentence several minutes in.

Testing runs in layers, from free to paid. The pure core runs scripted sessions with fixed judgment answers. A recorded production interview re-runs through today's code. Whole interviews run inside the real Durable Object with a scripted interviewer. Then a scripted candidate drives the real app in headless Chromium: it speaks synthesised audio through an overridden microphone, writes its replies with a language model, hears the interviewer, and draws on the canvas through Excalidraw's own controls. A last tool reads a finished take and explains each silence by the conditions that held the interviewer back.

The scripted candidate earned its keep early. The first voice session kept dying unheard after about thirty seconds, which looked like a model fault. The cause was the driver's fake microphone, which sent no audio packets between phrases, while the voice session's clock advances only with media. Once it sent silence instead of nothing, the interviewer's first words came after about a second instead of 45.

Charging for a session, not a minute

The first pricing sold minutes. A beta tester said that made them rush, which is the opposite of what the product trains, so three days later credits became whole sessions. The fixed end of the interview bounds what a session can cost. Starting one is a single SQL statement that creates the session only if none is running and the balance covers it, so two concurrent starts cannot both spend the last credit. Payment is Stripe Checkout, granted by the webhook and never by the success page. A session that fails on my side is never charged. Each session's usage is stored in each provider's raw units, with prices applied only at display, so a price change never rewrites history.

How it was built

I built Think Aloud as its director and reviewer more than its typist. The work ran in this order.

Specs came first. The first two days produced specs, a glossary and throwaway prototypes, and the prototypes were deleted once their findings were folded back in. Specs describe the running system in the present tense, decisions live in one glossary file, and measurements live in a separate validation document, so an agent reading a spec reads what is true now.

The repository is written for agents. A code map names the file that owns every concern, and a test fails when a new directory or command is missing from it. Before touching a judgment's wording or the Report prompt, an agent has to read how that change is measured.

Larger changes ran as programs. One coordinating session worked a parent issue through its sub-issues with background agents owning each change and others reviewing it, every pull request one ticket, and every decision logged with its reason and evidence. Paid calls had budgets. The rewrite of the control layer had ninety minutes of voice-model time for the whole program and used 86.9, so everything else had to be proven offline. That constraint is why the replay and simulation tools exist.

The process changed when it failed. The rewrite ran its implementation agents on the strongest models and reviewed every pull request four to six times, mostly on nits, and it ran into usage limits twice. After that, implementation moved to a faster model, the strongest model kept only the reviews, and later review rounds looked only at what had changed. A later program's owner spent most of its hours waiting on full test runs. The fixes for that (narrow checks while iterating, background waits, one full run per stack) went into a template every new program starts from.

What stayed mine: what to build, the architecture, the live interviews that decided whether a design shipped, and the call when an interview was not good enough.

In numbers: 256 commits over eighteen days, 142 merged pull requests, about 50,000 lines of TypeScript and 29,000 lines of tests across 150 test files.

Stack

  • Language: TypeScript (strict) across the app, the interview engine and the tooling; pnpm workspace with a runtime-free shared protocol package
  • Runtime: Cloudflare Workers, Durable Objects (SQLite storage, hibernatable WebSockets), D1, R2, Queues, Email, Cron Triggers, three Workers deployed from CI
  • Voice: OpenAI's speech-to-speech model over WebRTC, steered from the server over a sideband connection, so the browser never holds a key
  • Judgment: a typed judgment model for live decisions and transcript reads; an OpenAI reasoning model for the Report
  • Frontend: React, Vite, TanStack Router and Query, Excalidraw, Tailwind
  • Payments: Stripe Checkout
  • Tooling: Vitest (including the Workers test environment), oxlint, oxfmt, Playwright for the scripted candidate, GitHub Actions on a self-hosted runner

Outcome and evidence

Live in private beta at https://thinkaloud.dev, with four interviews (URL shortener, chat, video platform, ticketing), invites approved by hand, and payments on.

What is verified rather than asserted:

  • The decision core is pure and tested without a model: five hundred seeded random sessions against its invariants, and a refold of every prefix of a recorded Ledger
  • Recorded production interviews replay through current code and send the same Cards within 250 ms, on the three takes that log enough state to do so
  • Where a Card lands was measured on live interviews before the timing rule was written
  • Live judgment accuracy is measured on labelled windows from real takes, and the Report on gold-labelled fixtures with a pinned baseline
  • 1,555 test cases across 150 test files

What is not:

  • Report and judgment accuracy figures are in-sample, on a small set; a hold-out and a hand-labelled beta set are next
  • Surviving an eviction or a deploy across a full 45-minute interview is designed for and tested in parts, not yet run end to end
  • Two interviewer defects are open: speaking into a thinking pause, and answering from a canvas that has moved on

No usage figures are published.

Links