Claude Code notifications: when it finishes, and when it is waiting on you
The Stop hook fires when a turn ends and the Notification hook fires when Claude is blocked on you. Working configs for macOS, Linux, and Windows, per-project labels, the mobile push option, and why no notification is arriving when you swear the hook is right.
Claude Code ships with a hook system, and the one you want first is Stop. It fires the moment Claude finishes responding. Put a command there and you get a notification instead of watching a spinner.
Here is the smallest version that works. Open ~/.claude/settings.json and add:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"Turn finished\" with title \"Claude Code\"'"
}
]
}
]
}
}
Restart Claude Code and the next time a turn ends you get a macOS notification. That is the whole answer for the simple case. The rest of this post is what you run into after that: the other operating systems, the event that matters more than Stop, telling three agents apart, notifications on your phone, and what to check when nothing arrives.
Which hook event should you use
Claude Code fires several events, and picking the wrong one is the usual reason a notification feels noisy or never arrives.
| Event | Fires when | Good for |
|---|---|---|
Stop |
The turn is over and Claude is done | "It finished, come back" |
Notification |
Claude needs you: a permission prompt or input | "It is blocked on you" |
UserPromptSubmit |
You submit a prompt | Marking the start of a wait |
SubagentStop |
A subagent finishes | Usually too chatty |
Most people wire Stop and stop there. Wire Notification too, and if you only have the patience for one, that is arguably the one.
The event people skip: Notification
A finished agent announces itself eventually; you will notice the silent terminal. A blocked agent looks exactly like a working one. It sat down at a permission prompt ninety seconds after you tabbed away and it will wait there all afternoon without a sound. That is the most expensive dead time in agent work, and it has its own event:
{
"hooks": {
"Notification": [
{
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"Waiting on you\" with title \"Claude Code\" sound name \"Ping\"'"
}
]
}
]
}
}
The payload carries a message and a notification_type. The two types worth filtering on are permission_prompt (blocked on an approval) and idle_prompt (waiting for input). If you want a different sound for "it needs permission" than for "it finished", filter on that field:
#!/bin/sh
payload="$(cat)"
kind=$(printf '%s' "$payload" | /usr/bin/python3 -c \
'import json,sys;print(json.load(sys.stdin).get("notification_type",""))')
case "$kind" in
permission_prompt) afplay /System/Library/Sounds/Sosumi.aiff & ;;
*) afplay /System/Library/Sounds/Ping.aiff & ;;
esac
exit 0
Wire Stop and Notification separately. They mean different things, and merging them into one generic ping throws away the distinction that makes the whole setup worth having. The full field-by-field reference is in what Claude Code actually passes to your hooks.
Linux and Windows
The hook system is identical on every platform; only the notification command changes.
Linux (libnotify, present on most desktops, otherwise apt install libnotify-bin):
{
"type": "command",
"command": "notify-send 'Claude Code' 'Turn finished'"
}
For a sound, paplay /usr/share/sounds/freedesktop/stereo/complete.oga works on anything with PulseAudio or PipeWire. On a headless box, skip both and see the phone section below.
Windows native toasts have no dependency-free one-liner. The reliable path is the BurntToast module, installed once:
Install-Module BurntToast -Scope CurrentUser
{
"type": "command",
"command": "powershell -NoProfile -Command \"New-BurntToastNotification -Text 'Claude Code','Turn finished'\""
}
If you would rather not add a module, powershell -NoProfile -Command \"[console]::beep(800,300)\" gives you a sound and nothing else. From WSL, call powershell.exe (with the extension) the same way, since the hook runs inside Linux but the notification has to surface on the Windows side.
Any terminal, no dependencies at all: printf '\a' rings the bell. Inside tmux that is genuinely useful, because set -g monitor-bell on with set -g bell-action any highlights whichever window rang. That gives you a per-window signal with no scripts, which pairs well with running agents in tmux.
Make it a sound instead
Notifications stack up and get ignored. A short sound is often better, and macOS ships with a set of them:
{
"type": "command",
"command": "afplay /System/Library/Sounds/Glass.aiff"
}
/System/Library/Sounds/ has Glass, Ping, Hero, Submarine, and a few more. Pick a quiet one, and prefer a distinct sound for Notification so "come back" and "approve this" are audibly different.
The part that breaks: hooks run in your critical path
A hook is a command Claude Code runs and waits for. If your command is slow, Claude waits. If your command hangs, so does your session.
Two rules keep this from biting you:
- Background the work and return immediately. End the command with
&so the shell does not wait. - Give anything network-shaped a timeout. A hook that curls something should use
curl -m 1so a dead endpoint costs you one second, not a stuck session.
The pattern we use looks like this:
#!/bin/sh
# Read the hook JSON off stdin, fire and forget, always succeed.
payload="$(cat 2>/dev/null)"
curl -s -m 1 -X POST --data "$payload" http://127.0.0.1:4242/wait/end >/dev/null 2>&1 &
exit 0
exit 0 at the end matters. A hook that exits non-zero can surface as an error in your session, and a notification script is never worth failing a turn over.
How do you know which agent finished
This is where the one-line notification falls apart. If you run three agents in three terminals, "Turn finished" tells you nothing useful.
The fix is in the payload. Claude Code passes a JSON object on stdin with fields including session_id, cwd, and hook_event_name. The working directory is what you want, because the last path segment is usually the project name:
#!/bin/sh
payload="$(cat)"
project=$(printf '%s' "$payload" | /usr/bin/python3 -c \
'import json,sys,os;print(os.path.basename(json.load(sys.stdin).get("cwd","")))')
osascript -e "display notification \"$project finished\" with title \"Claude Code\"" &
exit 0
Now the notification says which project landed. You still have to find that window yourself, which is the next problem, and there is no hook for that one. session_id is the field that matters once two sessions share a project directory; it is what lets a listener track three terminals without mixing them up.
When you are not at the machine
Desktop notifications solve "I am at my desk in another window". They do nothing for "I am on the couch". Two options cover that case.
Mobile push through Remote Control. If your session runs with Remote Control connected, /config gives you Push when actions required for permission prompts and questions, and Push when Claude decides for proactive updates. Pushes are suppressed while you are typing in the connected terminal, so the phone stays quiet while you are actually there. The setup and its limits are in our Remote Control writeup.
Anything you already carry. A hook is just a command, so a one-line curl to a Slack incoming webhook, ntfy, or Pushover works from any machine, including a headless server. Keep the -m 1 timeout and the trailing &.
The two layers are complementary: hooks own the desk, push owns everywhere else. And note that hooks fire on the machine where claude runs, so an agent on a remote box needs a reverse tunnel before a desktop notification reaches your laptop at all.
Nothing is arriving. What to check
In rough order of how often each one turns out to be the cause:
- Focus or Do Not Disturb is on. The hook fired, the OS swallowed it. On macOS this also silences
display notificationwithout any error. - Your terminal has no notification permission. macOS attributes
osascriptnotifications to the app that ran them, so check System Settings, Notifications, and look for Terminal, iTerm2, Ghostty, or VS Code rather than for Claude Code. - The JSON is malformed. Claude Code will not start with broken settings, so if it started, this is not it. Validate anyway with
jq . ~/.claude/settings.jsonafter every hand edit. - You edited the wrong file.
~/.claude/settings.jsonis user-wide;.claude/settings.jsonin the project applies to that project only. A hook in the project file does nothing in your other repos. - The command works in your shell but not in the hook. Hooks do not run your interactive shell profile, so a command that depends on a
PATHentry your.zshrcsets will fail silently. Use absolute paths (/usr/bin/osascript,/opt/homebrew/bin/notify-send). - The script is not executable, or lacks a
#!line.chmod +xit. - You are waiting on the wrong event. If the notification only shows up at the very end of long turns, you wired
Stopand expectedNotificationbehavior.
To test a hook without waiting for a real turn, run the command by hand with a fake payload:
echo '{"cwd":"/Users/you/myproject","notification_type":"permission_prompt"}' | ~/.claude/hooks/notify.sh
If that produces a notification and the hook still does not, the problem is the wiring, not the script.
What about Codex
Codex does not use the same hook system. It has a single notify key in ~/.codex/config.toml that runs a command when a turn completes, passing a JSON string as the first argument rather than on stdin:
notify = ["/Users/you/bin/codex-notify.sh"]
There is no start event, so you can tell when Codex finished but not when it began, and there is no equivalent of Notification at all. The details and its sharp edges are in how to get notified when Codex finishes.
A word of caution on settings.json
~/.claude/settings.json is a normal JSON file that other tools also write to. Two things worth doing before you edit it:
- Back it up.
cp ~/.claude/settings.json ~/.claude/settings.json.bakcosts nothing. - Validate after editing.
jq . ~/.claude/settings.jsonwill tell you immediately if you left a trailing comma.
If you add hooks programmatically, append to the array for that event rather than replacing it. Overwriting is how people lose the Slack notifier they set up months ago.
Where to go from here
A notification tells you the wait ended. It does not do anything about the wait itself, which on a normal day adds up to a couple of hours of thirty-second gaps. That is the problem Unwait exists for: it listens to these same hooks, shows which session finished or got blocked, and puts one flashcard in the gap instead of a phone glance. If you would rather keep building your own, the hook payload reference and six hook configs worth stealing are the next two stops.
The hook events and payload fields are documented in the Claude Code hooks reference.