Lesson 8: Notify only when your action changes the outcome
What you'll learn: how to put Lesson 7's runner on a timer and hear from it only when you are needed: three tiers of event, quiet hours that hold one of them, and a rule that notices a run that never happened. What you need: the runner, workflow, and deny rules from Lesson 7, working by hand; a phone with the ntfy app; a machine that stays on and has cron. Time: thirty minutes once, then a day of ticks. Cost: zero new dollars.
The idea#
Lesson 7 ended with you reading the runner's last line. On a timer the runner writes one every hour, and most say the same: nothing ready, or an item is live. In a week you stop reading them, and the one that matters, a merge that never reached the site, sits unread under thirty that did. The fix is a rule, not a channel: an event reaches your phone only when something you do changes what happens next. Three tiers follow. Blocked: the system is not in a state you chose, so you hear now, at any hour. Review: one item waits for a decision from you, and it can wait until morning. Info: nothing waits, so it goes to a log you read when you choose. Lesson 6 spent tier on which model an item deserves; here it is which attention an event deserves. One event needs a rule of its own: a run that should have happened and did not, because a dead timer sounds exactly like a quiet week.
Why start here#
The timer is where runs begin without you, and your attention becomes the scarce resource. From here the runner produces it while you sleep, and a notification rule that passes when nothing arrives cannot tell silence from failure.
The template#
Three scripts in gate/ beside the runner, one line changed in the runner, two lines in your timer. Notifications first:
#!/bin/sh
# gate/notify.sh <tier> <message>: route one event by what your action changes.
# blocked the system is not in a state you chose. Push now, at any hour.
# review one item waits for your decision. Push, except in quiet hours; hold it then.
# info nothing waits for you. The log only; a pass also clears the repeat check.
# The same message twice in a row is pushed once. A push that fails is only logged:
# the runner repeats an ending until you act, so the next tick raises it again.
set -eu
tier=$1; shift; msg=$*
quiet_from=22; quiet_to=7 # local hours; the blocked tier ignores them
topic=$(cat gate/topic) # a random string; whoever knows it can read and post
quiet() { h=$(date +%H); h=${h#0}; [ "$h" -ge "$quiet_from" ] || [ "$h" -lt "$quiet_to" ]; }
push() { # $1 priority, $2 title, $3 body
[ "$3" != "$(cat gate/last-push 2>/dev/null)" ] || return 0
if curl -fsS -o /dev/null --max-time 20 -H "Title: $2" -H "Priority: $1" -d "$3" "https://ntfy.sh/$topic"
then printf '%s' "$3" > gate/last-push
else echo "notify: push failed; the next tick raises it again" >&2; fi
}
case $tier in
flush) # at the start of every tick: send what quiet hours held, as one message
[ -s gate/held ] && ! quiet || exit 0
body=$(cat gate/held); rm gate/held; push default review "$body"
printf '%s\n' "$body" | tail -n 1 > gate/last-push; exit 0 ;;
blocked) push urgent blocked "$msg" ;;
review) if ! quiet; then push default review "$msg"
elif [ "$msg" != "$(cat gate/last-push 2>/dev/null)" ]; then
grep -qxF -- "$msg" gate/held 2>/dev/null || echo "$msg" >> gate/held; fi ;;
info) rm -f gate/last-push ;;
*) echo "notify: unknown tier $tier" >&2; exit 2 ;;
esac
printf '%s %s %s\n' "$(date -u +%FT%TZ)" "$tier" "$msg" >> gate/events.log
The tick is what the timer runs: a lock, a start time (the heartbeat the watch reads), one run of the runner, one event:
#!/bin/sh
# gate/tick.sh: what the timer runs. Take the lock, record the start time (the heartbeat
# the watch reads), run Lesson 7's runner once, and send one event, its tier read from
# how the run ended.
cd "$(dirname "$0")/.."
mkdir gate/running 2>/dev/null || { echo "tick: a run is still going; skipping"; exit 0; }
trap 'rmdir gate/running' EXIT
printf '%s %s\n' "$(date +%s)" "$(date -u +%FT%TZ)" > gate/last-tick.new && mv gate/last-tick.new gate/last-tick
sh gate/notify.sh flush
out=$(sh gate/run-next.sh 2>&1); code=$?
last=$(printf '%s\n' "$out" | tail -n 1)
case $code:$last in
0:*) tier=info ;; # live, a STOP file, or nothing to do
1:*"STOPPED empty"*) tier=info ;; # no ready item
1:*"not live"*) tier=blocked ;; # merged, and the site does not show it
1:*"Session stopped"*|1:*"no live: line"*)
tier=review ;; # the agent asked, or the spec has no live: line
*) tier=blocked; last="run-next.sh exited $code: $last" ;; # the runner itself failed
esac
case $code:$out in # endings the runner prints a log after
1:*"stays unmerged"*) tier=review; last=$(printf '%s\n' "$out" | grep "stays unmerged") ;;
0:*"by hand"*) tier=review; last=$(printf '%s\n' "$out" | grep "by hand") ;;
esac
sh gate/notify.sh "$tier" "$last"
The watch is the missed-run rule, on its own timer line:
#!/bin/sh
# gate/watch.sh <seconds between ticks>: the missed-run rule, on its own timer line.
# A run that should have happened and did not is blocked, at any hour.
cd "$(dirname "$0")/.."
interval=${1:-3600}
read -r last at < gate/last-tick 2>/dev/null || { last=0; at=never; }
age=$(( $(date +%s) - last ))
[ "$age" -le $(( 2 * interval )) ] \
|| sh gate/notify.sh blocked "missed $(( age / interval )) runs; last tick started $at"
The timer, in crontab -e, with your paths; cron's PATH is short, so name the folders holding claude and gh:
PATH=/usr/local/bin:/usr/bin:/bin:/home/you/.local/bin
5 * * * * cd /home/you/project && sh gate/tick.sh >> gate/timer.log 2>&1
*/15 * * * * cd /home/you/project && sh gate/watch.sh 3600 >> gate/timer.log 2>&1
The runner's first prompt ends with or STOPPED <reason>."; replace that with three fixed stop words the tick can read:
STOPPED empty if no item qualifies; STOPPED investigation; or STOPPED ask <your question>."
Then make the topic, head -c 12 /dev/urandom | base64 | tr -dc A-Za-z0-9 > gate/topic, and subscribe to that string in the ntfy app.
Each part, and the mistake it prevents#
The tick chooses the tier from the runner's exit code and its fixed stop words. Exit 0 is info, unless the queue changed under the run and the item needs closing by hand. Exit 1 with one of the runner's own messages is review, except merged but not live, which is blocked, and an empty queue, which is info. Any other ending is the runner itself failing: blocked. An agent that graded its own failures would grade them gently.
Quiet hours hold review and nothing else. Held events go out as one message at the first tick after they end, so a night of refusals is one push, not a stack.
The same message twice in a row is pushed once, until a pass clears it. An investigate item left ready is picked and stopped on every tick; without this line you would hear about it hourly and stop listening. Move it to a session you watch, as Lesson 7 said.
The lock keeps two ticks off the same item when a run outlasts the interval. A run killed midway leaves gate/running behind; the watch then reports missed runs, the right alarm.
The heartbeat is written before the runner starts, so a crash partway still counts as a run. The watch has its own timer line because a dead tick cannot report itself. Two intervals, not one, so a slow run is not a false blocked; after that, one reminder per missed run.
Info is a log line because a pass you are told about is a pass you stop reading.
A worked example#
The same scratch project as Lesson 7's, the ticks started by hand with cron untested, and one ready item, B-005, a heading on the status page. Before the first tick I removed the live: line from the spec in my checkout. The topic, two minutes into the tick:
review specs/web/005-status-heading.md has no live: line; not merging.
The second tick found the same line. The log says review; the topic got nothing. I put the line back. The third tick merged B-005, the live check passed, and events.log gained one line:
2026-10-11T20:33:56Z info B-005 is live: curl -fsS --max-time 20 https://.../status.html | grep -q '<h1>Status</h1>'
The fourth tick found no ready item: info, silent. Then I set the heartbeat back twenty-five minutes and ran sh gate/watch.sh 600:
blocked missed 2 runs; last tick started 2026-10-11T20:09:53Z
Two messages on the topic across five runs, and both needed me.
How it scales#
The interval is the first knob: hourly for a queue you feed weekly, ten minutes for one you feed daily. The tier rule does not change; what grows is the tick's list of endings. A watch on the same machine dies with it; the next step is one that runs elsewhere, a scheduled job on your pipeline reading the last-run line from your status page.
Who does the work: you set quiet hours, read blocked now and review in the morning, and read info only when you choose. The agent writes nothing to your phone; the tick does, from the runner's ending.
From the field#
On July 2 a price alarm in my financial application fired in three places: the engine's log, a digest file, and an outbox of messages to send. The outbox entry's channel field read none-configured. Three records, zero deliveries, and I learned of it that afternoon from a review of the code instead of an alert. The fix that day was a topic on ntfy and one live test message. The alarm already refused to send the same message twice; the de-duplication line above is that rule, rewritten.
Exercise#
- Save the three scripts, make the one-line change in the runner, make the topic, and subscribe to it on your phone.
- Run
sh gate/tick.shonce by hand, then add the two timer lines with your paths and interval. - Outside quiet hours, inject a failure: remove the
live:line from the next ready item's spec in your checkout. Wait a tick. - Put the line back. Wait a tick; nothing should arrive.
- Comment out the tick's timer line and leave the watch's. Wait two intervals and a quarter hour.
- Restore it. A week later, for every message that reached your phone, name the decision it needed. One that needed none moves down a tier in the tick.
You're done when#
You saw three events, on your phone or by their absence: the injected failure arrived, the successful run stayed silent, and the skipped run raised blocked. Two results fall short: a review that arrived at 3 a.m. means your quiet hours are wrong, and a skipped run that raised nothing sends you to gate/timer.log first.
Common mistakes#
- Letting the agent name the tier. It can read the rule and still call its own failure info.
- A watch inside the tick dies with the run it watches.
claude: not foundin the timer log. cron has its own PATH; the crontab's first line sets it.a run is still goingon every tick. A killed run leftgate/running; remove it.- Treating the topic as public. Anyone who knows the string can post to your phone; generate it, never name it.
Thirty minutes once, and zero new dollars; from then on the runner reaches you only when a decision is yours.
Further reading#
The ntfy publishing docs cover priorities, titles, and self-hosting. Rob Ewaschuk's My Philosophy on Alerting is the source of the rule that a page must need a human. The Google SRE book's chapter on monitoring distributed systems separates pages from tickets from logs, the same three tiers at a larger scale.
Next lesson#
Keep it honest: one drill that breaks a barrier on purpose, confirms the refusal through the real path, confirms this lesson's alert, and restores the barrier.
Questions this post answers
- When should an unattended AI agent notify me?
- When something you do changes what happens next. Three tiers follow from that rule: blocked, the system is not in a state you chose, so you hear now at any hour; review, one item waits for your decision and can wait until morning; info, nothing waits, so it goes to a log and never to your phone. The agent never picks the tier; the script that ran it does, from the runner's exit code and stop words.
- How do I tell a quiet week from a dead runner?
- A notification rule that passes when nothing arrives cannot. The tick records the time it started before it does anything else, and a second script on its own timer line compares that heartbeat with the clock. Two missed intervals raise the blocked tier, and quiet hours never hold it.
- What do quiet hours hold?
- Only the review tier. A review event raised at night is written to a held file and sent as one message at the first tick after quiet hours end. Blocked events are pushed at any hour, because a merge that never reached the site is wrong at 3 a.m. too.
- Why does the same message get pushed only once?
- Because an alert you receive every hour is an alert you stop reading. The script keeps the last message it pushed; an identical one goes to the log only, until a pass clears that memory. An investigate item left ready, which the runner picks and stops on every tick, reaches your phone once instead of twenty-four times.