Skip to content

Instantly share code, notes, and snippets.

@CJHwong
Last active March 16, 2026 08:24
Show Gist options
  • Select an option

  • Save CJHwong/8bcbdc902c73e0c05a116ae2c01930e8 to your computer and use it in GitHub Desktop.

Select an option

Save CJHwong/8bcbdc902c73e0c05a116ae2c01930e8 to your computer and use it in GitHub Desktop.
Claude Code + ntfy.sh: Mobile notifications & remote tool approval via action buttons

Claude Code + ntfy.sh: Mobile Notifications & Remote Approval

Push Claude Code notifications to your phone via ntfy.sh, and approve/reject tool calls with action buttons — no server required.

Scripts

Script Hook Purpose
ntfy_notify.sh Notification Push alerts when Claude needs input or finishes a task
ntfy_approve.sh PreToolUse Approve/reject tool calls from your phone

Prerequisites

  • ntfy.sh app installed on your phone (Android/iOS)
  • curl and jq installed on your machine
  • A unique ntfy topic name (acts as your channel — pick something hard to guess)

Setup

1. Choose your topics

Pick a unique topic name and update both scripts:

# In ntfy_notify.sh
NTFY_TOPIC="https://ntfy.sh/my-unique-topic-abc123"

# In ntfy_approve.sh
NTFY_TOPIC="https://ntfy.sh/my-unique-topic-abc123"
NTFY_RESPONSE_TOPIC="https://ntfy.sh/my-unique-topic-abc123-response"

Subscribe to your topic in the ntfy mobile app to receive notifications.

2. Configure Claude Code hooks

Add to ~/.claude/settings.json:

{
  "hooks": {
    "Notification": [
      {
        "matcher": "permission_prompt|idle_prompt",
        "hooks": [
          {
            "type": "command",
            "command": "bash /path/to/ntfy_notify.sh"
          }
        ]
      }
    ],
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "bash /path/to/ntfy_approve.sh"
          }
        ]
      }
    ]
  }
}

3. Test

# Test notifications
echo '{"notification_type":"idle_prompt","message":"Hello from test"}' | bash ntfy_notify.sh

# Test approval (tap Approve/Reject on your phone)
echo '{"tool_name":"Bash","tool_input":{"command":"ls -la"}}' | bash ntfy_approve.sh
echo "Exit code: $?"  # 0 = approved, 2 = rejected/timeout

How it works

ntfy_notify.sh (fire-and-forget)

Sends a notification when Claude is idle or needs permission. Features:

  • Dynamic priority: permission_prompt → high (persistent on mobile), others → default
  • Dynamic emoji tags: lock for permissions, hourglass for idle
  • Smart message: uses Claude's message if available, falls back to sensible defaults
  • Cooldown: suppresses duplicate pings within 10 seconds (configurable)
  • Project name prefix: optionally prepend [project-name] (set SHOW_PROJECT=true)

ntfy_approve.sh (interactive)

Sends a notification with Approve/Reject action buttons. When tapped:

  1. The button publishes a session-tagged response (allow:<session_id>) to a response topic
  2. The script polls that topic, filtering for its own session ID
  3. Claude proceeds or blocks based on the response
Claude Code (PreToolUse)
  → ntfy_approve.sh sends notification with action buttons
    → You tap Approve or Reject on your phone
      → Button publishes "allow:<session_id>" to response topic
        → Script filters by session ID, returns decision to Claude Code
  • Multi-session safe: each approval is tagged with the session ID, so concurrent sessions won't cross-talk
  • Timeout: auto-denies after 120 seconds (configurable)
  • No server needed: uses ntfy topics for both directions

Configuration

Variable Script Default Description
NTFY_TOPIC both Your ntfy topic URL
NTFY_RESPONSE_TOPIC approve Topic for receiving button responses
COOLDOWN_SECS notify 10 Minimum seconds between notifications
SHOW_PROJECT notify false Prefix messages with [project-name]
TIMEOUT_SECS approve 120 Seconds to wait before auto-deny
POLL_INTERVAL approve 2 Seconds between response polls

Security note

ntfy.sh topics are public by default — anyone who knows your topic name can send/receive messages. Use a long, random topic name (e.g., claude-notify-a8f3x9k2m7). For production use, consider self-hosting ntfy or using access tokens.

#!/usr/bin/env bash
# ──────────────────────────────────────────────────────────────
# ntfy_approve.sh — Remote tool approval via ntfy.sh action buttons
#
# Usage in ~/.claude/settings.json hooks:
#
# "PreToolUse": [
# {
# "matcher": "Bash",
# "hooks": [
# {
# "type": "command",
# "command": "bash /path/to/ntfy_approve.sh"
# }
# ]
# }
# ]
#
# Flow:
# 1. Receives tool use request from Claude Code on stdin
# 2. Sends ntfy notification with Approve/Reject action buttons
# 3. Button tap publishes session-tagged response to a response topic
# 4. Script polls the response topic, filtering by session ID
# 5. Returns allow/deny to Claude Code
#
# Multi-session safe: each approval is tagged with the session ID,
# so concurrent sessions won't cross-talk.
#
# Requires: curl, jq
#
# Config:
# NTFY_TOPIC — topic for sending notifications
# NTFY_RESPONSE_TOPIC — topic for receiving button responses
# TIMEOUT_SECS — how long to wait before auto-denying (default: 120)
# POLL_INTERVAL — how often to poll for response (default: 2)
# ──────────────────────────────────────────────────────────────
set -euo pipefail
# --- Configuration ---
NTFY_TOPIC="https://ntfy.sh/YOUR_TOPIC_HERE" # change this
NTFY_RESPONSE_TOPIC="https://ntfy.sh/YOUR_TOPIC_HERE-response" # change this
TIMEOUT_SECS=120
POLL_INTERVAL=2
# --- Read tool request from stdin ---
INPUT=$(cat)
TOOL=$(echo "$INPUT" | jq -r '.tool_name // "unknown"')
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // .tool_input.file_path // "N/A"' | head -c 150)
SESSION=$(echo "$INPUT" | jq -r '.session_id // "default"')
SESSION_SHORT=${SESSION:0:8} # short ID for display
# --- Record start time for polling (ntfy accepts unix timestamps) ---
# Add 1s buffer to avoid picking up stale responses
SINCE=$(($(date +%s) + 1))
# --- Send notification with action buttons ---
# Button bodies are tagged with session ID: "allow:<session>" / "deny:<session>"
curl -s \
-H "Title: Claude Code [$TOOL] ($SESSION_SHORT)" \
-H "Tags: lock,warning" \
-H "Priority: high" \
-H "Actions: http, Approve, $NTFY_RESPONSE_TOPIC, method=POST, body=allow:$SESSION, clear=true; http, Reject, $NTFY_RESPONSE_TOPIC, method=POST, body=deny:$SESSION, clear=true" \
-d "$COMMAND" \
"$NTFY_TOPIC" > /dev/null
# --- Poll for response, filtering by this session ---
ELAPSED=0
while [ "$ELAPSED" -lt "$TIMEOUT_SECS" ]; do
sleep "$POLL_INTERVAL"
ELAPSED=$((ELAPSED + POLL_INTERVAL))
DECISION=$(curl -s "$NTFY_RESPONSE_TOPIC/json?poll=1&since=$SINCE" 2>/dev/null \
| jq -r "select(.message | test(\":$SESSION\$\")) | .message // empty" \
| tail -1)
if [ "$DECISION" = "allow:$SESSION" ]; then
echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}'
exit 0
elif [ "$DECISION" = "deny:$SESSION" ]; then
echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","reason":"Rejected from phone"}}'
exit 0
fi
done
# --- Timeout: deny by default ---
echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","reason":"Approval timed out (120s)"}}'
exit 0
#!/usr/bin/env bash
# ──────────────────────────────────────────────────────────────
# ntfy_notify.sh — Send context-aware notifications via ntfy.sh
#
# Usage in ~/.claude/settings.json hooks:
#
# "Notification": [
# {
# "matcher": "permission_prompt|idle_prompt",
# "hooks": [
# {
# "type": "command",
# "command": "bash /path/to/ntfy_notify.sh"
# }
# ]
# }
# ]
#
# Reads JSON from stdin with fields:
# - notification_type: permission_prompt | idle_prompt
# - message: what Claude actually said
# - title: optional notification title
# - cwd: current working directory
#
# Features:
# - Dynamic title, tags, and priority based on notification type
# - Cooldown to avoid duplicate pings (configurable)
# - Falls back to title, then type-specific default if message is empty
# - Optional project name prefix (SHOW_PROJECT toggle)
# - Truncates message to 200 chars for clean mobile display
#
# Requires: curl, jq
# ──────────────────────────────────────────────────────────────
set -euo pipefail
# --- Configuration ---
NTFY_TOPIC="https://ntfy.sh/YOUR_TOPIC_HERE" # change this
COOLDOWN_SECS=10
COOLDOWN_FILE="/tmp/ntfy_cooldown"
SHOW_PROJECT=false # prefix messages with [project-name]
# --- Read event from stdin ---
INPUT=$(cat)
TYPE=$(echo "$INPUT" | jq -r '.notification_type // "unknown"')
TITLE=$(echo "$INPUT" | jq -r '.title // empty')
RAW_MSG=$(echo "$INPUT" | jq -r '.message // empty')
CWD=$(echo "$INPUT" | jq -r '.cwd // empty' | xargs basename 2>/dev/null || true)
# --- Build a meaningful message from available fields ---
if [ -n "$RAW_MSG" ]; then
MSG="$RAW_MSG"
elif [ -n "$TITLE" ]; then
MSG="$TITLE"
else
case "$TYPE" in
idle_prompt) MSG="Task complete — waiting for input" ;;
permission_prompt) MSG="Permission needed" ;;
*) MSG="Needs attention" ;;
esac
fi
# --- Prefix with project name ---
if [ "$SHOW_PROJECT" = true ] && [ -n "$CWD" ]; then
MSG="[$CWD] $MSG"
fi
MSG=$(echo "$MSG" | head -c 200)
# --- Cooldown: skip if last ping was too recent ---
if [ -f "$COOLDOWN_FILE" ]; then
LAST=$(cat "$COOLDOWN_FILE")
NOW=$(date +%s)
if [ $((NOW - LAST)) -lt "$COOLDOWN_SECS" ]; then
exit 0
fi
fi
date +%s > "$COOLDOWN_FILE"
# --- Map type to tags and priority ---
case "$TYPE" in
permission_prompt)
TAGS="lock,warning"
PRIORITY="high"
;;
*)
TAGS="hourglass,eyes"
PRIORITY="default"
;;
esac
# --- Send notification ---
curl -s \
-H "Title: Claude Code [$TYPE]" \
-H "Tags: $TAGS" \
-H "Priority: $PRIORITY" \
-d "$MSG" \
"$NTFY_TOPIC"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment