The agent9 of 15
Reminders
On this page
One tool, cronjob, in the cron toolset. It creates, lists, cancels and skips reminders.
The important part of this page is not the tool. It is where a reminder rings, which is not always where you asked for it.
Where a reminder rings
A ticker polls the cronjobs table every 30 seconds. When a job is due it settles the row first —
reschedules a recurring one, marks a one-shot fired — and only then delivers.
It delivers to every session that is listening in its own process, right now. Not to the session that created the reminder.
That is deliberate. The frontend mints a fresh session id on every mount, so after any page reload the id recorded on the job belongs to nobody, and delivering to it dropped the reminder silently while consuming the job. A reminder is for you, not for a browser tab.
Measured, with a one-shot reminder due and two surfaces connected:
after tick with NO listener: job still due, nothing stashed
after tick with 2 listeners: job settled, stashed for BOTH sessions
browser frame: {'type':'task','kind':'reminder','message':'drink water','id':'j1'}
cli frame: {'type':'task','kind':'reminder','message':'drink water','id':'j1'}
If nothing is listening, it waits
The tick returns immediately when there are no event queues at all. The job is not settled and not delivered — it stays due and fires whenever you next open a surface.
More than one process may tick, and that is safe: a tick with no listener does nothing, and a claimed job is claimed exactly once. Delivering without the claim is how the same reminder got voiced twice.
Discord is the exception you will notice
The Discord bot does not ring reminders. Two facts, both readable:
- The bot starts its engine with
tickers=False(discord/run.py). There is no cron loop in that process at all. - Event queues are per process. A ticker running in the web server cannot see the bot's queues.
So a reminder you ask for in a Discord channel rings in the browser or the terminal instead — and if neither is open, it waits.
The tool says so itself. When the Discord runtime is live, cronjob's reply appends:
"It will not ring in this Discord channel: reminders ring where your screen is, the browser or the terminal, and wait if neither is open."
She brings it up herself
The SSE reminder frame is only a nudge. The message is also stashed in memory, the frontend fires a
hidden __reminder__ trigger turn, and the stashed text is injected as a developer note:
"[DUE REMINDER] A reminder you set for the user just came due: '…'. Bring it up with them NOW — naturally and warmly, in your own words, like a friend who remembered. Don't read it like a system notification, and don't mention reminders/cron as machinery."
The stash is cleared only once the assistant's row is written — only once she actually spoke. A superseded or failed turn does not eat the reminder.
The stash is in memory. A reminder that fires and is never voiced before the process restarts is gone, not replayed.
Creating one
Takes: message, plus either in_minutes or due_at (ISO-8601 UTC).
The schema pushes hard toward in_minutes for anything relative, and reserves due_at for an
absolute clock time — converting from your local time, which is given in the prompt, to UTC. The
clock number you say is local, not UTC.
Times are shown back to you in UTC.
The repeat rule
A repeat is your word, never the model's reflex.
Live QA found "remind me in two minutes" stored as hourly, and a "call my sister" repeating every
hour since July. The schema already said one-time-by-default in capitals and the model set hourly
anyway. So the burden moved: no repetition word in your message, no repetition.
Measured, all four with in_minutes: 2:
| You said | Model tagged | Stored | She is told |
|---|---|---|---|
| "remind me in two minutes to stretch" | hourly | None | "SAVED AS A ONE-TIME REMINDER … because they never asked for a repeat. Say it's set for that one time. Do NOT describe it as repeating." |
| "remind me every day at 8 to stretch" | daily | daily | "It repeats daily. Tell them how often it repeats." |
| "remind me in 20 minutes to stretch" | (none) | None | "It rings once and does not repeat." |
| "remind me monthly to pay rent" | monthly | None | "SAVED AS A ONE-TIME REMINDER: that cadence is not one I can schedule — only hourly, daily and weekly." |
The last row is a real disagreement worth naming. The schema's recurring enum is
once | hourly | daily | weekly, but the code accepts anything and maps a cadence the worker
cannot fire down to one-time. monthly once rang once while the tool said it repeated — a
promise nobody keeps. The code wins, and it says out loud what it decided.
The reply has two halves, deliberately kept apart:
- The stored fact — what the row actually says. This half informs her.
- The speech note — what she is allowed to say. This half only ever corrects her, and returns nothing when there is no discrepancy: "it rings once" is not news when you asked for a one-off, it is your own sentence read back.
Merged into one string they read as a long correction, and carried the conversation's densest run of cadence vocabulary into the context the next create is generated from — the very parameter this file is trying to keep empty.
The cadence can be one turn behind
Scheduling is a conversation: "remind me every morning to drink water" → "what time?" → "eight". The repeat guard reads the newest user message plus up to two earlier ones, and stops the moment the newest one asks for a reminder of its own — which is what keeps an old "cada día" from making every fresh one-off repeat forever.
If the newest message carries no text at all — a photo sent with no caption — it falls back to the
most recent one that does. That branch used to be skipped entirely, and the model's daily tag went
straight into the database.
Two reminders in one message
"CADA LUNES revisa las copias, y aparte EN UNA HORA llama al banco" is judged per reminder. The message is split into chunks, and each chunk must both ask for a reminder and name an errand of its own to count as a new one. If a repeat cannot be tied to this reminder, the answer is no and she is told to ask:
"SAVED AS A ONE-TIME REMINDER: that message asked for more than one reminder and the repeat in it could not be tied to THIS one, so this one rings once."
A refusal of a repeat is deliberately not scoped this way — it keeps the whole message and errs one-time.
Duplicates
An active, not-yet-fired reminder for this session whose due time is within 120 seconds of the new one and whose message is similar is treated as the same reminder — no second row is written. That absorbs the model's wording drift during an ElevenLabs turn-storm. Two genuinely different reminders, set more than two minutes apart, stay distinct.
list, remove, skip
action: "list" returns - [abc123] message (at 2026-09-09 08:00:00 UTC, daily). The cadence is
re-read through the same map, so an old row storing monthly is not read back as repeating.
action: "remove" cancels by id (a prefix of the listed one) and drops any stashed copy.
action: "skip" marks one occurrence as already done — for "ya la llamé", said before the
reminder fires.
- A one-time reminder is cancelled.
- A recurring one moves to its next slot… but only if it is due within half a period. Otherwise it keeps its schedule, because acknowledging a fire five minutes late would otherwise eat tomorrow's.
Skipping and removing both call discard, which drops that message from every session's stash —
you acknowledged the thing everywhere. Without it, "ya lo hice" was followed by her reminding it
anyway.
What the schedule does when nothing was running
A recurring job steps from its own due_at, not from now, so a daily reminder keeps its
time-of-day instead of drifting later by the poll latency each fire. If several periods elapsed while
the backend was down, the missed ones are skipped and it advances to the next future slot — it fires
once, not a backlog burst.
Measured: a daily reminder last due 2026-09-06 08:00:00, ticked at 2026-09-09 12:00:00, reschedules
to 2026-09-10 08:00:00.
At startup, cancelled and one-shot-fired jobs older than 30 days are deleted. Recurring jobs are
kept — their fired_at is cleared on every reschedule.
Caps
cronjob shares the raised per-turn cap with todo: 10 calls per turn instead of the default 3.
The schema also asks for exactly one create per request: "after a successful create, you are DONE — do NOT list/remove/re-create it to 'fix' it."
