Goal
When Claude Code finishes a turn, the final assistant message is read aloud
via local text-to-speech. Fully local, non-blocking. Primary target host:
tower (Linux/PipeWire). See darwin section for aaron.
This is also the v0 guinea pig for the planner→implement→review loop system
(tracked separately).
Mechanism — Stop hook + transcript parse (docs-confirmed)
Stop fires once per turn on the final assistant message — exactly what we
want to speak (not intermediate tool-call narration). The hook:
- Reads the event JSON on stdin, gets
transcript_path.
- Extracts the last assistant entry's
text content blocks (skipping
thinking and tool_use).
- Cleans markdown so symbols aren't read aloud.
- Pipes to the TTS engine.
Configured async: true so it never blocks the session.
Note: the Stop payload does not contain the message text directly — you
must read transcript_path (JSONL) and pull the last role:"assistant" entry's
content[].text blocks. Implementer must verify the jq filter against a real
transcript (schema is documented-but-informal).
Source: https://code.claude.com/docs/en/hooks.md
Components (follow existing repo patterns)
- New package
claude-speak (pkgs/claude-speak/) — a
writeShellApplication, inheritPath = false, mirroring
claude-rate-limit-notify:
runtimeInputs: coreutils, jq, gnused/gnugrep, the TTS engine, and
pipewire (for pw-play) on Linux.
- Logic: read stdin JSON →
jq last assistant text from transcript → strip
markdown (code fences, backticks, URLs, *_# markers) → if empty
(tool-only turn) exit silently → speak.
- Overlap guard: kill any in-flight speech (PID file) before speaking so
consecutive turns don't talk over each other. (PID path: $XDG_RUNTIME_DIR
on Linux; a tmp path on macOS.)
- Register in
pkgs/default.nix and add to binPath in
pkgs/claude-code-wrapped/default.nix so the hook resolves on PATH.
- Hook entry in
pkgs/claude-code-wrapped/config/settings.json:
"Stop": [{ "matcher": "", "hooks": [{ "type": "command", "command": "claude-speak", "async": true, "timeout": 30 }] }]
TTS engine — DECISION NEEDED (decision A)
Both confirmed available in nixpkgs:
espeak-ng — lightweight, self-contained, no model files, but robotic.
piper-tts (1.4.2) — neural/natural, but the closure pulls in
torch + onnxruntime (heavy), and voice models are NOT packaged
(piper-voices is not an attr) — you'd fetchurl an .onnx+.json voice
yourself.
Recommendation: espeak-ng for the first PR (tiny diff, proves the
pipeline); follow-up issue to upgrade to piper-tts once validated. (Product
owner may choose to go straight to piper despite the cost.)
Other decisions
- B — on/off toggle: env var gate (e.g.
CLAUDE_SPEAK=0 to mute) so it can
be silenced without a rebuild. (Recommended.)
- C — read scope: final
Stop message only; skip pure-tool-call turns.
(Recommended — not every displayed chunk.)
Streaming (read aloud AS text streams) — investigated, NOT in scope
Asked: can TTS start speaking while the message generates, instead of waiting for
turn end?
Finding (sourced):
- No per-token/per-sentence hook exists in the interactive TUI.
MessageDisplay exists but fires during display with undocumented
granularity and a hard 10s timeout — not reliable for sentence-by-sentence
speech.
- True streaming only exists in headless mode:
claude -p --output-format stream-json --include-partial-messages emits real
text_delta events you could pipe to a streaming TTS pipeline (split on
sentence boundaries → queue audio). But this gives up the interactive TUI
entirely — it's a different architecture, not a tweak to the Stop hook.
Conclusion: ship turn-level (Stop) now. Streaming is a separate research
spike, only worth it if turn-end latency genuinely bothers us.
Sources: https://code.claude.com/docs/en/hooks-guide.md ,
https://code.claude.com/docs/en/headless.md
macOS / aaron (darwin) support — low effort, decide whether same PR or fast-follow
macOS ships /usr/bin/say (good voices, no model, no packaging) — actually
simpler than Linux. Work needed:
- Make
claude-speak platform-aware: darwin → /usr/bin/say; linux →
espeak-ng/piper + pw-play. (With inheritPath = false, reference
/usr/bin/say by absolute path since it's a stable macOS system binary, not a
nixpkgs package.)
- The flake already builds per-system; the
settings.json hook is shared
(one claude-speak command name, script handles the platform branch).
- Minor: overlap-guard PID path differs (no
$XDG_RUNTIME_DIR on macOS).
Open: include darwin in the same PR (shared script, small branch) or as a fast
follow after tower is proven.
Audio stack facts (from repo scan)
tower runs PipeWire (security.rtkit.enable = true in
modules/nixos-workstation.nix; pw-*/wpctl used by audio-output-switcher
/ volume-control). pw-play is the playback path for synthesized WAV.
- No TTS packages currently in the repo.
claude-code-wrapped reads config from disk at runtime via
CLAUDE_CONFIG_DIR (pkgs/claude-code-wrapped/default.nix), so
settings.json hook changes hot-reload without a rebuild — but the
claude-speak package itself needs a rebuild to land on PATH.
- Existing hooks for reference:
SessionStart, StopFailure (rate_limit
matcher), PostToolUse (Edit|Write|MultiEdit) in
pkgs/claude-code-wrapped/config/settings.json.
Risks / verification
- Audio reaching PipeWire from the hook's environment (mitigated by
pw-play).
- Very long messages — consider a char cap (e.g. skip/truncate over ~2000 chars).
- Transcript JSONL schema is documented-but-informal — verify
jq filter on a
real transcript.
Test plan (loop "done" criteria)
nix build .#claude-speak succeeds.
- Feed a captured sample
Stop JSON into claude-speak → audible,
markdown-stripped speech on tower.
- A tool-only turn produces silence.
nix build the host config to prove the hook wiring evaluates.
Goal
When Claude Code finishes a turn, the final assistant message is read aloud
via local text-to-speech. Fully local, non-blocking. Primary target host:
tower(Linux/PipeWire). See darwin section foraaron.This is also the v0 guinea pig for the planner→implement→review loop system
(tracked separately).
Mechanism —
Stophook + transcript parse (docs-confirmed)Stopfires once per turn on the final assistant message — exactly what wewant to speak (not intermediate tool-call narration). The hook:
transcript_path.textcontent blocks (skippingthinkingandtool_use).Configured
async: trueso it never blocks the session.Note: the
Stoppayload does not contain the message text directly — youmust read
transcript_path(JSONL) and pull the lastrole:"assistant"entry'scontent[].textblocks. Implementer must verify thejqfilter against a realtranscript (schema is documented-but-informal).
Source: https://code.claude.com/docs/en/hooks.md
Components (follow existing repo patterns)
claude-speak(pkgs/claude-speak/) — awriteShellApplication,inheritPath = false, mirroringclaude-rate-limit-notify:runtimeInputs:coreutils,jq,gnused/gnugrep, the TTS engine, andpipewire(forpw-play) on Linux.jqlast assistant text from transcript → stripmarkdown (code fences, backticks, URLs,
*_#markers) → if empty(tool-only turn) exit silently → speak.
consecutive turns don't talk over each other. (PID path:
$XDG_RUNTIME_DIRon Linux; a tmp path on macOS.)
pkgs/default.nixand add tobinPathinpkgs/claude-code-wrapped/default.nixso the hook resolves on PATH.pkgs/claude-code-wrapped/config/settings.json:TTS engine — DECISION NEEDED (decision A)
Both confirmed available in nixpkgs:
espeak-ng— lightweight, self-contained, no model files, but robotic.piper-tts(1.4.2) — neural/natural, but the closure pulls intorch + onnxruntime (heavy), and voice models are NOT packaged
(
piper-voicesis not an attr) — you'dfetchurlan.onnx+.jsonvoiceyourself.
Recommendation:
espeak-ngfor the first PR (tiny diff, proves thepipeline); follow-up issue to upgrade to
piper-ttsonce validated. (Productowner may choose to go straight to piper despite the cost.)
Other decisions
CLAUDE_SPEAK=0to mute) so it canbe silenced without a rebuild. (Recommended.)
Stopmessage only; skip pure-tool-call turns.(Recommended — not every displayed chunk.)
Streaming (read aloud AS text streams) — investigated, NOT in scope
Asked: can TTS start speaking while the message generates, instead of waiting for
turn end?
Finding (sourced):
MessageDisplayexists but fires during display with undocumentedgranularity and a hard 10s timeout — not reliable for sentence-by-sentence
speech.
claude -p --output-format stream-json --include-partial-messagesemits realtext_deltaevents you could pipe to a streaming TTS pipeline (split onsentence boundaries → queue audio). But this gives up the interactive TUI
entirely — it's a different architecture, not a tweak to the Stop hook.
Conclusion: ship turn-level (
Stop) now. Streaming is a separate researchspike, only worth it if turn-end latency genuinely bothers us.
Sources: https://code.claude.com/docs/en/hooks-guide.md ,
https://code.claude.com/docs/en/headless.md
macOS /
aaron(darwin) support — low effort, decide whether same PR or fast-followmacOS ships
/usr/bin/say(good voices, no model, no packaging) — actuallysimpler than Linux. Work needed:
claude-speakplatform-aware: darwin →/usr/bin/say; linux →espeak-ng/piper+pw-play. (WithinheritPath = false, reference/usr/bin/sayby absolute path since it's a stable macOS system binary, not anixpkgs package.)
settings.jsonhook is shared(one
claude-speakcommand name, script handles the platform branch).$XDG_RUNTIME_DIRon macOS).Open: include darwin in the same PR (shared script, small branch) or as a fast
follow after
toweris proven.Audio stack facts (from repo scan)
towerruns PipeWire (security.rtkit.enable = trueinmodules/nixos-workstation.nix;pw-*/wpctlused byaudio-output-switcher/
volume-control).pw-playis the playback path for synthesized WAV.claude-code-wrappedreads config from disk at runtime viaCLAUDE_CONFIG_DIR(pkgs/claude-code-wrapped/default.nix), sosettings.jsonhook changes hot-reload without a rebuild — but theclaude-speakpackage itself needs a rebuild to land on PATH.SessionStart,StopFailure(rate_limitmatcher),
PostToolUse(Edit|Write|MultiEdit) inpkgs/claude-code-wrapped/config/settings.json.Risks / verification
pw-play).jqfilter on areal transcript.
Test plan (loop "done" criteria)
nix build .#claude-speaksucceeds.StopJSON intoclaude-speak→ audible,markdown-stripped speech on
tower.nix buildthe host config to prove the hook wiring evaluates.