Audio and desktop notifications for Claude Code. A Python HTTP server runs outside the bubblewrap sandbox, and a shell hook script inside the sandbox curls it to trigger sounds and notifications on Claude Code events.
A simple version of (and inspired by) peon-ping.
- Python 3.10+
- mpv for audio playback
notify-sendfor desktop notifications (optional, usually part oflibnotify)
./server.pyOptions:
| Flag | Default | Description |
|---|---|---|
--port |
7331 |
Port to listen on |
--config |
sounds.conf |
Event → sound mapping file |
--notify-config |
notify.conf |
Event → notification message file |
./server.py --port 8080./server.py --hooksThis wires every Claude Code hook event to hook.sh in ~/.claude/settings.json
(merging into your existing settings — other keys are left untouched), then exits.
Pass a path to target a different settings file: ./server.py --hooks /path/to/settings.json.
Each hook calls hook.sh with an event name, which fires a curl request to the server.
ln -s ~/sources/claude-hooks/claude-hooks.service ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now claude-hooksCheck status:
systemctl --user status claude-hooks
journalctl --user -u claude-hooks -fClaude Code hook event
-> hook.sh (curl, fire-and-forget)
-> server.py (plays sound via mpv, optionally sends notify-send)
At startup the server reads the event → sound mapping from sounds.conf and the
event → notification mapping from notify.conf. On each request it plays a
random sound for the event (if mapped) and/or sends a desktop notification (if
mapped) via notify-send. The two are independent — an event can have a sound,
a notification, both, or neither.
When running several Claude instances at once, notifications stay
distinguishable: each notification's title is Claude Code — <dir>:<branch>,
where <dir> is the instance's working-directory basename and <branch> its
git branch (worktree-aware; omitted outside a repo), plus a [<agent>] tag when
the hook fired inside a subagent. Notifications are also throttled per instance,
so one busy Claude never suppresses another's alerts.
The sound files live in sounds/ (the Orc Peon pack from
openpeon). sounds.conf maps each
Claude Code hook event to the files it can play. The defaults that ship enabled:
| Hook Event | Peon lines |
|---|---|
| Stop | "Ready to work?", "Something need doing?", "I can do that.", "Be happy to.", "Work, work.", "OK." |
| Notification, PermissionRequest | "Something need doing?", "Hmm?", "What you want?", "Yes?" |
sounds.conf also ships commented-out mappings for SessionStart,
UserPromptSubmit, SubagentStop, TaskCompleted, PostToolUseFailure,
and PreCompact — uncomment a line to enable that event.
The event → sound mapping. Each line is EVENT=file[,file,...]; one of the
listed files is picked at random per play. Bare filenames resolve against
sounds/, absolute paths are used as-is.
Map an event to one or more sounds:
Stop=PeonReady1.wav,PeonYes1.wav
Notification=/path/to/custom/sound.ogg
Silence an event (empty value), or comment it out to disable it entirely:
PreToolUse=
#PostToolUse=...
The event → desktop notification mapping, same conventions as sounds.conf.
Each line is EVENT=message; the event fires a notify-send notification with
that message as the body (title is "Claude Code", suffixed with the instance's
dir:branch — see "How it works"). Empty value silences, commenting out
disables. Notification and PermissionRequest ship enabled.
Notification=Claude has a notification
#Stop=Claude is done
An EVENT:TOOL=message line overrides the message for a specific tool, matched
against the hook payload's tool_name, and wins over the plain EVENT key. This
lets one event notify differently per tool — for example, AskUserQuestion
arrives as a PermissionRequest, so this distinguishes a question prompt from an
ordinary tool approval:
PermissionRequest=Claude needs permission
PermissionRequest:AskUserQuestion=Claude has a question
The message may contain placeholders: {message} (the event's own text) and
{tool} (the tool name). Notification={message} forwards the actual text
Claude wanted to show (e.g. "Claude needs your permission to use Bash").
The Notification event is routed by subtype — it reaches the server as
Notification:<subtype> (permission_prompt, idle_prompt, auth_success,
elicitation_dialog/_complete/_response). A Notification:<subtype> line
wins over the plain Notification line, so you can silence just one subtype:
Notification={message}
# mute the idle "waiting for your input" nag (empty value = silence)
Notification:idle_prompt=
Subtype routing is installed by --hooks, so re-run ./server.py --hooks
after adding subtype lines for the first time.
Set CLAUDE_AUDIO_PORT in the hook script's environment to match the server port:
CLAUDE_AUDIO_PORT=8080 ./hook.sh Notification| File | Description |
|---|---|
server.py |
HTTP server (plays sounds, sends notifications); --hooks installs the Claude Code hooks |
hook.sh |
Thin curl wrapper called by Claude Code hooks |
sounds.conf |
Event → sound mapping |
notify.conf |
Event → desktop notification mapping |
claude-hooks.service |
systemd user service unit |
sounds/ |
Orc Peon .wav files referenced by sounds.conf |
GET /play?event=<name>
| Response | Meaning |
|---|---|
| 200 | Sound and/or notification fired |
| 204 | Nothing to do — event unmapped, silenced, or its message expanded to empty |
| 404 | Unknown path |
| 500 | mpv not installed (the notification, if any, is still sent) |