Read Claude Code's responses aloud on macOS with the built-in say command.
Toggle it any time with /tts — no restart, no API keys, no external services.
Works in both the Claude Code terminal and the Desktop app.
- macOS — uses the system
say(text-to-speech) command. jq— parses the hook payload. Ships with macOS 15 (Sequoia)+; on older macOS install withbrew install jq.perl— ships with macOS (core modules only); cleans text, scans the transcript inallmode, and provides theflock/setsidthe background speaker needs.
The plugin lives in the plugin/ subdirectory; the repo root is a
Claude Code marketplace that points at it.
# From a Claude Code session:
/plugin marketplace add mek-earnin/cc-tts
/plugin install cc-tts@cc-tts-devRestart the session (or start a new one) so the hooks are registered — hooks are snapshotted at session start.
Invoke the /cc-tts:tts skill (shorthand /tts when unambiguous):
/tts on # start reading this session's responses aloud
/tts off # stop reading them (and stop any current speech)
/tts stop # stop the current utterance, but keep reading future ones
/tts # show current status (same as /tts status)
/tts toggle # flip on/off
- Per session.
/tts onaffects only the session you run it in. Other open sessions are unaffected; new sessions start silent. The state persists across turns within that session until you change it. /tts stopis for "shush this one" — it silences whatever is playing now (and clears any backlog inallmode) but leaves cc-tts on, so the next response is spoken.
/tts on optionally takes -v <voice> and/or -p <prefix>, in either order:
/tts on -v "Bad News" -p "Session A" # this session only: voice + spoken label
/tts on -v Samantha # just override the voice
/tts on -p "Session A" # just add a spoken label
/tts on # plain on, no voice/prefix
Quote multi-word values, as above.
-v <voice>overrides the voice for this session only. Precedence: session-v>CC_TTS_VOICE> macOS system voice. The value is passed tosay -vverbatim — it isn't validated, so a typo just getssay's own error.-p <prefix>is a spoken label announced before the text, as"<prefix> — <text>". It's spoken once per assistant message — infinalmode that's every reply; inallmode only the first chunk of a message carries it, so mid-message narration chunks aren't prefixed individually.- Declarative. Each
/tts onfully replaces the session's voice/prefix config. Re-running it updates them live; a bare/tts onafter a configured one clears both — flags aren't merged across invocations. - Voice and prefix are per-session and are cleared on
/tts offor when the session ends.
Set the reading mode with the CC_TTS_MODE environment variable — export it in
your shell rc, or launch CC_TTS_MODE=all claude. It's read at session start, so
it's a launch-time preference (on/off stays a live per-session toggle via /tts).
CC_TTS_MODE |
What gets read |
|---|---|
final (default) |
Only Claude's final message at the end of each turn. A new turn interrupts the previous one — you always hear the latest. |
all |
Everything Claude says, including the "Now I'll do X" narration before each tool call, as it works. |
all mode plays sequentially and never cuts. Narration chunks are queued and
read one after another to completion; the hook never blocks the agent (a detached
background speaker does the talking). The tradeoff: if Claude generates faster than
speech can keep up (long autonomous runs), the narration lags behind the actual
work — that's the unavoidable cost of "read everything without cutting." Use
/tts stop to flush the backlog and catch up without turning TTS off.
All optional, set as environment variables:
This needs CC_TTS_VOICE to be unset, or "system", or "default".
Then, open System Settings > Accessibility > Read & Speak
- A
/ttsskill (plugin/skills/tts/SKILL.md) has Claude runplugin/scripts/ttsvia the Bash tool, which flips a per-session flag file under~/.claude/cc-tts/sessions/<id>. The logic lives in the script, not inline. (It's a skill, not acommands/*.mdfile, because bash-preamble commands are terminal-only — a skill that shells out runs in the Desktop app too.) - State is keyed by session id. The command resolves the current session from
$CLAUDE_CODE_SESSION_ID; the Stop hook readssession_idfrom its payload. Both name the same main session, so a flag written by/ttsis read back by that session's hook — and no other. - Two hooks (
plugin/hooks/hooks.json) runplugin/scripts/speak:Stop(end of turn) andPostToolUse(after each tool). All text is cleaned (code blocks / markdown / URLs / emoji stripped, length capped) before it reachessay. finalmode usesStoponly: it speakslast_assistant_message(falling back to the transcript), interrupting the previous turn.PostToolUseis a no-op.allmode treats the transcript as a queue. Each hook hands off to a single detached drainer (plugin/scripts/drainer) that reads new assistant text blocks by byte cursor (plugin/scripts/scan.pl) and speaks them one at a time. A per-sessionflockguarantees exactly one drainer, so nothing is spoken twice and chunks never overlap; the hook only spawns it and returns, so the agent is never blocked./tts stop/offsignal the drainer via a flag file — it kills only its ownsaychild, never a pid from a file.- Because the hooks check the per-session flag at speak time, toggling with
/ttstakes effect immediately, mid-session. - Cleanup is time-based, with a clean-exit fast path. Every turn-end and every
/ttsinvocation sweeps any session whose state has been idle pastCC_TTS_STALE_DAYS(a singlefind … -delete, so the flag and all scratch files go together). ASessionEndhook additionally tears down a session's state the moment it exits cleanly. The drainer never re-creates a flag that's been removed and self-removes its scratch files once its session is disabled, so cleanup can't be undone by an in-flight drainer.
- Per session, so it needs
CLAUDE_CODE_SESSION_ID. Claude Code exports this to spawned shells; if it's ever missing,/ttsreports so and can't change state. - Abandoned sessions are pruned by time, not by a clean exit. People rarely
end a session cleanly — they just close the window — so there's no reliable
"session ended" signal to clean up on. Instead, any session whose state has sat
idle longer than
CC_TTS_STALE_DAYS(default 2) is swept — the flag and every scratch file, in one pass, so nothing accumulates. A live session refreshes its own state on every hook, so only genuinely idle ones age out; if you leave one idle past the window, TTS just turns off there and you re-enable with/tts on. The sweep runs whenever any session ends a turn or you run/tts, so files can't pile up between runs. As a bonus, a clean exit (/clear, quit, logout) fires aSessionEndhook that tears that one session's state down right away — but that's only a fast path; the time-based sweep is what actually guarantees cleanup, sinceSessionEnddoesn't fire on a crash,kill, or a window closed without exiting. - One set of speakers. Speech is serialized within a session (no overlap). Across sessions it isn't: if you enable TTS in two sessions at once, their audio can overlap. That runs against the per-session point, so it's left as a caveat rather than a global lock.
allmode can lag behind long autonomous runs (see Modes)./tts stopflushes the backlog.CC_TTS_MODEis launch-time (read at session start). Change it and start a new session; on/off stays a live toggle./ttstakes only the fixed subcommands above; the script rejects anything else (fail-closed whitelist), so a bad argument just prints usage.- Speech is capped (
CC_TTS_MAX_CHARS) per chunk so long answers don't monologue.
devtools/bump-version.sh keeps the version in sync across the plugin manifest
and the marketplace entry (declared in .version-bump.json):
devtools/bump-version.sh --check # show current versions, detect drift
devtools/bump-version.sh --audit # also scan the repo for stray version strings
devtools/bump-version.sh 0.2.0 # bump every declared file to 0.2.0The plugin/ layout (rather than a flat repo) keeps docs and tooling out of what
ships, and leaves room to add a plugin/.cursor-plugin/ manifest for Cursor
later without restructuring.
cc-tts/
├── .claude-plugin/marketplace.json # marketplace root → ./plugin
├── plugin/ # the installable plugin ("src")
│ ├── .claude-plugin/plugin.json
│ ├── skills/tts/SKILL.md # /cc-tts:tts (runs scripts/tts via Bash; Desktop + terminal)
│ ├── hooks/hooks.json # Stop + PostToolUse + SessionEnd → scripts/speak
│ └── scripts/
│ ├── lib.sh # shared state, mode, text cleanup
│ ├── tts # /tts control (on/off/stop/status/toggle)
│ ├── speak # hooks entry; final-mode speaker / all-mode launcher
│ ├── drainer # all-mode single background speaker
│ └── scan.pl # all-mode transcript cursor/scanner
├── devtools/bump-version.sh
└── .version-bump.json
