agent-notify-server
Tiny local HTTP server that plays notification sounds for long-running agents
(Claude Code, in particular). Replaces ad-hoc afplay bash loops that were
prone to leaving zombie processes — the server owns the audio pipeline and
exits cleanly with Ctrl+C.
Run
cargo run --bin agent-notify-server
# or, for a release-mode binary:
cargo run --release --bin agent-notify-server
The server listens on 127.0.0.1:43110 by default. Override with
HTTP_BIND_ADDRESS. Override the config path with NOTIFY_CONFIG_PATH
(defaults to crates/tools/servers/agent_notify_server/config/notify_config.yaml).
Endpoints
| Method | Path | Behavior |
|---|---|---|
| GET | / |
Static HTML page listing the API. |
| GET | /alert_beep |
Play alert_beep_sound once (mixes over any active loop). |
| GET | /alert_done |
Play alert_done_sound once. |
| GET | /alert_await |
Play alert_await_user_input_sound once. |
| GET | /loop_beep |
Loop alert_beep_sound. Replaces any active loop. |
| GET | /loop_done |
Loop alert_done_sound. Replaces any active loop. |
| GET | /loop_await |
Loop alert_await_user_input_sound. Replaces any active loop. |
| GET | /stop |
Stop everything — loops and queued one-shots. |
| GET | /state |
Read-only JSON snapshot of the audio engine and loaded config. Does not change playback. |
Mixing rules:
- One-shots mix with whichever loop is playing.
- Requesting a new loop replaces the prior loop.
/stophalts everything.- A missing config key makes the corresponding endpoint return
404.
/state response shape
{
"audio": {
"loop_playing": true,
"loop_name": "done",
"voices_active": 2,
"current_stage": 1,
"current_gap_millis": 1000,
"current_jitter_millis": 750,
"loop_pool_size": 4,
"loop_uptime_secs": 17
},
"config": {
"alert_beep_sound": "…/test_beep.wav",
"alert_done_sound": "…/smrpg_flower.wav",
"alert_await_user_input_sound": "…/smrpg_ghost.wav",
"extra_alert_beep_count": 0,
"extra_alert_done_count": 3,
"extra_alert_await_count": 3,
"gap_schedule_millis": [2000, 1000, 500, 200],
"jitter_schedule_millis": [1500, 750, 400, 180],
"escalate_waits_secs": [10, 20, 30]
}
}
loop_name reflects which endpoint started the loop (beep / done /
await). When idle, loop_playing is false and the loop fields collapse
to zero/null.
Config
config/notify_config.yaml:
alert_beep_sound: test_beep.wav
alert_done_sound: smrpg_flower.wav
alert_await_user_input_sound: smrpg_ghost.wav
extra_alert_beep_sounds: []
extra_alert_done_sounds:
- smrpg_specialflower.wav
- smrpg_correct.wav
extra_alert_await_sounds:
- smrpg_wrong.wav
- smrpg_drybones_crumble.wav
loop_alert_timeout_millis: 2000
loop_alert_timeout_millis_1: 1000
loop_alert_timeout_millis_2: 500
loop_alert_timeout_millis_3: 200
loop_alert_jitter_millis: 250
loop_alert_jitter_millis_1: 150
loop_alert_jitter_millis_2: 80
loop_alert_jitter_millis_3: 40
escalate_wait_1: 15
escalate_wait_2: 30
escalate_wait_3: 45
- Paths can be absolute, or relative to the YAML file's directory.
- WAV and MP3 are both supported (decoded via rodio + symphonia).
loop_alert_timeout_millisis the gap between consecutive plays of a single voice at stage 0. Omit to replay back-to-back.loop_alert_timeout_millis_{1,2,3}override the gap at each escalation stage. Each falls back to the previous stage when unset — so you can set just_1: 200to drop straight to a 200ms gap from voice 2 onward.loop_alert_jitter_millis[_1|_2|_3]add+/- rand(0..=jitter)to each sleep (clamped at 0). Same fallback chain as the timeouts. Useful to keep voices from re-aligning even when they share a gap. The upcoming stage's jitter is also applied to the escalation wait itself, so voices 2/3/4 don't always land exactly onescalate_wait_N— their phase drifts by+/- rand(0..=jitter)ms.- Escalation: a
/loop_*request starts one voice immediately. Atescalate_wait_1/escalate_wait_2/escalate_wait_3seconds, a second / third / fourth concurrent voice joins the mix. New voices are taken fromextra_alert_<state>_soundsin order; when that pool is exhausted the supervisor cycles back through the full pool (primary + extras), so existing voices double up. Each voice runs in its own thread and drifts naturally relative to the others. - All voices in a session share the current stage's gap, so existing voices also speed up when the supervisor advances stages.
Claude Code wiring
The intended workflow:
- When Claude needs input → start a
loop_awaitsound. - When the user replies →
stop. - When Claude finishes a turn → start a
loop_donesound (replacingloop_awaitif it was still playing); the next user prompt stops it.
~/.claude/agent_notify.sh
This helper probes port 43110, spawns the server with cargo run if nothing
is listening (waiting up to 60s for it to come up), then curls the requested
endpoint. Drop it at ~/.claude/agent_notify.sh and chmod +x it.
#!/bin/bash
# Usage: agent_notify.sh await | done | stop
set -u
PORT=43110
REPO="$HOME/dev/storyteller/artcraft"
LOGFILE="/tmp/agent-notify-server.log"
case "${1:-}" in
await) endpoint="loop_await" ;;
done) endpoint="loop_done" ;;
stop) endpoint="stop" ;;
*) echo "usage: $0 await|done|stop" >&2; exit 1 ;;
esac
probe() {
curl -fsS -o /dev/null -m 0.5 "http://127.0.0.1:$PORT/" 2>/dev/null
}
if ! probe; then
if [ ! -d "$REPO" ]; then
echo "agent-notify-server: repo not found at $REPO" >&2
exit 1
fi
(
cd "$REPO" || exit 1
nohup cargo run --bin agent-notify-server >>"$LOGFILE" 2>&1 &
disown
) </dev/null >/dev/null 2>&1
for _ in $(seq 1 120); do
probe && break
sleep 0.5
done
if ! probe; then
echo "agent-notify-server: failed to start; see $LOGFILE" >&2
exit 1
fi
fi
curl -fsS -m 1 "http://127.0.0.1:$PORT/$endpoint" -o /dev/null
~/.claude/settings.json
Merge these hooks into your existing hooks block. async: true ensures the
hook never blocks the UI while waiting for cargo run on the cold-start path.
{
"hooks": {
"Notification": [
{
"hooks": [
{
"type": "command",
"command": "bash ~/.claude/agent_notify_on_notification.sh",
"async": true
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "bash ~/.claude/agent_notify.sh done",
"async": true
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "bash ~/.claude/agent_notify.sh stop",
"async": true
}
]
}
],
"PostToolUse": [
{
"matcher": "AskUserQuestion",
"hooks": [
{
"type": "command",
"command": "bash ~/.claude/agent_notify.sh stop",
"async": true
}
]
}
]
}
}
Why two stop hooks? UserPromptSubmit fires only for typed user messages, not
for AskUserQuestion option selections. The PostToolUse matcher on
AskUserQuestion covers that case — it fires when the tool call returns
(i.e., once you've answered), and the helper's /stop silences whatever
loop the Notification or Stop hook started.
Why the notification wrapper? The Notification hook fires for every
Claude Code notification — idle reminders, permission asks, background-agent
attention pings — not just "Claude needs your input". Crucially, the idle
reminder uses the same message text as a real input prompt
("Claude is waiting for your input"), so the only reliable discriminator
is the notification_type field.
~/.claude/agent_notify_on_notification.sh reads the hook payload, logs
every event to /tmp/agent-notify-notifications.log for audit, and
skips events with notification_type == "idle_prompt". Everything else
(permission prompts, unknown future types) fires /loop_await — better
to ring the bell on an unknown attention prompt than miss a real one.
Replacing the old bash-loop system
Audit log
The Notification wrapper writes every received notification payload to
/tmp/agent-notify-notifications.log, tagged FIRE or SKIP. If the await
sound stops firing for a case you care about (or fires when it shouldn't),
inspect that log and adjust the case patterns in
agent_notify_on_notification.sh.
Replacing the old bash-loop system
If you previously had ~/.claude/notify_loop.sh, notify_stop.sh,
stop_loop.sh, stop_stop.sh, kill any survivors and remove them:
pkill -f claude_notify_loop_marker 2>/dev/null
pkill -f 'afplay.*smrpg' 2>/dev/null
rm -f ~/.claude/notify_loop.sh ~/.claude/notify_stop.sh \
~/.claude/stop_loop.sh ~/.claude/stop_stop.sh \
~/.claude/notify_loop.pid ~/.claude/stop_loop.pid
Shutdown
Ctrl+C exits cleanly in ~0.3s, even mid-playback. The server has
shutdown_timeout(0) and signals the audio engine to drop any in-flight
sound the moment SIGINT arrives — no zombie afplay processes.