Case study
AI-coached interview reps, on a $0 stack
A no-build, zero-dependency web app that turns a timed behavioural-prompt bank into coached practice — the library is teamwork-category today and growing — with a Whisper-powered AI Micro-Coach that gives per-part feedback immediately after every answer, and the whole thing runs for free.
1. The problem
Screening loops use timed behavioural prompts — here's a sample question from the current library (teamwork category): "You are six weeks into a three-month project and a teammate has missed two deadlines in a row — walk through what you would do over the next week." You get three minutes across three parts to answer out loud, under pressure.
That's a hard skill to practice alone. Timers are easy to find; an examiner isn't. Self-reviewing recordings is slow and biased, and there is no one to tell you — immediately — that you stopped early, or skipped the second part, or never explained why. The goal of this project: a practice partner that gives per-part, immediate, judgment-free feedback, that a single person can run for free, and that respects the user's privacy by default.
The whole exercise is deliberately constrained:
- It must cost nothing to operate (a personal tool, not a business).
- No framework, no build step, no database — plain static files and serverless functions.
- No third-party credentials in the frontend, ever.
- Feedback must arrive while the moment is fresh — a 2–4 second target, back-to-back, part after part.
2. The product in one screen
The flow is three screens (setup → practice → complete) in a single page, and it fits in one breath:
- Setup — pick a session length and per-part pacing (default 90 seconds/part), choose prompts, and toggle two independent switches: AI Coach (ON by default — microphone audio only) and Record video + audio (PIP preview + clip upload; never locked on by the coach).
- Practice — a prompt appears with a countdown per part. When the timer hits zero, the app goes quiet, the audio from that part is sent for evaluation, and a Feedback Card appears: a score, The Catch (one line on what you did well), The Fix (one line on what to change next), and a collapsed transcript accordion so you can tell a bad score from a mis-transcription. Next part stays disabled until you click "Apply & Continue" — the feedback is the point, not an interruption to skip.
- Complete — an in-browser session log with every prompt, per-part scores, and (optionally) an emailed summary and saved clips.
The prompt bank is 21 questions today, all in the teamwork category — but the tool is category-agnostic: the permanent, stable ids (o01–o08 open/scenario prompts and s01–s13 structured "name and explain N…" prompts) and the session mechanics don't care what a question is about. Sessions are built with a weighted mix (~65% structured / 35% open) because structured answers were the diagnosed gap in the original bank, and prompts are never repeated from the previous session. The ids are traced end-to-end: every recorded clip carries promptNo + promptId in its Cloudinary context, and the summary email logs Prompt 7 [o02] per row — so any session row can be mapped back to the exact wording with no truncation.
3. The constraint stack
A free-tier-only stack was a design decision, not an accident:
| Service | Role | Free-tier basis |
|---|---|---|
| Cloudflare Pages | Static hosting + functions/api/* serverless endpoints | Free |
| Groq | Whisper transcription + LLM evaluation for the coach | Free |
| Resend | Session-summary email from an unverified test sender | Free |
| Cloudinary | Signed clip uploads/storage | Free |
Vanilla JS (index.html + app.js + styles.css) | Entire frontend, one IIFE, CSS + JS no build | — |
The constraints acted as a forcing function: no ORM to lean on, no framework to hide in, no database to bolt on. The frontend is three files and a single self-contained IIFE. The only backend is four Cloudflare Pages Functions, filesystem-routed. The architecture is small enough to hold in your head — which matters, because the real product (below) is deceptively subtle.
4. Privacy
Privacy is engineered in, not bolted on:
- View-source is clean. No API key or secret has ever shipped in the frontend; all credentials are server-side env vars (
RESEND_API_KEY,SUMMARY_TO,CLOUDINARY_*,GROQ_API_KEY). - Audio-first capture. The AI Coach needs only a microphone.
getUserMediaalways asks for{audio:true}when the coach is on; the camera is only added when the user explicitly checks the record toggle. Coach audio never becomes video, and video never gets recorded "just in case." - The public demo keeps everything in-browser. The demo instance runs the same commit with
DEMO_MODE=trueand no secrets — the coach becomes a labeled "Heuristic preview" (time-based signals, no/api/coachcall), recording stays local with in-browser playback and a download link, and no session-summary email is sent. Zero/api/*calls during real work. - The real instance is invite-only, gated at the edge by Cloudflare Access (Email OTP allowlist) — unapproved visitors never even reach
index.html. Results go exactly one place: your own inbox (the free test sender delivers only to the account-owner address).
5. How it's built — architecture
The frontend is deliberately boring: one markup file, one logic file wrapped in a single IIFE, one stylesheet. Screens switch via a shared showScreen(). State lives in one module-level session object.
The backend is four functions. POST /api/coach is the interesting one:
browser: getUserMedia({audio:true}) → MediaRecorder, started with a 1s timeslice
→ chunks accumulate during the part (not only at stop)
endPart: audio blob + full prompt context (title/scenario/type/all parts/current part)
→ POST /api/coach
coach.js: multipart formData → Groq Whisper (transcription)
→ llm-evaluator.js (LLM evaluation) → {score, strength, fix, transcript}
browser: Feedback Card → "Apply & Continue" gates progression
Two pieces are worth calling out:
The provider-agnostic LLM boundary (functions/llm-evaluator.js) — the rest of the app has no idea which LLM (or transcription service) is running. evaluateAnswer(systemPrompt, userMessage, env) is the only file that knows Groq's URL, auth, and response parsing. Swapping providers means editing exactly one file, with zero changes elsewhere — transcription (Whisper) and evaluation (LLM) are separate concerns, so vendor lock-in is a non-issue by construction.
Fault-tolerant by design, not by accident. The coach loop treats failure as a first-class case:
- Retryable, never silent. On timeout, network error, bad HTTP status, or an unexpected response, the UI keeps the audio in hand and offers Retry (re-send the same clip) or Skip & move on — it never force-marches the user forward without feedback, and never pretends feedback arrived.
- No stale responses. An
AbortControllercancels any still-in-flight request when a retry or a new part starts, so a slow straggler cannot clobber fresh feedback. - Missing audio is observable. If the part ends with nothing captured, the miss is surfaced explicitly ("no audio captured") rather than swallowed.
- Failures aren't blamed on the user's mic. If the transcription step itself fails, the card reports "Evaluation failed (whisper HTTP 5xx)" — only a genuine 2xx-with-empty-transcript is labeled "Audio unclear."
Sessions upload clips to Cloudinary with a signed request (SHA-1 over the alphabetically-sorted params + secret) so nothing can be tampered with, and the session-summary email is proxied through a single-function Resend integration.
6. How it's built — reliability
There is no CI — so verification is a discipline instead. test/ is a layered harness:
test/regression.mjs— a deterministic, dependency-free static check that pins every previously-solved bug with source sentinels. A FAIL prints the name of the bug being guarded (thecontextshadow, the request-race abort, the recorder-stop await,isFileLike, the Cloudinary signature…). A refactor can't silently re-introduce a fixed bug.test/harness-llm.mjs— exercises the evaluation contract against a canned transcript, seconds-fast, no deploy.test/smoke-coach.mjs/smoke-upload.mjs/smoke-demo.mjs— probe the deployed endpoints (and the demo-mode contract) with real requests and a committed audio fixture.- Live verification records — the parts that genuinely can't be automated from a headless workstation are documented as such and verified on real devices: cross-browser recording end-to-end on Chrome/Firefox/Safari over HTTPS, signed-upload success (signature proof is live-only), the Cloudflare Access login flow, and push-to-deploy.
Constraints shaped the design here too: when the coach was built on the 60s upload bottleneck, the shift to audio-only capture (dropping the video from the coach path) removed the bottleneck and let the client cap drop to 30s with retry — instead of a bigger hammer, a smaller payload.
7. Things that went wrong (all of them, documented)
The honest failures are the interesting part — and every one below has a paper trail:
- The "unparseable form data" mystery.
/api/coachbroke with a formData error. The tempting diagnosis was "Cloudflare regressedrequest.formData()platform-wide" — but there was no external corroboration, so the hunt forced isolated, decisive tests, which proved the bug was ours: aconst contextshadowing the handler parameter, throwing aReferenceErrorin the TDZ beforeformData()could run. Lesson: prefer the boring, provable explanation. file instanceof Fileis unreliable in the CF Workers runtime domain. Replaced with anisFileLike()shape check — durable reality beats textbook typing.- The summary email got silently spammed. Web3Forms' anti-spam began flagging our server-side proxy submissions — the dashboard showed Sept 27 submissions in its Spam tab while inbox delivery had stopped Sept 19. The migration to Resend restored reliable delivery and added a
SUMMARY_TOrequirement that fails loud (500) rather than mailing nowhere. - This workstation's OpenSSL computes a non-standard SHA-1 (
sha1('abc')differs from FIPS). Every local check of the upload signature disagrees — so the signature is validated live, post-deploy, and the discipline is firmly do not "fix" the code to match local output. - Cloudinary blocked the entire account's upload action (
action is disabled for ehsyusri) — proven account-side with a rawcurl -u KEY:SECRETthat bypasses our proxy entirely. The right move was restraint: document it, stop chasing it with code, and re-verify with a smoke test. - One codebase, two deployments, no fork. The public demo is the same commit as the private app, toggled by a Pages env var (
DEMO_MODE=true, no secret set). Start stays disabled until/api/configresolves so the flag can't flip mid-session, and direct hits on the real endpoints return a clean 404 instead of a missing-secret 500. Same code, zero drift, nothing to steal, nothing to spend.
8. Outcomes & what I'd change
This section is deliberately short — it's a private tool, so the outcomes are qualitative and only the author can write them honestly. Suggestions: what the tool actually feels like in a practice loop, how often it's used, any late-stage interviews/games it was used to prep for, and the top 2–3 changes you'd make next.
The two changes I'd make next, from where the code sits today: a prompt/editor UI so the bank can grow past teamwork without editing app.js, and session analytics (score trends per prompt category) instead of the current pass/fail log. Both are in work, not shipped: the answer-modes rework is on the feature/answer-modes branch (docs/specs/answer-modes.md), and docs/new-features.md lists the transcript/audio-review direction as a candidate, not a commitment. The largest unfinished loop is the recorder itself — it's the one piece that still needs a manual real-device check, by nature of the environment.
9. Reading the repo
docs/prompt-library.md— the canonical prompt bank (id → full text).docs/specs/micro-coach.md— the AI Micro-Coach spec: UX loop, LLM abstraction contract, and pending plan.docs/DEPLOY-VERIFIED.md— the live verification record (deploy, devices, email, uploads).docs/portfolio-demo.md— how the same commit powers the public demo.test/README.md— the layered test harness and how to run it.