claude-tts
v1.3.0
Published
Speak Claude Code's responses aloud. Reads what you select, only from Claude Code. Zero dependencies, works on macOS, Windows and Linux.
Maintainers
Readme
claude-tts
Speak Claude Code's responses aloud. Zero dependencies, free, works on macOS, Windows and Linux.
npx claude-tts initThat's it. Claude Code will speak from your next response.
Why
Long responses are easy to lose track of when you're tabbed away. claude-tts reads the opening of each reply out loud so you know when Claude has finished and roughly what it said, without watching the terminal.
It runs as a Claude Code Stop hook: after every response, the hook reads the transcript, strips the Markdown, and speaks the result.
Install
npx claude-tts # interactive setup — does everything
npx claude-tts --yes # same, no promptsBare npx claude-tts fetches, installs, registers the hooks, and offers to add
the status-line indicator. It also drops a claude-tts launcher on your PATH
(~/.local/bin or similar), so the command keeps working after npx discards the
package. If no writable PATH directory exists it says so and prints npx
claude-tts … commands instead.
For finer control:
npx claude-tts init # sensible defaults
npx claude-tts init --dry-run # show what would change, write nothing
npx claude-tts init --mode sentence # speak only the opening sentence
npx claude-tts init --engine edge # neural voices (see below)Restart any running Claude Code session afterwards so it picks up the hook.
Engines
| Engine | Quality | Cost | Network | Setup |
|---|---|---|---|---|
| native | Robotic but clear | Free | None | Built in on macOS/Windows |
| edge | Neural, very good | Free | Sends text to Microsoft | pipx install edge-tts |
native resolves per platform:
- macOS —
say, preinstalled - Windows — SAPI via
System.Speech, preinstalled - Linux —
spd-say,espeak-ng,espeakorfestival, whichever is found
Linux is the one platform with no guaranteed synthesizer. If none is present, init tells you the install command for your distro.
A note on edge
The edge engine uses Microsoft Edge's read-aloud voices. They're free, need no API key, and sound dramatically better than the built-in ones.
It sends your response text to Microsoft's servers. That's why native is the default and edge is opt-in — if Claude is discussing proprietary code, this is your call to make deliberately, not a default you inherited.
It also depends on the community edge-tts CLI rather than reimplementing the protocol, because Microsoft gates the endpoint behind rotating tokens that break hand-rolled clients.
Usage
claude-tts ui # live control panel (see below)
claude-tts status # configuration and health
claude-tts test # speak a sample
claude-tts test --dry # show what would be spoken, no audio
claude-tts off # mute (instant, doesn't touch settings.json)
claude-tts on # unmute
claude-tts last # speak the most recent response
claude-tts clip # speak what you just selected
claude-tts watch # speak every selection (on by default)
claude-tts save # keep a passage in a file
claude-tts stop # interrupt the current utterance
claude-tts doctor # diagnose problems
claude-tts uninstall # remove the hookSpeaking only what you want
By default, responses are not spoken at all — you hear what you select. The clipboard watcher is the primary path; auto-reading every response gets tiring fast.
To change that:
claude-tts config trigger manual # never read responses (default)
claude-tts config trigger marker # only prompts containing !say
claude-tts config trigger always # read every responsemarker — opt in per prompt
Put the marker anywhere in your message and only that turn gets spoken:
explain what this regex does !sayNothing else is read aloud. Change the marker to whatever you like:
claude-tts config triggerMarker "@read"The hook finds your prompt by walking back from the reply to the nearest user message — past all the tool calls in between — and ignores harness-injected text like attachment notices and system reminders, so the marker is matched against what you actually typed.
manual — ask for it after the fact
claude-tts last # speak the most recent response
claude-tts last --full # all of it, not just the opening
claude-tts last --dry # print what would be spokenlast finds the most recently written transcript across all your projects, so
it always targets the session you were just in. It ignores the mute flag — you
asked for it explicitly.
Inside Claude Code, run it without leaving the session: !claude-tts last.
Speak exactly what you highlight
Selecting text in a terminal copies it, so the clipboard is the most precise "read this" there is — no markers, no config:
This is on by default. A SessionStart hook brings the watcher up with every
Claude Code session, so selecting text just works. One watcher serves every
session — a second window finds the first still running.
claude-tts clip # speak the current selection, once
claude-tts watch # start the watcher by hand
claude-tts watch stop # stop it
claude-tts watch status # is it running?
claude-tts config autoWatch false # do not start it automaticallyHighlight a paragraph, hear it; highlight the next, the previous is interrupted.
claude-tts watch --fg runs it in the foreground for debugging.
It only reads Claude's own prose
A watcher that read the whole clipboard would happily announce a password copied from a manager in another window. So a selection is spoken only if it actually appears in a recent Claude Code transcript:
claude-tts config watchScope claude # only Claude's prose (default)
claude-tts config watchScope any # anything you copyCommands and code are never spoken, even when Claude wrote them. Copying in a terminal is overwhelmingly copying a command or a path in order to paste it somewhere, and hearing a command spelled out loud is noise. Fenced blocks, indented output and anything that reads as a command line rather than a sentence are excluded, so the two things you do in the same window stay distinct: select an explanation and hear it, copy a command and get silence.
If a selection is not read aloud, ask why:
claude-tts clip --checkIt reports the watcher state, engine, selection length and the exact reason — usually that the selection was a command rather than prose, or too short to attribute (under 8 letters).
Rather than guessing at the focused application — unreliable, and different on
every platform — this compares the copied text against what Claude wrote.
Comparison is on alphanumerics only, because the transcript stores raw
Markdown (spoken **whole**) while the terminal copies it rendered, and
selections pick up wrapping, indentation and ANSI codes.
Scoring is line by line rather than whole-string. A terminal selection is mixed — a whole answer carries prose, code blocks and output together — so demanding an exact match on the entire selection would reject it. Half the prose lines must be attributable for a selection to be spoken, which a copied command never manages and a selected answer comfortably does.
Transcripts reach several megabytes, so the haystack is built incrementally — each poll parses only newly appended bytes. A cold build plus five lookups is ~7ms; 200 cached lookups are ~1ms.
A selection is spoken whole — mode is not applied, since you already chose
the extent yourself. Markdown is still stripped, so code blocks are not read out.
Both clip and last speak even while muted, because you asked directly.
Requires a clipboard reader: built in on macOS (pbpaste) and Windows
(Get-Clipboard); on Linux install xclip, xsel, or wl-clipboard.
On Windows the polling happens inside one long-lived PowerShell process, which reports only changes. Starting a PowerShell costs a couple of hundred milliseconds, so a 400ms poll implemented as one spawn per tick would keep the machine launching PowerShell more or less continuously.
Save a passage instead of only hearing it
claude-tts save # append the current selection
claude-tts save --last # append the most recent response
claude-tts save --to notes.md
claude-tts save --show # print the destinationAppends timestamped Markdown, so the file reads as a running log. The default
lives at ~/.claude/claude-tts/saved.md; change it with
claude-tts config saveFile <path>.
Or filter by content
Works in any trigger mode:
claude-tts config skipPatterns "^(done|ok|fixed)\b"Settings editor
claude-tts uiEvery setting in one place, navigable with arrow keys — nothing to memorise:
claude-tts ▬ armed
──────────────────────────────────────────────
▸ speech on
read responses manual
voice Samantha en_US
mode paragraph
rate 1.0×
max chars 500
watcher running
watch scope claude
auto-start yes
status icon block
debug log off
──────────────────────────────────────────────
master switch
↑↓ move ←→ change t test s stop q quit| Key | Action |
|---|---|
| ↑ ↓ | Move between settings |
| ← → | Change the selected setting |
| t | Speak a test line |
| s | Stop speaking |
| q | Quit |
Changing the voice plays a sample immediately — the only way to judge one. Changing the rate does too. The watcher can be started and stopped from here.
Changes save as you make them and apply to the next utterance. If you would rather edit the file:
claude-tts config --edit # opens config.json in $EDITORStatus line indicator
Show speech state inside Claude Code itself:
claude-tts statusline install # wraps whatever you already have
claude-tts statusline preview # see the segment
claude-tts statusline remove # restore your original lineA single block glyph, one column wide, so it reads as part of the status line rather than a pasted-in icon:
| State | Glyph |
|---|---|
| Armed (watcher running) | white ▬ |
| Idle (no watcher) | grey ▬ |
| Speaking | a block that fills ▁▂▃▄▅▆▇█ as the utterance plays |
| Muted | red ▬ |
How the speaking meter moves
Claude Code re-runs the status line on events, not on a timer — measured gaps between repaints ranged from 0.5s to 21s. A clock-driven animation therefore either never moves or jumps to a random frame.
So the block's height tracks progress through the utterance instead. Every repaint that does happen shows it higher, and the glyph carries real information rather than decoration:
▁ ▂ ▃ ▅ ▆ ▬ (sampled every 500ms across a 3s utterance)Duration is estimated from word count and rate, since no engine reports
progress. It only has to be roughly right — the alternative is no indication at
all. Motion is as smooth as the host repaints, which is not very; for a
genuinely live display run claude-tts ui, which owns its own render loop.
claude-tts config statusStyle emoji # 🔈 👂 🔊 🔇 instead
claude-tts config statusStyle text # words
claude-tts config statusStyle off # nothingClaude Code allows only one statusLine command, so this composes rather than
replaces. It runs your existing command — MarkR,
starship, anything — feeds it the same session payload, and attaches its segment
to the first line.
Why a single emoji on the first line
Both halves of that are the result of real failures, not taste.
Status lines that fill the terminal ignore the width advertised in the payload — MarkR produces byte-identical output whether you tell it 60 columns or 140. A wider segment therefore gets pushed onto a second line or truncated away entirely. Two columns survive regardless of what the wrapped command does.
And the segment attaches to the first line because trailing lines are the ones that get truncated or scrolled out of view.
When prefixing, the wrapped line's own leading indent is collapsed to
statusGap spaces — status lines often indent or right-align their first
segment, which would otherwise leave a wide gulf between the icon and the
content.
The width reservation is still applied, since well-behaved status lines do respect it — it just can't be relied on alone.
claude-tts config statusStyle text # emoji | text | off
claude-tts config statusPosition end # start | end of the first line
claude-tts config statusGap 0 # tighten the gap to the next segmentremove recovers the original command by parsing it back out of the wrapper.
Toggling is a command, since a status line cannot take input:
claude-tts toggleInside Claude Code, run it without leaving the session by typing !claude-tts toggle.
Just ask Claude
init writes a short block into ~/.claude/CLAUDE.md, which Claude Code loads
every session. That means you can change things conversationally instead of
remembering flags:
"mute the speech" → Claude runs
claude-tts off"read that again" →claude-tts last"read what I copied" →claude-tts clip"only read when I ask" →claude-tts config trigger marker
The block sits between <!-- claude-tts:start --> and <!-- claude-tts:end -->,
so it is rewritten cleanly on upgrade and removed on uninstall. Anything else in
your CLAUDE.md is left untouched — there are tests asserting a byte-for-byte
round trip.
Skip it with claude-tts init --no-memory.
Configuration
claude-tts config # show everything
claude-tts config mode full # set a value
claude-tts config --reset # back to defaults| Key | Default | Meaning |
|---|---|---|
| enabled | true | Master switch, flipped by on / off |
| engine | auto | auto | native | edge | none |
| mode | paragraph | How much to read: full | paragraph | sentence |
| maxChars | 500 | Hard ceiling on spoken characters |
| minChars | 12 | Responses shorter than this are skipped |
| rate | 1.0 | Speed multiplier, 0.5–3.0 |
| voice | null | Engine-specific voice id |
| interrupt | true | Stop the previous utterance before starting |
| skipPatterns | [] | Regexes; matching responses are never spoken |
| trigger | manual | Read responses: manual | marker | always |
| triggerMarker | !say | Text that opts a prompt in when trigger is marker |
| autoWatch | true | Start the clipboard watcher with each session |
| watchScope | claude | Which selections to read: claude | any |
| saveFile | null | Where save appends; null uses the default |
| statusStyle | block | Status line: block | emoji | text | off |
| statusGap | 1 | Spaces between the icon and the rest of the line |
| statusPosition | start | Which end of the first status line it attaches to |
| debug | false | Write a diagnostic log |
Picking a voice
claude-tts voices # numbered list, your language first
claude-tts voices 12 # set by number
claude-tts voices samantha # set by name — case and punctuation are ignored
claude-tts voices --pick # hear each one, then choose
claude-tts voices --all # every languageTwo things make voice names hostile, and both are handled:
They are hard to type. macOS ships 177 voices, 110 of them parenthesised
like Eddy (English (US)). So selection accepts a list number, a
case-insensitive name, or a unique prefix — sam finds Samantha.
A wrong name fails silently. say -v NotARealVoice exits 0 and speaks in
the default voice, so a typo gives you the wrong voice with no error. Every
selection is therefore validated against the engine's list before being saved,
and an unknown name is refused with suggestions.
Why paragraph is the default
Reading a full response takes minutes, and you can't skim audio. paragraph speaks the opening — enough to know what happened — and leaves the detail on screen. It skips past short leads and headings so you don't just hear "Here's the plan:".
Use skipPatterns to mute noisy categories, e.g. claude-tts config skipPatterns "^(done|ok)\b".
How it works
Claude Code finishes a response
└─ Stop hook: node ~/.claude/hooks/claude-tts/speak.mjs
├─ reads the transcript tail backwards → newest assistant text
├─ strips Markdown → selects by mode → truncates
└─ spawns the synthesizer detached, records its PID, exits 0Three design constraints drive the implementation:
The hook never blocks. Speech is spawned detached and the process exits immediately, so audio length has no effect on Claude Code's responsiveness.
The hook never fails loudly. Every error path exits 0 silently. A broken speech feature must not surface as a hook error on your response.
npx is the installer, not the runtime. init copies the runtime into ~/.claude/hooks/claude-tts/ and registers that path. Wiring the hook to npx claude-tts speak would add package resolution — and possibly a network round trip — to every single response.
Speaking the right message
Claude Code buffers transcript writes, so when the Stop hook fires the newest
line on disk is frequently still the previous turn's message — its
timestamp field is creation time, not flush time. Reading immediately speaks
the wrong response.
The hook therefore identifies messages by uuid and remembers the last one it
spoke, per session, in ~/.claude/claude-tts/spoken.json. If the newest message
on disk is one it has already spoken, it polls briefly (up to ~1.9s, well inside
the 10s hook timeout) for a genuinely new one, and skips rather than repeating if
none arrives.
Interruption
Speech is stopped by PID, recorded in ~/.claude/claude-tts/speaking.pid, not by process name. pkill say has no Windows equivalent, and killing by image name there would take down your own PowerShell sessions. Records older than 15 minutes are ignored, since the OS recycles PIDs.
What it touches
| Path | Purpose |
|---|---|
| ~/.claude/hooks/claude-tts/ | Installed runtime |
| ~/.claude/claude-tts/ | Config, pid files, log |
| ~/.claude/settings.json | Entries under hooks.Stop and hooks.SessionStart |
| ~/.claude/CLAUDE.md | One delimited block so Claude knows the commands |
init backs up settings.json before writing, refuses to proceed if it doesn't parse rather than overwriting it, and adds only its own hook. uninstall removes exactly that entry and leaves everything else alone.
Troubleshooting
Run claude-tts doctor first — it checks each stage and tells you what to fix.
Nothing is spoken. Restart Claude Code after init; the hook is read at session start.
Nothing is spoken, and the hook clearly ran. Hooks are spawned by Claude Code,
not by your login shell, so they may get a minimal PATH or none at all. Engines
therefore resolve binaries by absolute path (/usr/bin/say,
%SystemRoot%\System32\...\powershell.exe, /usr/bin/espeak-ng) rather than
trusting PATH. If you hit a synthesizer this misses, claude-tts config debug true
will name it in the log.
"Stop hook registered, but points elsewhere". The registered command embeds an absolute path to the Node binary that was active at install time. Switching Node versions (nvm, Homebrew, a system upgrade) invalidates it. Re-run npx claude-tts init.
Diagnosing a specific response. Turn on the log:
claude-tts config debug true
tail -f ~/.claude/claude-tts/claude-tts.logLinux: no synthesizer. sudo apt install espeak-ng (or your distro's equivalent).
Development
npm test # 118 tests, no dependenciesTests cover Markdown sanitizing, transcript parsing, config validation, and the settings.json merge — including that unrelated hooks and settings survive install and uninstall untouched.
License
MIT
