Approve or deny Claude Code tool calls from your phone via ntfy push notifications.
Claude Code is an AI coding agent that runs in your terminal. It can read files, write code, run shell commands — but it asks for permission before doing anything potentially destructive. That permission prompt blocks the terminal until you respond.
This is fine when you're at your desk. But Claude Code sessions can run for minutes at a time, and you might walk away to make coffee, sit on the couch, or be in another room entirely. When that happens, Claude is stuck waiting, and your task stalls.
ntfy-approve solves this by sending each permission request as a push notification to your phone. You get a notification with the tool name and command, tap Approve or Deny, and Claude continues — no need to walk back to your laptop.
The system has three components connected over a Tailscale mesh network:
┌─────────────────────────────────────────────────┐
│ YOUR MACHINE │
│ │
│ Claude Code (terminal) │
│ ├─ Needs permission for a tool │
│ └─→ Fires PermissionRequest hook │
│ │
│ ntfy-approve.py (runs in parallel w/ prompt) │
│ ├─ POST notification → ntfy server │
│ └─ Poll response topic every 3s │
│ │
│ ntfy server (Docker, localhost:8090) │
│ ├─ Topic: cc-approve (notifications out) │
│ └─ Topic: cc-response (decisions back) │
│ │
└──────────────────────┬──────────────────────────┘
│ Tailscale
┌──────────────────────┴──────────────────────────┐
│ YOUR PHONE │
│ │
│ ntfy app │
│ ├─ Subscribed to cc-approve via WebSocket │
│ ├─ Shows notification with [Approve] [Deny] │
│ └─ Button tap POSTs to cc-response topic │
│ │
└─────────────────────────────────────────────────┘
Both channels (terminal prompt and phone notification) are active simultaneously. Respond from whichever device is convenient:
- Phone answers first — Claude gets the decision, notification auto-dismisses
- Terminal answers first — the hook process is killed, cleanup deletes the phone notification
- Neither answers within 120s — the hook exits silently, terminal prompt remains active
A second hook (ntfy-notify.sh) sends a lightweight push notification whenever Claude is idle and waiting for user input, so you know when to check back.
Claude Code supports hooks — shell commands that run in response to lifecycle events. This project uses two:
PermissionRequest— fires when Claude needs permission for a tool. The hook runs in parallel with the normal terminal prompt. If the hook returns a JSON decision (allowordeny), Claude uses it. If the hook exits without output (timeout, error, or terminal answered first), Claude falls back to the terminal prompt.Notification— fires on events likeidle_prompt. This hook is fire-and-forget: it sends a notification and exits immediately.
The ntfy server runs on localhost and is not exposed to the internet. Tailscale creates a private mesh VPN between your devices, so your phone can reach the ntfy server at its Tailscale IP without any port forwarding, dynamic DNS, or public exposure.
The credentials file stores two URLs for this reason:
NTFY_SERVER—http://localhost:8090for the hook scripts (running on the same machine)NTFY_TAILSCALE_URL—http://<tailscale-ip>:8090for the phone (used in notification action button URLs, since the phone can't reachlocalhost)
Note: This approach is untested. It should work in theory but has not been verified.
If your phone and laptop are always on the same WiFi network, you can skip Tailscale entirely. The Docker port mapping ("8090:80") binds to all interfaces by default, so ntfy is already reachable from your LAN. Just set NTFY_TAILSCALE_URL to your machine's local IP:
NTFY_TAILSCALE_URL=http://192.168.1.x:8090This is simpler but comes with limitations: it only works on the same network (no approvals from mobile data or other WiFi), you need to know your laptop's IP and update the credentials file every time you switch WiFi networks, and traffic is unencrypted HTTP rather than a WireGuard tunnel.
You could use the public ntfy.sh server, but self-hosting means:
- No rate limits on your own topics
- Auth is locked down (
deny-alldefault — only your user can access the topics) - No dependency on an external service
- Full control over message retention and server config
Claude Code
|
+- PermissionRequest hook (ntfy-approve.py)
| Runs in parallel with terminal permission prompt.
| +- POST notification with Approve/Deny buttons
| +- Poll response topic for decision
| +- Phone tap -> return allow/deny -> tool executes or is denied
| +- Terminal answer first -> notification auto-deleted from phone
| +- Timeout (120s) -> exit silently, terminal prompt still active
|
+- Notification hook (ntfy-notify.sh)
+- idle_prompt -> fire-and-forget push notification
ntfy server (Docker, port 8090)
+- Auth: deny-all default, single user with topic access
+- Topic: cc-approve (notifications TO phone)
+- Topic: cc-response (decisions FROM phone)
Phone (ntfy app)
+- Connects via Tailscale or LAN
+- Subscribed to cc-approve, action buttons POST to cc-response
Notifications show the project name prominently so you can distinguish between multiple Claude Code sessions:
| Tool | Title | Body | Emoji |
|---|---|---|---|
| Bash | project . description |
$ command |
computer |
| Edit | project . Edit filename |
relative path, old/new first line | pencil |
| Write | project . Write filename |
relative path | page |
| Other | project . ToolName |
parameters | wrench |
- Docker
- Python 3 (stdlib only, no pip packages)
- jq (for the notification hook)
- Phone with the ntfy app (F-Droid recommended for self-hosted)
- Network access from phone to server (Tailscale or LAN)
Create ~/.config/ntfy/credentials:
NTFY_SERVER=http://localhost:8090
NTFY_TAILSCALE_URL=http://<your-tailscale-ip>:8090
NTFY_USER=<username>
NTFY_PASS=<your-password>
NTFY_TOPIC_APPROVE=cc-approve
NTFY_TOPIC_RESPONSE=cc-responseNTFY_SERVER is used by the hook scripts (local requests). NTFY_TAILSCALE_URL is used in the action button URLs (the phone makes these HTTP calls, so they must be phone-reachable).
cp docker/.env.example docker/.env # edit TZ if needed
cd docker/
docker compose up -dThe script reads username and topics from the credentials file:
NTFY_PASSWORD=<your-password> ./docker/setup-auth.shAdd to ~/.claude/settings.json:
{
"hooks": {
"PermissionRequest": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "/usr/bin/python3 /path/to/ntfy-approve/hooks/ntfy-approve.py",
"timeout": 180
}
]
}
],
"Notification": [
{
"matcher": "idle_prompt",
"hooks": [
{
"type": "command",
"command": "/path/to/ntfy-approve/hooks/ntfy-notify.sh",
"timeout": 10
}
]
}
]
}
}- Install ntfy from F-Droid
- Settings -> Manage users -> add your server URL and credentials
- Subscribe to the
cc-approvetopic on your server - Enable WebSockets when prompted
- Exempt ntfy from battery optimization (Android Settings -> Apps -> ntfy -> Battery -> Unrestricted)
# Server health
curl -u user:pass http://localhost:8090/v1/health
# Test notification on phone
curl -u user:pass -H "Title: Test" -d "Hello" http://localhost:8090/cc-approve
# Test approval hook (tap Approve/Deny on phone within 60s)
echo '{"hook_event_name":"PermissionRequest","tool_name":"Bash","tool_input":{"command":"echo test"},"cwd":"/tmp"}' \
| python3 hooks/ntfy-approve.pyhooks/
ntfy-approve.py PermissionRequest hook -- notification + polling + cleanup
ntfy-notify.sh Notification hook -- idle prompt alerts
docker/
docker-compose.yml ntfy container config (port 8090)
server.yml ntfy server settings (deny-all auth, local cache)
setup-auth.sh one-time user/topic access setup
.env.example environment template (timezone)