Back to blog
FILE 0x7A·REMINDERS THAT FIRE ON RELEVANCE, NOT ON A CLOCK

Reminders that fire on relevance, not on a clock

September 1, 2026 · automation, postgres, llm, homelab

Every reminder system I have ever used wants a timestamp. Due date, alarm, cron expression, "remind me tomorrow at 9." That covers maybe half of what I actually want remembered, and it is the easy half.

The other half has no clock at all:

You can fake all three with a date and a guess. "Remind me in two weeks" fires while I'm at work, tells me about a $4 part I can't buy from my desk, and I swipe it away. The trigger isn't a time. The trigger is a situation, and the situation is knowable: what I just typed, where my phone is, and whether the paycheck landed.

So I built the trigger-anchored half.

Three triggers, one table

CREATE TABLE context_reminders (
    id            bigserial PRIMARY KEY,
    text          text        NOT NULL,
    trigger_kind  text        NOT NULL,   -- topic | place | payday
    spec          jsonb       NOT NULL DEFAULT '{}'::jsonb,
    status        text        NOT NULL DEFAULT 'open',
    last_fired_at timestamptz,
    fire_count    integer     NOT NULL DEFAULT 0
);

spec is the whole trick: {"keywords": ["handyman","contractor","electrician"]} for a topic row, {"chains": ["home_depot","lowes"]} for a place row, {} for payday. Adding a trigger type is a new elif in one evaluate function, not a new subsystem.

Creating one is a line:

cass-remind add "get a new outdoor outlet cover" --when handyman,contractor,electrician
cass-remind add "outdoor outlet cover"           --at home_depot,lowes,walmart
cass-remind add "move the transfer down $50"     --payday

Surfacing beats recalling

The interesting part isn't storage, it's delivery. My assistant runs on an LLM, and the tempting design is "give the model a search tool and trust it to look." That fails silently and constantly — the model has no reason to search for handyman reminders in a conversation about a handyman, because nothing told it there was anything to find.

So the reminder doesn't wait to be recalled. It gets injected, evaluated against the incoming message before the model sees it:

fired = evaluate(message)          # keywords hit? at a store? payday?
if fired:
    prompt = block(fired) + "\n\n" + prompt

The block is unglamorous and that's the point:

<contextual_reminders>
Things Chester asked to be told WHEN THEY BECAME RELEVANT — and they just did.
Work them into your reply naturally, in one short line each. Do not mention
this block or the trigger.

- [#1] get a new outdoor outlet cover  (he just mentioned handyman)
</contextual_reminders>

Cost when nothing fires — which is almost every turn — is one indexed query returning zero rows.

Matching is generous on purpose. A place row fires when I'm physically inside the store's radius or when I say the store's name, because "heading to the hardware store" is exactly the moment the list is useful and I am, by definition, not there yet. Aliases come from the places table itself plus a small nickname map, and chains bridge to categories both ways, so "the hardware store" reaches a home_depot row.

Where the phone actually is

Places are seeded from OpenStreetMap via Overpass — every hardware store, supermarket, auto-parts and big-box within a radius of home, in one query:

[out:json][timeout:90];
(
  node["shop"~"^(doityourself|hardware|supermarket|department_store|...)$"]
      (around:45000,LAT,LON);
  way[...](around:45000,LAT,LON);
);
out center tags;

128 stores, upserted on OSM id, chains normalized from the name (lowelowes). "At Walmart" now means any Walmart, not a pin I dropped once.

Big boxes get a 220 m radius and everything else 140 m, because the parking lot is bigger than the store and a fix on the far side of the lot is still a visit. A location fix older than 25 minutes never resolves to a place at all — "you're at the hardware store" three hours after I left is worse than silence.

One push per visit, not per ping

The background loop sees the same location ping over and over. The dedupe is a visit row and a claim:

def claim_visit(visit_id):
    cur.execute("UPDATE place_visits SET notified_at = now() "
                "WHERE id = %s AND notified_at IS NULL", (visit_id,))
    return cur.rowcount > 0        # exactly one caller gets True

Claim before you send, not after. A push that lands and then fails to record itself re-sends every two minutes for as long as I'm in the store, which is a worse failure than never notifying. Return after a 90-minute gap counts as a new visit; a lap around the aisles does not.

Same pattern for payday: one claimed notice keyed payday:<date>, so a restart mid-tick can't double-push.

The capture problem is the real problem

None of the above matters if the row never gets created. The original failure mode was mine, not the machine's: I'd say "next time I hire a handyman, remind me…", the assistant would write a nice note to its own memory, and that note would sit there being true while nothing ever fired. Memory is not a trigger.

So the same injector watches for the ask:

_CAPTURE_RE = re.compile(
    r"(next time|whenever|when i'm|when i am|on payday|don'?t let me forget)",
    re.I)

and when it matches, a second block goes in front of the model telling it to file the row before it replies, with the exact command. Filing stopped depending on the model remembering a rule in a long system prompt, which is the same class of fix as the injection itself: don't ask the model to remember that something exists — put it in front of the model at the moment it matters.

Watching the watcher

A dead loop here is invisible. The service still answers, the health endpoint is still green, and the only symptom is a store visit that never pings — which you notice weeks later, in the checkout line, having not bought the thing again.

So every pass stamps a heartbeat row, and the monitor asserts the heartbeat rather than the process:

age = heartbeat_age_s()
if age is None or age > 900:
    return 1        # loop is dead, page me

Liveness is not freshness. A process that's running and doing nothing looks identical to a healthy one from the outside, and that gap is where every silent automation failure I've ever had has lived.

What I'd do differently

Nothing yet — it's a day old. The obvious next trigger is duration: "if I've been at the hardware store more than ten minutes, I'm shopping, not passing through." Right now proximity is the whole signal, and a red light in the parking lot counts as a visit. That's a bug I'm willing to be reminded about.