Reads Claude's replies out loud. Off by default; /read-aloud toggles it per session.

A Claude Code plugin that reads Claude's replies out loud, so you can look away from the screen while it works.
/read-aloud toggles it for the current session; /read-aloud on and /read-aloud off set it. A Voice on / Voice off button does the same: just above the prompt in the desktop app, in the prompt footer in the terminal (experimental, see below).claude -p, on your Claude login); if Haiku is slow or missing, from a local Ollama model; otherwise it's a canned one.AskUserQuestion prompt opens, it gives a short heads-up.say on macOS, spd-say or espeak on Linux. New speech cuts off the old.Scheduled tasks and subagents are never read aloud.
python3 on your PATH. A fresh Mac doesn't have it until the Xcode command line tools are installed (xcode-select --install). Without it the hooks fail silently.say built in. On Linux, install speech-dispatcher (spd-say) or espeak-ng.gemma4:e2b as the fallback for acknowledgments, and the Kokoro server below for a better voice (macOS only, since it plays through afplay).In Claude Code, add this repo as a marketplace, then install the plugin:
/plugin marketplace add ericcecchi/claude-voice-plugin
/plugin install read-aloud@claude-voice
A local checkout works too: /plugin marketplace add /path/to/claude-voice-plugin. Restart Claude Code, then type /read-aloud in any session to turn the voice on.
Speech is synthesized on your machine. Two things go to Claude Haiku through your own claude login: your prompt (its first 1,500 characters) for the spoken reaction, and Claude's final message (when it's longer than a couple of sentences) for the summary. That's up to two small calls per turn. Set READ_ALOUD_ACK_MODEL and READ_ALOUD_SUMMARY_MODEL to empty to keep everything local.
These are saved for every session (in ~/.claude/read-aloud/config.json):
| Command | Default | What it does | |
|---|---|---|---|
| `/read-aloud updates on\ | off` | off | Mid-task updates on long turns. |
| `/read-aloud reactions on\ | off` | on | The short spoken reaction when you send a prompt. |
/read-aloud speed 1.3 | 1.2 | Speaking speed, 0.5 to 2.0, for Kokoro and the system voice. | |
/read-aloud voice heart | af_heart | The Kokoro voice, by full or short name (heart, emma, am_fenrir). | |
/read-aloud voices | Lists the voices. | ||
/read-aloud settings | Shows what's set now. |
/read-aloud, /read-aloud on and /read-aloud off turn the voice on or off for the current session only.
For finer control, set these in the env block of ~/.claude/settings.json:
| Variable | Default | What it does |
|---|---|---|
READ_ALOUD_DIRS | unset | Colon-separated folders. If set, speak only when the session's cwd is inside one. |
READ_ALOUD_ACK_MODEL | haiku | Claude model for the acknowledgment, via claude -p. Empty to skip it. |
READ_ALOUD_ACK_WAIT | 8 | Seconds to wait for that model before falling back. |
READ_ALOUD_SUMMARY_MODEL | haiku | Claude model that sums up the final message. Empty for the rule-based reading only. |
READ_ALOUD_SUMMARY_WAIT | 60 | Seconds to wait for it before the rule-based reading. |
READ_ALOUD_EFFORT | high | Effort level for every Haiku call. |
READ_ALOUD_OLLAMA_MODEL | gemma4:e2b | Local Ollama model, the fallback. Empty to skip it. |
READ_ALOUD_SAY_RATE | 175 × speed | System voice words per minute, overriding the speed setting. |
READ_ALOUD_PROGRESS_GAP | 20 | Seconds of quiet before a mid-task update. 0 speaks every one. |
Each utterance is logged to ~/.claude/read-aloud.log with the hook that spoke it and the engine (kokoro or say).
touch ~/.claude/read-aloud.off silences it everywhere; delete the file to undo.
State lives in ~/.claude/read-aloud/on/<session id> (on or off), so other tools (for example a status-line toggle) can read or flip it.
The Voice on / Voice off button is drawn with Claude Code's early-access plugin UI API, which changes between releases, so it may not appear on some versions or surfaces. The /read-aloud command and everything spoken are ordinary hooks and don't depend on it.
scripts/kokoro-speak-server.py keeps Kokoro loaded and listens on ~/.claude/kokoro.sock.
brew install espeak-ng
python3 -m venv ~/.kokoro-venv && ~/.kokoro-venv/bin/pip install kokoro soundfile numpy torch
Copy the server somewhere stable (the plugin's install path changes between versions), then keep it running with launchd. Save this as ~/Library/LaunchAgents/local.kokoro-speak.plist, replacing YOU:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>local.kokoro-speak</string>
<key>ProgramArguments</key>
<array>
<string>/Users/YOU/.kokoro-venv/bin/python</string>
<string>/Users/YOU/.kokoro-venv/kokoro-speak-server.py</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key><string>/opt/homebrew/bin:/usr/bin:/bin</string>
</dict>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>ThrottleInterval</key><integer>30</integer>
<key>StandardOutPath</key><string>/Users/YOU/.claude/kokoro-speak.log</string>
<key>StandardErrorPath</key><string>/Users/YOU/.claude/kokoro-speak.log</string>
</dict>
</plist>
launchctl load ~/Library/LaunchAgents/local.kokoro-speak.plist
KOKORO_VOICE (the default voice, af_heart, which /read-aloud voice overrides) and KOKORO_DEVICE (cpu by default; mps uses the GPU, but it's slower for this small model and roughens the voice) go in the plist's EnvironmentVariables. Voices differ in quality: Kokoro grades the US voices af_heart (A) and af_bella (A-) highest; the British ones, bf_emma (B-) and bm_fable (C), lower. See Kokoro's VOICES.md for the full list. The server reads /read-aloud speed and /read-aloud voice on every request, so changes need no restart.
python3 -m unittest discover tests
claude plugin validate .
claude plugin test .
Installed copies run from Claude Code's plugin cache, which refreshes only when the version changes: bump version in .claude-plugin/plugin.json and .claude-plugin/marketplace.json with every change, then run claude plugin update read-aloud@claude-voice.
MIT
hooks/register.tsx 72 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderInput } from 'claude-code'
3
4// The read-aloud hook (scripts/read-aloud.py) speaks only when
5// ~/.claude/read-aloud/on/<session id> holds "on". `/read-aloud` and this toggle both write it.
6const isOn = atom({ plugin: 'read-aloud', key: 'isOn' } as const, false)
7
8// Lucide's volume-2 / volume-x, the stroke icon set the Claude UI draws with.
9const SPEAKER =
10 '<path d="M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z"/>'
11const WAVES = '<path d="M16 9a5 5 0 0 1 0 6"/><path d="M19.364 18.364a9 9 0 0 0 0-12.728"/>'
12const CROSS = '<path d="M22 9l-6 6"/><path d="M16 9l6 6"/>'
13const icon = (on: boolean) =>
14 `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#8b8b86" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${SPEAKER}${on ? WAVES : CROSS}</svg>`
15
16// The button itself, the same wherever it's drawn.
17async function control($: EngineInterface, e: RenderInput<'SessionMode' | 'AbovePrompt'>) {
18 const { Box, Button, Svg } = $.ui.resolve(e)
19 const voice = await read($, isOn)
20 const toggle = async () => {
21 const flag = `${await $.env.get('HOME')}/.claude/read-aloud/on/${await $.session.id()}`
22 await $.fs.write(flag, voice ? 'off' : 'on')
23 await update($, isOn, () => !voice)
24 }
25 const label = voice ? 'Voice on' : 'Voice off'
26 return (
27 <Box key="voice-row" flexDirection="row" alignItems="center">
28 {e.surface !== 'terminal' && Svg && <Svg source={icon(voice)} alt={label} width={14} height={14} />}
29 <Button key="voice" label={label} plain dimColor onPress={() => void toggle()} />
30 </Box>
31 )
32}
33
34export const register: Register = on => {
35 on('session.start', async ($, e, next) => {
36 const result = await next(e)
37 const flag = `${await $.env.get('HOME')}/.claude/read-aloud/on/${await $.session.id()}`
38 const sync = async () => {
39 const now = (await $.fs.exists(flag)) && (await $.fs.read(flag)).trim() !== 'off'
40 if (now !== (await read($, isOn))) await update($, isOn, () => now)
41 }
42 await sync()
43 $.clock.every(1000, () => void sync())
44 return result
45 })
46
47 // Terminal: in the prompt footer's right-hand mode area, after any mode labels (`focus`).
48 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
49 const base = await next(e)
50 if (e.surface !== 'terminal') return base
51 const { Box } = $.ui.resolve(e)
52 return (
53 <Box flexDirection="row" alignItems="center">
54 {base}
55 <Box marginLeft={1}>{await control($, e)}</Box>
56 </Box>
57 )
58 })
59
60 // Desktop and other surfaces: the footer slots aren't on screen there, so the button sits in
61 // the band just above the prompt, at its right edge. It gives way to a survey.
62 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
63 if (e.surface === 'terminal' || e.props.hasSurvey) return next(e)
64 const { Box } = $.ui.resolve(e)
65 return (
66 <Box flexDirection="row" justifyContent="flex-end">
67 {await control($, e)}
68 </Box>
69 )
70 })
71}
72types/index.d.ts 8 lines1export type ReadAloud = { isOn: boolean }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'read-aloud': { isOn: boolean }
6 }
7}
8