Skip to content

Read Claude Code messages aloud (TTS) via Stop hook #254

Description

@etrobert-bot

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:

  1. Reads the event JSON on stdin, gets transcript_path.
  2. Extracts the last assistant entry's text content blocks (skipping
    thinking and tool_use).
  3. Cleans markdown so symbols aren't read aloud.
  4. 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)

  1. 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.)
  2. Register in pkgs/default.nix and add to binPath in
    pkgs/claude-code-wrapped/default.nix so the hook resolves on PATH.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions