Claude Code notification when done: hook, sound, and terminal setup
One Notification hook in ~/.claude/settings.json pings you when Claude Code finishes or needs permission. macOS, Linux, a sound, and click-to-focus for Ghostty.
You start a long task, switch to Slack, and come back twenty minutes later to find Claude Code stopped after two, waiting for a permission. The fix is one hook. This is the setup that works, with the gotcha that makes half of them fail silently on macOS, from the Claude Code hooks documentation on 18 September 2026 and from running three sessions a day.
The hook
Open ~/.claude/settings.json (create it if it does not exist) and add a Notification hook:
{
"hooks": {
"Notification": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"Claude Code needs your attention\" with title \"Claude Code\"'"
}
]
}
]
}
}
If the file already has a hooks key, add Notification next to the other event names inside that one object; do not create a second hooks block.
Type /hooks in a session. Notification should show a count of 1. The menu is read-only: to change a hook you edit the JSON or ask Claude to do it.
The gotcha: nothing appears
osascript sends notifications through Script Editor, an app you have never opened, and macOS never asks you to allow them. Run this once in any terminal:
osascript -e 'display notification "test"'
Nothing shows. Now open System Settings → Notifications, find Script Editor, turn on Allow Notifications. Run the command again and the banner appears. Until this is done the hook runs, exits cleanly, and you see nothing, which is why “the hook doesn’t work” threads mostly end here.
Finished, or needs permission
Notification fires for a few named situations, and the matcher picks which:
| matcher | fires when |
|---|---|
permission_prompt | Claude is waiting for you to approve a tool and the prompt has sat for about six seconds |
idle_prompt | Claude finished responding about 60 seconds ago and you have not typed since |
elicitation_dialog, elicitation_url_dialog | an MCP server opened a form or asked for a browser, and you have not typed for about six seconds |
"" (empty) | any of the above |
Two hooks with two messages is clearer than one generic ping:
{
"hooks": {
"Notification": [
{
"matcher": "permission_prompt",
"hooks": [{ "type": "command", "command": "osascript -e 'display notification \"Waiting for a permission\" with title \"Claude Code\"'" }]
},
{
"matcher": "idle_prompt",
"hooks": [{ "type": "command", "command": "osascript -e 'display notification \"Done. Waiting for you.\" with title \"Claude Code\"'" }]
}
]
}
}
If you want the ping the instant Claude stops, not 60 seconds later, use the Stop event. It fires every time Claude finishes responding, short turns included, and not on your own interrupts. That is a lot of pings on a chatty session, so most people keep Stop for the sound and Notification for the banner.
Sound
macOS ships sounds in /System/Library/Sounds. Add a second command to the same hook, or a Stop hook:
{
"hooks": {
"Stop": [
{
"hooks": [{ "type": "command", "command": "afplay /System/Library/Sounds/Glass.aiff" }]
}
]
}
}
Glass, Ping, Pop, Purr and Tink are the quiet ones. Hooks for one event run in parallel, so the sound and the banner do not wait for each other.
Which session finished
Three sessions in three worktrees (how we set those up: Claude Code worktrees: run three sessions, keep the thread), one notification that says “Claude Code needs your attention” is useless. The hook receives JSON on stdin with the session’s cwd and session_id, and for Notification the message text. Read it once, put the folder in the title. Quoting a jq pipeline inside JSON inside a shell gets ugly, so use a script: ~/.claude/hooks/notify.sh, chmod +x, absolute path in the hook.
#!/bin/sh
# Claude Code Notification hook: a banner with the project folder in the title.
input=$(cat)
dir=$(printf '%s' "$input" | jq -r '.cwd // "" | split("/") | last')
msg=$(printf '%s' "$input" | jq -r '.message // "needs your attention"')
osascript -e "display notification \"$msg\" with title \"Claude Code · $dir\""
{
"hooks": {
"Notification": [
{ "matcher": "", "hooks": [{ "type": "command", "command": "\"$HOME\"/.claude/hooks/notify.sh" }] }
]
}
}
The same script serves a Stop hook; there is no message on that event, so the fallback text shows.
Click to land in the right terminal
osascript banners do nothing when clicked. terminal-notifier (brew install terminal-notifier) can activate an app on click, so the banner takes you to the terminal that asked:
terminal-notifier -title "Claude Code · $dir" -message "$msg" -activate com.mitchellh.ghostty
Bundle ids: Ghostty com.mitchellh.ghostty, iTerm2 com.googlecode.iterm2, Warp dev.warp.Warp-Stable, Terminal com.apple.Terminal, VS Code com.microsoft.VSCode. It lands you in the app, not the tab; with three tabs you still pick.
Linux and Windows
Linux: replace the command with notify-send 'Claude Code' 'Claude Code needs your attention'. It needs a desktop notification daemon, which SSH sessions and containers do not have; test it in a terminal first. Windows: the hooks guide’s own example runs powershell.exe with [System.Windows.Forms.MessageBox]::Show(...), which is a dialog rather than a corner banner and can open behind the terminal; under WSL powershell.exe has to be on the PATH.
What a hook cannot tell you
A hook fires per session and only for Claude Code. It does not know that Codex in the next tab is also waiting, or that the session you left an hour ago has been idle since. And it lands you in a terminal, not on the thought you had when you left.
That gap is what Sharpy is for. It reads the text of your terminal windows through macOS Accessibility, so it can tell for each agent whether it is running (a spinner, esc to interrupt, output streaming), waiting with the prompt open, asking you something, exited, or stuck on an error, and its island shows which one needs you and what you were doing there. No hook, no terminal setting, and it works the same for Claude Code, Codex and Cursor’s CLI. The hooks above still belong in your settings; Sharpy is the layer over all of them.
If you run several agents at once, join the waitlist and say so in the reply to the welcome email. The setup for reading the same memory from Claude Code is in Claude Code memory: what it keeps, and how to add your screen.
Questions
How do I get a notification when Claude Code is done?
Add a Notification hook to ~/.claude/settings.json with an empty matcher and a command that shows a desktop notification, on macOS osascript -e 'display notification ...'. Claude Code fires the event when it is waiting for your input or for a permission. Type /hooks to confirm it is registered.
Why does my Claude Code notification not appear on macOS?
osascript sends notifications through Script Editor, and if Script Editor has no notification permission the command fails silently. Run osascript -e 'display notification "test"' once in Terminal, then open System Settings, Notifications, find Script Editor and allow notifications.
What is the difference between the Notification hook and the Stop hook?
Stop fires every time Claude finishes responding, including short turns, and not on your own interrupts. Notification fires for specific situations: permission_prompt when a tool permission has waited about six seconds, idle_prompt when Claude finished about 60 seconds ago and you have not typed. For 'tell me when it needs me' use Notification; for 'tell me the instant it stops' use Stop.
Can the notification say which project finished?
Yes. The hook receives JSON on stdin with the session's cwd. Read it with jq and put the folder name in the title, so three sessions in three worktrees send three different notifications.
How do I click the notification and land in the right terminal?
Use terminal-notifier (brew install terminal-notifier) instead of osascript and pass -activate with the terminal's bundle id: com.mitchellh.ghostty, com.googlecode.iterm2, dev.warp.Warp-Stable, com.apple.Terminal or com.microsoft.VSCode.