Several Claude Code sessions run on this machine at once, and ntfy pushes to my phone when one needs me. Wired naively, every permission prompt and idle timer pinged, and the one real question sat unread for hours. The fix costs two ~30-line hook scripts and one instruction block.
Hand the whole thing to Claude Code instead
Set up "question-only" phone notifications for Claude Code using ntfy. Goal: my
phone pings ONLY when a Claude session raises an AskUserQuestion dialog — nothing
else. No permission prompts, no idle pings, no plain-text questions.
My ntfy topic: <TOPIC-OR-LEAVE-BLANK>. If I left that blank, generate a random
topic like claude-<8 hex chars> and use it everywhere below. Topic names are
public read/write on ntfy.sh, so treat the name as a password.
Prerequisites, before building anything:
- Verify jq and curl are on PATH (the scripts depend on both). If jq is
missing, install it (e.g. brew install jq) or write the scripts without it.
- If my settings already contain a Notification-event hook that pushes to ntfy
(an older noisy setup), show it to me and ask whether to remove it as part of
this — otherwise the old noise continues alongside the new signal.
Build all of this:
1. ~/.claude/scripts/ntfy-ask-question.sh — a PreToolUse hook for the
AskUserQuestion tool. Read the hook JSON from stdin. Body = the first
question's text (.tool_input.questions[0].question), append "(+N more)" if
there are several, truncate to ~120 chars. Title = tmux pane title when
$TMUX_PANE is set, else the basename of .cwd, else the first 4 chars of
.session_id (never leave it empty). Push with:
curl -s -H "Title: $title" -d "$body" ntfy.sh/<TOPIC>
Always exit 0. CRITICAL: this must be a PreToolUse hook with matcher
"AskUserQuestion" — the Notification hook event does NOT fire for
AskUserQuestion, so a Notification-based version is silently dead.
2. ~/.claude/scripts/block-prose-question-stop.sh — a Stop hook. Read the hook
JSON. If .stop_hook_active is true, exit 0 immediately (loop guard: block at
most once per turn). Otherwise read the last assistant message's text from
.transcript_path (a JSONL file; message content is either a string or an
array of blocks — join the .text fields). If its last non-blank line ends
with "?", print exactly one JSON object to stdout:
{"decision":"block","reason":"Your final message ends with a question.
Plain-text questions never notify the user — only the AskUserQuestion dialog
does. If you need an answer to proceed, re-ask exactly that question via the
AskUserQuestion tool now. If it was rhetorical or an optional follow-up
offer, end your turn without it."}
Otherwise exit 0 silently.
3. chmod +x both scripts.
4. Register both in ~/.claude/settings.json under "hooks", MERGING with any
existing hooks (never remove or reorder what's already there):
- PreToolUse: {"matcher": "AskUserQuestion", "hooks": [{"type": "command",
"command": "$HOME/.claude/scripts/ntfy-ask-question.sh"}]}
- Stop: {"hooks": [{"type": "command",
"command": "$HOME/.claude/scripts/block-prose-question-stop.sh"}]}
5. Append a section to ~/.claude/CLAUDE.md telling every session: questions
that block progress MUST go through the AskUserQuestion tool (2–4 concrete
options; the auto-added "Other" covers free-form); when not blocked, ask
nothing and just finish and report; subagents can't reach me, so they return
open questions to their orchestrator, which asks via AskUserQuestion. Note
that a Stop hook enforces this by blocking turns that end with "?".
6. Test before declaring success, in this order, and show me the results:
a. Offline: pipe fixture JSON into each script with curl stubbed via a PATH
override. Assert: dialog fixture → push with the question text and a
cwd-basename title; prose-question transcript fixture → the block JSON;
statement-ending transcript → silent; stop_hook_active=true → silent.
b. Server-side: send one numbered test push with plain curl, then confirm
receipt with: curl -s "https://ntfy.sh/<TOPIC>/json?poll=1&since=5m"
c. Subscription: BEFORE the live test, print the topic name and tell me to
install the ntfy app and subscribe to it now — the next step's ping is
lost or delayed if I subscribe after it fires.
d. Live: call AskUserQuestion once for real, asking me whether that very
dialog's ping reached my phone. That call is itself the end-to-end test.
If I say no ping arrived but 6b showed server-side receipt, have me open
/hooks in THIS session (hook registrations can need a re-read even in the
session that added them, depending on version), then repeat the dialog
once.
7. When done, tell me the caveats: hook registrations snapshot at session
start, so my OTHER already-running sessions need /hooks opened in them (or a
restart) before they ping; permission prompts are now silent by design, so a
session stuck on an approval waits until I look at it; phone silent mode /
Focus shows pings without sound.
Work autonomously — the only questions you ask me are the old-noisy-hook
decision in the prerequisites (if one exists) and the live test in 6d.
Point ntfy at your phone
The finished thing feels like this: a session deep into a long refactor hits a genuine fork and raises its question dialog, and the phone lights up with the project's name and the question itself. Answering unblocks that session. Nothing else makes a sound.
ntfy is the pipe: a free publish-subscribe push service. Install its app, subscribe it to a topic name, and from then on anything that can make an HTTP request can light the phone up — no account, no API key. One curl proves the pipe end to end:
$ curl -s -d "Claude can reach this phone now" ntfy.sh/YOUR-SECRET-TOPIC {"id":"jntwBtABMVPe","time":1787254062,"expires":1787297262,"event":"message", "topic":"YOUR-SECRET-TOPIC","message":"Claude can reach this phone now"}
ntfy topics are open — anyone who knows the name can read and post. The topic name is the password, so it has to be unguessable.
The rest of the recipe wires exactly one Claude Code event into that pipe — and closes every other route a question could take.
Push on the question dialog
The phone rings for exactly one event: Claude raising its question dialog, the AskUserQuestion tool. A tool call either happened or it did not, and a PreToolUse hook runs every time any session makes this one, with the call as JSON on stdin. The notification body is the question itself.
The whole hook is one script: pull the question out of the tool-call JSON, pick a title a lock screen can use, and post it to the topic.
#!/bin/bash # PreToolUse hook (matcher: AskUserQuestion) — the ONE thing that pings my phone. input=$(cat) q=$(printf '%s' "$input" | jq -r '.tool_input.questions[0].question // ""') n=$(printf '%s' "$input" | jq -r '.tool_input.questions | length' 2>/dev/null) [ -z "$q" ] && q="Claude has a question for you" [ "${n:-1}" -gt 1 ] 2>/dev/null && q="$q (+$((n-1)) more)" if [ ${#q} -gt 120 ]; then q="${q:0:120}…"; fi # Title: tmux pane title -> project dir -> session id. Names beat id fragments. title="" if [ -n "$TMUX_PANE" ] && command -v tmux >/dev/null 2>&1; then title=$(tmux display -p -t "$TMUX_PANE" '#{pane_title}' 2>/dev/null) [ -z "$title" ] && title=$(tmux display -p -t "$TMUX_PANE" '#W' 2>/dev/null) fi if [ -z "$title" ]; then cwd=$(printf '%s' "$input" | jq -r '.cwd // ""') [ -n "$cwd" ] && title="${cwd##*/}" fi if [ -z "$title" ]; then sid=$(printf '%s' "$input" | jq -r '.session_id // ""') title="${sid:0:4}" fi title=$(printf '%s' "$title" | sed 's/^[^A-Za-z]*//' | tr -d '\r\n') [ -z "$title" ] && title="claude" curl -s -H "Title: $title" -d "$q" "ntfy.sh/YOUR-SECRET-TOPIC" > /dev/null 2>&1 exit 0
The script tests with no dialog involved: fixture JSON in, and a poll of the topic shows what the phone received — titled by the tmux pane when there is one, else the project folder.
$ echo '{"tool_input":{"questions":[{"question":"Publish the draft, or keep iterating?"}]}, "cwd":"/Users/dg/GitHub/D4G4/daksh-gargas-dot-com"}' | ~/.claude/scripts/ntfy-ask-question.sh $ curl -s "https://ntfy.sh/YOUR-SECRET-TOPIC/json?poll=1&since=1m" | jq -r '[.title, .message] | @tsv' daksh-gargas-dot-com Publish the draft, or keep iterating?
The dialog blocks its session until answered, so every ping is by construction a session waiting on an answer.
Block turns that end with a prose question
Models do not reliably reach for the dialog. They love ending a turn with an optional offer in plain text, and a plain-text question here notifies nobody. A Stop hook reads the turn's final message and refuses to let a turn end on one.
#!/bin/bash # Stop hook: a turn may not end on a prose question — re-ask via AskUserQuestion. input=$(cat) active=$(printf '%s' "$input" | jq -r '.stop_hook_active // false') [ "$active" = "true" ] && exit 0 # loop guard: block at most once per turn transcript=$(printf '%s' "$input" | jq -r '.transcript_path // ""') [ -n "$transcript" ] && [ -f "$transcript" ] || exit 0 last_asst=$(tail -n 200 "$transcript" | jq -sr ' map(select(.type=="assistant")) | last.message.content // "" | if type == "array" then map(.text // "") | join("") else . end ' 2>/dev/null) last_nonblank=$(printf '%s' "$last_asst" | awk 'NF{last=$0} END{print last}' | sed -E 's/[[:space:]]+$//') case "$last_nonblank" in *\?) jq -n '{decision: "block", reason: "Your final message ends with a question. Plain-text questions never notify the user — only the AskUserQuestion dialog does. If you need an answer to proceed, re-ask exactly that question via the AskUserQuestion tool now. If it was rhetorical or an optional follow-up offer, end your turn without it."}' ;; esac exit 0
Saved beside the first script and fed a transcript whose last assistant line ends with a question mark, it blocks the stop and hands the model its way out:
$ echo '{"transcript_path":"transcript.jsonl"}' | ~/.claude/scripts/block-prose-question-stop.sh { "decision": "block", "reason": "Your final message ends with a question. Plain-text questions never notify the user — only the AskUserQuestion dialog does. If you need an answer to proceed, re-ask exactly that question via the AskUserQuestion tool now. If it was rhetorical or an optional follow-up offer, end your turn without it." }
stop_hook_active is the loop guard: the hook blocks at most once per turn, so a stubborn model cannot ping-pong forever. The question-mark check is a heuristic — but a false positive now costs one dropped rhetorical question, not a wrong ping.
Register both, and tell Claude the rules
Two registrations in ~/.claude/settings.json, merged alongside the hooks already there: the ping on a PreToolUse matcher for the one tool, the blocker on Stop with no matcher.
"hooks": {
"PreToolUse": [
{
"matcher": "AskUserQuestion",
"hooks": [{ "type": "command", "command": "$HOME/.claude/scripts/ntfy-ask-question.sh" }]
}
],
"Stop": [
{ "hooks": [{ "type": "command", "command": "$HOME/.claude/scripts/block-prose-question-stop.sh" }] }
]
}
And one block in ~/.claude/CLAUDE.md, so the model asks through the right channel on the first try instead of getting bounced by the hook:
# Questions MUST go through AskUserQuestion (ntfy pushes on nothing else)
The ONLY thing that pings my phone is a PreToolUse hook on the AskUserQuestion
tool. Everything else is silent: plain-text questions, permission prompts, idle
pings. A Stop hook blocks any turn whose final line ends with "?" — re-ask via
the dialog, or drop the question if rhetorical.
1. Blocked on a decision → AskUserQuestion, never prose.
2. Not blocked → no question at all. Finish the work and report.
3. Subagents can't reach me: return open questions to your orchestrator,
which asks via AskUserQuestion.
Registrations snapshot at session start. An edited script applies instantly — the file runs fresh on each event — but a new settings.json entry never reaches a running session until a restart or /hooks. Registrations are also per-machine: syncing the scripts folder moves the scripts, not the registrations, which is how a second machine ends up silently quiet.
Poll the topic to see what fired
The trap that ate the first version of this: the Notification hook event does not fire for AskUserQuestion, so a version matched on notification text was silent forever. ntfy keeps topic history, and polling it is what caught that — numbered test pushes through each path, then real dialogs:
$ curl -s "https://ntfy.sh/YOUR-SECRET-TOPIC/json?poll=1&since=36h" | jq -r '[.title, .message] | @tsv' eb3 When a session tries to end its turn with a prose question (like "Want me… TEST 1 — direct push Plain curl, default priority. 11:04:56 TEST 2 — high priority Same path, priority=high. 11:04:58 test TEST 3 — push sent through ntfy-ask-question.sh itself eb3 TEST 4 — this dialog's own ping. Which of the four test notifications act… daksh-gargas-dot-com Publish the draft, or keep iterating?
An empty poll after a day of sessions is its own answer. Two rows arrived titled eb3, a session-id fragment — pings from before the title chain preferred names, useless on a lock screen. One trade is accepted on purpose: permission prompts stay silent, so a session stuck on an approval waits until I look. The cure for that is fewer permission prompts, not more notifications.