---
title: Reminders that don't rest until it's done
series: Build It Yourself — briefs
brief: 02
license: CC BY 4.0
tested_on: OpenClaw 2026.6.11 · Claude Code (Aug 2026) · Python 3.12 · macOS
effort: one evening (3–5 h) if you already have a WhatsApp-connected agent
---

# Reminders that don't rest until it's done

## TL;DR
Give your AI assistant real reminders: "remind me Sunday about the bank form" in WhatsApp →
it fires **in the same chat**, in the assistant's own voice, re-nags if you ignore it, and
understands "got it" — without a to-do app, without a cron per reminder, and without the
assistant ever *claiming* it sent something it didn't. This brief is the design, the traps
we hit building it, and the prompts to paste into Claude Code.

---

## What you get (a scene)

> **You (WhatsApp, Thursday):** remind me Sunday at 10 to send the bank form
> **Assistant:** Sure — Sunday 10:00. Want me to keep nagging until you confirm?
> **You:** yes
>
> *(Sunday 10:00, same chat)*
> **Assistant:** Bank form time — the one from Thursday. Send it and tell me "done".
> *(10:30, no reply)*
> **Assistant:** Another nudge — no answer on the bank form. Still on it?
> **You:** got it
> **Assistant:** 👍 closed.

The last two lines are the whole point. "got it" resolved to *that* reminder because the
fire happened inside your conversation, not from some detached job. Nothing about this needs
a special app; it's a small service, a 10-second scheduler tick, and one skill document that
teaches the assistant the API.

---

## Prerequisites

- An always-on machine (Mac mini, Raspberry Pi, VPS). Reminders fire from here.
- A **chat channel** and a **CLI you can call from a script** to push a message into a
  chat session. We use OpenClaw on WhatsApp; anything with "send message to session X" +
  "run a shell command" works. **Starting from nothing? Build brief 00 first**
  (`start-here-always-on-agent.md`) — a Telegram bot and a bridge to Claude Code in one
  evening; the reminder design here is identical whichever channel you land on.
- **Claude Code** to build it. Python 3.11+ (FastAPI, SQLAlchemy, APScheduler, python-dateutil).
- 3–5 hours. Read the traps section *before* building; it will save you most of that.

---

## Architecture (the 15-line version)

```
 you ──WhatsApp──▶ assistant ──POST /reminders──▶ ┌────────────────────┐
                                                  │  reminders service │  FastAPI + sqlite
                                                  │  (10 s tick)       │  APScheduler
                                                  └─────────┬──────────┘
                                                            │ due? push [REMINDER_FIRE] envelope
                                                            ▼   INTO YOUR CHAT SESSION (CLI)
                                              assistant composes, sends via its message tool,
                                              then POST /reminders/{id}/confirm_sent {message_id}
                                                            │
                          no confirm in 90 s ───▶ retry ──▶ dumb send (stored text) ──▶ failed + alert
```

1. **Storage:** a `reminders` table (id, fire_at_utc, tz, rrule, message, mode, target,
   status, retry/reack columns) + a `reminder_events` log. Status machine:
   `scheduled → firing → fired → acked` · `snoozed → scheduled` · `cancelled` · `failed`.
2. **Worker:** APScheduler *inside* the FastAPI process, one tick every 10 s. Not one cron
   job per reminder (cost + sprawl), not an LLM loop (cost).
3. **Two fire modes:** `agent_voiced` (default — the assistant writes the message, in
   context) and `dumb` (worker sends the stored text verbatim; alarms).
4. **The handshake:** a fire is not "sent" until the assistant POSTs `confirm_sent` with
   the real message id. Deadline lock → retry → fallback to dumb send → `failed` + alert.
5. **The skill doc:** a markdown file the assistant loads that says *when* to create a
   reminder, how to parse "Sunday at 10" to ISO, the API calls, and the fire protocol.
6. **The ladder:** `reack` = re-fire after N minutes up to K times if not acked; opt-in
   per reminder (only where missing it costs something). Extend with speaker / phone
   later — see *Extend it*.

---

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

Each step ends with a check you can run. Don't skip the checks; the traps live between steps.

### Step 1 — the service

```text
Build a small FastAPI service "reminders" with sqlite (SQLAlchemy) in ./reminders-service.

Tables:
- reminders: reminder_id (str PK, format RMD-<unix_ms>), fire_at_utc, tz, next_fire_at_utc,
  last_fired_at_utc, rrule (nullable, RFC 5545), message, message_mode ('agent_voiced'|'dumb'),
  owner_person, target_person, target_address (phone or group id), target_channel ('whatsapp'),
  status ('scheduled','firing','fired','acked','snoozed','cancelled','failed'), retry_count,
  last_error, reack_policy_enabled (bool, default false), reack_after_seconds (default 1800),
  reack_max_count (default 2), reack_count, fire_deadline_utc, sent_message_id, sent_at_utc,
  fallback_used, created_by, source_session_key, origin_text, created_at, updated_at.
- reminder_events: id, reminder_id, at_utc, actor, event, note.

Endpoints under /api/v1/reminders:
POST / (create; accept fire_at_local + tz OR fire_at_utc; reject a fire time in the past by
more than 60 s), GET /upcoming?person=&hours=, GET /recent_fired?person=&hours=,
GET /{id}, PATCH /{id} (reschedule), POST /{id}/snooze (accept {duration:"10m"|"1h30m"|"2d"}
OR {new_fire_at_local, tz}), POST /{id}/ack, POST /{id}/cancel, POST /{id}/confirm_sent
{message_id, via, composed_text, actor}, GET /health, GET /summary.
Every mutation appends a reminder_events row. Enforce the status state machine (e.g. no
acked→scheduled). Support ?compact=true returning only: reminder_id, status,
next_fire_at_local, message_preview, target_person, target_address, rrule.
confirm_sent lifecycle: if rrule set → advance to next occurrence, status scheduled;
one-shot without reack → status acked (actor 'system'); one-shot with reack → stay 'fired'.
Write tests for: create→snooze→confirm_sent→ack, recurrence advance, past-time rejection.
```
**Check:** `pytest` green; `curl localhost:8100/api/v1/reminders/health` returns JSON.

### Step 2 — the worker (fire path)

```text
Add an APScheduler job inside the FastAPI lifespan that ticks every 10 s:
1. select reminders where status in (scheduled, snoozed) and next_fire_at_utc <= now,
   lock the row (with_for_update or a status flip to 'firing' in one statement), set
   fire_deadline_utc = now + 90 s.
2. mode 'dumb': shell the assistant framework's CLI to send `message` to target_address;
   parse the returned message id; write sent_message_id; then run the confirm_sent
   lifecycle yourself.
3. mode 'agent_voiced': shell the CLI to push an envelope INTO THE TARGET'S CHAT SESSION
   (not a fresh/isolated session — see note). Envelope text:
     [REMINDER_FIRE] (machine-triggered — fire this reminder NOW)
     reminder_id: …  target_person: …  target_address: …  target_channel: whatsapp
     fallback_text: "<stored message>"  origin: "<origin_text>" (delayed Nm if late,
     reack X/Y if a re-fire)
   Then WAIT for the assistant to POST confirm_sent (poll the row).
4. deadline passed with no confirm → retry once → then fall back to a dumb send of
   fallback_text (fallback_used=true) → if that also fails after 4 shell retries with
   exponential backoff (30 s × 2^n) → status failed, last_error set, and POST an alert line
   to my admin channel.
5. reack: a 'fired' reminder with reack_policy_enabled, not acked after reack_after_seconds
   and reack_count < reack_max_count → re-fire (reack_count += 1, envelope says "reack X/Y").
6. late-fire policy: if the machine was down and a reminder is > 6 h overdue, still fire it
   once with "(delayed)" in the envelope; do not burst multiple missed recurrences.
Config via env: worker mode dry_run|live (start in dry_run and log what WOULD fire),
tick seconds, deadline seconds, retry counts, absolute path to the CLI binary.
Also: GET /health must report last_tick_at, seconds_since_tick, stuck_firing_rows.
```
**Check:** in `dry_run`, create a reminder 2 minutes out and watch the log say it would
fire. Flip to `live`, create a `dumb` one to yourself, receive it. Then a `agent_voiced` one.

### Step 3 — the skill document (teach the assistant)

```text
Write reminders/SKILL.md for my assistant. It must contain:
- WHEN to use: any "remind me / send me a message at / every Tuesday at…" phrase; NOT for
  lookups, NOT for todos with completion state, NOT for calendar events.
- Mandatory rules: create in the SAME turn the user asks and confirm only after the API
  returns 201; normalize to ISO using the current-date line in my bootstrap; store the
  message AS IT WILL READ TO THE TARGET (second person, self-contained, no internal
  jargon); verify real-world dates (holidays, deadlines) instead of guessing; never create
  a cron job for an ad-hoc reminder.
- Parsing: fire_at_local 'YYYY-MM-DDTHH:MM:SS' + tz; RRULE cookbook (daily, weekdays,
  weekly by day, monthly, COUNT, UNTIL); NOTE that BYHOUR is UTC — for "every day at 9
  local" anchor fire_at_local at 09:00 and use FREQ=DAILY without BYHOUR.
- When to enable reack: only when missing has a real cost (medication, payments,
  deadlines, "make sure", "keep reminding me"); defaults 30 min × 2; never > 3.
- The [REMINDER_FIRE] protocol, verbatim: (1) compose a short natural message from
  origin/fallback_text, say it's a re-fire if reack>0; (2) send via the message tool and
  capture the message id; (3) POST confirm_sent immediately with that id; (4) log one line
  to the admin channel; (5) end the turn with exactly the framework's silent marker
  (e.g. NO_REPLY) — any other final text will be dispatched to the user as an extra message.
- Resolving replies: "done/got it/👍" → ack the most recent fired reminder; "snooze 10m" →
  snooze duration; "tomorrow at 9" → snooze new_fire_at_local; "cancel" → cancel. HARD
  RULE: on a short ack whose referent isn't obvious, call GET /recent_fired?hours=6 FIRST
  and treat a pending fired reminder as the referent — never attribute the ack to an older
  topic. Ambiguous → ask "which one? (1) X (2) Y".
- API quick reference with curl examples, always ?compact=true.
```
**Check:** in WhatsApp: "remind me in 3 minutes to stretch" → row created → fires in your
chat → reply "done" → status `acked`. Then "remind me every day at 9 to take the pill" and
verify `next_fire_at_local` is 09:00 *local*, not 09:00 UTC.

### Step 4 — context injection (so the assistant sees what's pending)

```text
Extend my session-bootstrap hook (or system prompt builder) to fetch
GET /reminders/upcoming?person=<session person>&hours=24 and
GET /reminders/recent_fired?person=<session person>&hours=6 and inject a short
"REMINDERS — upcoming / recently fired (awaiting ack)" block, filtered to the person this
chat belongs to. Render nothing when both are empty. Re-inject after context compaction.
```
**Check:** open a chat after a fire; the assistant's context shows the awaiting-ack row.

### Step 5 — a page (optional, 30 min)

```text
Add a minimal /reminders page: Upcoming · Recently fired (awaiting ack) · Recurring ·
Failed, with +10m / +1h / ack / cancel / retry buttons, and a health badge (worker mode,
last tick, stuck rows). Poll every 15 s.
```

---

## The traps we hit (read these twice)

**1. The reminder fired from a detached session — and the nag ignored the "got it".**
Our first version fired via a fresh isolated turn. The message arrived, the user answered
"got it", and the assistant — in the *real* chat, which had never seen the fire — read
"got it" as an answer to yesterday's topic. The reminder re-nagged. Three times.
*Fix:* fire **inside the target's chat session** (`--session-key`, not `--to`), so the fire
is conversation history; and the short-ack rule: check `recent_fired` before interpreting a
short reply.

**2. Every final text the assistant writes is a message.** In a chat-bound session, the
turn's final text is dispatched to the human. "OK, done, confirmed!" after a fire = a
second message. *Fix:* the protocol ends with the framework's silent marker and nothing
else. Put it in the skill doc in bold.

**3. "Sending…" — and nothing was sent.** An LLM turn that times out and retries on a
cheaper model can reply "sending it now" and send nothing; exit code 0, no error, nobody
knows for a day. *Fix:* the handshake. A fire counts as sent only when `confirm_sent`
carries a real message id from the send tool; the worker's deadline turns silence into a
retry, then a dumb send, then a loud `failed`. Never let the LLM be the last word on
delivery.

**4. `BYHOUR` is UTC.** "Every day at 9" as `FREQ=DAILY;BYHOUR=9` fires at 9 UTC. *Fix:*
anchor the local time in the first fire and use `FREQ=DAILY` without BYHOUR; the RRULE
expands at the same wall-clock time. Say so in the skill doc's cookbook.

**5. The worker's shell has no PATH.** Under uvicorn (and under launchd/systemd) the CLI
you call from the worker isn't found, or `node` isn't. *Fix:* absolute path to the CLI in
config, and prepend the runtime's bin dirs to the subprocess env.

**6. The stored message read like a bug ticket.** "review X — see reply-ledger.json, ASK/
REJECT ratios" fired at a human verbatim. *Fix:* the rule "write the message as it will
read to the target". If the reminder needs analysis first, that's a task whose *output* is
a message — not a reminder.

**7. Guessed dates.** A holiday reminder set by guess would have fired 18 days late.
*Fix:* the skill doc says "verify external dates (search / a calendar API) before creating,
and state in the confirmation what you verified".

**8. Nag fatigue.** Reack on by default = every FYI ping nags twice. *Fix:* reack opt-in,
only where missing has a cost; the assistant asks "want me to keep nagging until you
confirm?" when unsure.

**9. Two channels said the same thing.** A calendar-alert lane and a reminder both fired
about the same meeting an hour apart. *Fix:* same-topic dedupe before sending — check the
last ~3 h of the chat; if covered, still `confirm_sent` with `composed_text: "DEDUPED — …"`
so the lifecycle advances. Never dedupe critical/reack fires.

**10. Green ≠ delivered.** "The tick ran" proves nothing. *Fix:* `/health` reports
`seconds_since_tick` and `stuck_firing_rows`; alert when the tick is stale or a row sits in
`firing` past its deadline. Monitor the deliverable, not the liveness.

**11. Machine down at fire time.** Without a late-fire policy, boot-up fires everything
missed in a burst — or nothing. *Fix:* fire once with "(delayed)"; never replay N missed
recurrences.

**12. Past-time rejection saves you.** Date-rollover parse errors ("tomorrow at 9" computed
for today, already past) are the most common LLM mistake here. Reject > 60 s in the past
and let the assistant re-parse.

---

## You know it works when

- [ ] "remind me in 3 minutes to stretch" → arrives in the SAME chat, in the assistant's voice.
- [ ] Reply "done" → status `acked`; no second nudge.
- [ ] A reack reminder you ignore re-fires exactly `reack_max_count` times, then stops.
- [ ] "every day at 9" → `next_fire_at_local` shows 09:00 local, tomorrow.
- [ ] Kill the assistant process during a fire → after 90 s the stored text arrives via the
      dumb path and the row says `fallback_used = true`.
- [ ] Stop the service for 30 min with a reminder due → on restart it fires once, "(delayed)".
- [ ] `/health` shows a fresh tick and 0 stuck rows.

## Extend it

- **Arrival trigger:** `trigger: "arrival"` — fire when the person gets home (presence
  signal from a phone Shortcut) → a card on a wall tablet with a ✅ button → WhatsApp nudge
  after 45 min → one last nudge 2 h later → quiet. Never voice on a shared speaker for
  private content.
- **Escalation ladder:** re-fire → speaker announcement → phone call for the truly critical
  ("flight in 90 minutes"). Keep it opt-in per reminder.
- **Third-party reminders:** "remind Alex tomorrow at 10" → route the send through whatever
  persona talks to outsiders, never the personal one; ask for the phone number if you do not have it.
- **Weekly archive:** move acked/cancelled rows older than N days to a notes vault as a
  markdown digest.

## Changelog

- 2026-08-16 — 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*
