DEV Community

shahab
shahab

Posted on Originally published at github.com

How to Get Notified When Claude Code Finishes a Task

You start a task in Claude Code. It will take a few minutes, so you switch to
something else while you wait. Then you get absorbed in the new task and forget
about the first one.

Ten minutes later you check back. It finished after ninety seconds. Or worse: it
stopped after twenty seconds to ask you a simple yes/no question, and has been
sitting there waiting the whole time.

The fix is about fifteen lines of shell script. But the beep is the easy part.
The hard part is teaching it when to stay quiet.

This post walks through building Claude Notify,
a plugin that solves this. Everything here works on macOS and Linux.

Why you cannot just ask Claude to tell you

The obvious approach is to tell Claude "let me know when you are done." This
does not work, and the reason is worth understanding.

Anything you put in a prompt, a CLAUDE.md file, or a memory is a request to
the model. The model has to choose to follow it. And once the turn is over,
the model is not running at all — which is exactly the moment you care about.

You need something the harness runs, not something the model decides to do.
In Claude Code, that is a hook.

What a hook is

A hook is a shell command that Claude Code runs automatically when something
happens in your session. The contract is simple:

  1. An event fires.
  2. Your command runs, with information about the event as JSON on standard input.
  3. Your command exits.

These are the events you are most likely to use:

Event Fires when
UserPromptSubmit You press enter
PreToolUse / PostToolUse Before or after a tool runs
Notification Claude is blocked, waiting for you
Stop The turn ended
SessionStart / SessionEnd The session opens or closes

Hooks go in settings.json, grouped by event:

{
  "hooks": {
    "Stop": [{
      "hooks": [{
        "type": "command",
        "command": "~/.claude/hooks/notify.sh done",
        "async": true,
        "timeout": 15
      }]
    }]
  }
}
Enter fullscreen mode Exit fullscreen mode

async: true matters. Speech takes a second or two, and without it you would
wait for the sound to finish before you could type again.

The first version

Here is the whole thing:

#!/bin/bash
afplay /System/Library/Sounds/Glass.aiff
say "Claude is done"
Enter fullscreen mode Exit fullscreen mode

Attach it to Stop and it works immediately. It is also annoying within an
hour. Three separate reasons.

Problem 1: it does not tell you which session

If you run Claude in three repositories at once, "Claude is done" tells you that
something, somewhere, finished. You still have to go and look.

The hook input solves this. It includes cwd, the working directory:

project=$(jq -r '.cwd' <<< "$payload" | xargs basename)
say "$project is done"
Enter fullscreen mode Exit fullscreen mode

Now it says "vela is done" and you know which window to open.

Problem 2: it fires when nothing happened

Stop means "the turn ended." That includes /clear, /compact, and resuming
an old session. You get a sound for housekeeping that you did not care about.

Problem 3: it fires while you are looking at the screen

This is the important one. An alert you did not need is worse than no alert,
because it teaches you to ignore the ones you do need.

Gate one: was this actually a long task?

The Stop event does not tell you how long the turn took. But UserPromptSubmit
fires when you submit, so you can measure it yourself.

Write a timestamp when the turn starts:

date +%s > "$statedir/$session.start"
Enter fullscreen mode Exit fullscreen mode

Read it when the turn ends:

[ -f "$stamp" ] || exit 0
elapsed=$(( $(date +%s) - $(cat "$stamp") ))
rm -f "$stamp"
[ "$elapsed" -lt "$MIN_SECONDS" ] && exit 0
Enter fullscreen mode Exit fullscreen mode

That first line was meant as a safety check for a missing file. It turned out to
fix Problem 2 as well.

/clear, /compact, and resume all fire Stop without a preceding
UserPromptSubmit. So no timestamp gets written, the check finds nothing, and
the script exits. One line, two problems solved.

Gate two: are you already watching?

This is what makes the tool worth keeping. Two questions, and both must be true
for it to stay silent.

How long since you touched the computer?

ioreg -c IOHIDSystem | awk '/HIDIdleTime/ {print int($NF/1000000000); exit}'
Enter fullscreen mode Exit fullscreen mode

Which application is in front?

osascript -e 'tell application "System Events" \
  to get name of first process whose frontmost is true'
Enter fullscreen mode Exit fullscreen mode

The second question has a catch. To compare the focused app against your
session, the script needs to know which app owns the session. That is harder
than it sounds. $TERM_PROGRAM looks promising but lies — both Cursor and
Windsurf report vscode.

Walking the process tree is exact. The hook's parent processes lead all the way
up to the application that started everything:

zsh → claude → zsh → Code Helper → Code.app/Contents/MacOS/Code
Enter fullscreen mode Exit fullscreen mode

Collect the name of every ancestor. If the focused application matches any of
them, you are looking at this session. No process is ever called zsh or
launchd in the window server, so wrong matches are not a risk.

Put together, the logic is:

Stop fires
  ├─ No timestamp?          → silent   (/clear, /compact, resume)
  ├─ Ran under 60 seconds?  → silent   (you barely left)
  ├─ App focused + active?  → silent   (you can see it already)
  └─ Anything else          → play the sound
Enter fullscreen mode Exit fullscreen mode

"Needs input" skips the timing check — a blocked session needs you no matter how
quickly it got stuck — but it still respects the focus check.

What I could not make work

Reading window titles would let the script tell tabs apart. macOS blocks it:

execution error: osascript is not allowed assistive access. (-1719)
Enter fullscreen mode Exit fullscreen mode

So it can identify the application, but not the tab. Two Claude sessions in two
tabs of one terminal look the same to the script. Focus that terminal and both
stay quiet, even though only one is visible.

Granting Accessibility permission would fix it. I left it out, because asking
for a broad system permission during install is a poor trade for the benefit.

This is a real limit, not a detail that goes away if you ignore it. Notification
tools that promise more than they deliver get uninstalled.

Turning it into a plugin

Copying a script into ~/.claude/hooks/ is fine for one machine. To share it,
you need a plugin:

claude-notify/
├── .claude-plugin/marketplace.json
└── claude-notify-plugin/
    ├── .claude-plugin/plugin.json
    ├── hooks/
    │   ├── hooks.json
    │   └── notify.sh
    └── commands/claude-notify.md
Enter fullscreen mode Exit fullscreen mode

Use ${CLAUDE_PLUGIN_ROOT} for file paths inside hooks.json. The plugin is
installed into a cache directory, and you do not control where that is.

That same fact drives the one decision worth copying: a plugin script is not
editable by the user.
It gets overwritten every time the plugin updates.

My local version had settings at the top of the file. That is perfect for one
machine and useless for sharing. Every setting had to become an environment
variable:

MIN_SECONDS=${CLAUDE_NOTIFY_MIN_SECONDS:-60}
IDLE_SECONDS=${CLAUDE_NOTIFY_IDLE_SECONDS:-30}
PRESENCE=${CLAUDE_NOTIFY_PRESENCE:-1}
Enter fullscreen mode Exit fullscreen mode

Users set those in their own settings.json, where an update cannot overwrite
them.

Two more rules for anything you plan to share:

Degrade, do not fail. No paplay? Try aplay, then fall back to the
terminal bell. No jq? Keep a sed fallback for reading the payload. Cannot
read idle time? Skip the focus check and alert anyway. A missed alert is worse
than an extra one, so every unknown should resolve toward telling the user.

Always exit 0. A hook that returns an error can interfere with the session it
is attached to. A notifier has no business doing that.

Testing it

Check the manifests:

claude plugin validate ./claude-notify-plugin
claude plugin validate .
Enter fullscreen mode Exit fullscreen mode

Test the logic by feeding the script fake input, instead of starting real
sessions and waiting:

echo '{"session_id":"t","cwd":"/repo"}' | bash notify.sh done
Enter fullscreen mode Exit fullscreen mode

One warning from experience. I wrote a test script that passed environment
variables through a shell variable, and spent a while chasing a bug that did not
exist. zsh does not split unquoted variables into separate words the way bash
does, so env $VARS command silently collapsed into one long assignment. The
script was fine; my test was wrong.

When a test fails, check the test before you change the code.

The takeaway

Hooks are how you make things happen reliably in Claude Code, because they are
not the model's decision. Timing, alerts, formatting, logging — anything that
must happen every time belongs in a hook.

And if you build a notifier, remember that the sound is the easy part. The value
is in every case where it decides to say nothing.

How this was built

Built and written with Claude Code, directed by me. The tool is real, runs on my
machine, and every gate described here was tested with the synthetic payloads
shown above. The Accessibility limitation is one I actually hit, not a
hypothetical.


Claude Notify is MIT licensed and available at
github.com/shahabyounas/claude-notify.

/plugin marketplace add shahabyounas/claude-notify
/plugin install claude-notify@claude-notify
Enter fullscreen mode Exit fullscreen mode

Top comments (0)