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.
The other half of the problem is what the agent was doing for you. Sharpy is a to-do list for Mac that writes itself from your Slack, mail, calls and screen, and Claude Code, Codex and Cursor can read it over MCP. Give an agent the top task, and it marks the task done in Sharpy when the work lands; the next one moves up. The banner tells you a session stopped; the list tells you what is left.
Sharpy is free in early access on Macs with Apple silicon: join the waitlist. The MCP setup for each agent is on the MCP page, and what Claude Code remembers on its own is in Claude Code memory: what it keeps, and what it can’t see.






