Skip to main content

It's All About Hooks

· 3 min read
JS Koo
Developer
Series

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

HookWhen
UserPromptSubmitWhen I type something
StopWhen Claude finishes
NotificationWhen permission needed or idle
SessionEndWhen 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:


Docs: Claude Code Notifier Docs

GitHub: https://github.com/js-koo/claude-code-notifier