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
| Tool | Purpose |
|---|---|
| ntfy | Free push notifications (self-hostable) |
| Claude Code hooks | Trigger 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
- Install ntfy app on your phone (iOS or Android)
- Subscribe to a random topic in the app (e.g.,
claude-abc123xyz) - 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
⬇️ 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
| Event | When | Notification |
|---|---|---|
Stop | Task completes | ✅ projectname: Task completed |
AskUserQuestion | Claude asks you something | ❓ projectname: Which approach? |
Bash (advanced) | Potentially risky command | ⚠️ projectname: Command: rm -rf… |
Notification | Permission/idle prompts | ⏳ projectname: Waiting for input |
Troubleshooting
| Issue | Fix |
|---|---|
| Hooks not firing | Restart Claude Code after editing settings.json |
| Duplicate notifications | Use advanced script — has 10-second rate limiting per project |
| No notifications when at computer | Intentional — advanced script checks if Terminal is focused |
| Notifications delayed | Check ntfy app settings, ensure notifications are enabled |
| Script errors | Test 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:
- Use a random topic name (e.g.,
claude-a7x9k2m4) instead of something guessable - Self-host ntfy with authentication enabled
- 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
- Add remote access — SSH in from your phone when you get a notification: Control Claude Code from iPhone with SSH
- Try the Happy app — A polished mobile UI: Self-Host Happy Server
Links
- ntfy — Push notification service
- Claude Code hooks docs — Official documentation
Questions? Find me using the links on the left or in my site’s menu.