TL;DR: I wanted Claude Code to read out each response through say on macOS so I could step away and listen instead of watching the terminal. Four bugs got in the way: the hook was on the wrong event, tac does not exist on macOS, the Stop hook fires before Claude flushes the transcript, and pkill say clobbered speech from other sessions. A 1-second sleep, jq -rs, and a per-session PID file fixed it. I also trim the message to the last non-empty line and strip markdown, so long replies don’t drone on and URLs don’t get spoken character by character.

Motivation Link to heading

I leave long-running Claude Code sessions churning in the background and want audible progress updates. A shell toggle controls whether the hook speaks or stays silent, so I can mute it without restarting.

The hook script Link to heading

I put the hook in ~/.claude/hooks/speak-response.sh and point it at the Stop event in ~/.claude/settings.json:

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "$HOME/.claude/hooks/speak-response.sh"
          }
        ]
      }
    ]
  }
}

The script itself:

#!/bin/bash

[[ -f ~/.claude/.silence ]] && exit 0

INPUT=$(cat)
TRANSCRIPT_PATH=$(echo "$INPUT" | jq -r '.transcript_path')
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // "default"')
PID_FILE="/tmp/speak-response-${SESSION_ID}.pid"

sleep 1

LAST_RESPONSE=$(jq -rs '
  [.[] | select(.type == "assistant") | .message.content[]? | select(.type == "text") | .text]
  | map(select(length > 0))
  | last // ""
' "$TRANSCRIPT_PATH" 2>/dev/null)

LAST_LINE=$(echo "$LAST_RESPONSE" | awk 'NF{line=$0} END{print line}')

CLEAN=$(echo "$LAST_LINE" | sed -E \
  -e 's/\[([^]]+)\]\([^)]+\)/\1/g' \
  -e 's/`([^`]+)`/\1/g' \
  -e 's/\*\*([^*]+)\*\*/\1/g' \
  -e 's/(^|[[:space:]])\*([^*]+)\*/\1\2/g' \
  -e 's/https?:\/\/[^[:space:]]+//g')

MESSAGE="${CLEAN:-Needs your input}"

if [[ -f $PID_FILE ]]; then
  kill "$(cat "$PID_FILE")" 2>/dev/null
fi

nohup say -- "$MESSAGE" >/dev/null 2>&1 &
echo $! >"$PID_FILE"

Bugs I hit first Link to heading

1. Wrong event Link to heading

I had the hook on Notification with "matcher": "assistant". The Notification event fires on system notifications like permission prompts or idle waits, not after every response. The Stop event fires when the assistant finishes a turn.

2. tac does not exist on macOS Link to heading

My first attempt used tac to reverse the transcript so I could grab the last assistant message from the top:

LAST_RESPONSE=$(tac "$TRANSCRIPT_PATH" | jq -r '...' | head -1)

tac ships with GNU coreutils on Linux but not with macOS, so the pipeline failed without a useful error. I replaced it with jq -rs (slurp mode), which reads the whole JSONL file as an array and lets me grab the last matching text with | last.

3. The Stop hook races the transcript flush Link to heading

After fixing the event and the tac bug, I kept hearing the response from the turn before the one that just finished. The Stop hook fires the instant the assistant stops streaming, before Claude has appended the final message to the .jsonl transcript. A sleep 1 before the jq read lets the write land first.

4. pkill say killed other sessions Link to heading

If a new response arrived while an old one was mid-sentence, I used pkill -x say to cut it off. That worked for one session. With two Claude Code windows open, each hook would kill the other one’s speech.

The hook input JSON includes a session_id. I stash the say PID in /tmp/speak-response-<id>.pid and kill that PID on the next turn, leaving other sessions alone.

Last line only Link to heading

Reading the whole response out loud got tedious on long replies. I pipe the text through awk 'NF{line=$0} END{print line}' to grab the last non-empty line, which is where I put the summary.

Strip markdown before speaking Link to heading

say reads the text as-is, so [PR #58](https://github.com/...) comes out as “open square bracket P R hash fifty-eight close square bracket open paren h t t p s colon…”. A short sed pass keeps the visible text and drops the link target, backticks, bold/italic markers, and bare URLs:

CLEAN=$(echo "$LAST_LINE" | sed -E \
  -e 's/\[([^]]+)\]\([^)]+\)/\1/g' \
  -e 's/`([^`]+)`/\1/g' \
  -e 's/\*\*([^*]+)\*\*/\1/g' \
  -e 's/(^|[[:space:]])\*([^*]+)\*/\1\2/g' \
  -e 's/https?:\/\/[^[:space:]]+//g')

Toggle alias Link to heading

I added a shell function that flips a sentinel file and prints the new state:

ccspeak() {
  local f=~/.claude/.silence
  if [[ -f $f ]]; then
    rm "$f"
    echo "ccspeak: ON — claude will speak responses via Stop hook"
  else
    touch "$f"
    echo "ccspeak: OFF — claude will stay silent"
  fi
}

The hook checks for ~/.claude/.silence at the top and exits early if it exists. Running ccspeak toggles the setting without touching the hook config.

Further reading Link to heading