SLOPSHOPPER

read-aloud

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

newbandspinnertimer
v1.15.0MITupdated 2026-10-13blank-slate-creative/claude-voice-plugin
A shopper browsing a rack in a slop shop
README

read-aloud

A Claude Code plugin that reads Claude's replies out loud, so you can look away from the screen while it works.

  • A voice per session. Each session gets its own voice, so with several sessions running you can tell which one is talking; turning voice on says which voice the session has.
  • Off by default. /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).
  • Replies: when Claude finishes, Claude Haiku sums up the final message for the ear, warmer and more conversational than the written reply: the outcome, anything you need to do or decide, and any question for you, at a length that fits. Short replies are read as they are. If Haiku is slow or missing, it reads the message minus code, tables and pleasantries.
  • Acknowledgment: a beat after you send a prompt, it reacts out loud in a sentence or two, so there's no dead air. The line comes from Claude Haiku (through claude -p, on your Claude login); if Haiku is slow or missing, from a local Ollama model; otherwise it's a canned one.
  • Updates: on long tasks, it reads what Claude last wrote between tool calls (each line once), but only after 20 seconds of quiet, so short turns stay quiet.
  • Questions: when an AskUserQuestion prompt opens, it gives a short heads-up.
  • Approvals: when a permission prompt is waiting for you, it says so.
  • Voice: a warm Kokoro server if one is running, otherwise the system voice: 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.

Requirements

  • Claude Code with plugin support.
  • 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.
  • A speech engine. macOS has say built in. On Linux, install speech-dispatcher (spd-say) or espeak-ng.
  • Optional: a local Ollama with gemma4:e2b as the fallback for acknowledgments, and the Kokoro server below for a better voice (macOS only, since it plays through afplay).

Install

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.

Privacy and cost

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.

Settings

These are saved for every session (in ~/.claude/read-aloud/config.json):

CommandDefaultWhat it does
`/read-aloud updates on\off`onMid-task updates on long turns.
`/read-aloud reactions on\off`onThe short spoken reaction when you send a prompt.
`/read-aloud interrupt on\off`offOff: 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.31.2Speaking speed, 0.5 to 2.0, for Kokoro and the system voice.
/read-aloud engine kokoroelevenlabsWhat 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`onEach new session gets its own voice, so you can tell sessions apart by ear. Off: every session uses the default voice.
/read-aloud voice heartThis session's voice only: a Kokoro name (heart, emma, am_fenrir), or an ElevenLabs name (George, Sarah) or voice id.
/read-aloud default-voice heartaf_heart / GeorgeThe voice sessions use when rotate is off.
/read-aloud voicesThis session's voice, the rotation, and the engine's voices.
/read-aloud settingsShows 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:

VariableDefaultWhat it does
READ_ALOUD_DIRSunsetColon-separated folders. If set, speak only when the session's cwd is inside one.
READ_ALOUD_ACK_MODELhaikuClaude model for the acknowledgment, via claude -p. Empty to skip it.
READ_ALOUD_ACK_WAIT8Seconds to wait for that model before falling back.
READ_ALOUD_SUMMARY_MODELhaikuClaude model that sums up the final message. Empty for the rule-based reading only.
READ_ALOUD_SUMMARY_WAIT60Seconds to wait for it before the rule-based reading.
READ_ALOUD_EFFORThighEffort level for every Haiku call.
READ_ALOUD_OLLAMA_MODELgemma4:e2bLocal Ollama model, the fallback. Empty to skip it.
READ_ALOUD_SAY_RATE175 × speedSystem voice words per minute, overriding the speed setting.
READ_ALOUD_PROGRESS_GAP20Seconds 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

  1. Get an API key from your ElevenLabs account, then make it available to Claude Code in one of two ways (never paste it into a chat):
  2. add "ELEVENLABS_API_KEY": "..." to the env block of ~/.claude/settings.json, or
  3. store it in the macOS Keychain: security add-generic-password -s elevenlabs -a "$USER" -w (it prompts for the key).
  4. Run /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 button (experimental)

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.

Optional: Kokoro voice

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.

Development

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.

License

MIT

Source 2 files
hooks/register.tsx 75 lines
1import { 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}
75
types/index.d.ts 8 lines
1export type ReadAloud = { isOn: boolean }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'read-aloud': { isOn: boolean }
6  }
7}
8