Spoken mock interview in a pane: asks aloud, records you, measures WPM and fillers, coaches

Thirteen mods I wrote for Claude Code in October 2026, the week mods were announced. A mod is a plugin of TypeScript hooks that runs inside Claude Code: it can draw panes and bands in the terminal, add slash commands, give Claude new tools, and block or react to tool calls. I built these to see how far the system goes, and a few of them I now use every day.
The one I use most is interview-coach: it asks a placement interview question aloud, records my spoken answer, transcribes it on the Mac with Whisper, and scores pace, filler words and structure against my own CV stories.
| Mod | How you use it | What it does |
|---|---|---|
| interview-coach | /coach | Spoken mock interview in a side pane. Asks a question aloud, records from the mic, transcribes locally (Whisper large-v3-turbo via whisper.cpp), then shows speaking time against the target, words per minute, filler words, time spent on each step of the answer framework, and two stronger example answers drawn from your profile. Keeps a WPM trend across sessions. |
| nihongo | /nihongo | Japanese tutor pane: kana drill that weights look-alike pairs and brings misses back sooner, 44 N5 word cards, and a sentence builder where Claude Haiku sets a line from the week's grammar and corrects your kana answer. A word band above the prompt shows a new word each turn. |
| joka-pond | /pond | Animated ASCII pond that earns XP for every tool Claude uses and climbs a consulting ladder from Intern to Managing Partner. Storms when a tool fails, keeps a daily streak. |
| aurora | /visuals, /aurora | Truecolor pixel art: an aurora band above the prompt whose colours follow the time of day, plus a gallery (plasma, ocean sunset, warp, a 4-4-6 breathing lotus). |
| avatar | /buddy | A pixel companion docked beside the chat. Its mood follows Claude: thinking, happy after a reply, worried after a failure, sleepy after midnight. |
| chart | Claude calls it | Gives Claude a tool that draws bar, line, area and donut charts inline in the chat, with axes, labels and legends. |
| heatmap | Claude calls it | Inline truecolor heatmaps with heat, green and diverging palettes. |
| diagram | Claude calls it | Flowcharts drawn in the terminal: coloured boxes by role, laid out top to bottom with labelled arrows. |
| animate | Claude calls it | Plays short truecolor animations (fireworks, rain, aurora and others) inside a chat row at about 15 fps. |
| pixel | Claude calls it | Draws a pixel-art image in the chat from a JSON grid of colours. |
| change-ledger | /changes | Pane listing every file Claude created or edited this session. |
| em-dash-guard | automatic | Blocks any write or edit that adds an em dash, my house style for documents. |
| hello-mod | /hi | Starter mod: an instant reply with no model call, a guard that blocks rm -rf, and a toast at the end of each turn. |
statusline/statusline-command.sh is my status line: model, effort, context used, 5-hour and weekly usage with time left, folder and git branch, colour-coded green, yellow and red.
claude --plugin-dir ./mods/chart
Pass --plugin-dir once per mod you want. claude plugin validate ./mods/chart checks a mod before loading it.
interview-coach needs ffmpeg, whisper-cpp (brew install ffmpeg whisper-cpp) and a Whisper model file. Set the paths at the top of mods/interview-coach/hooks/register.tsx, and copy profile.example.md to the profile path with your own experiences.
$ context object can't be stored or passed around. Long-running work has to start from a worker in session.start; buttons only queue jobs for it.$.ui.blit instead.session.start becomes callable only from the next prompt.-progress flag must come before -i, or progress output never arrives.hooks/register.tsx 371 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Better, Coach, Part, View } from '../types'
5
6const PANE = 'interview-coach'
7const DIR = '/tmp/interview-coach'
8const RAW = `${DIR}/answer.raw`
9const WAV = `${DIR}/answer.wav`
10// Edit the paths below for your machine. Mods run sandboxed, so there is no $HOME to read.
11const PROFILE = '/Users/YOU/.claude/interview-coach/profile.md'
12const FFMPEG = '/opt/homebrew/bin/ffmpeg'
13const WHISPER = '/opt/homebrew/bin/whisper-cli'
14const MODEL = '/Users/YOU/.whisper-models/ggml-large-v3-turbo.bin'
15
16const TRACKS = [
17 'behavioural (leadership, conflict or failure)',
18 'consulting fit',
19 'product management',
20 'why MBA / why IIM Calcutta / career goals',
21 'resume deep-dive on a past internship',
22]
23
24const FILLERS = /\b(um+|uh+|erm|basically|actually|literally|you know|i mean|sort of|kind of|like)\b/gi
25
26const blank: Coach = {
27 phase: 'idle', view: 'summary', track: '', question: '', seconds: 0, spoken: 0, idealMin: 0, idealMax: 0,
28 transcript: '', words: 0, wpm: 0, fillers: 0, fillerWords: '', framework: '', parts: [], better: [],
29 verdict: '', fixNext: '', error: '',
30}
31
32const coach = atom({ plugin: 'interview-coach', key: 'coach' } as const, blank)
33const history = atom({ plugin: 'interview-coach', key: 'history' } as const, [])
34
35const paceColor = (wpm: number) => (wpm > 175 ? 'red' : wpm > 155 || wpm < 110 ? 'yellow' : 'green')
36const paceWord = (wpm: number) =>
37 wpm > 175 ? 'too fast' : wpm > 155 ? 'a bit fast' : wpm < 110 ? 'a bit slow' : 'good pace'
38const verdictColor = (v: string) => (v === 'strong' ? 'green' : v === 'weak' ? 'yellow' : 'red')
39const verdictMark = (v: string) => (v === 'strong' ? '✓' : v === 'weak' ? '~' : '✗')
40const secs = (n: number) => (n >= 60 ? `${Math.floor(n / 60)}m${String(Math.round(n % 60)).padStart(2, '0')}s` : `${Math.round(n)}s`)
41
42// "[00:00:01.120 --> 00:00:04.500] text" lines from whisper-cli
43const toSec = (t: string) => {
44 const [h, m, s] = t.split(':')
45 return Number(h) * 3600 + Number(m) * 60 + Number(s)
46}
47
48const parseJson = (text: string): any => {
49 const start = text.indexOf('{')
50 const end = text.lastIndexOf('}')
51 if (start < 0 || end <= start) return null
52 try {
53 return JSON.parse(text.slice(start, end + 1))
54 } catch {
55 return null
56 }
57}
58
59const COACH_SYSTEM = `You are a sharp MBA final-placements interview coach at IIM Calcutta. You know the candidate's real background (given). Be specific and blunt, no praise padding, no em dashes anywhere.
60Return ONLY a JSON object, no prose, with exactly these keys:
61{
62 "framework": "the best framework for this question type, e.g. STAR, CAR, Past-Present-Future, Problem-Approach-Impact",
63 "idealMinSeconds": number, "idealMaxSeconds": number, // how long a strong spoken answer to THIS question should run
64 "parts": [ { "label": "framework step name", "said": "one sentence: what he actually said for this step, or 'Not covered'",
65 "words": number (approx words of his answer spent on this step, 0 if missing),
66 "idealShare": number (percent of ideal answer time this step deserves; all parts sum to 100),
67 "verdict": "strong" | "weak" | "missing",
68 "fix": "one concrete improvement for this step, max 25 words" } ],
69 "verdict": "one line overall judgement, max 20 words",
70 "fixNext": "the single most important change next attempt, max 20 words",
71 "better": [ { "title": "a better example from HIS profile", "why": "why it fits this question better, max 20 words",
72 "outline": "framework steps as 'Step: content' joined by ' | ', with real numbers from the profile, max 60 words" } ] // exactly 2
73}
74Only use numbers that appear in the profile. Never invent metrics.`
75
76export const register: Register = on => {
77 // Buttons only queue a job; a worker started in session.start runs it,
78 // because $ cannot be stored and a button's dispatch is short-lived.
79 let isRecording = false
80 let isBusy = false
81 let pending: 'ask' | 'record' | null = null
82 let round = 0
83
84 on('session.start', async ($, e, next) => {
85 const set = (patch: Partial<Coach>) => update($, coach, c => ({ ...c, ...patch }))
86 const fail = (error: string) => set({ phase: 'error', error })
87
88 const ask = async () => {
89 const track = TRACKS[round++ % TRACKS.length]
90 await set({ ...blank, phase: 'asking', track })
91 const r = await $.model.complete({
92 model: 'haiku',
93 timeoutMs: 30000,
94 effort: 'low',
95 maxTokens: 120,
96 prompt:
97 `You interview MBA students at IIM Calcutta for final placements. Ask ONE ${track} question. ` +
98 'Under 30 words. Output only the question, no preamble, no quotes, no em dashes.',
99 })
100 if (!r.isAnswered) return fail(`Could not get a question (${r.reason}).`)
101 const question = r.text.trim()
102 await set({ phase: 'ready', question })
103 void $.audio.speak(question).catch(() => {})
104 }
105
106 const record = async () => {
107 if (isRecording) return
108 await $.process.run(['mkdir', '-p', DIR])
109 isRecording = true
110 await set({ phase: 'recording', seconds: 0 })
111
112 const rec = $.process.spawn({
113 argv: [FFMPEG, '-hide_banner', '-loglevel', 'error', '-y', '-progress', 'pipe:2', '-stats_period', '0.5',
114 '-f', 'avfoundation', '-i', ':0', '-ac', '1', '-ar', '16000', '-t', '300', '-f', 's16le', RAW],
115 })
116 let seconds = 0
117 try {
118 for await (const { text } of rec) {
119 const m = /out_time_us=(\d+)/.exec(text)
120 if (m) {
121 seconds = Number(m[1]) / 1e6
122 await set({ seconds })
123 }
124 if (!isRecording) break
125 }
126 } catch (err) {
127 isRecording = false
128 return fail(`Mic recording failed: ${String(err)}. Grant your terminal microphone access in System Settings.`)
129 }
130 isRecording = false
131 await score(seconds)
132 }
133
134 const score = async (seconds: number) => {
135 if (seconds < 3) return fail('That was under 3 seconds. Press Record and answer out loud.')
136 await set({ phase: 'transcribing', seconds })
137
138 const conv = await $.process.run([FFMPEG, '-hide_banner', '-loglevel', 'error', '-y',
139 '-f', 's16le', '-ar', '16000', '-ac', '1', '-i', RAW, WAV])
140 if (conv.exitCode !== 0) return fail(`Audio conversion failed: ${conv.stderr.slice(0, 120)}`)
141
142 const w = await $.process.run([WHISPER, '-m', MODEL, '-f', WAV, '-np', '-l', 'en'], { timeoutMs: 180000 })
143 const lines = [...w.stdout.matchAll(/\[(\d\d:\d\d:\d\d\.\d+) --> (\d\d:\d\d:\d\d\.\d+)\]\s*(.*)/g)]
144 const transcript = lines.map(l => l[3]).join(' ').replace(/\s+/g, ' ').trim()
145 if (w.exitCode !== 0 || transcript === '') return fail('Transcription came back empty. Speak closer to the mic.')
146
147 // Speaking time: first word to last word, so silence before and after does not count
148 const spoken = Math.max(1, toSec(lines[lines.length - 1]?.[2] ?? '0:0:0') - toSec(lines[0]?.[1] ?? '0:0:0'))
149 const words = transcript.split(' ').filter(Boolean).length
150 const wpm = Math.round(words / (spoken / 60))
151 const found = (transcript.match(FILLERS) ?? []).map(f => f.toLowerCase())
152 const tally = [...new Set(found)].map(f => `${f} x${found.filter(x => x === f).length}`).join(', ')
153 await set({ phase: 'scoring', transcript, words, wpm, spoken, fillers: found.length, fillerWords: tally })
154
155 const profile = await $.fs.read(PROFILE).catch(() => '')
156 const c = await read($, coach)
157 const r = await $.model.complete({
158 model: 'sonnet',
159 timeoutMs: 90000,
160 maxTokens: 1800,
161 system: COACH_SYSTEM,
162 prompt: `CANDIDATE PROFILE:\n${profile}\n\nQUESTION (${c.track}): ${c.question}\n\n` +
163 `TRANSCRIPT OF HIS SPOKEN ANSWER (${words} words in ${Math.round(spoken)}s, ${wpm} wpm, ${found.length} fillers):\n${transcript}`,
164 })
165 const j = r.isAnswered ? parseJson(r.text) : null
166 if (j === null) {
167 await set({ phase: 'done', verdict: r.isAnswered ? 'Coach reply was not readable; try again.' : `No feedback (${r.reason}).` })
168 } else {
169 const parts: Part[] = (j.parts ?? []).map((p: any) => ({
170 label: String(p.label ?? ''), said: String(p.said ?? ''), fix: String(p.fix ?? ''),
171 verdict: p.verdict === 'strong' || p.verdict === 'weak' ? p.verdict : 'missing',
172 words: Number(p.words) || 0, idealShare: Number(p.idealShare) || 0,
173 }))
174 const better: Better[] = (j.better ?? []).slice(0, 2).map((b: any) => ({
175 title: String(b.title ?? ''), why: String(b.why ?? ''), outline: String(b.outline ?? ''),
176 }))
177 await set({
178 phase: 'done', framework: String(j.framework ?? ''), parts, better,
179 idealMin: Number(j.idealMinSeconds) || 0, idealMax: Number(j.idealMaxSeconds) || 0,
180 verdict: String(j.verdict ?? ''), fixNext: String(j.fixNext ?? ''),
181 })
182 }
183
184 const past = ((await $.store.get('wpmHistory')) as number[] | undefined) ?? []
185 const trend = [...past, wpm].slice(-10)
186 await $.store.set('wpmHistory', trend)
187 await update($, history, () => trend)
188
189 const done = await read($, coach)
190 const timing = done.idealMax > 0
191 ? (spoken < done.idealMin ? `, ${Math.round(done.idealMin - spoken)} seconds short` : spoken > done.idealMax ? `, ${Math.round(spoken - done.idealMax)} seconds too long` : ', good length')
192 : ''
193 void $.audio.speak(`${wpm} words per minute, ${paceWord(wpm)}${timing}. ${found.length} filler words.`).catch(() => {})
194 }
195
196 $.clock.every(300, () => {
197 if (isBusy || pending === null) return
198 const job = pending
199 pending = null
200 isBusy = true
201 void (job === 'ask' ? ask() : record())
202 .catch(err => fail(`Something broke: ${String(err)}`))
203 .finally(() => { isBusy = false })
204 })
205
206 await $.command.register({ name: 'coach', description: 'Open the spoken interview coach pane' })
207 const past = ((await $.store.get('wpmHistory')) as number[] | undefined) ?? []
208 await update($, history, () => past)
209 await update($, coach, () => blank)
210 return next(e)
211 })
212
213 on('command.run', { command: 'coach' }, async $ => {
214 await $.ui.open({ id: PANE, title: 'Interview Coach' })
215 return { text: 'Interview Coach opened. Press "New question", answer out loud, then "Stop".' }
216 })
217
218 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
219 const { Box, Text, Button } = $.ui.resolve(e)
220 const c = await read($, coach)
221 const hist = await read($, history)
222 const busy = c.phase === 'asking' || c.phase === 'transcribing' || c.phase === 'scoring'
223 const width = Math.max(30, (e.viewport?.columns ?? 60) - 4)
224 const barW = Math.max(10, Math.min(30, width - 34))
225 const show = (v: View) => update($, coach, x => ({ ...x, view: v }))
226
227 const status =
228 c.phase === 'asking' ? 'Thinking of a question...'
229 : c.phase === 'recording' ? `● Recording ${Math.floor(c.seconds)}s, speak then press Stop`
230 : c.phase === 'transcribing' ? `Transcribing ${Math.round(c.seconds)}s of audio locally...`
231 : c.phase === 'scoring' ? 'Coach is breaking down your answer (up to 90s)...'
232 : ''
233
234 // Time bar: where his speaking time lands against the ideal window
235 const scaleMax = Math.max(c.idealMax * 1.4, c.spoken * 1.1, 1)
236 const pos = (s: number) => Math.min(barW - 1, Math.round((s / scaleMax) * barW))
237 const timeBar = Array.from({ length: barW }, (_, i) =>
238 i === pos(c.spoken) ? '●' : i >= pos(c.idealMin) && i <= pos(c.idealMax) ? '═' : '─').join('')
239 const timeColor = c.idealMax === 0 ? 'white'
240 : c.spoken < c.idealMin * 0.8 || c.spoken > c.idealMax * 1.2 ? 'red'
241 : c.spoken < c.idealMin || c.spoken > c.idealMax ? 'yellow' : 'green'
242 const idealMid = (c.idealMin + c.idealMax) / 2
243 const totalWords = Math.max(1, c.parts.reduce((n, p) => n + p.words, 0))
244
245 const tab = (v: View, label: string) => (
246 <Button key={`tab-${v}`} label={c.view === v ? `▸${label}` : label} onPress={() => void show(v)} />
247 )
248
249 return (
250 <Box flexDirection="column">
251 <Box gap={1}>
252 {!busy && c.phase !== 'recording' && <Button key="ask" label="New question" onPress={() => { pending = 'ask' }} />}
253 {(c.phase === 'ready' || c.phase === 'done' || c.phase === 'error') && c.question !== '' &&
254 <Button key="rec" label="Record answer" onPress={() => { pending = 'record' }} />}
255 {c.phase === 'recording' && <Button key="stop" label="Stop" onPress={() => { isRecording = false }} />}
256 {(busy || c.phase === 'error') && (
257 <Button key="reset" label="Reset" onPress={() => {
258 pending = null
259 isRecording = false
260 isBusy = false
261 void update($, coach, () => blank)
262 }} />
263 )}
264 </Box>
265 {status !== '' && <Text color={c.phase === 'recording' ? 'red' : 'cyan'}>{status}</Text>}
266 {c.phase === 'error' && <Text color="red">{c.error}</Text>}
267 {c.phase === 'idle' && <Text dimColor>Asks a placement question aloud, records your answer, times it, breaks it into a framework and suggests better examples from your own background.</Text>}
268
269 {c.question !== '' && <Text bold>{c.question}</Text>}
270
271 {c.wpm > 0 && (
272 <Box gap={1}>
273 {tab('summary', 'Summary')}
274 {c.parts.length > 0 && tab('breakdown', 'Breakdown')}
275 {c.better.length > 0 && tab('better', 'Better answers')}
276 {tab('transcript', 'Transcript')}
277 </Box>
278 )}
279
280 {c.wpm > 0 && c.view === 'summary' && (
281 <Box flexDirection="column">
282 <Text>
283 Spoke <Text bold color={timeColor}>{secs(c.spoken)}</Text>
284 {c.idealMax > 0 && <Text dimColor> ideal {secs(c.idealMin)} to {secs(c.idealMax)}</Text>}
285 {c.idealMax > 0 && c.spoken < c.idealMin && <Text color={timeColor}> ({secs(c.idealMin - c.spoken)} short)</Text>}
286 {c.idealMax > 0 && c.spoken > c.idealMax && <Text color={timeColor}> ({secs(c.spoken - c.idealMax)} over)</Text>}
287 </Text>
288 {c.idealMax > 0 && <Text><Text dimColor>0 </Text><Text color={timeColor}>{timeBar}</Text><Text dimColor> ═ ideal ● you</Text></Text>}
289 <Text>
290 Pace <Text bold color={paceColor(c.wpm)}>{c.wpm} wpm</Text><Text dimColor> {paceWord(c.wpm)}, aim 130 to 155</Text>
291 <Text> Fillers </Text>
292 <Text bold color={c.fillers > 4 ? 'red' : c.fillers > 1 ? 'yellow' : 'green'}>{c.fillers}</Text>
293 <Text dimColor>{c.fillerWords !== '' ? ` ${c.fillerWords}` : ''}</Text>
294 </Text>
295 {c.framework !== '' && (
296 <Text>
297 {c.parts.map((p, i) => (
298 <Text key={`flow-${i}`}>
299 <Text bold color={verdictColor(p.verdict)}>{verdictMark(p.verdict)} {p.label}</Text>
300 {i < c.parts.length - 1 ? <Text dimColor> ──▶ </Text> : ''}
301 </Text>
302 ))}
303 </Text>
304 )}
305 {c.verdict !== '' && <Text>{c.verdict}</Text>}
306 {c.fixNext !== '' && <Text><Text bold color="cyan">Next time: </Text>{c.fixNext}</Text>}
307 {hist.length > 1 && <Text dimColor>Pace trend: {hist.map((w, i) => (i === hist.length - 1 ? `[${w}]` : `${w}`)).join(' › ')}</Text>}
308 </Box>
309 )}
310
311 {c.view === 'breakdown' && c.parts.length > 0 && (
312 <Box flexDirection="column" gap={1}>
313 <Text dimColor>{c.framework}: your time per step vs ideal</Text>
314 <Box flexDirection="column">
315 {c.parts.map((p, i) => {
316 const yours = (p.words / totalWords) * c.spoken
317 const ideal = (p.idealShare / 100) * idealMid
318 const scale = Math.max(1, ...c.parts.map(q => Math.max((q.words / totalWords) * c.spoken, (q.idealShare / 100) * idealMid)))
319 const yb = Math.round((yours / scale) * (barW - 6))
320 const ib = Math.round((ideal / scale) * (barW - 6))
321 return (
322 <Box key={`bar-${i}`} flexDirection="column">
323 <Text><Text bold color={verdictColor(p.verdict)}>{verdictMark(p.verdict)} {p.label}</Text></Text>
324 <Text> you <Text color={verdictColor(p.verdict)}>{'█'.repeat(yb) || '·'}</Text><Text dimColor> {secs(yours)}</Text></Text>
325 <Text dimColor> ideal {'▒'.repeat(ib) || '·'} {secs(ideal)}</Text>
326 </Box>
327 )
328 })}
329 </Box>
330 {c.parts.map((p, i) => (
331 <Box key={`para-${i}`} flexDirection="column">
332 <Text bold color={verdictColor(p.verdict)}>{i + 1}. {p.label} ({p.verdict})</Text>
333 <Text wrap="wrap"><Text dimColor>You said: </Text>{p.said}</Text>
334 <Text wrap="wrap"><Text color="cyan">Fix: </Text>{p.fix}</Text>
335 </Box>
336 ))}
337 </Box>
338 )}
339
340 {c.view === 'better' && c.better.length > 0 && (
341 <Box flexDirection="column" gap={1}>
342 {c.better.map((b, i) => (
343 <Box key={`better-${i}`} flexDirection="column">
344 <Text bold color="green">{i + 1}. {b.title}</Text>
345 <Text dimColor wrap="wrap">{b.why}</Text>
346 {b.outline.split('|').map((step, k) => {
347 const [head, ...rest] = step.split(':')
348 return (
349 <Text key={`step-${i}-${k}`} wrap="wrap">
350 {k === 0 ? ' ┌ ' : k === b.outline.split('|').length - 1 ? ' └▶ ' : ' ├▶ '}
351 <Text bold>{(head ?? '').trim()}</Text>{rest.length > 0 ? `: ${rest.join(':').trim()}` : ''}
352 </Text>
353 )
354 })}
355 </Box>
356 ))}
357 <Text dimColor>Examples come from profile.md in the mod folder; edit it to add stories.</Text>
358 </Box>
359 )}
360
361 {c.view === 'transcript' && c.transcript !== '' && (
362 <Box flexDirection="column">
363 <Text dimColor>{c.words} words, {secs(c.spoken)} speaking, {secs(c.seconds)} recorded</Text>
364 <Text italic wrap="wrap">"{c.transcript}"</Text>
365 </Box>
366 )}
367 </Box>
368 )
369 })
370}
371types/index.d.ts 42 lines1export type Phase = 'idle' | 'asking' | 'ready' | 'recording' | 'transcribing' | 'scoring' | 'done' | 'error'
2export type View = 'summary' | 'breakdown' | 'better' | 'transcript'
3
4export type Part = {
5 label: string
6 said: string
7 fix: string
8 verdict: 'strong' | 'weak' | 'missing'
9 words: number
10 idealShare: number
11}
12
13export type Better = { title: string; why: string; outline: string }
14
15export type Coach = {
16 phase: Phase
17 view: View
18 track: string
19 question: string
20 seconds: number
21 spoken: number
22 idealMin: number
23 idealMax: number
24 transcript: string
25 words: number
26 wpm: number
27 fillers: number
28 fillerWords: string
29 framework: string
30 parts: Part[]
31 better: Better[]
32 verdict: string
33 fixNext: string
34 error: string
35}
36
37declare module 'claude-code' {
38 interface PluginState {
39 'interview-coach': { coach: Coach; history: number[] }
40 }
41}
42