A Sound When It Is Done: Desktop Notifications for Claude Code

René Kulik on 19.10.2026

Introduction

Once an agent can work on its own for a few minutes, watching it becomes a waste of time. I switch to another window, read something, or start a second session in a different project. The problem is knowing when to come back. Either I check too early and interrupt myself for nothing, or I check too late and the agent has been waiting for a permission prompt for ten minutes.

In my post about setting up Claude Code, I mentioned that a hook sends me a desktop notification when the agent is done or needs my input. This post shows how that works: two hooks, one small shell script and one filter.

My machine runs Ubuntu with GNOME, so the tools below are the ones that ship with that desktop. The idea carries over to any system with a notification daemon.

Two Hooks, One Script

Claude Code hooks run a command when a certain event happens in a session. Two events are relevant here:

Both point to the same script in ~/.claude/settings.json:

{
  "hooks": {
    "Notification": [
      {
        "matcher": "permission_prompt|auth_success|elicitation_dialog|elicitation_complete|elicitation_response|agent_needs_input|agent_completed",
        "hooks": [
          {
            "type": "command",
            "command": "bash ~/.claude/notify-command.sh"
          }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "bash ~/.claude/notify-command.sh"
          }
        ]
      }
    ]
  }
}

The matcher on the Notification hook filters which notifications get through. I will come back to it below.

The Script

Here is the complete notify-command.sh:

#!/usr/bin/env bash
input=$(cat)

event=$(echo "$input" | jq -r '.hook_event_name // ""')
message=$(echo "$input" | jq -r '.message // ""')
directory=$(echo "$input" | jq -r '.cwd // ""')

case "$event" in
  Notification)
    summary="${message:-Claude needs your attention}"
    sound="window-attention"
    ;;
  Stop)
    summary="Ready in ${directory##*/}"
    sound="complete"
    ;;
  *)
    exit 0
    ;;
esac

notify-send -i dialog-information "Claude Code" "$summary"

# canberra bypasses the notification daemon, so do-not-disturb has to be checked by hand
if [ "$(gsettings get org.gnome.desktop.notifications show-banners)" = "true" ]; then
  canberra-gtk-play -i "$sound"
fi

It needs three tools: jq to parse JSON, notify-send from libnotify-bin for the popup and canberra-gtk-play from gnome-session-canberra for the sound. On Ubuntu, notify-send is usually already installed.

Reading the Hook Input

Claude Code passes the event to the hook as JSON on standard input. The script reads it once and pulls out three fields with jq:

The // "" fallback makes jq print an empty string instead of null when a field is missing. Stop events, for example, have no message.

For Stop, the notification reads “Ready in” followed by the name of the project folder. ${directory##*/} strips everything up to the last slash, so /home/user/projects/my-app becomes my-app. When I have sessions running in several projects, this tells me which one is done without switching to the terminal.

Any other event ends the script with exit 0. The script should only ever be wired to these two hooks, but if it ends up somewhere else, it stays silent instead of sending a notification with an empty text.

Different Sounds for Different Events

The popup alone is easy to miss, especially on a second screen. A sound is what actually pulls me back. The two events get different sounds, so I can tell them apart without looking:

Both names come from the freedesktop sound theme, which ships with Ubuntu. canberra-gtk-play -i takes such a name and plays the matching file from the active theme. The files live in /usr/share/sounds/freedesktop/stereo/, so it is easy to listen through them and pick different ones.

Respecting Do Not Disturb

This is the one gotcha in the script. GNOME has a do not disturb mode, and notify-send respects it: the popup simply does not appear. canberra-gtk-play does not. It talks to the sound system directly and never goes through the notification daemon, so without an extra check, my laptop would chime in the middle of a call even though notifications are switched off.

GNOME stores the state of do not disturb in a setting. When it is on, show-banners is false:

gsettings get org.gnome.desktop.notifications show-banners
# true

The script only plays the sound when this returns true. That is why the comment above the check exists: without it, the condition looks unrelated to the line it guards.

Filtering Out the Idle Prompt

Without a matcher, the Notification hook fires for every notification Claude Code sends. One of them is noise to me: idle_prompt. Claude Code sends it when the agent has been waiting for input for a while.

In my setup, that notification never tells me anything new. When the agent finishes, Stop has already notified me. If I have not come back yet, it is because I am busy with something else, and a second ping a minute later does not change that. It only teaches me to ignore the sound, which defeats the purpose of having one.

The Notification hook supports a matcher that is checked against the notification type. Instead of excluding idle_prompt, the matcher lists the types I do want. This way, the agent notifies me in exactly two situations: when it is done, and when it is blocked on me.

Trying It Out

Since the script only reads from standard input, it can be tested without starting a session:

echo '{"hook_event_name":"Stop","cwd":"/tmp/demo"}' | bash ~/.claude/notify-command.sh
echo '{"hook_event_name":"Notification","message":"Claude needs your permission to use Bash"}' | bash ~/.claude/notify-command.sh

The first command shows “Ready in demo” with the complete sound. The second shows the message with the window-attention sound. With do not disturb on, both stay silent.

Conclusion

A notification hook takes a few lines of configuration and a shell script of about 25 lines, and it changes how I work with the agent. I no longer check on it. I start a task, move on to something else, and come back when I hear the sound. Which sound it is tells me whether I need to come back now or can finish what I am doing first.

Two details made the difference between a useful notification and an annoying one. The sound has to respect do not disturb, which canberra-gtk-play does not do on its own. And the hook should only fire when there is actually something to do. A notification that fires all the time is one I would quickly learn to ignore.