Project 4: Notification Hub - Multi-Channel Alerts
Project 4: Notification Hub - Multi-Channel Alerts
Project Overview
| Attribute | Value |
|---|---|
| Project Number | 4 of 40 |
| Category | Hooks System Mastery |
| Main Programming Language | Python |
| Alternative Languages | Bun/TypeScript, Go, Rust |
| Difficulty Level | Level 2: Intermediate |
| Time Estimate | 1 Week |
| Coolness Level | Level 3: Genuinely Clever |
| Business Potential | Micro-SaaS / Pro Tool |
| Knowledge Area | Hooks / Notifications / Multi-Channel Communication |
| Primary Tool | Notification Hook, macOS say/afplay, ntfy.sh, Slack webhooks |
| Main Reference | “Building Microservices” by Sam Newman |
Summary: Build a comprehensive notification system that uses Stop, Notification, and SubagentStop hooks to alert you via multiple channels (audio, system notifications, ntfy.sh push, Slack) when Claude finishes tasks, encounters errors, or needs your attention.
Real World Outcome
Your notification hub alerts you through multiple channels based on event type and priority:
+-----------------------------------------------------------------------+
| NOTIFICATION HUB IN ACTION |
+-----------------------------------------------------------------------+
When Claude finishes a long task:
+--------------------------------------------------+
| macOS: "Claude Code: Task completed" |
| ntfy.sh: [Push to phone] "Your code review |
| is ready - project-x" |
| Audio: [chime sound plays] |
+--------------------------------------------------+
When Claude needs your attention:
+--------------------------------------------------+
| macOS: "Claude Code: Input needed" |
| ntfy.sh: [Push to phone] "Claude is waiting |
| for your response" |
| Audio: [attention sound plays] |
+--------------------------------------------------+
When an error occurs:
+--------------------------------------------------+
| Slack: "#alerts: Claude Code error in |
| project-x - Build failed (3 errors)" |
| ntfy.sh: [Push to phone] "ERROR: Build failed" |
| Audio: [error sound plays] |
| macOS: "Claude Code: Error occurred" |
+--------------------------------------------------+
What This Teaches You
- Combining multiple hook events into unified systems
- Integrating with external notification services
- Rate limiting to prevent notification fatigue
- Channel prioritization based on event severity
- State management between hook invocations
The Core Question You’re Answering
“How can I be notified through my preferred channels when Claude Code needs my attention or completes work?”
The Notification hook is unique in Claude Code - it fires when Claude needs user attention but the terminal might not be visible. Combined with Stop (task complete) and SubagentStop (subagent finished), you can build a complete awareness system.
+-----------------------------------------------------------------------+
| THREE NOTIFICATION EVENTS |
+-----------------------------------------------------------------------+
| |
| +------------------+ +------------------+ +------------------+ |
| | Notification | | Stop | | SubagentStop | |
| +------------------+ +------------------+ +------------------+ |
| | When: Claude | | When: Session | | When: Subagent | |
| | needs attention | | ends (any cause) | | completes task | |
| | | | | | | |
| | Payload: | | Payload: | | Payload: | |
| | - message | | - reason | | - subagent_id | |
| | - urgency | | - error (if any) | | - result | |
| | | | - duration | | - duration | |
| | Use: Alert user | | Use: Task done | | Use: Collect | |
| | immediately | | notifications | | parallel results | |
| +------------------+ +------------------+ +------------------+ |
| |
+-----------------------------------------------------------------------+
Concepts You Must Understand First
Stop and research these before coding:
1. Notification Event Semantics
The Notification hook fires when Claude explicitly needs user attention:
{
"hook_event_name": "Notification",
"session_id": "abc123",
"message": "I need your approval to proceed with the database migration",
"urgency": "high"
}
| Field | Description |
|---|---|
message |
What Claude wants to tell the user |
urgency |
Priority level (low, medium, high) |
session_id |
Current session identifier |
When does it fire?
- Claude asks a question requiring response
- Claude needs permission for a sensitive action
- Claude encounters ambiguity needing clarification
Reference: Claude Code Docs - “Notification Hook”
2. Push Notification Services
ntfy.sh is a simple pub/sub notification service:
# Send a notification (no account needed!)
curl -d "Your build is complete" ntfy.sh/my-alerts
# With title and priority
curl -H "Title: Claude Code" \
-H "Priority: high" \
-d "Build failed with 3 errors" \
ntfy.sh/my-alerts
| Feature | ntfy.sh | Pushover | Pushbullet |
|---|---|---|---|
| Free Tier | Unlimited | 10k/month | 500/month |
| No Account | Yes (public topics) | No | No |
| Self-Hostable | Yes | No | No |
| API Simplicity | Excellent | Good | Good |
Reference: ntfy.sh documentation
3. Webhook Integration (Slack)
Slack Incoming Webhooks let you post messages to channels:
curl -X POST -H 'Content-type: application/json' \
--data '{"text":"Hello from Claude Code!"}' \
https://hooks.slack.com/services/T00/B00/xxx
Advanced formatting with blocks:
{
"blocks": [
{
"type": "header",
"text": {"type": "plain_text", "text": "Claude Code Alert"}
},
{
"type": "section",
"text": {"type": "mrkdwn", "text": "*Error:* Build failed\n*Project:* my-app"}
}
]
}
Reference: Slack API documentation - Incoming Webhooks
Questions to Guide Your Design
Before implementing, think through these notification design decisions:
1. Channel Priority Matrix
Which notifications go to which channels?
+------------------+--------+--------+--------+--------+
| Event | Desktop| Audio | ntfy.sh| Slack |
+------------------+--------+--------+--------+--------+
| Notification | X | X | X* | |
| (attention) | | |(if away)| |
+------------------+--------+--------+--------+--------+
| Stop (success) | X | | X* | X* |
| | | |(if away)|(long) |
+------------------+--------+--------+--------+--------+
| Stop (error) | X | X | X | X |
+------------------+--------+--------+--------+--------+
| SubagentStop | | | X* | |
| | | |(batch) | |
+------------------+--------+--------+--------+--------+
* = conditional
2. Rate Limiting Strategy
What if 10 subagents finish in 1 second?
+-----------------------+--------------------------------+
| Problem | Solution |
+-----------------------+--------------------------------+
| Notification flood | Debounce: batch within 5s |
| User overwhelmed | Rate limit: max 1 per 30s |
| Important lost | Priority bypass for errors |
| Battery drain (mobile)| Quiet hours configuration |
+-----------------------+--------------------------------+
3. Configuration Options
What should users be able to customize?
{
"channels": {
"desktop": {"enabled": true},
"audio": {"enabled": true, "volume": 0.5},
"ntfy": {"enabled": true, "topic": "claude-alerts"},
"slack": {"enabled": false, "webhook": "https://..."}
},
"rules": {
"error_always_notify": true,
"quiet_hours": {"start": "22:00", "end": "08:00"},
"min_task_duration_for_notify": 60
}
}
Thinking Exercise
Design the Notification Router
Create the routing logic for different events:
+-----------------------------------------------------------------------+
| NOTIFICATION ROUTER |
+-----------------------------------------------------------------------+
| |
| Incoming Event |
| | |
| v |
| +-------------------+ |
| | Parse Event Type | |
| +-------------------+ |
| | |
| +--------------------+--------------------+ |
| | | | |
| v v v |
| +--------------+ +--------------+ +--------------+ |
| | Notification | | Stop | | SubagentStop | |
| +--------------+ +--------------+ +--------------+ |
| | | | |
| v v v |
| +-------------+ +-----------+ +-------------+ |
| | Always: | | Has Error?| | Add to batch| |
| | - Desktop | +-----------+ +-------------+ |
| | - Audio | | | | |
| +-------------+ v v v |
| | +------+ +------+ +-----------+ |
| v | Yes | | No | | Batch full| |
| +-----------+ +------+ +------+ | or 5s? | |
| | User Away?| | | +-----------+ |
| +-----------+ v v | |
| | | +--------+ +--------+ v |
| v v | All | | If long| +---------+ |
| +----+ +----+ |channels| | task: | | Send | |
| |Yes | | No | +--------+ | Desktop| | summary | |
| +----+ +----+ +--------+ +---------+ |
| | | |
| v v |
| +------+ +------+ |
| |ntfy.sh| |Skip | |
| +------+ +------+ |
| |
+-----------------------------------------------------------------------+
Questions to Answer:
- How do you know if the terminal is focused?
- On macOS: Check frontmost app with osascript
- On Linux: Check active window with xdotool
- Alternative: Track last keystroke time
- How do you track “away time”?
- Store last activity timestamp in temp file
- Update on UserPromptSubmit hook
- Compare current time - last activity
- Should you store state between hook invocations?
- Yes! Use a temp file or SQLite for:
- Rate limiting timestamps
- Batch accumulation
- Away detection
- Yes! Use a temp file or SQLite for:
The Interview Questions They’ll Ask
1. “How would you design a multi-channel notification system with different priority levels?”
Answer: Create a notification router that:
- Parses incoming events and extracts priority/type
- Maintains a channel registry with each channel’s capabilities
- Routes based on rules: errors go everywhere, info only to desktop
- Implements priority queues: high priority bypasses rate limits
- Uses a config file for user customization
Key pattern: Strategy pattern for channels, Chain of Responsibility for filtering.
2. “How do you prevent notification fatigue in an automated system?”
Answer:
- Rate limiting: Max N notifications per time window
- Debouncing: Batch rapid events (10 subagents = 1 notification)
- Quiet hours: No notifications during configured times
- Priority tiers: Only high-priority bypasses limits
- Aggregation: “5 tasks completed” instead of 5 separate notifications
- User controls: Easy mute/unmute per channel
3. “What’s the difference between push notifications and webhooks?”
Answer:
- Push Notifications (ntfy.sh, APNs, FCM): Deliver to user’s device through OS notification system. User-facing, designed for immediate attention.
- Webhooks (Slack, Discord): HTTP callbacks to a URL. Service-to-service, can be processed programmatically.
Push is for humans, webhooks are for systems (though Slack shows to humans too).
4. “How would you handle a notification channel being down?”
Answer:
- Timeout handling: Set reasonable timeouts (3-5s)
- Fallback channels: If Slack fails, try ntfy.sh
- Queue and retry: Store failed notifications, retry with backoff
- Health checks: Periodically verify channels work
- Graceful degradation: Log failures, don’t crash hook
- Alert on failures: Meta-notification if primary channel is down
5. “How would you make notification preferences configurable per-project?”
Answer: Layer configuration:
1. Default config: ~/.claude/notification-config.json
2. Project override: .claude/notification-config.json
3. Environment override: CLAUDE_NOTIFY_SLACK=off
4. Runtime override: Command-line flags
Merge configs with project taking precedence over default.
Hints in Layers
Hint 1: Start with Desktop Notifications
Use osascript on macOS:
osascript -e 'display notification "Task complete" with title "Claude Code"'
Hint 2: Add ntfy.sh
No account needed - just POST to a topic:
import requests
requests.post(
"https://ntfy.sh/your-topic",
data="Claude finished your task",
headers={"Title": "Claude Code"}
)
Hint 3: Implement Rate Limiting
Store timestamps in a temp file:
import os
import time
import json
RATE_FILE = "/tmp/claude-notify-rate.json"
def should_notify():
try:
with open(RATE_FILE) as f:
data = json.load(f)
if time.time() - data["last"] < 30: # 30s cooldown
return False
except:
pass
with open(RATE_FILE, "w") as f:
json.dump({"last": time.time()}, f)
return True
Hint 4: Add Channel Config
Create a config file with channel settings:
def load_config():
config_path = os.path.expanduser("~/.claude/notification-config.json")
try:
with open(config_path) as f:
return json.load(f)
except:
return {
"desktop": {"enabled": True},
"audio": {"enabled": True},
"ntfy": {"enabled": False, "topic": ""},
"slack": {"enabled": False, "webhook": ""}
}
Books That Will Help
| Topic | Book | Chapter | Why It Helps |
|---|---|---|---|
| API integration | “Building Microservices” by Sam Newman | Ch. 4 | Service integration patterns |
| Event-driven systems | “Designing Event-Driven Systems” by Stopford | Ch. 3 | Event routing patterns |
| Python HTTP | “Fluent Python” by Luciano Ramalho | Ch. 21 | HTTP client patterns |
| Notification design | “Designing Interfaces” by Jenifer Tidwell | Ch. 8 | Notification UX patterns |
| System programming | “The Linux Command Line” by Shotts | Ch. 26 | Process and IPC |
Implementation Guide
Complete Python Implementation
#!/usr/bin/env python3
"""
Notification Hub - Multi-Channel Alert System
Handles: Notification, Stop, and SubagentStop hooks
Channels: Desktop, Audio, ntfy.sh, Slack
"""
import json
import os
import sys
import time
import subprocess
from datetime import datetime
from pathlib import Path
from typing import Optional
import urllib.request
import urllib.error
# ===== CONFIGURATION =====
CONFIG_PATH = os.path.expanduser("~/.claude/notification-config.json")
STATE_PATH = "/tmp/claude-notification-state.json"
SOUNDS_DIR = os.path.expanduser("~/.claude/sounds")
DEFAULT_CONFIG = {
"channels": {
"desktop": {"enabled": True},
"audio": {
"enabled": True,
"success_sound": "Glass", # macOS system sound
"error_sound": "Basso",
"attention_sound": "Ping"
},
"ntfy": {
"enabled": False,
"server": "https://ntfy.sh",
"topic": "claude-alerts"
},
"slack": {
"enabled": False,
"webhook": ""
}
},
"rules": {
"rate_limit_seconds": 30,
"quiet_hours": {"enabled": False, "start": "22:00", "end": "08:00"},
"min_task_duration_for_slack": 60, # seconds
"batch_subagents": True,
"batch_window_seconds": 5
}
}
# ===== STATE MANAGEMENT =====
def load_state() -> dict:
"""Load notification state from temp file."""
try:
with open(STATE_PATH) as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return {
"last_notification": 0,
"subagent_batch": [],
"batch_start": 0
}
def save_state(state: dict):
"""Save notification state to temp file."""
with open(STATE_PATH, "w") as f:
json.dump(state, f)
def load_config() -> dict:
"""Load notification config, merging with defaults."""
config = DEFAULT_CONFIG.copy()
try:
with open(CONFIG_PATH) as f:
user_config = json.load(f)
# Deep merge
for key in user_config:
if isinstance(user_config[key], dict) and key in config:
config[key].update(user_config[key])
else:
config[key] = user_config[key]
except (FileNotFoundError, json.JSONDecodeError):
pass
return config
# ===== CHANNEL IMPLEMENTATIONS =====
def send_desktop_notification(title: str, message: str, sound: bool = False):
"""Send macOS desktop notification."""
script = f'''
display notification "{message}" with title "{title}"
'''
try:
subprocess.run(["osascript", "-e", script], capture_output=True, timeout=5)
except Exception as e:
print(f"Desktop notification failed: {e}", file=sys.stderr)
def play_audio(sound_name: str):
"""Play a macOS system sound."""
# Try system sound first
sound_path = f"/System/Library/Sounds/{sound_name}.aiff"
if os.path.exists(sound_path):
subprocess.Popen(["afplay", sound_path])
return
# Try custom sound
custom_path = os.path.join(SOUNDS_DIR, f"{sound_name}.mp3")
if os.path.exists(custom_path):
subprocess.Popen(["afplay", custom_path])
return
# Fallback: use 'say' command
subprocess.Popen(["say", "-v", "Samantha", "Notification"])
def send_ntfy(server: str, topic: str, title: str, message: str, priority: str = "default"):
"""Send push notification via ntfy.sh."""
url = f"{server}/{topic}"
headers = {
"Title": title,
"Priority": priority,
"Tags": "robot"
}
req = urllib.request.Request(
url,
data=message.encode("utf-8"),
headers=headers,
method="POST"
)
try:
with urllib.request.urlopen(req, timeout=5) as response:
return response.status == 200
except Exception as e:
print(f"ntfy.sh failed: {e}", file=sys.stderr)
return False
def send_slack(webhook: str, title: str, message: str, color: str = "#36a64f"):
"""Send message to Slack via webhook."""
payload = {
"attachments": [{
"color": color,
"blocks": [
{
"type": "header",
"text": {"type": "plain_text", "text": title}
},
{
"type": "section",
"text": {"type": "mrkdwn", "text": message}
},
{
"type": "context",
"elements": [{
"type": "mrkdwn",
"text": f"_Sent at {datetime.now().strftime('%H:%M:%S')}_"
}]
}
]
}]
}
req = urllib.request.Request(
webhook,
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST"
)
try:
with urllib.request.urlopen(req, timeout=5) as response:
return response.status == 200
except Exception as e:
print(f"Slack webhook failed: {e}", file=sys.stderr)
return False
# ===== RATE LIMITING =====
def should_notify(state: dict, config: dict, priority: str = "normal") -> bool:
"""Check if we should send a notification based on rate limits."""
# High priority always goes through
if priority == "high":
return True
# Check quiet hours
quiet = config["rules"].get("quiet_hours", {})
if quiet.get("enabled"):
now = datetime.now().strftime("%H:%M")
start = quiet.get("start", "22:00")
end = quiet.get("end", "08:00")
if start <= now or now <= end:
return False
# Check rate limit
rate_limit = config["rules"].get("rate_limit_seconds", 30)
last = state.get("last_notification", 0)
if time.time() - last < rate_limit:
return False
return True
def update_rate_limit(state: dict):
"""Update the last notification timestamp."""
state["last_notification"] = time.time()
save_state(state)
# ===== EVENT HANDLERS =====
def handle_notification(payload: dict, config: dict, state: dict):
"""Handle Notification hook event (Claude needs attention)."""
message = payload.get("message", "Claude needs your attention")
urgency = payload.get("urgency", "medium")
priority = "high" if urgency == "high" else "normal"
if not should_notify(state, config, priority):
return
channels = config["channels"]
# Desktop notification (always for Notification events)
if channels["desktop"]["enabled"]:
send_desktop_notification("Claude Code", message)
# Audio alert
if channels["audio"]["enabled"]:
sound = channels["audio"].get("attention_sound", "Ping")
play_audio(sound)
# ntfy.sh if configured
if channels["ntfy"]["enabled"]:
ntfy_priority = "high" if urgency == "high" else "default"
send_ntfy(
channels["ntfy"]["server"],
channels["ntfy"]["topic"],
"Claude Code - Attention Needed",
message,
ntfy_priority
)
update_rate_limit(state)
def handle_stop(payload: dict, config: dict, state: dict):
"""Handle Stop hook event (session ended)."""
reason = payload.get("reason", "unknown")
error = payload.get("error")
duration = payload.get("duration_seconds", 0)
channels = config["channels"]
if error:
# Error notifications always go through
title = "Claude Code - Error"
message = f"Session ended with error: {error}"
if channels["desktop"]["enabled"]:
send_desktop_notification(title, message)
if channels["audio"]["enabled"]:
play_audio(channels["audio"].get("error_sound", "Basso"))
if channels["ntfy"]["enabled"]:
send_ntfy(
channels["ntfy"]["server"],
channels["ntfy"]["topic"],
title,
message,
"high"
)
if channels["slack"]["enabled"] and channels["slack"]["webhook"]:
cwd = payload.get("cwd", "unknown")
slack_msg = f"*Error:* {error}\n*Project:* `{os.path.basename(cwd)}`"
send_slack(channels["slack"]["webhook"], title, slack_msg, "#ff0000")
else:
# Success notification
if not should_notify(state, config):
return
title = "Claude Code"
message = "Task completed successfully"
if channels["desktop"]["enabled"]:
send_desktop_notification(title, message)
if channels["audio"]["enabled"]:
play_audio(channels["audio"].get("success_sound", "Glass"))
# Slack only for long tasks
min_duration = config["rules"].get("min_task_duration_for_slack", 60)
if channels["slack"]["enabled"] and duration >= min_duration:
cwd = payload.get("cwd", "unknown")
mins = int(duration / 60)
slack_msg = f"*Task completed* in {mins} minutes\n*Project:* `{os.path.basename(cwd)}`"
send_slack(channels["slack"]["webhook"], title, slack_msg, "#36a64f")
update_rate_limit(state)
def handle_subagent_stop(payload: dict, config: dict, state: dict):
"""Handle SubagentStop hook event (subagent completed)."""
subagent_id = payload.get("subagent_id", "unknown")
result = payload.get("result", {})
# Batching: collect subagent completions
if config["rules"].get("batch_subagents", True):
batch = state.get("subagent_batch", [])
batch_start = state.get("batch_start", 0)
batch_window = config["rules"].get("batch_window_seconds", 5)
# Start new batch if window expired
if time.time() - batch_start > batch_window:
batch = []
state["batch_start"] = time.time()
batch.append({
"id": subagent_id,
"success": result.get("success", True)
})
state["subagent_batch"] = batch
save_state(state)
# Check if we should send batched notification
# (This would typically be called by a separate timer, but for simplicity
# we'll just send if batch is large enough)
if len(batch) >= 5:
successes = sum(1 for s in batch if s["success"])
failures = len(batch) - successes
message = f"{successes} subagents completed"
if failures:
message += f" ({failures} failed)"
channels = config["channels"]
if channels["ntfy"]["enabled"]:
send_ntfy(
channels["ntfy"]["server"],
channels["ntfy"]["topic"],
"Claude Code - Subagents",
message
)
# Clear batch
state["subagent_batch"] = []
save_state(state)
else:
# No batching - notify immediately
if should_notify(state, config):
channels = config["channels"]
if channels["ntfy"]["enabled"]:
status = "completed" if result.get("success") else "failed"
send_ntfy(
channels["ntfy"]["server"],
channels["ntfy"]["topic"],
"Claude Code",
f"Subagent {subagent_id} {status}"
)
update_rate_limit(state)
# ===== MAIN =====
def main():
# Read payload from stdin
try:
payload = json.loads(sys.stdin.read())
except json.JSONDecodeError:
sys.exit(0)
event = payload.get("hook_event_name", "")
config = load_config()
state = load_state()
if event == "Notification":
handle_notification(payload, config, state)
elif event == "Stop":
handle_stop(payload, config, state)
elif event == "SubagentStop":
handle_subagent_stop(payload, config, state)
# Always exit 0
sys.exit(0)
if __name__ == "__main__":
main()
Configuration in settings.json
{
"hooks": [
{
"event": "Notification",
"type": "command",
"command": "python3 ~/.claude/hooks/notification-hub.py",
"timeout": 10000
},
{
"event": "Stop",
"type": "command",
"command": "python3 ~/.claude/hooks/notification-hub.py",
"timeout": 10000
},
{
"event": "SubagentStop",
"type": "command",
"command": "python3 ~/.claude/hooks/notification-hub.py",
"timeout": 5000
}
]
}
User Config File (~/.claude/notification-config.json)
{
"channels": {
"desktop": {"enabled": true},
"audio": {
"enabled": true,
"success_sound": "Glass",
"error_sound": "Basso",
"attention_sound": "Ping"
},
"ntfy": {
"enabled": true,
"server": "https://ntfy.sh",
"topic": "my-claude-alerts"
},
"slack": {
"enabled": true,
"webhook": "https://hooks.slack.com/services/T00/B00/xxx"
}
},
"rules": {
"rate_limit_seconds": 30,
"quiet_hours": {
"enabled": true,
"start": "22:00",
"end": "08:00"
},
"min_task_duration_for_slack": 60,
"batch_subagents": true,
"batch_window_seconds": 5
}
}
Architecture Diagram
+-----------------------------------------------------------------------+
| NOTIFICATION HUB ARCHITECTURE |
+-----------------------------------------------------------------------+
| |
| +-------------+ +-------------+ +-------------+ |
| | Notification| | Stop | |SubagentStop | |
| | Event | | Event | | Event | |
| +------+------+ +------+------+ +------+------+ |
| | | | |
| +------------------+------------------+ |
| | |
| v |
| +-------------------+ |
| | notification-hub | |
| | .py | |
| +-------------------+ |
| | |
| +------------------+------------------+ |
| | | | |
| v v v |
| +-------------+ +-------------+ +-------------+ |
| | Load Config | | Load State | | Parse Event | |
| +-------------+ +-------------+ +-------------+ |
| | | | |
| +------------------+------------------+ |
| | |
| v |
| +-------------------+ |
| | Route by Event | |
| +-------------------+ |
| | |
| +------------------+------------------+ |
| | | | |
| v v v |
| +-------------+ +-------------+ +-------------+ |
| | handle_ | | handle_ | | handle_ | |
| | notification| | stop | | subagent_ | |
| +------+------+ +------+------+ | stop | |
| | | +------+------+ |
| | | | |
| +------------------+------------------+ |
| | |
| v |
| +-------------------+ |
| | Check Rate Limit | |
| +-------------------+ |
| | |
| v |
| +---------------------------------------------------------------+ |
| | CHANNEL DISPATCH | |
| +---------------------------------------------------------------+ |
| | | |
| | +----------+ +----------+ +----------+ +----------+ | |
| | | Desktop | | Audio | | ntfy.sh | | Slack | | |
| | | Notif | | Alert | | Push | | Webhook | | |
| | +----------+ +----------+ +----------+ +----------+ | |
| | | | | | | |
| | v v v v | |
| | osascript afplay HTTP POST HTTP POST | |
| | | |
| +---------------------------------------------------------------+ |
| |
+-----------------------------------------------------------------------+
Learning Milestones
Milestone 1: Desktop Notifications Work
Goal: See macOS notification when Claude finishes
Test:
# Trigger a Stop event manually:
echo '{"hook_event_name": "Stop", "reason": "complete"}' | python3 notification-hub.py
# You should see a macOS notification
What You’ve Learned:
- osascript for macOS notifications
- Basic event parsing
- Hook execution flow
Milestone 2: Multiple Channels Integrate
Goal: Desktop + Audio + ntfy.sh all fire
Test:
- Enable ntfy.sh in config
- Subscribe to your topic on phone
- Trigger an error event
- Verify all channels fire
What You’ve Learned:
- Multi-channel dispatch
- HTTP API calls from Python
- Channel configuration
Milestone 3: Rate Limiting Prevents Spam
Goal: Rapid events don’t flood notifications
Test:
# Send 5 events rapidly:
for i in {1..5}; do
echo '{"hook_event_name": "Stop"}' | python3 notification-hub.py
done
# Should only see 1 notification
What You’ve Learned:
- State persistence between invocations
- Rate limiting algorithms
- Priority bypass
Common Mistakes to Avoid
Mistake 1: Blocking on HTTP Failures
# WRONG - Hook hangs if ntfy.sh is down
response = requests.post(url, data=message) # No timeout!
# RIGHT - Always use timeouts
response = requests.post(url, data=message, timeout=5)
Mistake 2: Not Handling Missing Config
# WRONG - Crashes if config key missing
webhook = config["channels"]["slack"]["webhook"]
# RIGHT - Use .get() with defaults
webhook = config.get("channels", {}).get("slack", {}).get("webhook", "")
Mistake 3: Forgetting to Save State
# WRONG - Rate limit state not persisted
state["last_notification"] = time.time()
# Missing: save_state(state)
# RIGHT
state["last_notification"] = time.time()
save_state(state)
Mistake 4: Not Escaping Notification Messages
# WRONG - Special characters break osascript
message = 'Error: "file not found"'
script = f'display notification "{message}"' # Breaks!
# RIGHT - Escape quotes
message = message.replace('"', '\\"')
Extension Ideas
Once the basic hub works, consider these enhancements:
- Discord Integration: Add Discord webhook channel
- Email Digest: Batch notifications into daily email summary
- Mobile App: Create a simple React Native app for push
- Voice Alerts: Use macOS
sayfor spoken notifications - Smart Routing: ML-based priority detection
- Dashboard: Web UI showing notification history
Summary
This project taught you to build a multi-channel notification system:
- Multiple Hook Events: Combining Notification, Stop, and SubagentStop
- Channel Integration: Desktop, audio, ntfy.sh, Slack webhooks
- Rate Limiting: Preventing notification fatigue
- State Management: Persisting state between hook invocations
- Configuration: User-customizable channel and rule settings
With the Notification Hub, you’ll never miss when Claude needs attention. Project 5 will explore UserPromptSubmit for input validation, and Project 6 will tackle building a reusable hook framework.