I usually have three or four Claude Code sessions open, each in a different client folder. My hooks played Blow when a session needed me and Ping when it finished. The same two sounds for every project. So every Ping meant: switch through all the terminal tabs and look.
Today I fixed that in about an hour, and most of the hour went into finding out what not to do.
First idea: a different sound per project
Claude Code passes JSON to every hook on stdin, and that JSON has the session's cwd. My projects live in ~/work/<client>/, so the client name is just the first folder after ~/work/. One script can read it and pick a sound from a small map.
macOS ships 14 system sounds in /System/Library/Sounds. I gave three clients two sounds each, one for "needs you" and one for "done". It worked. It was also useless: after ten minutes I could not tell Bottle from Frog, or remember which one was which client.
What stuck: a spoken word
The fix was to drop sounds for the projects I care about and let macOS say one short word instead. No learning, no mapping in my head.
Folder in ~/work | Says | Speed |
|---|---|---|
vimo | "vimo" | 280 wpm |
mtl | "MTL" | 280 wpm |
acme | "acme" | 280 wpm |
| anything else | a plain Ping |
Three things I changed after hearing it in real use:
The default voice speed is about 175 words per minute. At 280 a word takes under half a second and still sounds clear.
Beep plus word felt like two notifications. The word alone is enough, and the beep stays only for unlisted projects.
Two sessions finishing at once spoke over each other. A lock file now makes them wait their turn.
How it fits together
flowchart LR
A["Claude Code<br/>Notification or Stop"] --> B["project-sound.sh"]
B --> C{"muted?"}
C -- yes --> D[silence]
C -- no --> E{"client in map?"}
E -- yes --> F["say word"]
E -- no --> G["afplay Ping"]
F --> H["lockf queue"]
G --> H
The map is a plain text file, one line per client: the folder name, then the word. The heart of the script is a few lines:
client=${${cwd#$HOME/work/}%%/*}
if [[ -n $word ]]; then
lockf -k $lock zsh -c 'say -r 280 "$1"; sleep 0.3' _ "$word" &!
else
lockf -k $lock zsh -c 'afplay "$1"; sleep 0.3' _ /System/Library/Sounds/Ping.aiff &!
fi
lockf ships with macOS, so the queue needs nothing extra. The &! runs the sound in the background, so the hook returns at once and Claude Code does not wait for the voice.
Worktrees in ~/work/<client>/workdirs/ map to the same client for free, because only the first folder after ~/work/ counts.
A mute switch for meetings
A laptop that says "acme" in the middle of a call with a different client is not great. So the same script takes on, off, toggle and status. off creates a flag file, and while it exists every hook stays silent.
I can flip it two ways: /sounds off inside any Claude session, or cc-sounds off in a terminal. The flag file is in .gitignore, so muting my laptop does not mute the other Mac when I sync ~/.claude.
settings.json is not in my synced repo, so the second Mac needed a manual change to point its hooks at the script. I left that as a handoff note for the other machine.
What it took
One script, one text file, two hooks and a slash command. The code was the easy part. The real work was listening: sounds that looked fine in a table were impossible to tell apart, the first voice speed was too slow, and the beep in front of the word was noise. Next time I will start with spoken words and skip the sound palette.

Related notes
Two Macs, one Claude Code setup: skills as plugins, not symlinks
I moved 36 Claude Code skills out of 17 tiny repos and a pile of symlinks into two plugins, one public and one private, with a make pull on the other machine. Here is what changed and why.
Connecting Your Max for Live Device to a Cloud API
Send song structure data from Ableton Live to a cloud API. Build a complete DAW-to-database pipeline with real-time visualization.



