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:

  1. 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
  2. How do you track “away time”?
    • Store last activity timestamp in temp file
    • Update on UserPromptSubmit hook
    • Compare current time - last activity
  3. Should you store state between hook invocations?
    • Yes! Use a temp file or SQLite for:
      • Rate limiting timestamps
      • Batch accumulation
      • Away detection

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:

  1. Parses incoming events and extracts priority/type
  2. Maintains a channel registry with each channel’s capabilities
  3. Routes based on rules: errors go everywhere, info only to desktop
  4. Implements priority queues: high priority bypasses rate limits
  5. 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:

  1. Timeout handling: Set reasonable timeouts (3-5s)
  2. Fallback channels: If Slack fails, try ntfy.sh
  3. Queue and retry: Store failed notifications, retry with backoff
  4. Health checks: Periodically verify channels work
  5. Graceful degradation: Log failures, don’t crash hook
  6. 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:

  1. Enable ntfy.sh in config
  2. Subscribe to your topic on phone
  3. Trigger an error event
  4. 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:

  1. Discord Integration: Add Discord webhook channel
  2. Email Digest: Batch notifications into daily email summary
  3. Mobile App: Create a simple React Native app for push
  4. Voice Alerts: Use macOS say for spoken notifications
  5. Smart Routing: ML-based priority detection
  6. 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.