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. Or ElevenLabs, with your own API key. A new line waits for the one playing to finish, unless you turn on interrupt.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. With the ElevenLabs engine, every spoken line also goes to ElevenLabs.
These are saved for every session (in ~/.claude/read-aloud/config.json):
| Command | Default | What it does | |
|---|---|---|---|
| `/read-aloud updates on\ | off` | on | Mid-task updates on long turns. |
| `/read-aloud reactions on\ | off` | on | The short spoken reaction when you send a prompt. |
| `/read-aloud interrupt on\ | off` | off | Off: a new line waits for the one playing to finish (a reaction that would have to wait is skipped). On: new speech cuts off what's playing. |
/read-aloud speed 1.3 | 1.2 | Speaking speed, 0.5 to 2.0, for Kokoro and the system voice. | |
/read-aloud engine kokoro | elevenlabs | What speaks: elevenlabs (with an API key; without one, Kokoro), kokoro (Kokoro when its server runs, else the system voice), or system. | |
| `/read-aloud rotate on\ | off` | on | Each new session gets its own voice, so you can tell sessions apart by ear. Off: every session uses the default voice. |
/read-aloud voice heart | This session's voice only: a Kokoro name (heart, emma, am_fenrir), or an ElevenLabs name (George, Sarah) or voice id. | ||
/read-aloud default-voice heart | af_heart / George | The voice sessions use when rotate is off. | |
/read-aloud voices | This session's voice, the rotation, and the engine's 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.
"ELEVENLABS_API_KEY": "..." to the env block of ~/.claude/settings.json, orsecurity add-generic-password -s elevenlabs -a "$USER" -w (it prompts for the key)./read-aloud engine elevenlabs, then /read-aloud voices and /read-aloud voice <name> to pick a voice. A restricted key needs the Text to Speech permission to speak, and Voices (read) to list voices or pick one by name; without it, pick one by its voice id. A 20-character voice id always works, including Voice Library voices, and can be set before switching engines.It uses eleven_v4 by default; /read-aloud model eleven_v4_turbo (or any model id) switches it and your speed setting. Eleven v4 ignores the API's speed, so for v4 models the speed is applied during playback (pitch kept, the full 0.5–2.0 range); older models get it from the API, within ElevenLabs' 0.7–1.2. If a request fails (or you're out of credits), that line falls back to Kokoro, in a matching voice: each ElevenLabs voice has a fixed Kokoro twin of the same gender and accent (George to Kokoro's George, Alice to Emma, Brian to Fenrir), so a session still sounds like itself and sessions stay apart. ElevenLabs bills by character, so long replies cost more; the Haiku summary keeps them short.
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. The hooks go through scripts/launch.py, which runs the newest installed version, so script changes reach sessions that are already open; changes to hooks/hooks.json itself still need a new session. Each line in ~/.claude/read-aloud.log names the version that spoke it.
MIT
hooks/register.tsx 75 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, beside whatever other mods draw there.
62 // It gives way to a survey.
63 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
64 const base = await next(e)
65 if (e.surface === 'terminal' || e.props.hasSurvey) return base
66 const { Box } = $.ui.resolve(e)
67 return (
68 <Box flexDirection="row" alignItems="center">
69 <Box flexGrow={1}>{base}</Box>
70 {await control($, e)}
71 </Box>
72 )
73 })
74}
75types/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