Sound and desktop alerts for Claude Code with hooks
Get a sound and a native macOS banner when Claude Code finishes a turn or is blocked waiting for your permission, using the settings.json hooks system.
If you use Claude Code for anything that runs for a while, you know the feeling. You kick off a long task, tab away to read something or answer a message, and then forget to check back. Minutes later you realize Claude finished ages ago. Or worse, it’s been sitting there the whole time, blocked on a permission prompt, waiting for you to click “yes.”
I wanted a nudge. A sound and a little desktop banner that tells me two things: “Claude is done” and “Claude needs you.” So I wired it up with the hooks system, and it took about five minutes. Let me show you how.
This is especially handy for long-running work, the kind where you kick off a big task and step away instead of staring at the terminal the whole time.
TL;DR
- Add two hooks to
~/.claude/settings.json: aStophook (fires when Claude finishes a turn) and aNotificationhook with thepermission_promptmatcher (fires when Claude is blocked on your approval). - Each hook plays a different macOS sound with
afplay(so you can tell “done” from “needs you” by ear) and posts a Notification Center banner withosascript. jq -r '.cwd' | xargs basenameputs the project folder name in the banner title, so you know which session is calling when several are open.- The commands are macOS-specific, but the hook wiring in
settings.jsonis identical on any OS. The full config is below.
What are hooks?
Claude Code has a hooks system in settings.json. Hooks are just shell commands that fire on certain lifecycle events. You tell Claude “when X happens, run this command,” and it runs it.
Two events matter for our alerts:
Stopfires every time Claude finishes responding.Notification(with the matcherpermission_prompt) fires when Claude is blocked waiting for you to approve a tool call.
Each hook receives a JSON payload on standard input with fields like cwd (the project directory) and session_id. A shell command can read that payload to add some context, which we’ll use to put the project name in the banner.
The config
Here’s what I added to ~/.claude/settings.json. I put it at the user level (that ~ path) so it applies to every project and every session globally, not just one repo.
"hooks": {
"Stop": [
{ "hooks": [
{ "type": "command", "command": "afplay /System/Library/Sounds/Glass.aiff" },
{ "type": "command", "command": "proj=$(jq -r '.cwd' | xargs basename); osascript -e \"display notification \\\"Claude finished\\\" with title \\\"Claude Code — $proj\\\"\"" }
]}
],
"Notification": [
{ "matcher": "permission_prompt", "hooks": [
{ "type": "command", "command": "afplay /System/Library/Sounds/Ping.aiff" },
{ "type": "command", "command": "proj=$(jq -r '.cwd' | xargs basename); osascript -e \"display notification \\\"Claude needs your permission\\\" with title \\\"Claude Code — $proj\\\"\"" }
]}
]
}
If your settings.json already has other keys, add the hooks block alongside them (don’t paste a second top-level object). And since it’s JSON, watch the commas.
How it works, piece by piece
There are only three moving parts here, so let’s break them down.
afplay plays a built-in macOS sound. I use Glass.aiff when Claude finishes and Ping.aiff when it needs permission. Two different tones on purpose, so I can tell by ear which situation it is without looking. Those files live in /System/Library/Sounds/, so go poke around in there and pick whatever you like.
osascript -e 'display notification ...' posts a native Notification Center banner. The nice thing about going through AppleScript is that it’s independent of which terminal you’re using. Terminal.app, iTerm2, and the VS Code integrated terminal all behave exactly the same.
jq -r '.cwd' | xargs basename pulls the project name out of the hook’s payload. Remember that JSON on standard input? jq grabs the cwd field, and basename trims it down to just the folder name. So instead of a generic “Claude finished,” the banner title reads something like Claude Code — my-mule-app. That is genuinely useful when you’ve got a few sessions open in different terminals and need to know which one is calling.
A few gotchas I hit
Nothing here was hard, but a couple of things tripped me up on the way, and they’re worth knowing.
If you test afplay or osascript through Claude’s normal Bash tool, they’ll fail with errors like AudioQueueStart failed or nice() failed: operation not permitted. That’s the sandbox blocking audio and AppleEvents. To pipe-test the real commands, you have to explicitly bypass the sandbox. The good news: the actual hooks aren’t sandboxed the same way at runtime. Hooks run as regular subprocesses, so they work fine once they’re wired into settings.json.
The second thing: you can’t control how long the banner stays on screen from the hook. display notification has no duration parameter. Whether a banner disappears after a few seconds or sticks around is a macOS-wide choice, set per app under System Settings > Notifications (the “Banners” vs. “Alerts” style). It’s not something the hook script can override.
Tip
If you want the alert to stay put until you dismiss it, set your terminal app (or osascript) to “Alerts” instead of “Banners” in System Settings > Notifications.
Why I kept it simple
I originally wanted to put a snippet of Claude’s last message into the banner for extra context. The Stop hook’s payload has a last_assistant_message field available, so it’s doable. But once I started wrestling with the jq and shell-escaping to get a clean one-liner, the complexity wasn’t worth the payoff. The project name alone already answers the only question I actually have in that moment, which is “which session is this?” So I left it out. Sometimes the simple version is just the right version.
One caveat: this is macOS-specific
afplay and osascript are macOS tools, so this exact config won’t work on Linux. The good news is that the hook wiring in settings.json is identical everywhere. You’d only swap the commands:
- For sound, use something like
paplayoraplay. - For the desktop banner, use
notify-send.
Everything else (the Stop and Notification events, the matcher, the jq payload trick) stays exactly the same.
That’s it. Five minutes of setup, and now I actually notice when Claude is done or waiting on me. Go tab away with confidence. :-)
FAQs
Frequently asked questions about this post.
-
How do I get a sound when Claude Code finishes a task?
Add a
Stophook to your~/.claude/settings.jsonthat runs a command likeafplay /System/Library/Sounds/Glass.aiff. TheStopevent fires every time Claude finishes responding, so the sound plays each time a turn completes. -
How do I get notified when Claude Code is waiting for a permission prompt?
Add a
Notificationhook with the matcherpermission_prompt. That event fires when Claude is blocked waiting for you to approve a tool call. Point it at a different sound (for examplePing.aiff) and anosascriptbanner so you can tell it apart from the finished sound by ear. -
How do I show the project name in the Claude Code notification?
Each hook receives a JSON payload on standard input that includes a
cwdfield. Pipe it throughjq -r '.cwd' | xargs basenameto reduce the working directory to just the folder name, then pass that into theosascriptbanner title. The banner then reads something likeClaude Code — my-mule-app, which helps when several sessions are open at once. -
Do Claude Code hook alerts work in any terminal?
Yes. The banner is posted with
osascript -e 'display notification ...', which goes through macOS Notification Center independently of the terminal. Terminal.app, iTerm2, and the VS Code integrated terminal all behave the same. -
Can I control how long the macOS notification banner stays on screen?
No, not from the hook. The
display notificationcommand has no duration parameter. How long a banner lingers is a macOS-wide setting under System Settings > Notifications, chosen per app as either "Banners" (auto-dismiss) or "Alerts" (stay until dismissed). -
Do these Claude Code alerts work on Linux?
The exact commands are macOS-specific, but the hook wiring in
settings.jsonis identical everywhere. On Linux you would swapafplayfor something likepaplayoraplayfor sound, andosascriptfornotify-sendfor the desktop banner. TheStopandNotificationevents, the matcher, and thejqpayload trick stay the same.