Claude Code Notification When Done: A Desktop Ping and Sound on Linux or Mac
📅 September 29, 2026 · ⏱️ 14 min read
You start Claude Code on a long task, switch to a browser, and come back ten minutes later to find it has been sitting on a permission prompt for nine of them. The fix is a desktop notification and a sound whenever Claude is done or needs you. Claude Code does this through hooks: shell commands it runs at fixed points in a session. Two hooks and one short script cover it. The notification is titled with the project Claude is working in and carries Claude’s own message, on Linux or a Mac.
Everything below about hook events, the settings file and the JSON a hook receives is taken from the official Claude Code hooks reference and its companion hooks guide, both fetched on 2026-09-29. Where this page says what Claude Code does, it links to the line that says so.
The shortest working setup
Wire the Notification and Stop hooks in ~/.claude/settings.json to one script that reads the hook’s JSON from stdin and calls notify-send on Linux or osascript on macOS. From the event table in the reference:
Notification: “When Claude Code sends a notification”.Stop: “When Claude finishes responding”.
Add this to ~/.claude/settings.json. If the file already has a hooks key, add Notification and Stop inside it rather than replacing it:
{
"hooks": {
"Notification": [
{
"hooks": [
{ "type": "command", "command": "~/.claude/hooks/claude-notify.sh" }
]
}
],
"Stop": [
{
"hooks": [
{ "type": "command", "command": "~/.claude/hooks/claude-notify.sh" }
]
}
]
}
}
The shape is the one the reference’s Configuration section describes: a hook event (Notification), then a list of matcher groups, then the handlers to run. Each handler here is {"type": "command", "command": ...}. The matcher is left out on purpose. For Notification, the reference says to “Omit the matcher to run hooks for all notification types”.
The ~ works because a command hook without args is run in shell form. The reference’s exec-form section says the command string “is passed to a shell: sh -c on macOS and Linux”, and the shell expands ~. If you would rather not depend on that, write the absolute path.
The same settings file also takes a statusLine block, which keeps the model, git branch, session cost and context use on screen while you do watch; Claude Code Status Line walks through that one.
Hooks can also refuse a tool call: Stop Claude Code From Running rm -rf, Force-Pushing or Reading .env uses a PreToolUse hook to block destructive commands and .env reads.
Where the block goes decides where it applies. The reference’s hook locations table lists ~/.claude/settings.json as “All your projects” and .claude/settings.json as “Single project”, shareable because it “can be committed to the repo”. A notification is personal, so the user file is the right place. Then create the script’s folder, save the script from the next section into it, and make it executable:
mkdir -p ~/.claude/hooks
# save the script below as ~/.claude/hooks/claude-notify.sh, then:
chmod +x ~/.claude/hooks/claude-notify.sh
The script: project name and Claude’s message in the notification
Every hook payload carries cwd, the session’s working directory, so the basename of cwd is the project name, and the Notification payload carries message with the notification text. A hook script gets its context as JSON on stdin. The reference’s hook lifecycle section says: “For command hooks, input arrives on stdin.” Four fields in that JSON do the work here:
hook_event_name, one of the common input fields: “Name of the event that fired”. It tells one script whether it is running forNotificationorStop.cwd, also common to every event: “Current working directory when the hook is invoked”. Its last path component is the project name. The title carries it because with three sessions open, “Claude needs you” is useless until you know which project.message: the Notification input section says Notification hooks “receivemessagewith the notification text, an optionaltitle, andnotification_typeindicating which type fired.”last_assistant_message: the Stop input section says it “contains the text content of Claude’s final response”. The reference steers “read-aloud or notification hooks” to this field instead of the transcript file, which “isn’t guaranteed to include the final message at Stop time on all versions.”
The payload also carries session_id and transcript_path. This script doesn’t need them.
The usual way to read those fields is jq, and the reference’s own examples use it. This script doesn’t, because jq is not guaranteed to be on every machine a hook runs on, and on a Mac python3 needs Apple’s Command Line Tools. A sed expression that pulls one string field out of a JSON object is enough for four known fields, and sed is everywhere.
#!/usr/bin/env bash
# claude-notify.sh: a desktop notification plus a sound when Claude Code
# finishes (Stop) or needs you (Notification). Reads the hook JSON on stdin.
# Needs only bash and sed: no jq, no python.
payload=$(tr '\n' ' ') # the JSON on one line; string values never hold raw newlines
# field NAME -> the string value of "NAME" in the payload, lightly unescaped
field() {
printf '%s' "$payload" |
sed -nE 's/.*"'"${1}"'"[[:space:]]*:[[:space:]]*"(([^"\\]|\\.)*)".*/\1/p' |
sed -E 's/\\[nrt]/ /g; s/\\"/"/g; s/\\\//\//g; s/\\\\/\\/g'
}
event=$(field hook_event_name)
cwd=$(field cwd)
project=${cwd##*/} # basename of the session's working directory
project=${project:-Claude Code}
if [ "$event" = "Stop" ]; then
title="Done: $project"
body=$(field last_assistant_message)
[ -n "$body" ] || body="Claude finished responding"
else
title="Needs you: $project"
body=$(field message)
[ -n "$body" ] || body="Claude Code is waiting for you"
fi
# no leading markdown bullet (a body starting with "-" reads like an option),
# and short enough for a notification bubble
body=$(printf '%s' "$body" | sed -E 's/^[-*[:space:]]+//')
body=${body:0:180}
if [ "$(uname -s)" = "Darwin" ]; then
# title and body go in as arguments, so quotes in Claude's text can't break the AppleScript
osascript -e 'on run argv' \
-e 'display notification (item 2 of argv) with title (item 1 of argv)' \
-e 'end run' "$title" "$body"
afplay "${CLAUDE_NOTIFY_SOUND:-/System/Library/Sounds/Glass.aiff}" 2>/dev/null
else
notify-send --app-name="Claude Code" "$title" "$body"
if command -v paplay >/dev/null; then
paplay "${CLAUDE_NOTIFY_SOUND:-/usr/share/sounds/freedesktop/stereo/complete.oga}" 2>/dev/null
fi
fi
exit 0
Fed the permission-prompt payload from the Troubleshooting section below, the Linux branch calls notify-send with three arguments: --app-name=Claude Code, Needs you: my-project and Claude needs your permission to use Bash. It then calls paplay with the complete.oga path. When a turn ends with Claude’s reply starting - Fixed the "login" bug, followed by a second bullet, the notification reads Done: my-project over Fixed the "login" bug - Added 3 tests. The escaped quotes are restored, the newline is flattened, and the leading bullet is dropped.
A few of the choices are deliberate:
- It always exits 0. The hooks guide explains that an unexpected non-zero exit makes Claude Code print a “hook error” notice in the transcript. A missing sound file is not worth that, so errors from the sound players are discarded.
- It prints nothing to stdout. For most events, the reference says, “Claude Code writes stdout to the debug log and doesn’t show it in the transcript”. It also reads JSON from stdout, so a script that chatters there only adds noise.
CLAUDE_NOTIFY_SOUNDis this script’s own variable, not Claude Code’s. Set it in your shell to any sound file you like.
The limits of the sed parser are real but narrow. It reads only flat string fields, which is all four of these are. A \uXXXX escape comes through as those six characters rather than the character it encodes. A literal backslash that is followed by n in Claude’s text becomes a space. If you already have jq, jq -r '.message // empty' is the sturdier choice for the same job.
Linux: notify-send and paplay
notify-send takes a summary and an optional body, notify-send [OPTIONS] {summary} [body], and hands them to your desktop’s notification daemon. The script passes --app-name, which the man page documents as “Specifies the app name for the notification”. The command comes from the libnotify-bin package on Debian and Ubuntu.
For the sound, paplay plays one file through the PulseAudio server (paplay [options] [FILE]), and its man page says it “understands all audio file formats supported by libsndfile”. The default file, complete.oga, is part of the sound-theme-freedesktop package’s stereo set. That set also has bell.oga, message.oga and window-attention.oga if you prefer one of those. The script checks command -v paplay first, so a machine without it still gets the notification, silently.
macOS: osascript and afplay
macOS has no notify-send, but AppleScript’s display notification does the same job, and osascript runs AppleScript from a shell. Two lines of its man page shape the script. “Multiple -e options may be given to build up a multi-line script.” And “Any arguments following the script will be passed as a list of strings to the direct parameter of the” script’s run handler. So the script builds an on run argv handler from three -e lines and passes the title and body as arguments. The quickest version you will see elsewhere splices a message into the AppleScript source: osascript -e 'display notification "..." with title "..."'. That breaks the moment Claude’s message contains a double quote. Apple’s notification chapter of the Mac Automation Scripting Guide documents the display notification command, its with title parameter and an optional sound name.
The sound is afplay, which “plays an audio file to the default audio output”. Apple’s NSSound documentation lists /System/Library/Sounds as the system’s own sound folder. The file name Glass.aiff does not appear in any document fetched for this page, so run ls /System/Library/Sounds once and use a name you see there. If the file is missing, afplay fails quietly and the notification still appears.
The hooks guide has one Mac-only warning worth repeating: “osascript routes notifications through the built-in Script Editor app. If Script Editor doesn’t have notification permission, the command fails silently, and macOS won’t prompt you to grant it.” Its fix is to run a test notification once so that Script Editor shows up under System Settings > Notifications, then turn on Allow Notifications there.
When each hook fires, and why you may get two pings
Stop fires when Claude finishes responding; Notification fires when Claude Code sends a notification, which includes asking for permission and sitting idle. Both fire more often than “the task is finished” suggests, and that affects how many pings you get.
- The Stop section says it “Runs when the main Claude Code agent has finished responding. Does not run if the stoppage occurred due to a user interrupt.” The guide adds that “
Stophooks fire whenever Claude finishes responding, not only at task completion.” A quick answer to a quick question pings too. - The Notification section says it “Matches on notification type”. Two types matter most here.
permission_promptfires when “Claude needs you to approve a tool use or a sandboxed command’s network request, and the prompt has waited about six seconds”.idle_promptfires when “Claude finished responding about 60 seconds ago and you haven’t typed since”.
So with both hooks wired, a finished turn you ignore pings twice: once from Stop, and again about a minute later from idle_prompt. If that is one too many, give the Notification group a matcher so only the prompts that block Claude get through: "matcher": "permission_prompt". A permission prompt still reaches you, and Stop covers “done”. The reference also notes that hooks keep firing when Claude Code’s own desktop notifications are off: “You receive these hook events even with desktop notifications turned off”.
Why echo -e ‘\a’ in a hook does nothing
The first thing most people try for a sound is the terminal bell: echo -e '\a' as the hook command. It never rings. A hook has no terminal of its own. Its stdout goes to Claude Code, not to your screen, and it cannot open /dev/tty to reach the terminal behind it. The reference’s hook input and output section says so: “On macOS and Linux, command hooks run in their own session without a controlling terminal. The hook process and any child processes can’t open /dev/tty or send escape sequences directly to the Claude Code interface.”
That is why the script above plays a sound file instead of ringing a bell. There are two ways to get the terminal itself to react.
Ask Claude Code to ring the bell for you
A hook may print JSON with a terminalSequence field. Claude Code writes that sequence “through its own terminal write path”. The allowlist includes a “Bare BEL”, and the reference says the field works on Notification even though that event discards other output. The limit is that “Claude Code writes the sequence only in an interactive session, and only while its interface is on screen.” A complete bell hook:
#!/usr/bin/env bash
# claude-bell.sh: ring the terminal bell by asking Claude Code to write it.
cat >/dev/null # the payload is not needed
printf '%s\n' '{"terminalSequence":"\u0007"}' # \u0007 is BEL, as a JSON escape
Save it as ~/.claude/hooks/claude-bell.sh, chmod +x it, and add it as a second handler in the same group:
{
"hooks": {
"Stop": [
{
"hooks": [
{ "type": "command", "command": "~/.claude/hooks/claude-notify.sh" },
{ "type": "command", "command": "~/.claude/hooks/claude-bell.sh" }
]
}
]
}
}
Light up the right terminal
If you run several sessions and want the right terminal window to light up and stay lit until you answer, that is a bigger job. Make Your Terminal Glow When Claude Code Needs You is the per-session upgrade to this page. Its Hooks Have No Terminal section walks up the process tree from the hook to the terminal Claude is running in.
Troubleshooting
Test the script without Claude. Pipe a sample payload into the script by hand. If a notification appears, the script works and any remaining problem is in settings.json. These are the two payloads this page’s test uses, minus the fields the script ignores:
echo '{"hook_event_name":"Notification","cwd":"/home/you/my-project","message":"Claude needs your permission to use Bash"}' | ~/.claude/hooks/claude-notify.sh
echo '{"hook_event_name":"Stop","cwd":"/home/you/my-project","last_assistant_message":"All tests pass."}' | ~/.claude/hooks/claude-notify.sh
Nothing appears even by hand. Test the notifier with no script involved:
notify-send 'Claude Code' 'test' # Linux
osascript -e 'display notification "test"' # macOS
On Linux, the guide warns that notify-send “needs a desktop notification daemon, which headless servers, SSH sessions, and most containers don’t have”. A Claude Code session over SSH cannot pop a notification on the laptop you are typing from this way. On macOS, see the Script Editor permission above.
The script works by hand but the hook does not fire. Work through the guide’s hook-not-firing list:
- Type
/hooksinside Claude Code. The reference describes it as a read-only browser that shows each event with a count of configured hooks and “a source indicating where it was defined”. IfNotificationandStopare not listed, Claude Code did not read your block. - Check the JSON. The guide’s checklist for
/hooksshowing no hooks starts with valid JSON: “trailing commas and comments aren’t allowed”. - Check the file.
.claude/settings.jsonis the project file;~/.claude/settings.jsonis the global one. - Edits normally apply without a restart. The reference says: “Direct edits to hooks in settings files are normally picked up automatically by the file watcher.” If yours don’t show up in
/hooks, the guide says to restart the session.
Permission denied, or nothing at all. A script without the execute bit can’t be run as a command. The guide’s words: “If the script isn’t running at all, make it executable: chmod +x ./my-hook.sh”. Run chmod +x ~/.claude/hooks/claude-notify.sh again after copying the file from another machine.
See what the hook actually did. The reference’s Debug hooks section: start Claude Code with claude --debug-file <path>, or run claude --debug and read ~/.claude/debug/<session-id>.txt. The reference says “Hook execution details are written to the debug log file”.
What was tested, and what was not
The scripts on this page were tested on Linux with stub notify-send, osascript, afplay and paplay binaries that record their arguments, not on a Mac and not inside a live Claude Code session. The test pulls every script and command block out of the built page and runs it with a Notification payload and a Stop payload on stdin. It checks that the title carries the project folder’s name and the body carries Claude’s message. The macOS branch was run by faking uname to report Darwin. It also runs each command in the settings blocks through sh -c, the way the reference says shell-form hooks are run. What that cannot prove is how a given notification daemon or macOS version displays the result. If yours behaves differently, the pipe test in Troubleshooting is the quickest way to see where.
Follow new posts by RSS: paste dankdev.com/feed.xml into any feed reader. It is a plain XML file — no signup, no email address, no account. Posts land there the same day they go up, and there is nothing to unsubscribe from later.