Claude Code notification hook generator
Claude Code can run a command when it needs you and when it finishes a reply. Pick the events and where the alert should go, and copy the hooks setting plus one bash script. Our tests run every generated script under bash with hostile notification text.
By Jake Marsh. Event names, input fields and matcher syntax checked against Claude Code's hooks reference on 2026-10-31. Runs in your browser and sends nothing anywhere.
1. The hooks setting
Merge into ~/.claude/settings.json: if the file already has a "hooks" object, add Notification and Stop next to the events already there. Claude Code watches settings files and picks the change up; /hooks lists what it loaded.
{
"hooks": {
"Notification": [
{
"matcher": "permission_prompt|idle_prompt",
"hooks": [
{
"type": "command",
"command": "~/.claude/hooks/claude-notify.sh notification",
"async": true
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "~/.claude/hooks/claude-notify.sh stop",
"async": true
}
]
}
]
}
}2. The script
Save as ~/.claude/hooks/claude-notify.sh. Needs bash; curl for ntfy, Pushover and Slack; jq is optional.
#!/usr/bin/env bash
# Claude Code notification hook, from https://taskhold.com/tools/claude-code-notification-hook
# Claude Code runs it for Notification and Stop with the event's JSON on stdin and the event name as $1.
# It always exits 0 and prints nothing, so it never blocks or redirects Claude.
# jq is optional: with it, alerts carry Claude's own message and the project name.
TITLE='Claude Code'
event="${1:-notification}"
input=$(cat)
get() {
command -v jq >/dev/null 2>&1 || return 0
printf '%s' "$input" | jq -r "$1 // empty" 2>/dev/null
}
one_line() { printf '%s' "$1" | tr '\r\n\t' ' '; }
dir=$(get .cwd)
[ -n "$dir" ] || dir="${CLAUDE_PROJECT_DIR:-$PWD}"
project=${dir##*/}
if [ "$event" = "stop" ]; then
# Another Stop hook already kept Claude going; don't alert twice for one turn.
[ "$(get .stop_hook_active)" = "true" ] && exit 0
msg="Finished in $project"
urgent=0
else
msg=$(get .message)
[ -n "$msg" ] || msg="Claude Code needs your attention"
msg="$msg ($project)"
urgent=1
fi
msg=$(one_line "$msg")
# macOS: the text goes in as arguments, never into the AppleScript source.
if command -v osascript >/dev/null 2>&1; then
osascript -e 'on run argv' -e 'display notification (item 2 of argv) with title (item 1 of argv)' -e 'end run' "$TITLE" "$msg" >/dev/null 2>&1
fi
case "$(uname -s)" in
Darwin) afplay '/System/Library/Sounds/Glass.aiff' >/dev/null 2>&1 ;;
Linux)
if [ "$urgent" = 1 ]; then snd=message.oga; else snd=complete.oga; fi
command -v paplay >/dev/null 2>&1 && paplay "/usr/share/sounds/freedesktop/stereo/$snd" >/dev/null 2>&1 ;;
esac
exit 0
3. Install and test
Run in a terminal. The last line sends a test alert through every channel you picked.
mkdir -p ~/.claude/hooks
# paste the script into ~/.claude/hooks/claude-notify.sh, then:
chmod +x ~/.claude/hooks/claude-notify.sh
echo '{"hook_event_name":"Notification","message":"Test from the hook generator","notification_type":"permission_prompt"}' | ~/.claude/hooks/claude-notify.sh notificationWhat the two events do
Notification runs when Claude Code sends a notification. The matcher filters on the notification type, and leaving it out matches all of them. The input has message, an optional title and notification_type, plus the common fields such as cwd and session_id.
permission_promptfires once a permission prompt has waited about six seconds without a keystroke, andidle_promptabout 60 seconds after Claude finishes, if you haven't typed since and no background agent is still running. Both share their timing with Claude Code's own desktop notifications, so at your keyboard you won't get them.- A matcher made only of letters, digits, underscores and
|is an exact list, sopermission_prompt|idle_promptmatches those two types and nothing else. - Notification hooks can't block or change the notification. They exist for side effects like the ones here.
Stop runs when the main agent finishes responding. It has no matcher. The input adds last_assistant_message (the text of the reply) and stop_hook_active, which is true when another Stop hook already kept Claude going. A Stop hook that exits with code 2 stops Claude from stopping, so the generated script always exits 0, prints nothing, and skips its alert when stop_hook_active is true.
Both handlers are marked "async": true, which runs them in the background, so a slow push service never holds up Claude. The event name is passed as the script's first argument, so the script knows which one fired even on a machine without jq.
Where the setting goes
| File | Applies to | Shared |
|---|---|---|
~/.claude/settings.json | Every project on this machine | No |
.claude/settings.json | One project | Yes, committed with the repo |
.claude/settings.local.json | One project | No, gitignored when Claude Code writes it |
Alerts are personal, so most people want the first row. Hooks merge across files instead of replacing each other, and Claude Code reloads settings files when they change, so a saved edit reaches the running session. Type /hooks to see what it loaded. A project hook runs the script through "$CLAUDE_PROJECT_DIR", quoted for paths with spaces, and the channel settings stay in your home folder so no key ends up in the repo. Cloud sessions don't read your local ~/.claude/settings.json.
How the script handles text
- The message comes from Claude Code and from tool output, so the script treats it as data. On macOS it goes to osascript as an argument, never into the AppleScript source. ntfy gets it through
--data-raw, so a message starting with @ isn't read as a file name. Pushover gets it through--form-stringfor the same reason, and the Slack body is JSON-escaped. - Your title is single-quoted in the script, so quotes and dollar signs come through as you typed them.
- Every network call has a 10-second limit and its output goes to /dev/null.
When nothing arrives
- Run the test line from the last block on its own. If that alerts you and Claude Code doesn't, check /hooks and that the script is executable.
- On macOS, give Script Editor notification permission (see the questions below). On Linux, notify-send needs a running notification daemon, which SSH sessions and containers usually lack; install libnotify-bin on Debian or Ubuntu if the command is missing.
- For ntfy, Pushover and Slack, run the same curl by hand with your values to see the service's error.
- Waiting at your keyboard? permission_prompt and idle_prompt don't fire while you are typing. Claude Code waits for input silently covers the terminal bell and the other settings involved.
Where Taskhold goes further
A hook is free, and for Claude Code on one machine it may be all you need. The alert says Claude is waiting; to answer, you go back to that terminal. It covers Claude Code only.
In Taskhold, an agent that needs you blocks its task with the question, and it can offer two to four answers. The question shows on the Needs you page, which is laid out for a phone: tap an option or type a reply, and the agent reads it on its next check. The same board takes questions and progress from Cursor, Codex, Gemini CLI and any other MCP client, on any machine. Try the live board demo without an account. It costs $12 a month or $120 a year per person after a 7-day trial that starts with a card. If you only use Claude Code at one desk, keep the hook.
Questions
- Which hook event fires when Claude Code is waiting for input?
- Notification. Its matcher filters on notification_type: permission_prompt when Claude needs approval for a tool, idle_prompt when it finished about 60 seconds ago and you haven't typed. In a terminal both fire only when you seem to be away. For an alert the moment a permission dialog opens, use a PermissionRequest hook.
- Why does my Stop hook fire after every answer?
- Stop runs each time Claude finishes responding, which is the end of every turn, not the end of a task. It doesn't run when you interrupt, and API errors fire StopFailure instead. If that is too many alerts, keep only the Notification hook with idle_prompt, which waits until you've been away for a minute.
- Should the hook go in user or project settings?
- ~/.claude/settings.json applies to every project on your machine and is the usual place for alerts. .claude/settings.json in a repo is shared with everyone who clones it, and .claude/settings.local.json is yours alone for that project. Hooks from all three run; none replaces another.
- Why do I get no macOS notification?
- osascript posts notifications as Script Editor. If Script Editor has no notification permission, the command fails without a prompt. Run osascript -e 'display notification "test"' once, then turn on Allow Notifications for Script Editor in System Settings > Notifications.
- Does the generator send my keys or webhook URL anywhere?
- No. The page builds the text in your browser and makes no requests with what you type. Pushover keys and the Slack webhook URL aren't asked for at all: the script reads them from ~/.claude/hooks/claude-notify.env, a file you fill in on your own machine.
Sources
Checked 2026-10-31.
- Claude Code hooks reference: Notification and Stop input, matcher patterns, hook locations, async
- Claude Code hooks guide: Get notified when Claude needs input
- Claude Code settings: when edits take effect
- ntfy docs: publishing (Title, Priority, Tags headers, access tokens)
- Pushover Message API
- Slack docs: sending messages using incoming webhooks
Answer your agents from your phone.
Taskhold gives Claude Code, Cursor, Codex and other MCP clients one task list with steps, progress bars and questions you answer from your phone. $12 a month or $120 a year per person, 7-day trial with a card.