It's All About Hooks
This is Part 2 of the Claude Code Notifier series. (4 parts total)
Sending notifications is easy. Knowing when to send them is the hard part.
Claude Code has Hooks. They run scripts at specific moments—when a task finishes, when permission is needed, when a session ends. You can hook into those moments and do whatever you want.
That's it.
Four Hooks
| Hook | When |
|---|---|
| UserPromptSubmit | When I type something |
| Stop | When Claude finishes |
| Notification | When permission needed or idle |
| SessionEnd | When session ends |
I use all four. Nothing left out.
Flow
I type something
↓
[UserPromptSubmit] → save timestamp
↓
Claude works
↓
[Stop / Notification] → send notification
↓
[SessionEnd] → cleanup
Simple. Simple is good.
Why Track Time
To implement "only notify after 20 seconds" from Part 1, you need to measure time.
# On input
date +%s > timestamp-${SESSION_ID}.txt
# On completion
start=$(cat timestamp-${SESSION_ID}.txt)
now=$(date +%s)
elapsed=$((now - start))
if [ $elapsed -lt $MIN_DURATION ]; then
exit 0
fi
Save on UserPromptSubmit, compare on Stop.
Permission Requests Are Different
No time check for permission requests. Notify immediately.
Why? Claude is waiting, not me. If Claude needs approval to create a file and I don't know for an hour, I've wasted an hour.
case "$NOTIFICATION_TYPE" in
"permission_prompt")
# Immediate
notify "$MSG_PERMISSION"
;;
"idle_prompt")
# Immediate
notify "$MSG_IDLE"
;;
*)
# Task complete - check time
if should_notify; then
notify "$MSG_COMPLETED"
fi
;;
esac
Three-line conditional. That's the entire notification logic.
hooks.json
Config file looks like this:
{
"hooks": {
"UserPromptSubmit": [{
"hooks": [{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks-handlers/save-prompt.sh"
}]
}],
"Stop": [{
"hooks": [{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks-handlers/notify.sh"
}]
}],
"Notification": [
{
"matcher": "permission_prompt",
"hooks": [{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks-handlers/notify.sh"
}]
},
{
"matcher": "idle_prompt",
"hooks": [{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks-handlers/notify.sh"
}]
}
],
"SessionEnd": [{
"hooks": [{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks-handlers/cleanup-session.sh"
}]
}]
}
}
matcher is key. permission_prompt catches only permission requests. idle_prompt catches only idle states.
Session Independence
Three terminals means three session IDs.
Terminal 1: session-abc123
Terminal 2: session-def456
Terminal 3: session-ghi789
Three files too:
timestamp-abc123.txt
timestamp-def456.txt
timestamp-ghi789.txt
No mixing. Terminal 1 finishing only notifies for Terminal 1.
This is essential. Without multi-session support, the plugin is useless.
Works Without jq
Most people use jq for JSON parsing. But not everyone has jq.
json_get() {
if command -v jq &> /dev/null; then
# Use jq if available
echo "$1" | jq -r ".$2"
else
# Fall back to grep
echo "$1" | grep -oE "\"$2\":[[:space:]]*\"[^\"]*\"" | \
sed "s/\"$2\":[[:space:]]*\"\(.*\)\"/\1/"
fi
}
Use jq if it exists. Otherwise, grep and sed. Not perfect—can't parse nested JSON. But this plugin doesn't need nested JSON.
Minimizing dependencies matters more. Too many requirements and people won't use it.
Summary
prompt input → save timestamp
↓
task complete → over 20s? → notify
↓
permission request → notify immediately
↓
session end → delete files
That's all. No reason to overcomplicate.
Four hooks. One conditional. Time check.
Next post covers making this work on macOS, Linux, and Windows. Started when someone asked "Is that macOS only?"
Series:
- Part 1: Notify Me When Claude Code Finishes
- Part 2: It's All About Hooks (current)
- Part 3: macOS Only?
- Part 4: Create pull request
