iPhone showing push notifications from Claude Code terminal via ntfy

Push Notifications for Claude Code with ntfy + Hooks

Claude Code sessions can run for hours. Get push notifications on your phone when Claude needs input or finishes a task—no need to keep checking.

TL;DR: Install ntfy app on your phone, create a notification script, configure Claude Code hooks. Get notified when Claude asks questions or completes tasks. ~15 minutes.

This pairs well with SSH access from iPhone for a complete remote workflow.

When I say “we” throughout this post, I mean me and Claude Code. Claude helped research, write configs, and debug issues.

The Stack

ToolPurpose
ntfyFree push notifications (self-hostable)
Claude Code hooksTrigger scripts on events (Stop, PreToolUse, etc.)

Prerequisites

  • Claude Code installed and working
  • An iPhone or Android for receiving notifications
  • Python 3 (comes with macOS)

Time to complete: ~15 minutes.


Install ntfy

  1. Install ntfy app on your phone (iOS or Android)
  2. Subscribe to a random topic in the app (e.g., claude-abc123xyz)
  3. Test from your Mac:
curl -d "Hello from Mac" ntfy.sh/your-topic-name

You should see a notification on your phone within seconds.


Basic Notification Script

Start simple—notifications for task completion and questions only.

Save to /usr/local/bin/ntfy-claude:

#!/usr/bin/env python3
"""Basic push notifications for Claude Code"""
import sys, json, subprocess, os

NTFY_TOPIC = "your-ntfy-topic"  # Change this!

def send(title, message):
    subprocess.run([
        "curl", "-s",
        "-H", f"Title: {title}",
        "-d", message,
        f"https://ntfy.sh/{NTFY_TOPIC}"
    ], capture_output=True)

def main():
    if len(sys.argv) < 2:
        return
    event = sys.argv[1]
    data = json.loads(sys.stdin.read())
    cwd = os.path.basename(data.get("cwd", ""))

    if event == "Stop":
        send(f"✅ {cwd}", "Task completed")
    elif event == "PreToolUse":
        tool = data.get("tool_name", "")
        if tool == "AskUserQuestion":
            questions = data.get("tool_input", {}).get("questions", [])
            q = questions[0].get("question", "Question") if questions else "Question"
            send(f"❓ {cwd}", q[:100])

if __name__ == "__main__":
    main()

Make executable:

chmod +x /usr/local/bin/ntfy-claude

Test it:

echo '{"cwd": "/Users/me/myproject"}' | /usr/local/bin/ntfy-claude Stop
# Should send notification to your phone

Advanced Script (Optional)

Want more features? This version adds:

  • Rate limiting — Skip duplicate notifications when multiple hooks fire for the same action (e.g., PreToolUse + Notification both fire for permission prompts)
  • Smart filtering — Skip notifications for safe commands (git status, ls, etc.)
  • Idle detection — Only notify when you’re away from your computer
  • Rich formatting — Shows question options in the notification
#!/usr/bin/env python3
"""Push notifications for Claude Code events via ntfy"""

import os, sys, json, subprocess, time, hashlib

NTFY_TOPIC = "your-ntfy-topic"  # Change this!
NTFY_SERVER = "https://ntfy.sh"
RATE_LIMIT_SECONDS = 10

# Commands that are safe (don't notify)
SAFE_COMMANDS = ["git status", "git diff", "ls", "cat", "grep"]

def get_rate_limit_file(project):
    # ... more code below
⬇️ Click to expand full script (~150 lines)

Save to /usr/local/bin/ntfy-claude:

#!/usr/bin/env python3
"""Push notifications for Claude Code events via ntfy"""

import os
import sys
import json
import subprocess
import time
import hashlib

NTFY_TOPIC = "your-ntfy-topic"  # Change this!
NTFY_SERVER = "https://ntfy.sh"
RATE_LIMIT_SECONDS = 10

# Commands that are safe (don't notify)
SAFE_COMMANDS = ["git status", "git diff", "ls", "cat", "grep"]

def get_rate_limit_file(project):
    """Get path to rate limit file for this project"""
    safe_name = hashlib.md5(project.encode()).hexdigest()[:8]
    return f"/tmp/ntfy-claude-{safe_name}.last"

def should_rate_limit(project):
    """Check if we should skip notification due to rate limiting"""
    rate_file = get_rate_limit_file(project)
    try:
        if os.path.exists(rate_file):
            last_time = float(open(rate_file).read().strip())
            if time.time() - last_time < RATE_LIMIT_SECONDS:
                return True
    except:
        pass
    return False

def record_notification(project):
    """Record that we sent a notification for this project"""
    try:
        with open(get_rate_limit_file(project), 'w') as f:
            f.write(str(time.time()))
    except:
        pass

def is_terminal_focused():
    """Check if Terminal.app is frontmost"""
    try:
        result = subprocess.run(
            ["osascript", "-e",
             'tell app "System Events" to get name of first process whose frontmost is true'],
            capture_output=True, text=True
        )
        return "Terminal" in result.stdout
    except:
        return False

def is_system_idle(threshold=300):
    """Check if user has been idle for threshold seconds"""
    try:
        result = subprocess.run(
            ["ioreg", "-c", "IOHIDSystem"],
            capture_output=True, text=True
        )
        for line in result.stdout.split('\n'):
            if 'HIDIdleTime' in line:
                idle_ns = int(line.split('=')[1].strip())
                return (idle_ns / 1_000_000_000) > threshold
    except:
        pass
    return False

def send_notification(title, message, project, priority="default", tags=None):
    """Send notification via ntfy"""
    # Rate limit: skip if we just sent a notification for this project
    if should_rate_limit(project):
        return

    # Skip if user is actively using terminal
    if is_terminal_focused() and not is_system_idle():
        return

    cmd = [
        "curl", "-s",
        "-H", f"Title: {title}",
        "-H", f"Priority: {priority}",
    ]

    if tags:
        cmd.extend(["-H", f"Tags: {tags}"])

    cmd.extend(["-d", message, f"{NTFY_SERVER}/{NTFY_TOPIC}"])
    subprocess.run(cmd, capture_output=True)

    # Record that we sent a notification
    record_notification(project)

def format_ask_user_question(tool_input):
    """Format AskUserQuestion for rich notification"""
    questions = tool_input.get("questions", [])
    if not questions:
        return "Question from Claude"

    q = questions[0]
    header = q.get("header", "")
    question = q.get("question", "")[:120]
    options = q.get("options", [])

    parts = []
    if header:
        parts.append(f"**{header}**")
    if question:
        parts.append(question)
    if options:
        opts = " | ".join([o.get("label", "") for o in options[:4]])
        parts.append(f"→ {opts}")

    return "\n".join(parts)

def handle_pre_tool_use(data):
    """Handle PreToolUse events"""
    tool_name = data.get("tool_name", "")
    tool_input = data.get("tool_input", {})
    cwd = os.path.basename(data.get("cwd", ""))

    if tool_name == "AskUserQuestion":
        message = format_ask_user_question(tool_input)
        send_notification(
            f"❓ {cwd}",
            message,
            cwd,
            priority="high",
            tags="question"
        )
    elif tool_name == "Bash":
        command = tool_input.get("command", "")
        # Skip safe commands
        if any(command.startswith(safe) for safe in SAFE_COMMANDS):
            return
        # Notify for potentially dangerous commands
        send_notification(
            f"⚠️ {cwd}",
            f"Command: {command[:100]}",
            cwd,
            priority="default",
            tags="warning"
        )

def handle_stop(data):
    """Handle Stop events (task completed)"""
    cwd = os.path.basename(data.get("cwd", ""))
    send_notification(
        f"✅ {cwd}",
        "Task completed",
        cwd,
        priority="default",
        tags="white_check_mark"
    )

def handle_notification(data):
    """Handle Notification events"""
    message = data.get("message", "")
    cwd = os.path.basename(data.get("cwd", ""))

    if "waiting for" in message.lower():
        send_notification(
            f"⏳ {cwd}",
            "Waiting for input",
            cwd,
            priority="high",
            tags="hourglass"
        )

def main():
    if len(sys.argv) < 2:
        return

    event_type = sys.argv[1]
    data = json.loads(sys.stdin.read())

    handlers = {
        "PreToolUse": handle_pre_tool_use,
        "Stop": handle_stop,
        "Notification": handle_notification,
    }

    handler = handlers.get(event_type)
    if handler:
        handler(data)

if __name__ == "__main__":
    main()

Configure Claude Code Hooks

Add to ~/.claude/settings.json:

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {"type": "command", "command": "/usr/local/bin/ntfy-claude Stop"}
        ]
      }
    ],
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {"type": "command", "command": "/usr/local/bin/ntfy-claude PreToolUse"}
        ]
      },
      {
        "matcher": "AskUserQuestion",
        "hooks": [
          {"type": "command", "command": "/usr/local/bin/ntfy-claude PreToolUse"}
        ]
      }
    ],
    "Notification": [
      {
        "matcher": "permission_prompt|idle_prompt",
        "hooks": [
          {"type": "command", "command": "/usr/local/bin/ntfy-claude Notification"}
        ]
      }
    ]
  }
}

Restart Claude Code after editing settings.json (hooks only load on startup).


What Gets Notified

EventWhenNotification
StopTask completes✅ projectname: Task completed
AskUserQuestionClaude asks you something❓ projectname: Which approach?
Bash (advanced)Potentially risky command⚠️ projectname: Command: rm -rf…
NotificationPermission/idle prompts⏳ projectname: Waiting for input

Troubleshooting

IssueFix
Hooks not firingRestart Claude Code after editing settings.json
Duplicate notificationsUse advanced script — has 10-second rate limiting per project
No notifications when at computerIntentional — advanced script checks if Terminal is focused
Notifications delayedCheck ntfy app settings, ensure notifications are enabled
Script errorsTest manually: echo '{"cwd":"/test"}' | /usr/local/bin/ntfy-claude Stop

Security: ntfy Topic Privacy

By default, ntfy topics are public—anyone who guesses your topic name can read your notifications.

Options:

  1. Use a random topic name (e.g., claude-a7x9k2m4) instead of something guessable
  2. Self-host ntfy with authentication enabled
  3. Use ntfy’s access control (paid on ntfy.sh, free if self-hosted)

FAQ

Can I use a different notification service?

Yes. Replace the curl call in the script with your preferred service (Pushover, Slack, Discord webhook, etc.).

How do I stop getting too many notifications?

Use the advanced script—it filters out safe commands and skips notifications when you’re at your computer.

Do I need the SSH setup too?

No, notifications work independently. But they pair well—get notified, then SSH in to respond. See SSH access from iPhone.


Next Steps



Questions? Find me using the links on the left or in my site’s menu.