<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: shahab</title>
    <description>The latest articles on DEV Community by shahab (@shahabyounas).</description>
    <link>https://dev.to/shahabyounas</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F243688%2Fa892035a-81d0-41b8-80d5-cdf1f25e7350.jpeg</url>
      <title>DEV Community: shahab</title>
      <link>https://dev.to/shahabyounas</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/shahabyounas"/>
    <language>en</language>
    <item>
      <title>How to Get Notified When Claude Code Finishes a Task</title>
      <dc:creator>shahab</dc:creator>
      <pubDate>Sun, 23 Aug 2026 07:56:10 +0000</pubDate>
      <link>https://dev.to/shahabyounas/how-to-get-notified-when-claude-code-finishes-a-task-3kbn</link>
      <guid>https://dev.to/shahabyounas/how-to-get-notified-when-claude-code-finishes-a-task-3kbn</guid>
      <description>&lt;p&gt;You start a task in Claude Code. It will take a few minutes, so you switch to&lt;br&gt;
something else while you wait. Then you get absorbed in the new task and forget&lt;br&gt;
about the first one.&lt;/p&gt;

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

&lt;p&gt;The fix is about fifteen lines of shell script. But the beep is the easy part.&lt;br&gt;
The hard part is teaching it when to stay quiet.&lt;/p&gt;

&lt;p&gt;This post walks through building &lt;a href="https://github.com/shahabyounas/claude-notify" rel="noopener noreferrer"&gt;Claude Notify&lt;/a&gt;,&lt;br&gt;
a plugin that solves this. Everything here works on macOS and Linux.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why you cannot just ask Claude to tell you
&lt;/h2&gt;

&lt;p&gt;The obvious approach is to tell Claude "let me know when you are done." This&lt;br&gt;
does not work, and the reason is worth understanding.&lt;/p&gt;

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

&lt;p&gt;You need something the &lt;strong&gt;harness&lt;/strong&gt; runs, not something the model decides to do.&lt;br&gt;
In Claude Code, that is a hook.&lt;/p&gt;
&lt;h2&gt;
  
  
  What a hook is
&lt;/h2&gt;

&lt;p&gt;A hook is a shell command that Claude Code runs automatically when something&lt;br&gt;
happens in your session. The contract is simple:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;An event fires.&lt;/li&gt;
&lt;li&gt;Your command runs, with information about the event as JSON on standard input.&lt;/li&gt;
&lt;li&gt;Your command exits.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;These are the events you are most likely to use:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Event&lt;/th&gt;
&lt;th&gt;Fires when&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;UserPromptSubmit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;You press enter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;PreToolUse&lt;/code&gt; / &lt;code&gt;PostToolUse&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Before or after a tool runs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Notification&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Claude is blocked, waiting for you&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Stop&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The turn ended&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;SessionStart&lt;/code&gt; / &lt;code&gt;SessionEnd&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;The session opens or closes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Hooks go in &lt;code&gt;settings.json&lt;/code&gt;, grouped by event:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Stop"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"~/.claude/hooks/notify.sh done"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"async"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"timeout"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;async: true&lt;/code&gt; matters. Speech takes a second or two, and without it you would&lt;br&gt;
wait for the sound to finish before you could type again.&lt;/p&gt;
&lt;h2&gt;
  
  
  The first version
&lt;/h2&gt;

&lt;p&gt;Here is the whole thing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
afplay /System/Library/Sounds/Glass.aiff
say &lt;span class="s2"&gt;"Claude is done"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Attach it to &lt;code&gt;Stop&lt;/code&gt; and it works immediately. It is also annoying within an&lt;br&gt;
hour. Three separate reasons.&lt;/p&gt;
&lt;h3&gt;
  
  
  Problem 1: it does not tell you which session
&lt;/h3&gt;

&lt;p&gt;If you run Claude in three repositories at once, "Claude is done" tells you that&lt;br&gt;
something, somewhere, finished. You still have to go and look.&lt;/p&gt;

&lt;p&gt;The hook input solves this. It includes &lt;code&gt;cwd&lt;/code&gt;, the working directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.cwd'&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | xargs &lt;span class="nb"&gt;basename&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
say &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$project&lt;/span&gt;&lt;span class="s2"&gt; is done"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now it says "vela is done" and you know which window to open.&lt;/p&gt;

&lt;h3&gt;
  
  
  Problem 2: it fires when nothing happened
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;Stop&lt;/code&gt; means "the turn ended." That includes &lt;code&gt;/clear&lt;/code&gt;, &lt;code&gt;/compact&lt;/code&gt;, and resuming&lt;br&gt;
an old session. You get a sound for housekeeping that you did not care about.&lt;/p&gt;
&lt;h3&gt;
  
  
  Problem 3: it fires while you are looking at the screen
&lt;/h3&gt;

&lt;p&gt;This is the important one. An alert you did not need is worse than no alert,&lt;br&gt;
because it teaches you to ignore the ones you do need.&lt;/p&gt;
&lt;h2&gt;
  
  
  Gate one: was this actually a long task?
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;Stop&lt;/code&gt; event does not tell you how long the turn took. But &lt;code&gt;UserPromptSubmit&lt;/code&gt;&lt;br&gt;
fires when you submit, so you can measure it yourself.&lt;/p&gt;

&lt;p&gt;Write a timestamp when the turn starts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$statedir&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$session&lt;/span&gt;&lt;span class="s2"&gt;.start"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it when the turn ends:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$stamp&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;span class="nv"&gt;elapsed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$stamp&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="k"&gt;))&lt;/span&gt;
&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$stamp&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$elapsed&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-lt&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$MIN_SECONDS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That first line was meant as a safety check for a missing file. It turned out to&lt;br&gt;
fix Problem 2 as well.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;/clear&lt;/code&gt;, &lt;code&gt;/compact&lt;/code&gt;, and resume all fire &lt;code&gt;Stop&lt;/code&gt; without a preceding&lt;br&gt;
&lt;code&gt;UserPromptSubmit&lt;/code&gt;. So no timestamp gets written, the check finds nothing, and&lt;br&gt;
the script exits. One line, two problems solved.&lt;/p&gt;
&lt;h2&gt;
  
  
  Gate two: are you already watching?
&lt;/h2&gt;

&lt;p&gt;This is what makes the tool worth keeping. Two questions, and both must be true&lt;br&gt;
for it to stay silent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How long since you touched the computer?&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ioreg &lt;span class="nt"&gt;-c&lt;/span&gt; IOHIDSystem | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'/HIDIdleTime/ {print int($NF/1000000000); exit}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Which application is in front?&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;osascript &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'tell application "System Events" \
  to get name of first process whose frontmost is true'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;Walking the process tree is exact. The hook's parent processes lead all the way&lt;br&gt;
up to the application that started everything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;zsh → claude → zsh → Code Helper → Code.app/Contents/MacOS/Code
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;Put together, the logic is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"Needs input" skips the timing check — a blocked session needs you no matter how&lt;br&gt;
quickly it got stuck — but it still respects the focus check.&lt;/p&gt;
&lt;h2&gt;
  
  
  What I could not make work
&lt;/h2&gt;

&lt;p&gt;Reading window &lt;strong&gt;titles&lt;/strong&gt; would let the script tell tabs apart. macOS blocks it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;execution error: osascript is not allowed assistive access. (-1719)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;Granting Accessibility permission would fix it. I left it out, because asking&lt;br&gt;
for a broad system permission during install is a poor trade for the benefit.&lt;/p&gt;

&lt;p&gt;This is a real limit, not a detail that goes away if you ignore it. Notification&lt;br&gt;
tools that promise more than they deliver get uninstalled.&lt;/p&gt;
&lt;h2&gt;
  
  
  Turning it into a plugin
&lt;/h2&gt;

&lt;p&gt;Copying a script into &lt;code&gt;~/.claude/hooks/&lt;/code&gt; is fine for one machine. To share it,&lt;br&gt;
you need a plugin:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;claude-notify/&lt;/span&gt;
&lt;span class="s"&gt;├── .claude-plugin/marketplace.json&lt;/span&gt;
&lt;span class="s"&gt;└── claude-notify-plugin/&lt;/span&gt;
    &lt;span class="s"&gt;├── .claude-plugin/plugin.json&lt;/span&gt;
    &lt;span class="s"&gt;├── hooks/&lt;/span&gt;
    &lt;span class="s"&gt;│   ├── hooks.json&lt;/span&gt;
    &lt;span class="s"&gt;│   └── notify.sh&lt;/span&gt;
    &lt;span class="s"&gt;└── commands/claude-notify.md&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use &lt;code&gt;${CLAUDE_PLUGIN_ROOT}&lt;/code&gt; for file paths inside &lt;code&gt;hooks.json&lt;/code&gt;. The plugin is&lt;br&gt;
installed into a cache directory, and you do not control where that is.&lt;/p&gt;

&lt;p&gt;That same fact drives the one decision worth copying: &lt;strong&gt;a plugin script is not&lt;br&gt;
editable by the user.&lt;/strong&gt; It gets overwritten every time the plugin updates.&lt;/p&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;MIN_SECONDS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CLAUDE_NOTIFY_MIN_SECONDS&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;60&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;
&lt;span class="nv"&gt;IDLE_SECONDS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CLAUDE_NOTIFY_IDLE_SECONDS&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;30&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;
&lt;span class="nv"&gt;PRESENCE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CLAUDE_NOTIFY_PRESENCE&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;1&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Users set those in their own &lt;code&gt;settings.json&lt;/code&gt;, where an update cannot overwrite&lt;br&gt;
them.&lt;/p&gt;

&lt;p&gt;Two more rules for anything you plan to share:&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Always exit 0.&lt;/strong&gt; A hook that returns an error can interfere with the session it&lt;br&gt;
is attached to. A notifier has no business doing that.&lt;/p&gt;
&lt;h2&gt;
  
  
  Testing it
&lt;/h2&gt;

&lt;p&gt;Check the manifests:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude plugin validate ./claude-notify-plugin
claude plugin validate &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Test the logic by feeding the script fake input, instead of starting real&lt;br&gt;
sessions and waiting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'{"session_id":"t","cwd":"/repo"}'&lt;/span&gt; | bash notify.sh &lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;When a test fails, check the test before you change the code.&lt;/p&gt;
&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

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

&lt;p&gt;And if you build a notifier, remember that the sound is the easy part. The value&lt;br&gt;
is in every case where it decides to say nothing.&lt;/p&gt;
&lt;h2&gt;
  
  
  How this was built
&lt;/h2&gt;

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



&lt;p&gt;&lt;strong&gt;Claude Notify&lt;/strong&gt; is MIT licensed and available at&lt;br&gt;
&lt;a href="https://github.com/shahabyounas/claude-notify" rel="noopener noreferrer"&gt;github.com/shahabyounas/claude-notify&lt;/a&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;/plugin marketplace add shahabyounas/claude-notify
/plugin &lt;span class="nb"&gt;install &lt;/span&gt;claude-notify@claude-notify
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>ai</category>
      <category>bash</category>
      <category>productivity</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
