---
title: Leave-now alerts — and a real phone call for the ones that matter
series: Build It Yourself — briefs
brief: 03
license: CC BY 4.0
tested_on: OpenClaw 2026.6.11 · Claude Code (Sep 2026) · bash + jq · macOS
effort: one evening (2–4 h); +1–2 h if you wire up the phone-call escalation
---

# Leave-now alerts — and a real phone call for the ones that matter

## TL;DR
Every calendar event with a location gets a heads-up ~40 minutes before it starts: a spoken
announcement in the house, a WhatsApp message with a one-tap map link, and — for the events
that actually matter (a flight, a doctor, a hospital) — a real outbound **phone call** from
an AI voice, because a WhatsApp notification is exactly the kind of thing you silence on your
way out the door. This brief is the design, the traps, and the prompts to build your own.

---

## What you get (a scene)

> *(40 minutes before a dentist appointment, WhatsApp)*
> **Assistant:** 🚗 you should get moving soon — "Dentist" starts in about 40 minutes at
> Some Clinic
> https://waze.com/ul?q=Some%20Clinic&navigate=yes
>
> *(the house speaker, at the same moment)*
> **Speaker:** you should get moving soon. Dentist starts in about 40 minutes.
>
> *(a flight, same flow, plus:)*
> **Your phone rings.** *"Hi, this is your assistant. Important reminder: your flight to
> Athens starts in about 40 minutes. You should leave now."*

The WhatsApp line and the speaker line are the same design as any other alert. The phone
call is the interesting part: it only fires for a short, deliberately narrow list of
keywords (flight / doctor / hospital, in two languages in our case), because a phone call is
the loudest, most interruptive channel you have, and if it fires for a dentist appointment
you'll silence your assistant the same way you silenced the calendar app.

---

## Prerequisites

- An always-on machine, a **calendar API** you can poll (Google Calendar here), and a way to
  speak into a room and send a chat message — the same base as every brief in this series.
  Starting from nothing? Build **brief 00** first (`start-here-always-on-agent.md`).
- A **maps deep-link** scheme for your region (Waze, Google Maps, Apple Maps all support a
  URL that opens turn-by-turn directly).
- **Optional, for the phone-call escalation:** an AI voice-calling API (we use Vapi, over a
  SIP trunk) and a phone number to call from. Skip this part entirely and you still get a
  fully working leave-now alert on chat + speaker.
- 2–4 hours; add 1–2 for the phone integration if you want it.

---

## Architecture (the 15-line version)

```
 launchd/cron, every 10 min
        │
        ▼
 poll calendar (next ~90 min, per person) ──▶ has a location? has a start time?
        │
        ▼
 compute minutes-until-start ──▶ in the alert window (38–50 min)? ──▶ already alerted? (dedupe file)
        │ no                                            │ yes, new
        ▼                                                ▼
     skip                                     compose message + speak line
                                                          │
                                          ┌───────────────┼────────────────┐
                                          ▼               ▼                ▼
                                   WhatsApp + map    speaker (TTS)   critical keyword?
                                       link                             │ yes
                                                                        ▼
                                                              outbound AI phone call
```

1. **Poll, don't push.** Nothing subscribes to calendar webhooks here; a 10-minute poll of a
   90-minute lookahead window is simpler, and a missed tick just gets caught by the next one.
2. **A fixed alert window, not "T-minus a countdown".** We fire once, when an event is
   38–50 minutes out (a band wide enough that a 10-minute poll cadence can't skip it), and
   record the event id in a flat dedupe file so it never fires twice. Real drive-time
   (a maps ETA API) is a nice upgrade — v1 uses a flat 30-minute buffer and says so.
3. **Three channels, one alert.** WhatsApp (a link you tap), a spoken line on a house
   speaker (nobody's looking at a screen), and — gated by keyword — a phone call. Each
   channel is a separate best-effort call; one failing must never block the others.
4. **The critical-keyword gate is the whole safety design of the phone call.** A phone call
   is loud and interruptive by nature. It is not a general escalation channel — it fires for
   a short, named list of high-stakes event types (flight, doctor, hospital) and nothing
   else, so it stays trustworthy instead of becoming another thing to silence.
5. **The dedupe file is a flat list of event ids, not a database.** One id per line, checked
   with a substring match before alerting, appended after, pruned to the last ~200 lines.
   No schema, no service — this alert doesn't need one.

---

## Build steps — paste each block into Claude Code, in order

### Step 1 — the poll + alert window

```text
Write a script (bash or Node, your choice) that runs every 10 minutes:
1. For each person to watch, query the calendar API for events starting in the next
   90 minutes that have BOTH a location and a start time set. Skip all-day events and
   events with no location (there's nothing to leave FOR).
2. For each event: compute minutes_until_start. If it's outside a 38–50 minute window,
   skip it. (This band matters: with a 10-minute poll cadence, a narrower window can be
   stepped over entirely if the previous tick landed just before it opened.)
3. Check a flat dedupe file (one event id per line) for this event's id. If present, skip.
   If not, append the id and continue to alert.
4. Compose: a short line naming the event, the rounded minutes remaining, and the location;
   build a maps deep-link that URL-encodes the location into your maps provider's scheme
   (e.g. Waze: https://waze.com/ul?q=<encoded location>&navigate=yes).
5. Send the message to that person's chat channel, AND speak a shorter version of the same
   line on a house speaker/TTS device if you have one — treat both as best-effort
   (`|| true` / try-catch around each; one failing must not stop the other or crash the run).
6. Prune the dedupe file to its last ~200 lines at the end of each run (unbounded growth
   is a slow leak nobody notices for months).
Guard rails: quiet hours (skip entirely outside e.g. 07:00–22:00 local) and a heartbeat
file write at the TOP of the script, before any guard — so a monitoring check can tell
"still ticking" apart from "silently dead", even during quiet hours.
```
**Check:** create a test calendar event ~40 minutes out with a location; run the script by
hand; you get exactly one chat message and (if wired) one spoken line, and running it again
immediately produces nothing (dedupe held).

### Step 2 — the critical-keyword phone call

```text
Add a phone-call escalation to the same script. After sending the WhatsApp + speaker alert
for an event, check the event's title + location text (case-insensitive) against a SHORT,
named list of critical keywords (e.g. flight/טיסה, doctor/רופא, hospital/בית חולים — adapt
to your own languages and life). Match found → place an outbound AI voice call to that
person's phone number, fire-and-forget (do not block the rest of the run waiting on the
call), with a short spoken message naming the event and the minutes remaining. Log that a
critical call was placed. No match → do nothing further; the chat + speaker alert already
went out for every event regardless of keyword.
Use whatever AI voice-calling API you have (Vapi, Twilio + a TTS voice, etc.) — the
INTERFACE is one function: call(phone_number, spoken_message) fire-and-forget. Wrap it so a
call failure (bad number, provider outage) logs an error and does NOT crash the leave-now
run — the chat alert already succeeded and that's the one that must not be lost.
```
**Check:** create a test event titled "Flight to somewhere" 40 minutes out with a location;
confirm you get the WhatsApp alert AND a phone call with a natural-sounding line. Create a
second test event with a plain title (e.g. "Coffee") and confirm NO call fires — only chat
+ speaker.

### Step 3 — don't alert twice for the same trip

```text
If you build "leave-by" notes elsewhere in your system (e.g. a richer per-event prep note
sent hours ahead, with its own map link), teach leave-now to check whether that note already
covered THIS event before alerting, and if so, skip the leave-now alert entirely (still mark
the event as handled in the dedupe file so it's never revisited). Two heads-up messages 90
minutes apart for the same trip is noise, not help — pick one.
```
**Check:** if you don't have a separate prep-note lane, skip this step; it's an integration
point, not a requirement.

---

## The traps we hit (read these twice)

**1. A narrow alert window plus a slow poll cadence = missed alerts.** With a 10-minute poll
and a "fire at exactly N minutes out" rule, a tick landing at minute 39 and the next at
minute 49 can straddle right past a 5-minute window. *Fix:* make the window at least twice
as wide as the poll interval (here: 10-minute poll, 12-minute window at minimum — we used
38–50 for margin), and dedupe on the event id so a wide window still only fires once.

**2. A phone call for everything is a phone call for nothing.** The temptation is to make
every "important" event call — but "important" drifts fast (a dentist appointment feels
important the morning of). *Fix:* a short, explicit, rarely-touched keyword list, not a
severity score or an LLM judgment call. The list should be so short you can read it in one
line of the script.

**3. The map link needs URL-encoding, not string concatenation.** A location like
"Some Clinic, Suite 4B" breaks a naively concatenated maps URL at the comma and the space.
*Fix:* always run the location through your language's URL-encoder before building the
link — one line, easy to forget, breaks silently (the link opens maps with garbage) rather
than loudly.

**4. Quiet hours can hide a dead job.** If your guard clause is "before 07:00 or after
22:00, exit immediately" placed at the TOP of the script, a monitoring check based on "did
this script write a heartbeat recently" will report the job as dead every single night.
*Fix:* write the heartbeat/liveness stamp BEFORE the quiet-hours guard, not after — liveness
and "did work" are two different questions.

**5. One channel failing shouldn't sink the other two.** An early version let a speaker/TTS
failure (device offline, volume driver hiccup) throw and skip the WhatsApp send that should
have already happened, or vice versa. *Fix:* wrap each channel's send independently; a
failure in one is a log line, never a reason to skip the others.

**6. Duplicate heads-up messages train people to ignore both.** If you also send richer
per-event prep notes on a separate schedule, two different messages about the same trip,
generated by two different code paths, WILL eventually both fire for the same event with
slightly different numbers. *Fix:* one lane checks whether the other already covered this
event before speaking up.

**7. Real drive-time isn't a fixed number.** A flat 30-minute buffer is honest and simple,
but it's wrong for both directions — too early to a coffee shop, too late in traffic to the
airport. *Fix (not built yet, listed as an open upgrade):* a maps Directions/Routes API for
a real ETA, keeping the fixed buffer as a fallback when the API errors.

---

## You know it works when

- [ ] A test event 40 minutes out with a location produces exactly one chat message and one
      spoken line, and re-running the script immediately produces nothing more.
- [ ] The message includes a map link that opens turn-by-turn directions when tapped.
- [ ] A critical-keyword event (flight/doctor/hospital) ALSO triggers a phone call with a
      natural spoken line; a non-critical event does not.
- [ ] Running the script outside your quiet hours does nothing, but the heartbeat file
      still updates every tick, even during quiet hours.
- [ ] Killing one channel (e.g. unplug the speaker) does not stop the WhatsApp alert from
      going out, and vice versa.

## Extend it

- **Real ETA:** swap the fixed 30-minute buffer for a maps Directions/Routes API call,
  falling back to the fixed buffer on any API error.
- **Per-person keyword lists:** a shared list works for a household; a bigger group might
  want per-person critical keywords (someone's recurring hospital visits vs. someone else's
  flights).
- **Snooze/ack:** let a reply like "thanks, on it" suppress the speaker line for that event
  without touching the WhatsApp dedupe — useful if the house speaker is asleep-adjacent.

## Changelog

- 2026-09-14 — first public version. Tested on the versions in the header.

---

*Part of **Build It Yourself** — free briefs on building a personal AI Chief of Staff, one
capability at a time: https://chief.natalicot.com/briefs. Free, forever. · License CC BY 4.0*
