# Project export: InterviewIQ

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: UC Berkeley AI Hackathon 2026
- Tagline: Bad hires kill startups. InterviewIQ trains founders and managers how to find real talent through feedback focused mock interviews.
- Devpost: https://devpost.com/software/interviewiq-83fgmt
- GitHub: https://github.com/evananderson06/berkeley-ai-26
- Video: https://www.youtube.com/embed/xt1Fw-IeYRk?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 3 GitHub contributor(s) — Claude (24 commits), Evan Anderson (18 commits), Aayush Maharaj (13 commits)

## Devpost submission (written by the team)

### Inspiration

The first four hires at a startup will be one of the most impactful choices a founder will make. As young founders begin to scale, they need practice finding real talent before making such important decisions. We wanted to make a platform to help founders, employers, and managers perform mock interviews on fake candidates, providing feedback on how they got to their final decision, and if they were able to get red flags out of candidates in the interview.

### What it does

InterviewIQ begins by taking a job title and description for the mock interviews. It then generates multiple personas, each with their own resume, knowledge, and some red/green flags. One candidate is determined to be the "right" choice. You can then review a candidates resume and interview them. We've also included a code editor which the candidate can write in, so you can practice delivering technical questions, and guiding a candidate through the solution. Once you've interviewed all the candidates, you can make your final hiring decision, along with a justification for your choice. InterviewIQ then takes all of your interviews, notes, and final choice, to give you a score on how well you performed. It detects unfair biases, and poor interviewer performance which might deter candidates from accepting an offer.

### How we built it

All of the AI functionality is powered by Claude models. Large system prompts are given to explain the candidates experience and knowledge. The response is separated into speech and code, so the frontend can display the response correctly. All of the voice functionality is powered by deepgram through their text-to-speech and speech-to-text services. This is combined with our speech normalizer, to ensure the models can correctly say technical terms like \(O(n)\) and \(n^2\), as well as read code blocks like nums.length without pausing at the dot.

### Challenges we ran into

One of the biggest challenges was getting the technical questions to feel natural. On our first implementation, the candidate would write out the entire solution in one pass, while talking. We decided to try to get the candidate to slowly write out the solution, with the ability to explain its thinking along the way. Syncing the speech and typing together to feel natural was tricky and required a lot of trial and error, but we're super happy with the end result. Another challenge we ran into was getting sonnet to be bad at programming... You could originally generate candidates for an internship, take a candidate with basically no experience, and ask it to solve a leetcode hard. It would get it first try, explaining every component of the solution perfectly. We couldn't fully resolve this issue, but we have gotten poor candidates to create worse solutions to questions.

### Accomplishments we're proud of

We're super happy with how the technical question functionality came out. The candidate can create, edit, and delete code along the way. This allows you to do longer questions with multiple parts, or correct a candidate and suggest a better solution. Another big accomplishment for us was the feedback. The results we've seen are genuinely useful, and we think that the feedback could be used in real workplace environments. It explains how you could have gotten more out of a candidate, how a question you asked didn't help you learn about the candidate, or where you could have gone deeper to reveal a red/green flag about the candidate.

### What we learned

This was our first time creating LLM based project, and it was huge learning opportunity for us. Understanding how to get the models to deliver structured output, and how to parse that output for the application was extremely helpful, and something we will use in many future projects. We've also learned a lot about speech synthesis and how to make the voice sound natural, especially when using technical terms.

### What's next

There's a lot of features we'd love to add to InterviewIQ if we had the chance, including: The ability for the candidate to use a whiteboard for system design interviews Mixing up skills within candidates as currently the worst candidate is generally worse at everything Panel/group interviews

## README (from the GitHub repository)

# InterviewIQ

**An AI hiring simulator.** You play the *interviewer*: enter a role, get a pool of three realistic AI candidates, interview them by voice (or text) — including live coding questions in a shared editor — then commit to a hire. The app grades **you** on how well you interviewed and whether you picked the right person, revealing the hidden truth about each candidate you couldn't see going in.

The twist: each candidate has a **hidden "truthfulness profile"** (how good they really are, what they're hiding) that the UI never displays. A polished résumé can hide a weak hire; a nervous, stuttering candidate might be the best in the pool. Your job is to find out through the conversation.

---

## Table of contents

1. [Quickstart](#quickstart)
2. [Environment variables](#environment-variables)
3. [The full user journey](#the-full-user-journey)
4. [How it works (architecture)](#how-it-works-architecture)
5. [The multi-agent system](#the-multi-agent-system)
6. [The candidate simulation](#the-candidate-simulation)
7. [The voice interview pipeline](#the-voice-interview-pipeline)
8. [Pages](#pages)
9. [API routes](#api-routes)
10. [State & data model](#state--data-model)
11. [Project structure](#project-structure)
12. [Tech stack](#tech-stack)
13. [Configuration & tuning](#configuration--tuning)
14. [Troubleshooting](#troubleshooting)
15. [Security notes](#security-notes)

---

## Quickstart

**Prerequisites**

- **Node.js 18.17+** (20+ recommended) and npm — the floor comes from Next.js 14.2; the app itself declares no `engines`
- An **Anthropic API key** (required — powers every AI feature)
- A **Deepgram API key** (required for the voice interview; the rest of the app works without it)
- A modern Chromium-based browser (the voice mode uses the microphone, Web Audio API, and `MediaRecorder`)

**Install & run**

```bash
npm install

# Create .env.local in the project root by hand and fill in your keys
# (see the "Environment variables" section below for the template —
#  there is no committed .env.local.example to copy).

npm run dev
```

Open **http://localhost:3000**.

**Scripts**

| Command | What it does |
|---|---|
| `npm run dev` | Start the Next.js dev server on `:3000` |
| `npm run build` | Production build |
| `npm run start` | Serve the production build |
| `npm run lint` | Run ESLint (`next lint`) |

> First voice interview will prompt for **microphone permission**. **Headphones are recommended** — on open speakers the candidate's own voice can leak into the mic and trip the "interrupt" detector (tunable; see [Configuration](#configuration--tuning)).

---

## Environment variables

Create `.env.local` in the project root. **Never commit real keys** (`.env.local` should be git-ignored).

| Variable | Required? | Used for |
|---|---|---|
| `ANTHROPIC_API_KEY` | **Yes** | Candidate generation, the interview agent, the post-interview summary, and the final feedback. All Claude calls use `claude-sonnet-4-6`. |
| `DEEPGRAM_API_KEY` | **Yes (for voice)** | Minting short-lived browser tokens for speech-to-text (Nova-3) and text-to-speech (Aura-2). Server-side only. |
| `UPSTASH_REDIS_REST_URL` | Optional | Upstash Redis client. Wired up (`lib/redis.ts`) but **not currently on the hot path** — session state lives in the browser's `localStorage`. Safe to leave blank for local use. |
| `UPSTASH_REDIS_REST_TOKEN` | Optional | Token for the Redis client above. |
| `NEXT_PUBLIC_SENTRY_DSN` | Optional | Sentry error monitoring (read in `sentry.client/server/edge.config.ts`). Sentry is configured but effectively **off** when this is empty. |
| `ARIZE_API_KEY` | Optional | Arize/OpenTelemetry tracing. Currently a **stub** (`lib/tracing.ts`); not active. |

> **Build-time only (not needed in `.env.local` for local dev):** `next.config.mjs` reads `SENTRY_ORG`, `SENTRY_PROJECT`, and `CI` for Sentry source-map upload during `next build`. Leave them unset locally; CI sets them. These are the only other environment variables the repo reads.

Example `.env.local` (placeholders — substitute your own):

```ini
ANTHROPIC_API_KEY="sk-ant-..."
DEEPGRAM_API_KEY="..."
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=
NEXT_PUBLIC_SENTRY_DSN=
ARIZE_API_KEY=
```

---

## The full user journey

```
 ┌─────────────┐   generate 3 candidates    ┌──────────────┐   pick one to talk to   ┌──────────────────────┐
 │  Landing /  │ ─────────────────────────▶ │ Candidates   │ ──────────────────────▶ │ Résumé  /  Interview │
 │ (job title  │  POST /api/generate-        │ /candidates  │                          │  /candidates/[id]/…  │
 │  + JD)      │  candidate ×3 (parallel)    │              │ ◀──── back, repeat ────  │                      │
 └─────────────┘                            └──────┬───────┘                          └──────────┬───────────┘
                                                   │ "Make hiring decision"                      │ interview by voice/text
                                                   ▼                                             │ + live coding editor
                                            ┌──────────────┐   POST /api/generate-feedback  ┌────▼─────────────┐
                                            │ Decision      │ ─────────────────────────────▶ │ Verdict /feedback│
                                            │ /decision     │   (streaming SSE)              │ score + who you  │
                                            │ pick + reason │                                │ should've hired  │
                                            └──────────────┘                                └──────────────────┘
```

1. **Landing (`/`)** — Enter a **job title** and **job description**. On submit, the app fires **three parallel** `POST /api/generate-candidate` calls (a randomized slate of candidate archetypes — see below), shows a progress loading screen, then saves the resulting candidates to `localStorage` and routes to the pool.

2. **Candidates (`/candidates`)** — A grid of the 3 candidates (avatar, role, years, skills). Each card links to the candidate's **résumé** and to **interview** them. Once interviewed, a card shows an "Interviewed" badge and a jot-note summary.

3. **Résumé (`/candidates/[id]/resume`)** — The candidate's résumé, rendered in one of **five visual formats** (`classic`, `modern`, `executive`, `flashy`, `garish`) chosen at generation time. A sixth `chaotic` template lives in `components/resume-templates.tsx` for future use but isn't currently in the generation rotation. This is a *document* the candidate "submitted," so it's deliberately styled like a real résumé, not like the app.

4. **Interview (`/candidates/[id]/interview`)** — The core experience. A three-pane workspace:
   - **Left:** a voice "call" view (an animated avatar that reacts to who's speaking and your mic level) with a **mute** button and a barge-in level meter. A header **transcript toggle** swaps the avatar for the running transcript and reveals a **text box** to type questions (the text box is hidden in the default voice-only view).
   - **Center:** a read-only **Monaco code editor**. When you ask a coding question, the candidate "thinks out loud while typing" — narration and code play back **line by line, in sync**.
   - **Right:** your private **interview notes**.
   - A **"View résumé"** button opens the résumé in a dialog without leaving the call.
   - Voice is **always-on**: it connects automatically and just listens; talk naturally. **End interview** generates a jot-note summary (shown on a loading screen) and returns you to the pool.

5. **Decision (`/decision`)** — Review every candidate's summary + your notes, pick who you'd hire, and write your reasoning. Submitting streams progress while Claude evaluates everything.

6. **Verdict (`/feedback`)** — Your score (0–100), whether you picked the **objectively correct hire**, what you did well, where to sharpen, and key moments pulled from your transcripts.

---

## How it works (architecture)

A

[README truncated for size]

## Detected evidence (automated analysis)

Indexed codebase: 54 recognized source files, 327 KB.
- Anthropic (technology) — detected in the code
- CSS (language) — detected in the code
- Next.js (technology) — detected in the code
- React (technology) — detected in the code
- Tailwind CSS (technology) — detected in the code
- TypeScript (language) — detected in the code
- AI coding agent: Claude Code — evidence: commit authorship or trailers

## Codebase structure (from repository index)

### Files (61 of 61)

```
.eslintrc.json
.gitignore
app/api/deepgram-token/route.ts
app/api/generate-candidate/route.ts
app/api/generate-candidates/route.ts
app/api/generate-feedback/route.ts
app/api/interview-summary/route.ts
app/api/interview/code/route.ts
app/api/interview/route.ts
app/api/save-notes/route.ts
app/candidates/[id]/interview/page.tsx
app/candidates/[id]/resume/page.tsx
app/candidates/page.tsx
app/decision/page.tsx
app/feedback/page.tsx
app/globals.css
app/layout.tsx
app/page.tsx
components.json
components/code-editor.tsx
components/loading-screen.tsx
components/resume-templates.tsx
components/summary-notes.tsx
components/ui/avatar.tsx
components/ui/badge.tsx
components/ui/button.tsx
components/ui/card.tsx
components/ui/dialog.tsx
components/ui/input.tsx
components/ui/label.tsx
components/ui/progress.tsx
components/ui/radio-group.tsx
components/ui/separator.tsx
components/ui/textarea.tsx
CONTEXT.md
lib/anthropic.ts
lib/coding/edits.ts
lib/coding/parser.ts
lib/coding/persona.ts
lib/coding/playback.ts
lib/data.ts
lib/redis.ts
lib/session.ts
lib/tracing.ts
lib/utils.ts
lib/voice/config.ts
lib/voice/mic.ts
lib/voice/pronounce.ts
lib/voice/stt.ts
lib/voice/tts.ts
lib/voice/useVoiceInterview.ts
next.config.mjs
package.json
postcss.config.mjs
README.md
sentry.client.config.ts
sentry.edge.config.ts
sentry.server.config.ts
tailwind.config.ts
tsconfig.json
types/index.ts
```

### Dependencies

- package.json: @anthropic-ai/sdk@^0.105.0, @base-ui/react@^1.6.0, @deepgram/sdk@^5.4.0, @monaco-editor/react@^4.7.0, @opentelemetry/api@^1.9.1, @opentelemetry/sdk-trace-base@^2.8.0, @opentelemetry/sdk-trace-node@^2.8.0, @radix-ui/react-avatar@^1.2.0, @radix-ui/react-label@^2.1.10, @radix-ui/react-progress@^1.1.10, @radix-ui/react-radio-group@^1.4.1, @radix-ui/react-separator@^1.1.10, @radix-ui/react-slot@^1.3.0, @radix-ui/react-toast@^1.2.17, @sentry/nextjs@^10.59.0, @types/node@^20, @types/react@^18, @types/react-dom@^18, @types/uuid@^10.0.0, @upstash/redis@^1.38.0, class-variance-authority@^0.7.1, clsx@^2.1.1, eslint@^8, eslint-config-next@14.2.35, lucide-react@^1.21.0, next@14.2.35, postcss@^8, react@^18, react-dom@^18, shadcn@^4.11.0, tailwind-merge@^3.6.0, tailwindcss@^3.4.1, tw-animate-css@^1.4.0, typescript@^5, uuid@^14.0.1

### Recent commits (newest first)

- readme update.
- 3 candidates sorted best to worst now.
- Merge pull request #13 from evananderson06/claude/busy-wozniak-o1fsqm
- Make interview screen fill the full viewport
- added new candidate tiers (exceptional + mediocre)
- Say "O of n" instead of "big O of n"
- Fix TTS pronunciation of technical notation
- randomized candidate stuttering. skills are now mostly tailored to candidate background. updated readme.
- Merge pull request #12 from evananderson06/claude/remove-editor-language-preview
- Remove language preview from code editor header
- Merge pull request #11 from evananderson06/claude/laughing-clarke-3kidzq
- Generate 3 candidates instead of 5
- Remove the top navbar
- Pause code editor auto-follow when viewer scrolls up
- Merge pull request #10 from evananderson06/ui-improvements
- candidate skills/experiences improvement.
- Merge branch 'main' into ui-improvements
- Merge pull request #9 from evananderson06/claude/clever-bell-ijyh8n
- ui improvements.
- Voice: eager pipelined TTS feeding to kill clause-boundary stutter

## Key source files (fetched from GitHub, selected and truncated for size)

### package.json

```
{
  "name": "berkeley-ai-26",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  },
  "dependencies": {
    "@anthropic-ai/sdk": "^0.105.0",
    "@base-ui/react": "^1.6.0",
    "@deepgram/sdk": "^5.4.0",
    "@monaco-editor/react": "^4.7.0",
    "@opentelemetry/api": "^1.9.1",
    "@opentelemetry/sdk-trace-base": "^2.8.0",
    "@opentelemetry/sdk-trace-node": "^2.8.0",
    "@radix-ui/react-avatar": "^1.2.0",
    "@radix-ui/react-label": "^2.1.10",
    "@radix-ui/react-progress": "^1.1.10",
    "@radix-ui/react-radio-group": "^1.4.1",
    "@radix-ui/react-separator": "^1.1.10",
    "@radix-ui/react-slot": "^1.3.0",
    "@radix-ui/react-toast": "^1.2.17",
    "@sentry/nextjs": "^10.59.0",
    "@upstash/redis": "^1.38.0",
    "class-variance-authority": "^0.7.1",
    "clsx": "^2.1.1",
    "lucide-react": "^1.21.0",
    "next": "14.2.35",
    "react": "^18",
    "react-dom": "^18",
    "shadcn": "^4.11.0",
    "tailwind-merge": "^3.6.0",
    "tw-animate-css": "^1.4.0",
    "uuid": "^14.0.1"
  },
  "devDependencies": {
    "@types/node": "^20",
    "@types/react": "^18",
    "@types/react-dom": "^18",
    "@types/uuid": "^10.0.0",
    "eslint": "^8",
    "eslint-config-next": "14.2.35",
    "postcss": "^8",
    "tailwindcss": "^3.4.1",
    "typescript": "^5"
  }
}

```

### app/layout.tsx

```typescript
import type { Metadata } from 'next'
import localFont from 'next/font/local'
import { Newsreader } from 'next/font/google'
import './globals.css'

// Type roles: Geist Sans = UI/body, Geist Mono = data/transcripts/code chrome/timecodes,
// Newsreader = display/headlines/verdict.
const geistSans = localFont({
  src: './fonts/GeistVF.woff',
  variable: '--font-geist-sans',
  weight: '100 900',
})
const geistMono = localFont({
  src: './fonts/GeistMonoVF.woff',
  variable: '--font-geist-mono',
  weight: '100 900',
})
const newsreader = Newsreader({
  subsets: ['latin'],
  variable: '--font-display',
  weight: ['400', '500', '600'],
  style: ['normal', 'italic'],
  display: 'swap',
  adjustFontFallback: false,
})

export const metadata: Metadata = {
  title: 'InterviewIQ — Interview AI candidates, learn who to hire',
  description:
    'Interview realistic AI candidates by voice or text, then see who you should have hired and what you missed.',
}

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" className={`${geistSans.variable} ${geistMono.variable} ${newsreader.variable}`}>
      <body className="min-h-screen bg-ground font-sans text-ink antialiased">
        <main>{children}</main>
      </body>
    </html>
  )
}

```

### types/index.ts

```typescript
export type ResumeStyle = 'classic' | 'modern' | 'executive' | 'flashy' | 'garish' | 'chaotic'

export interface WorkExperience {
  company: string
  title: string
  startDate: string
  endDate: string
  bullets: string[]
}

export interface Education {
  institution: string
  degree: string
  year: string
}

export interface Resume {
  summary: string
  experience: WorkExperience[]
  education: Education[]
  skills: string[]
}

export interface Candidate {
  id: string
  name: string
  initials: string
  role: string
  yearsExperience: number
  summary: string
  skills: string[]
  qualityTier: 'exceptional' | 'strong' | 'adequate' | 'mediocre' | 'poor'
  redFlags: string[]
  greenFlags: string[]
  resume: Resume
  resumeStyle?: ResumeStyle
}

export interface Message {
  role: 'user' | 'assistant'
  content: string
  timestamp: string
}

export interface InterviewSession {
  sessionId: string
  jobTitle: string
  jobDescription: string
  candidates: Candidate[]
  interviews: Record<string, Message[]>
  notes: Record<string, string>
  hiringDecision?: string
  reasoning?: string
}

export interface FeedbackReport {
  overallScore: number
  whatWentWell: string[]
  areasForImprovement: string[]
  correctHire: string
  userPickedCorrectly: boolean
  keyMoments: { quote: string; commentary: string }[]
}

```

### app/page.tsx

```typescript
'use client'

import { useState } from 'react'
import { flushSync } from 'react-dom'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { LoadingScreen } from '@/components/loading-screen'
import type { CandidateSpec } from '@/app/api/generate-candidate/route'
import type { Candidate } from '@/types'

const FIRST_NAMES = [
  'Amara', 'Anjali', 'Beatriz', 'Björn', 'Camille', 'Catalina', 'Chioma', 'Dae-Jung',
  'Dmitri', 'Elif', 'Emeka', 'Fatima', 'Florencia', 'Gustavo', 'Hana', 'Hector',
  'Imani', 'Ingrid', 'Jae-Won', 'Javier', 'Kemi', 'Kiran', 'Kwame', 'Layla',
  'Leila', 'Luciana', 'Magnus', 'Mahmoud', 'Makena', 'Mateus', 'Mei-Ling', 'Miriam',
  'Nadia', 'Ngozi', 'Nizhoni', 'Olumide', 'Penelope', 'Rashid', 'Ryo', 'Saoirse',
  'Seun', 'Siobhan', 'Sofía', 'Takoda', 'Tariq', 'Tomas', 'Uma', 'Vicente',
  'Wanjiku', 'Xochitl', 'Yael', 'Yosef', 'Zara', 'Zineb', 'Aleksei', 'Amani',
  'Chiamaka', 'Daria', 'Ekene', 'Fumiko', 'Geneviève', 'Hamid', 'Ifeoma', 'Joon-Ho',
]

const LAST_NAMES = [
  'Adeyemi', 'Al-Amin', 'Al-Hassan', 'Andersson', 'Brightwater', 'Castro', 'Chen',
  'Diallo', 'Ferreira', 'Flores', 'Gutierrez', 'Herrera', 'Huang', 'Kamau', 'Khalil',
  'Kim', 'Kowalski', 'Kumar', 'Laurent', 'Lindqvist', 'Mensah', 'Mizrahi', 'Morales',
  'Murphy', 'Nair', 'Nakamura', 'Nazari', 'Nguyen', 'Okafor', 'Okonkwo', 'Osei',
  'Patel', 'Petrov', 'Reyes', 'Santos', 'Singh', 'Svensson', 'Tremblay', 'Volkov',
  'Wanjiku', 'Whitehorse', 'Yamamoto', 'Yılmaz', 'Zuberi', 'Abebe', 'Boateng', 'Cardoso',
  'Delacroix', 'Esposito', 'Farouk', 'Gomez', 'Hashimoto', 'Ibrahim', 'Jensen', 'Kapoor',
  'Lindberg', 'Mwangi', 'Nkrumah', 'Okeke', 'Park', 'Quiroga', 'Rousseau', 'Suzuki',
]

function pickDistinctNames(count: number): string[] {
  const firsts = [...FIRST_NAMES].sort(() => Math.random() - 0.5)
  const lasts = [...LAST_NAMES].sort(() => Math.random() - 0.5)
  return Array.from({ length: count }, (_, i) => `${firsts[i]} ${lasts[i]}`)
}

type Archetype = CandidateSpec['tierSpec']
type Spec = Omit<CandidateSpec, 'jobTitle' | 'jobDescription' | 'index'>

const POOL_SIZE = 3

// The one guaranteed "good fit" slot draws from these — weighted toward strong, with adequate
// as the floor (never worse), so every pool has at least one genuinely hireable candidate.
const GOOD_FIT_ARCHETYPES: Archetype[] = [
  'exceptional_standout',
  'strong_solid',
  'strong_solid',
  'strong_understated',
  'adequate_senior',
  'adequate_junior',
]

// The remaining slots can be anyone — the full spread, good or bad.
const ALL_ARCHETYPES: Archetype[] = [
  'exceptional_standout',
  'strong_solid',
  'strong_understated',
  'adequate_senior',
  'adequate_junior',
  'mediocre_coaster',
  'poor_deceptive',
  'poor_underqualified',
]

const RESUME_STYLES: Spec['resumeStyle'][] = ['executive', 'modern', 'classic', 'flashy', 'garish']

// Generic loading flavor, shown in completion order — never maps to a specific candidate.
const GENERATION_MESSAGES = [
  'Reviewing the role…',
  'Sourcing candidates…',
  'Writing up résumés…',
  'Finishing the candidate pool…',
]

function pickRandom<T>(arr: T[]): T {
  return arr[Math.floor(Math.random() * arr.length)]
}

function shuffle<T>(arr: T[]): T[] {
  const a = [...arr]
  for (let i = a.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1))
    ;[a[i], a[j]] = [a[j], a[i]]
  }
  return a
}

// A fresh randomized slate: one guaranteed good-fit candidate plus the rest drawn from the full
// archetype spread, each with a distinct résumé style, then shuffled so the good fit isn't always
// in the same position.
function buildSlate(): Spec[] {
  const tiers: Archetype[] = [pickRandom(GOOD_FIT_ARCHETYPES)]
  while (tiers.length < POOL_SIZE) tiers.push(pickRandom(ALL_ARCHETYPES))
  const styles = shuffle(RESUME_STYLES)
  return shuffle(tiers).map((tierSpec, i) => ({ tierSpec, resumeStyle: styles[i % styles.length] }))
}

export default function HomePage() {
  const router = useRouter()
  const [jobTitle, setJobTitle] = useState('')
  const [jobDescription, setJobDescription] = useState('')
  const [loading, setLoading] = useState(false)
  const [loadingMessage, setLoadingMessage] = useState('')
  const [loadingProgress, setLoadingProgress] = useState(0)
  const [error, setError] = useState('')

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault()
    if (!jobTitle.trim() || !jobDescription.trim()) {
      setError('Please fill in both fields.')
      return
    }
    setError('')

    flushSync(() => {
      setLoading(true)
      setLoadingMessage('Generating your candidate pool…')
      setLoadingProgress(5)
    })

    try {
      let completed = 0
      const slate = buildSlate()
      const names = pickDistinctNames(slate.length)

      const promises = slate.map((spec, i) =>
        fetch('/api/generate-candidate', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ ...spec, jobTitle, jobDescription, index: i, nameHint: names[i] }),
        }).then(async (res) => {
          if (!res.ok) throw new Error(`Failed on candidate ${i + 1}`)
          const data = await res.json()
          completed++
          flushSync(() => {
            setLoadingMessage(GENERATION_MESSAGES[completed - 1] ?? 'Almost done…')
            setLoadingProgress(Math.round((completed / slate.length) * 88) + 5)
          })
          return { index: i, candidate: data.candidate as Candidate }
        })
      )

      const results = await Promise.all(promises)
      results.sort((a, b) => a.index - b.index)
      const candidates = results.map((r) => r.candidate)

      flushSync(() => {
        setLoadingMessage('Finalizing yo
[truncated — 3327 more characters]
```

### app/feedback/page.tsx

```typescript
'use client'

import { useEffect, useState } from 'react'
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Progress } from '@/components/ui/progress'
import { Separator } from '@/components/ui/separator'
import { CheckCircle2, AlertCircle, Quote } from 'lucide-react'
import { PLACEHOLDER_FEEDBACK, PLACEHOLDER_CANDIDATES } from '@/lib/data'
import { FeedbackReport, Candidate } from '@/types'
import { cn } from '@/lib/utils'

export default function FeedbackPage() {
  const [report, setReport] = useState<FeedbackReport | null>(null)
  const [candidates, setCandidates] = useState<Candidate[]>([])

  useEffect(() => {
    const rawFeedback = localStorage.getItem('interviewiq_feedback')
    setReport(rawFeedback ? JSON.parse(rawFeedback) : PLACEHOLDER_FEEDBACK)

    const rawCandidates = localStorage.getItem('interviewiq_candidates')
    setCandidates(rawCandidates ? JSON.parse(rawCandidates) : PLACEHOLDER_CANDIDATES)
  }, [])

  if (!report) {
    return (
      <div className="mx-auto max-w-3xl px-6 py-10">
        <div className="h-96 flex items-center justify-center text-ink-2 text-sm">Loading…</div>
      </div>
    )
  }

  const correctCandidate = candidates.find((c) => c.id === report.correctHire)
  const scoreColor =
    report.overallScore >= 80 ? 'text-good' : report.overallScore >= 60 ? 'text-brass' : 'text-bad'

  return (
    <div className="mx-auto max-w-3xl px-6 py-12">
      <div className="mb-8">
        <p className="font-mono text-[11px] uppercase tracking-[0.18em] text-pine mb-2">The verdict</p>
        <h1 className="font-display text-3xl tracking-tight text-ink">How you did as an interviewer</h1>
      </div>

      {/* The ruling */}
      <div className="relative overflow-hidden rounded-2xl bg-ink p-8 shadow-soft mb-7 animate-reveal-up">
        <div
          className="absolute inset-0 pointer-events-none"
          style={{ background: 'radial-gradient(600px 320px at 82% -10%, rgba(198,138,46,.16), transparent 60%)' }}
        />
        <div className="relative space-y-4">
          <span
            className={cn(
              'inline-flex items-center gap-2 font-mono text-[11px] uppercase tracking-[0.16em] px-3 py-1.5 rounded-full border',
              report.userPickedCorrectly
                ? 'bg-good/20 text-[#9FE3C0] border-good/40'
                : 'bg-bad/20 text-[#F0A893] border-bad/40'
            )}
          >
            {report.userPickedCorrectly ? (
              <>
                <CheckCircle2 className="h-3.5 w-3.5" /> Good call
              </>
            ) : (
              <>
                <AlertCircle className="h-3.5 w-3.5" /> Reconsider
              </>
            )}
          </span>
          <h2 className="font-display text-3xl sm:text-[40px] leading-[1.12] text-white max-w-2xl">
            {report.userPickedCorrectly ? (
              'You picked the right candidate.'
            ) : (
              <>
                The strongest hire was <em className="italic text-brass">{correctCandidate?.name ?? 'someone else'}</em>.
              </>
            )}
          </h2>
          {!report.userPickedCorrectly && correctCandidate && (
            <p className="text-[#C7D2CC] max-w-2xl leading-relaxed">{correctCandidate.summary}</p>
          )}
        </div>
      </div>

      {/* Score */}
      <Card className="border-line bg-surface shadow-soft rounded-xl mb-6">
        <CardContent className="px-6 py-5">
          <div className="flex items-center justify-between mb-3">
            <p className="text-sm font-semibold text-ink">Overall score</p>
            <p className={cn('font-display text-3xl', scoreColor)}>
              {report.overallScore}
              <span className="font-mono text-sm font-normal text-ink-2 ml-1">/ 100</span>
            </p>
          </div>
          <Progress value={report.overallScore} className="h-2 bg-surface-2 [&>div]:bg-pine" />
        </CardContent>
      </Card>

      {/* What went well */}
      <Card className="border-line bg-surface shadow-soft rounded-xl mb-5">
        <CardHeader className="px-6 pt-5 pb-3">
          <CardTitle className="font-mono text-[11px] uppercase tracking-[0.14em] text-ink-2 font-medium">
            What you did well
          </CardTitle>
        </CardHeader>
        <CardContent className="px-6 pb-5 pt-0">
          <ul className="space-y-2.5">
            {report.whatWentWell.map((item, i) => (
              <li key={i} className="flex gap-2.5 text-sm text-ink leading-relaxed">
                <CheckCircle2 className="h-4 w-4 text-good shrink-0 mt-0.5" />
                {item}
              </li>
            ))}
          </ul>
        </CardContent>
      </Card>

      {/* Areas for improvement */}
      <Card className="border-line bg-surface shadow-soft rounded-xl mb-5">
        <CardHeader className="px-6 pt-5 pb-3">
          <CardTitle className="font-mono text-[11px] uppercase tracking-[0.14em] text-ink-2 font-medium">
            Where to sharpen
          </CardTitle>
        </CardHeader>
        <CardContent className="px-6 pb-5 pt-0">
          <ul className="space-y-2.5">
            {report.areasForImprovement.map((item, i) => (
              <li key={i} className="flex gap-2.5 text-sm text-ink leading-relaxed">
                <AlertCircle className="h-4 w-4 text-brass shrink-0 mt-0.5" />
                {item}
              </li>
            ))}
          </ul>
        </CardContent>
      </Card>

      {/* Key moments */}
      {report.keyMoments.length > 0 && (
        <Card className="border-line bg-surface shadow-soft rounded-xl mb-8">
          <CardHeader className="px-6 pt-5 pb-3">
            <CardTitle className="font-mono text-[11px] uppercase tracking-[0.14em] text-ink-2 font-medium">
              Key moments
            </CardTitle>
          </CardHeader>
          <CardContent className="px-6 pb-5 pt
[truncated — 865 more characters]
```

### app/candidates/page.tsx

```typescript
'use client'

import { useEffect, useState } from 'react'
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardFooter } from '@/components/ui/card'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { CheckCircle2, ArrowRight } from 'lucide-react'
import { PLACEHOLDER_CANDIDATES } from '@/lib/data'
import { Candidate } from '@/types'
import { SummaryNotes } from '@/components/summary-notes'

// Best candidate first → top-left of the grid, so a demo can spot the ideal hire at a glance.
// Ordering only: the (hidden) true-quality tier sorts the cards but is never shown on them.
const TIER_RANK: Record<Candidate['qualityTier'], number> = {
  exceptional: 5,
  strong: 4,
  adequate: 3,
  mediocre: 2,
  poor: 1,
}

export default function CandidatesPage() {
  const [candidates, setCandidates] = useState<Candidate[]>([])
  const [jobTitle, setJobTitle] = useState<string>('')
  const [completed, setCompleted] = useState<Record<string, boolean>>({})
  const [summaries, setSummaries] = useState<Record<string, string>>({})

  useEffect(() => {
    const raw = localStorage.getItem('interviewiq_candidates')
    const loaded: Candidate[] = raw ? JSON.parse(raw) : PLACEHOLDER_CANDIDATES
    // Strongest first so the ideal candidate sits in the top-left card.
    const ranked = [...loaded].sort((a, b) => (TIER_RANK[b.qualityTier] ?? 0) - (TIER_RANK[a.qualityTier] ?? 0))
    setCandidates(ranked)

    const job = localStorage.getItem('interviewiq_job')
    if (job) setJobTitle(JSON.parse(job).jobTitle)

    const done: Record<string, boolean> = {}
    const sums: Record<string, string> = {}
    for (const c of loaded) {
      if (localStorage.getItem(`interviewiq_completed_${c.id}`) === 'true') done[c.id] = true
      const s = localStorage.getItem(`interviewiq_summary_${c.id}`)
      if (s) sums[c.id] = s
    }
    setCompleted(done)
    setSummaries(sums)
  }, [])

  return (
    <div className="mx-auto max-w-6xl px-6 py-12">
      <div className="mb-9">
        <p className="font-mono text-[11px] uppercase tracking-[0.18em] text-pine mb-2">Candidate pool</p>
        <h1 className="font-display text-3xl tracking-tight text-ink">Who do you want to talk to?</h1>
        <p className="mt-2 text-ink-2 text-sm">
          {jobTitle ? (
            <>
              For <span className="font-mono text-ink">{jobTitle}</span> · interview them by voice or text, then decide.
            </>
          ) : (
            'Review resumes and interview candidates. Take notes as you go.'
          )}
        </p>
      </div>

      <div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
        {candidates.map((candidate, i) => {
          const isDone = completed[candidate.id]
          return (
            <Card
              key={candidate.id}
              className="border-line bg-surface shadow-soft rounded-xl flex flex-col transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lift animate-reveal-up"
              style={{ animationDelay: `${i * 60}ms` }}
            >
              <CardContent className="px-6 pt-6 flex-1">
                <div className="flex items-start gap-4">
                  <Avatar className="h-12 w-12 shrink-0 rounded-xl bg-pine-soft border border-line">
                    <AvatarFallback className="rounded-xl bg-pine-soft text-pine font-mono text-sm font-semibold">
                      {candidate.initials}
                    </AvatarFallback>
                  </Avatar>
                  <div className="min-w-0 flex-1">
                    <div className="flex items-center gap-2">
                      <p className="font-semibold text-ink truncate">{candidate.name}</p>
                      {isDone && (
                        <span className="shrink-0 inline-flex items-center gap-1 rounded-full border border-good/25 bg-good/10 px-2 py-0.5 font-mono text-[10px] uppercase tracking-[0.1em] text-good">
                          <CheckCircle2 className="h-3 w-3" />
                          Interviewed
                        </span>
                      )}
                    </div>
                    <p className="text-sm text-ink-2 truncate">{candidate.role}</p>
                    <p className="font-mono text-[11px] text-ink-2/70 mt-0.5">{candidate.yearsExperience} yrs experience</p>
                  </div>
                </div>

                <div className="mt-4 flex flex-wrap items-center gap-1.5">
                  {candidate.skills.slice(0, 4).map((skill) => (
                    <span
                      key={skill}
                      className="inline-flex items-center rounded-md bg-pine-soft px-2 py-1 font-mono text-[11px] font-medium text-pine"
                    >
                      {skill}
                    </span>
                  ))}
                  {candidate.skills.length > 4 && (
                    <span className="font-mono text-[11px] text-ink-2/55">
                      +{candidate.skills.length - 4} more
                    </span>
                  )}
                </div>

                {isDone && summaries[candidate.id] && (
                  <div className="mt-4 rounded-lg bg-surface-2 border border-line px-3.5 py-3">
                    <p className="font-mono text-[10px] uppercase tracking-[0.14em] text-ink-2/80 mb-2.5">
                      Interview summary
                    </p>
                    <SummaryNotes summary={summaries[candidate.id]} />
                  </div>
                )}
              </CardContent>

              <CardFooter className="gap-2 pt-2 pb-5 px-6">
                <Button
                  asChild
                  variant="outline"
                  size="sm"
                  className="flex-1 border-line text-ink hover:bg-surface-2"
                >
                  <Link href={`/candidates/${candidate.id}/resume`}>View resume</Link>
                </Button>
                {isDone ? (
   
[truncated — 913 more characters]
```

### app/decision/page.tsx

```typescript
'use client'

import { useEffect, useState } from 'react'
import { flushSync } from 'react-dom'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Textarea } from '@/components/ui/textarea'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Label } from '@/components/ui/label'
import { PLACEHOLDER_CANDIDATES } from '@/lib/data'
import { Candidate, Message } from '@/types'
import { LoadingScreen } from '@/components/loading-screen'
import { SummaryNotes } from '@/components/summary-notes'

export default function DecisionPage() {
  const router = useRouter()
  const [candidates, setCandidates] = useState<Candidate[]>([])
  const [notes, setNotes] = useState<Record<string, string>>({})
  const [interviews, setInterviews] = useState<Record<string, Message[]>>({})
  const [summaries, setSummaries] = useState<Record<string, string>>({})
  const [jobTitle, setJobTitle] = useState('')
  const [selected, setSelected] = useState('')
  const [reasoning, setReasoning] = useState('')
  const [loading, setLoading] = useState(false)
  const [loadingMessage, setLoadingMessage] = useState('Reviewing your interviews…')
  const [loadingProgress, setLoadingProgress] = useState(5)

  useEffect(() => {
    const raw = localStorage.getItem('interviewiq_candidates')
    const loaded: Candidate[] = raw ? JSON.parse(raw) : PLACEHOLDER_CANDIDATES
    setCandidates(loaded)

    const job = localStorage.getItem('interviewiq_job')
    if (job) setJobTitle(JSON.parse(job).jobTitle)

    const notesMap: Record<string, string> = {}
    const interviewMap: Record<string, Message[]> = {}
    const summaryMap: Record<string, string> = {}
    for (const c of loaded) {
      const n = localStorage.getItem(`interviewiq_notes_${c.id}`)
      if (n) notesMap[c.id] = n
      const m = localStorage.getItem(`interviewiq_messages_${c.id}`)
      if (m) interviewMap[c.id] = JSON.parse(m)
      const s = localStorage.getItem(`interviewiq_summary_${c.id}`)
      if (s) summaryMap[c.id] = s
    }
    setNotes(notesMap)
    setInterviews(interviewMap)
    setSummaries(summaryMap)
  }, [])

  async function handleGetFeedback() {
    if (!selected) return
    setLoading(true)

    try {
      const res = await fetch('/api/generate-feedback', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ candidates, interviews, notes, jobTitle, hiringDecision: selected, reasoning }),
      })

      if (!res.body) throw new Error('No response body')

      const reader = res.body.getReader()
      const decoder = new TextDecoder()
      let buffer = ''

      while (true) {
        const { done, value } = await reader.read()
        if (done) break

        buffer += decoder.decode(value, { stream: true })
        const parts = buffer.split('\n\n')
        buffer = parts.pop() ?? ''

        for (const part of parts) {
          const line = part.trim()
          if (!line.startsWith('data: ')) continue
          const event = JSON.parse(line.slice(6))

          if (event.type === 'progress') {
            flushSync(() => {
              setLoadingMessage(event.message)
              setLoadingProgress(event.progress)
            })
          } else if (event.type === 'done') {
            localStorage.setItem('interviewiq_feedback', JSON.stringify(event.feedback))
            router.push('/feedback')
          } else if (event.type === 'error') {
            throw new Error(event.message)
          }
        }
      }
    } catch {
      setLoading(false)
    }
  }

  if (loading) return <LoadingScreen message={loadingMessage} progress={loadingProgress} />

  return (
    <div className="mx-auto max-w-3xl px-6 py-12">
      <div className="mb-9">
        <p className="font-mono text-[11px] uppercase tracking-[0.18em] text-pine mb-2">Hiring decision</p>
        <h1 className="font-display text-3xl tracking-tight text-ink">Who are you hiring?</h1>
        <p className="mt-2 text-ink-2 text-sm">
          Review what each interview surfaced and commit to a pick. The verdict comes next.
        </p>
      </div>

      <RadioGroup value={selected} onValueChange={setSelected} className="space-y-4">
        {candidates.map((candidate) => (
          <div key={candidate.id} className="relative">
            <RadioGroupItem value={candidate.id} id={candidate.id} className="peer sr-only" />
            <Label htmlFor={candidate.id} className="cursor-pointer block">
              <Card
                className={`border-line bg-surface shadow-soft rounded-xl transition-all ${
                  selected === candidate.id ? 'border-pine ring-1 ring-pine' : 'hover:border-ink-2/30'
                }`}
              >
                <CardContent className="py-4 px-5 flex items-start gap-4">
                  <Avatar className="h-10 w-10 shrink-0 rounded-xl bg-pine-soft border border-line">
                    <AvatarFallback className="rounded-xl bg-pine-soft text-pine font-mono text-sm font-semibold">
                      {candidate.initials}
                    </AvatarFallback>
                  </Avatar>
                  <div className="flex-1 min-w-0">
                    <div className="flex items-center justify-between">
                      <p className="font-semibold text-ink text-sm">{candidate.name}</p>
                      <p className="font-mono text-[11px] text-ink-2/70">{candidate.role}</p>
                    </div>
                    {summaries[candidate.id] ? (
                      <SummaryNotes summary={summaries[candidate.id]} className="mt-2" />
                    ) : (
                      <p className="text-sm text-ink-2/60 mt-1 italic">Not interviewed yet.</p>
                    )}
                    {notes[candidate.id] && (
                      <p className="text-xs 
[truncated — 972 more characters]
```

### app/api/save-notes/route.ts

```typescript
import { NextRequest, NextResponse } from 'next/server'

interface SaveNotesRequest {
  sessionId: string
  candidateId: string
  notes: string
}

export async function POST(req: NextRequest) {
  try {
    const body: SaveNotesRequest = await req.json()

    if (!body.sessionId || typeof body.sessionId !== 'string') {
      return NextResponse.json({ error: 'sessionId is required' }, { status: 400 })
    }
    if (!body.candidateId || typeof body.candidateId !== 'string') {
      return NextResponse.json({ error: 'candidateId is required' }, { status: 400 })
    }
    if (typeof body.notes !== 'string') {
      return NextResponse.json({ error: 'notes must be a string' }, { status: 400 })
    }

    // TODO: Persist to Redis via lib/redis.ts
    // await saveSession(body.sessionId, updatedSession)

    return NextResponse.json({ success: true })
  } catch {
    return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
  }
}

```

### app/api/deepgram-token/route.ts

```typescript
import { NextResponse } from 'next/server'
import { DeepgramClient } from '@deepgram/sdk'

// Mints a short-lived (~30s) Deepgram access token for the browser, so the
// long-lived DEEPGRAM_API_KEY never leaves the server. The token is only needed
// to OPEN the STT/TTS sockets; once open they stay connected. See CONTEXT.md §17.2.
export const dynamic = 'force-dynamic' // never cache a token

export async function POST() {
  try {
    const apiKey = process.env.DEEPGRAM_API_KEY
    if (!apiKey) {
      return NextResponse.json({ error: 'DEEPGRAM_API_KEY is not set' }, { status: 500 })
    }

    const dg = new DeepgramClient({ apiKey })
    // v5: client.auth.v1.tokens.grant({ ttl_seconds }) -> { access_token, expires_in }
    const res = await dg.auth.v1.tokens.grant({ ttl_seconds: 30 })

    return NextResponse.json({
      accessToken: res.access_token,
      expiresIn: res.expires_in ?? 30,
    })
  } catch (err) {
    console.error('[deepgram-token]', err)
    return NextResponse.json({ error: 'Failed to mint Deepgram token' }, { status: 500 })
  }
}

```

### app/api/interview-summary/route.ts

```typescript
import { NextRequest, NextResponse } from 'next/server'
import { Candidate, Message } from '@/types'
import { anthropic } from '@/lib/anthropic'

interface SummaryRequest {
  candidate: Candidate
  messages: Message[]
}

export async function POST(req: NextRequest) {
  try {
    const body: SummaryRequest = await req.json()
    if (!body.candidate || !Array.isArray(body.messages)) {
      return NextResponse.json({ error: 'candidate and messages are required' }, { status: 400 })
    }

    const c = body.candidate
    const transcript = body.messages
      .filter((m) => m.content?.trim())
      .map((m) => `${m.role === 'user' ? 'INTERVIEWER' : c.name}: ${m.content}`)
      .join('\n')

    if (!body.messages.some((m) => m.role === 'user')) {
      return NextResponse.json({ summary: 'No substantive interview was conducted with this candidate.' })
    }

    const message = await anthropic.messages.create({
      model: 'claude-sonnet-4-6',
      max_tokens: 400,
      system: `You write post-interview JOT NOTES for a hiring manager to review later.
Output 3–6 short bullet notes — terse, telegraphic fragments, NOT full sentences. One note per line,
each line starting with "- ". Capture: what was covered, how the candidate came across, specific
strengths shown, and any moments of hesitation, vagueness, or weakness. Ground every note in the
actual transcript.
You are given hidden notes about the candidate's true ability — use them ONLY to gauge how accurate the
candidate's answers were; do NOT state their quality tier or label them strong/weak/deceptive outright
(a separate final verdict does that). Read like an interviewer's own shorthand — neutral and specific.

Example format:
- Walked through the payments migration confidently; gave concrete metrics
- Vague on rollback strategy when pressed — deflected to "the team handled it"
- Strong on system design tradeoffs
- Didn't ask any clarifying questions`,
      messages: [
        {
          role: 'user',
          content: `Candidate: ${c.name} — ${c.role}, ${c.yearsExperience} yrs.
Hidden notes [DO NOT REVEAL]: tier=${c.qualityTier}; red flags=${c.redFlags?.join('; ') || 'none'}; green flags=${c.greenFlags?.join('; ') || 'none'}.

Transcript:
${transcript}

Write the jot notes now (3–6 bullets, one per line, each starting with "- ").`,
        },
      ],
    })

    const summary = message.content[0]?.type === 'text' ? message.content[0].text.trim() : ''
    return NextResponse.json({ summary: summary || 'Interview completed.' })
  } catch (err) {
    console.error('[interview-summary]', err)
    return NextResponse.json({ error: 'Failed to generate summary' }, { status: 500 })
  }
}

```

[43 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]