Background Loops¶
Seventeen asyncio tasks start inside the FastAPI lifespan and run for the lifetime of the process. They are cancelled cleanly on shutdown. The loop functions live in app/background.py (the meeting-follow-up loop in app/meeting_followup.py); app/main.py's lifespan imports and spawns them.
Startup Hooks (run once, before the loops)¶
Before any periodic loop starts, the lifespan runs three one-shot self-healing operations in order (app/main.py). These are not loops — they execute once per boot:
load_overrides(deps)— reads persisted runtime-config overrides from thecontexttable (system:config:*) and applies them onto the livesettingsobject, so a model/schedule the agent changed viaset_runtime_configsurvives the restart. Runs first, so everything downstream sees the effective config.recover_orphan_delegations(deps, telegram_app)— marks anydelegationsrow stillrunningasfailed(its E2B sandbox died with the old container) and notifies the user. Runs after the Telegram bot is up.confirm_pending_self_redeploy(deps, telegram_app)— if asystem:pending_self_redeploymarker exists (the agent redeployed itself), confirms the result to the user with a proactive ✅/⚠️ and consumes the marker. Uses the capturedprev_deploy_idto suppress the message when no new deployment actually happened (failed build / no-op). See Architecture → Self-Management Subsystem.
Loop Health Heartbeats¶
Loops that can fail silently write a heartbeat row to the context KV table each cycle: key system:<loop>:heartbeat, content iso|status|detail (e.g. 2026-06-09T…|ok|3_processed; statuses include disabled, skipped, started, ok, error). The key list is HEARTBEAT_KEYS in app/background.py — a new loop that writes a heartbeat must be added there. Two consumers read it: diagnose_self and the dashboard Command Centre. It lived inside diagnose_self until 2026-08-19, when the two consumers were found maintaining divergent copies.
Overview¶
graph LR
subgraph Startup["FastAPI lifespan — startup"]
L1[Start Telegram bot]
L2[Start reminder loop]
L3[Start briefing loop]
L4[Start reflection loop]
L5[Start evening recap loop]
L6[Start weekly recap loop]
L7[Start weekly prep loop]
L8[Start user scheduled tasks loop]
L9[Start read-later digest loop]
L10[Start journal digest loop]
L11[Start email intelligence loop]
L12[Start alert loop]
L13[Start approval expiry loop]
L14[Start meeting prep loop]
L15[Start PR review loop]
L16[Start meeting follow-up loop]
L17[Start To Do sync loop]
L18[Start phone digest loop]
end
subgraph Loops["Running loops"]
RL["Reminder loop\nevery 5 min"]
BL["Briefing loop\ndaily at BRIEFING_TIME (UTC)"]
ER["Evening recap loop\ndaily at EVENING_RECAP_TIME (local)"]
WR["Weekly recap loop\nFriday at WEEKLY_RECAP_TIME (local)"]
WP["Weekly prep loop\nSunday at WEEKLY_PREP_TIME (local)"]
RF["Reflection loop\ndaily at 2 AM UTC"]
ST["User scheduled tasks loop\nevery 60 seconds"]
RLD["Read-later digest loop\nSaturday at READ_LATER_DIGEST_TIME (local)"]
JD["Journal digest loop\nSunday at JOURNAL_DIGEST_TIME (local)"]
EI["Email intelligence loop\ndaily at EMAIL_INTEL_TIME (local)"]
AL["Alert loop\nevery 5 minutes"]
AE["Approval expiry loop\nevery 5 minutes"]
MP["Meeting prep loop\nevery 5 minutes"]
PR["PR review loop\ndaily at PR_REVIEW_TIME (local)"]
MFU["Meeting follow-up loop\nevery MEETING_FOLLOWUP_POLL_MINUTES (30)"]
TS["To Do sync loop\nevery TODO_SYNC_INTERVAL_SECONDS (300)"]
PD["Phone digest loop\ndaily at PHONE_DIGEST_TIME (local)"]
end
subgraph Actions["What they do"]
RL --> A1[Check due reminders → send via Telegram or WhatsApp]
RL --> A2[Check due tasks → send notification]
BL --> A3[Run briefing_agent with morning prompt]
ER --> A5[Run briefing_agent with evening recap prompt]
WR --> A6[Run briefing_agent with weekly recap prompt]
WP --> A7[Run briefing_agent with week-ahead prep prompt]
RF --> A4[Analyse 24h interactions → update UserContext + extract UserFacts + PendingIntentions + AgentLearnings]
ST --> A8[Evaluate cron expressions → run due tasks via agent]
RLD --> A9[Compile read-later digest → send to BRIEFING_CHAT_ID]
JD --> A13[Compile journal entries from past week → send digest]
EI --> A14[Triage today's emails → send proactive intelligence summary]
AL --> A10[Evaluate alert rules → send proactive notifications]
AE --> A11[Mark expired pending_actions → edit Telegram preview messages]
MP --> A12[Fetch calendar events → send pre-meeting brief 30 min before each meeting]
PR --> A15[Discover review-requested PRs via Gmail + GitHub → run mini-model review → send to BRIEFING_CHAT_ID]
MFU --> A16[Poll work Gmail for Gemini meeting notes → extract Lawrence's action items → Telegram card]
TS --> A17[Pull the To Do default list → apply completions + app-created tasks to the tasks table]
PD --> A18[Summarise yesterday's app usage → Telegram digest]
end
L2 --> RL
L3 --> BL
L4 --> RF
L5 --> ER
L6 --> WR
L7 --> WP
L8 --> ST
L9 --> RLD
L10 --> JD
L11 --> EI
L12 --> AL
L13 --> AE
L14 --> MP
L15 --> PR
L16 --> MFU
L17 --> TS
L18 --> PD
Reminder Loop¶
Trigger condition: TELEGRAM_TOKEN is set (Telegram bot is active)
Frequency: Every 5 minutes
Handles two jobs in each tick:
1. Time-based reminders¶
Checks get_due_reminders() — reminders where status = 'pending' and remind_at <= now (UTC).
For each due reminder:
1. Routes delivery based on reminder.channel:
- "telegram" → bot.send_message(chat_id=reminder.chat_id, ...)
- "whatsapp" → wa_send(reminder.chat_id, ..., settings)
2. Sends ⏰ Reminder: {message}
3. Sets status = 'sent' in storage
2. Task due-date notifications¶
Requires: BRIEFING_CHAT_ID is set.
Checks get_tasks_due() — tasks where due_date <= today, notified = false, and status != 'done'.
For each due task:
1. Sends 📋 Task due today: {title} or 📋 Task overdue (was due {date}): {title}
2. Sets notified = true in storage — notification fires exactly once
flowchart TD
A([Wait 5 minutes]) --> B[get_due_reminders]
B --> C{Any due?}
C -- Yes --> D[Send ⏰ message to chat_id]
D --> E[Mark reminder 'sent']
E --> C
C -- No --> F{BRIEFING_CHAT_ID set?}
F -- No --> A
F -- Yes --> G[get_tasks_due]
G --> H{Any due tasks?}
H -- Yes --> I[Send 📋 notification]
I --> J[Mark task notified=true]
J --> H
H -- No --> A
Briefing Loop¶
Trigger condition: BRIEFING_CHAT_ID is set
Frequency: Once daily at BRIEFING_TIME (UTC, default 08:00)
Agent: briefing_agent (email + calendar + memory + utility tools)
Uses _send_briefing_message() to run the dedicated briefing_agent with a morning prompt. The agent calls calendar, email, task, and weather tools in parallel before composing the briefing.
The response is sent directly to BRIEFING_CHAT_ID via bot.send_message() (not as a reply to a user message), split if over 4096 characters.
Briefing structure¶
- Calendar — today's events in chronological order; flags conflicts and tight transitions
- Email — fetches today's emails from Gmail (
get_todays_emails_wrapper) and Outlook (outlook_todays_emails_wrapper), then categorises into three tiers: - Urgent (reply needed today): questions, decisions, time-sensitive requests. For each: shows
[From] · [Subject] — one-line summary, silently saves a draft reply viadraft_email_wrapper(Gmail) oroutlook_draft_wrapper(Outlook), and confirms"Draft saved → '[first line]...'"in the briefing. The user can then send, edit, or ignore the draft. - Action required: meeting invites, document reviews, approvals — listed with one-line descriptions, no drafts.
- FYI / Skip: newsletters, receipts, automated alerts — collapsed to a single count (
"8 FYI (newsletters, receipts, etc.)"). - Also reports the real unread total from
get_unread_count_wrapper/outlook_unread_count_wrapper. Gmail and Outlook are labelled separately if both have activity. Section capped at 10 lines. - Tasks — Kwasi native tasks + Microsoft To Do; overdue first, then today's, then upcoming this week
- Weather — current conditions and anything notable
- Transit — only shown if a disruption is active on the user's usual lines (silent if all clear)
- Sign-off — one encouraging line to start the day
Draft auto-save and the approval gate: _send_briefing_message() sets interface="cli" on AgentDeps. When draft_email_wrapper / outlook_draft_wrapper are called by the briefing agent, approval_gate sees interface != "telegram" and executes immediately — the draft is saved silently with no confirmation prompt. To send the draft later, the user says e.g. "send my draft to Sarah" — that call goes through the interactive Telegram approval flow as normal.
Catch-up on restart¶
If the app restarts after BRIEFING_TIME (e.g., from a Railway deploy), the loop checks the context table under user_id="system:briefing" for today's date before firing. This prevents both:
- Missed briefings — if the container restarts after 8am, the briefing fires immediately on startup rather than waiting until the next day
- Duplicate briefings — if the container restarts multiple times in one day, subsequent starts see today's date already recorded and skip
Evening Recap Loop¶
Trigger condition: BRIEFING_CHAT_ID is set
Frequency: Once daily at EVENING_RECAP_TIME in USER_TIMEZONE (default 21:00)
Agent: briefing_agent
Sleeps until the next local-time occurrence using _seconds_until() (DST-safe via zoneinfo). Runs the agent with the _EVENING_RECAP prompt structure:
- Done today — completed tasks, meetings attended
- Left open — unfinished items, light nudge
- Tomorrow — calendar preview and tasks due
- One-liner close
Weekly Recap Loop¶
Trigger condition: BRIEFING_CHAT_ID is set
Frequency: Once weekly on WEEKLY_RECAP_DAY at WEEKLY_RECAP_TIME local (default Friday 18:00)
Agent: briefing_agent
Runs the agent with the _WEEKLY_RECAP prompt — wins, unfinished work, notable email/calendar, one honest reflection. Under 250 words, like a Friday debrief with a colleague.
Weekly Prep Loop¶
Trigger condition: BRIEFING_CHAT_ID is set
Frequency: Once weekly on WEEKLY_PREP_DAY at WEEKLY_PREP_TIME local (default Sunday 18:00)
Agent: briefing_agent
Runs the agent with the _WEEKLY_PREP prompt — week's calendar, prioritised tasks, email watch, suggested Monday focus, one-liner close.
User Scheduled Tasks Loop¶
Trigger condition: BRIEFING_CHAT_ID is set
Frequency: Every 60 seconds
Evaluates user-defined recurring tasks stored in the scheduled_tasks table. Uses croniter to determine whether a task is due.
For each enabled task:
1. Computes the last scheduled fire time via croniter.get_prev()
2. Compares against task.last_run (with a 2-minute grace window to avoid double-firing)
3. If due: runs _send_agent_message(deps, telegram_app, settings, task.prompt)
4. Updates last_run to now
Tasks are created, listed, updated, and deleted via agent tools (create_scheduled_task, list_scheduled_tasks, update_scheduled_task, delete_scheduled_task). Cron expressions follow the standard 5-field format (minute hour day month weekday).
flowchart TD
A([Wait 60 seconds]) --> B[list_scheduled_tasks]
B --> C{Any enabled tasks?}
C -- No --> A
C -- Yes --> D[For each task: croniter.get_prev]
D --> E{Due since last_run?}
E -- No --> D
E -- Yes --> F[_send_agent_message with task.prompt]
F --> G[Update last_run = now]
G --> D
Read-Later Digest Loop¶
Trigger condition: BRIEFING_CHAT_ID is set
Frequency: Once weekly on READ_LATER_DIGEST_DAY at READ_LATER_DIGEST_TIME local (default Saturday 09:00)
Agent: briefing_agent
Calls get_read_later_digest (a Content Curator skill) to compile all saved read-later articles into a digest, then delivers it to BRIEFING_CHAT_ID. Only fires if there are saved articles. Articles are not deleted after the digest — the user must call delete_read_later explicitly.
Alert Loop¶
Trigger condition: BRIEFING_CHAT_ID is set
Frequency: Every 5 minutes
Evaluates user-configured alert rules stored in the alert_rules table. Two built-in system defaults are seeded on first run:
- "Tasks due today" — fires at a configured local hour (default 11am) if any Microsoft To Do tasks are due that day
- "Overdue tasks" — fires daily when tasks have been past due for a configurable number of days
Trigger types:
| Type | Behaviour |
|---|---|
task_due_today |
Fires at fire_hour (local time) on days where To Do tasks are due today. Checks once per hour window. |
task_overdue |
Fires when tasks have been past due for days_overdue or more days. |
Each rule has a cooldown_hours field — the loop tracks last_fired per rule and skips evaluation until the cooldown has elapsed, preventing repeated notifications.
Messages are informational only: the agent informs the user and may suggest an action, but never auto-acts.
At the end of each alert loop tick, _check_intention_followups() runs — it checks for pending intentions where follow_up_at <= now and sends a proactive follow-up message. A daily cap of 2 follow-up messages is enforced via count_followups_today(). After sending, last_follow_up_at is updated on the intention.
Phase 2 trigger types (meeting_soon, email_arrived) are specced but not yet implemented.
Agent tools (registered on full agent and memory_agent):
| Tool | What it does |
|---|---|
create_alert_rule |
Create a new proactive alert rule with trigger type and conditions |
list_alert_rules |
List all alert rules with their current enabled/disabled state |
update_alert_rule |
Change the name, conditions, cooldown, or enabled state of a rule |
delete_alert_rule |
Remove an alert rule permanently |
Meeting Prep Loop¶
Trigger condition: BRIEFING_CHAT_ID is set
Frequency: Every 5 minutes
Agent: briefing_agent
Polls both Google Calendar and Outlook Calendar every 5 minutes. For any event starting within MEETING_PREP_LEAD_MINUTES (default 30) with duration ≥ MEETING_PREP_MIN_DURATION_MINUTES (default 10):
- Skips solo calendar blocks when
MEETING_PREP_REQUIRE_ATTENDEESis true (the default) — see below - Checks the
contexttable undersystem:meeting_prep:<event_id>for today's date — skips if already sent - Deduplicates across calendar sources by
(summary, start_minute)— the same event in both Google and Outlook fires only one brief - Runs
briefing_agentwith a_MEETING_PREPprompt containing the event title, start time, and attendees - The agent searches history, notes, tasks, and emails for context about the meeting and attendees
- Persists
today_strin the context table to prevent duplicate sends
Solo blocks are not meetings (MEETING_PREP_REQUIRE_ATTENDEES, default true)
Meeting prep is the largest single source of proactive messages — measured over 31 days of Langfuse traces it fired 2.8×/day on average and 8× on the busiest day — and a large share were not meetings at all. "Laundry — bed sheets", "Micah Davide bday", "Hiking group", "Family devotion" each produced a full LLM prep brief about an event only Lawrence was attending.
The event dicts already build their attendee list with if not a.get("self"), so an empty attendee list is a reliable "personal block" signal — no title heuristics and no per-calendar allowlist needed. The setting is in CONFIG_REGISTRY, so it is tunable from Telegram via set_runtime_config without a redeploy, and reversible to the old behaviour.
flowchart TD
A([Wait 5 minutes]) --> B[Fetch Google Calendar events]
B --> C[Fetch Outlook Calendar events]
C --> D[Deduplicate by summary + start_minute]
D --> E{Any event starting\nwithin lead_minutes?}
E -- No --> A
E -- Yes --> F{Already sent\ntoday for this event?}
F -- Yes --> E
F -- No --> G[Run briefing_agent\nwith _MEETING_PREP prompt]
G --> H[Send to BRIEFING_CHAT_ID]
H --> I[Mark sent in context table]
I --> E
Configuration:
| Env var | Default | Description |
|---|---|---|
MEETING_PREP_LEAD_MINUTES |
30 |
How many minutes before a meeting to fire the brief |
MEETING_PREP_MIN_DURATION_MINUTES |
10 |
Minimum meeting duration to trigger a brief (skips quick 5-min syncs) |
All-day events are always skipped.
Meeting Follow-up Loop¶
Trigger condition: BRIEFING_CHAT_ID is set and GMAIL_WORK_REFRESH_TOKEN is configured
Frequency: Every MEETING_FOLLOWUP_POLL_MINUTES (default 30)
Agent: None — direct extraction via the mini-model, no agent involved
Polls work Gmail for new emails from gemini-notes@google.com (the Gemini-for-Workspace meeting-notes service) and surfaces a Telegram approval card with the user's action items.
- Search work Gmail:
from:gemini-notes@google.com newer_than:1d(limit 20) - For each message: check
contexttable undersystem:meeting_followup:gmail:<message_id>— if any non-empty state exists, skip (already handled) - Parse meeting title from subject (
Notes: '<title>' <date>) and Google Doc ID from the email body (docs.google.com/document/d/<id>/...) - Fetch the doc via
read_drive_file(doc_id, work=True) - Run the typed extractor (
extract_meeting_followupinapp/skills/meeting_notes.py) — populatesMeetingFollowup(attendees, decisions, actions, discussion_points, summary)via Pydantic AI structured output on the mini-model - Filter
actionsto those owned byUSER_FIRST_NAME(case-insensitive prefix match —"Lawrence"matches"Lawrence Adu-Gyamfi") - If filtered actions and decisions are both empty → record
no_signal:<date>, skip silently. Otherwise: - Persist a
PendingAction(action_type="meeting_followup")with a 24-hour expiry (longer than the standard 30-min approval expiry — the user may not see the card immediately) - Send a Telegram message with the action items + highlights and a three-button inline keyboard
- Record
surfaced:<date>in the context table
flowchart TD
A([Wait MEETING_FOLLOWUP_POLL_MINUTES]) --> B[Gmail search: from:gemini-notes@google.com newer_than:1d]
B --> C{Any new messages?}
C -- No --> A
C -- Yes --> D[For each message: read state from context table]
D --> E{Already handled?}
E -- Yes --> C
E -- No --> F[Parse subject → meeting_title]
F --> G[Parse body → doc_id]
G --> H[read_drive_file doc_id]
H --> I[extract_meeting_followup → MeetingFollowup]
I --> J[filter_actions_for_user]
J --> K{Any actions or decisions?}
K -- No --> L[Record no_signal] --> C
K -- Yes --> M[Save PendingAction 24h expiry]
M --> N[Send Telegram card w/ 3 buttons]
N --> O[Record surfaced]
O --> C
Three-button callback handler (handle_callback in app/meeting_followup.py, registered in app/interfaces/telegram/bot.py with pattern=r"^meeting_fu:"):
- Save as intentions — creates one
PendingIntentionper action item with a 3-day follow-up; intention text is"<action> (by <due>) — from \"<meeting title>\"" - Save as note — creates one
Notetitled"Meeting follow-up: <title>"containing the full rendered insights (all sections) - Dismiss — marks the
PendingActioncancelled; nothing persisted
Manual companion: the same pipeline is also exposed as the check_meeting_notes(hours_back=24) skill (in app/skills/meeting_notes.py), registered on every domain agent via include_skills=True. Calling "Kwasi, check for new meeting notes" triggers the same Gmail poll + extraction + card-send flow on demand — useful when the next scheduled poll hasn't fired yet.
Why Gmail-driven, not calendar-driven: Gemini Notes don't land in Drive under a discoverable name pattern — search_meeting_transcripts filters on filenames containing Transcript/Recording/Meet, which Gemini's docs do not match. The Gmail email is the reliable signal: exact sender filter (zero false positives), the subject carries the meeting title verbatim, the body carries the doc ID, and the email's existence confirms the notes are ready.
Configuration:
| Env var | Default | Description |
|---|---|---|
MEETING_FOLLOWUP_ENABLED |
true |
Kill switch |
MEETING_FOLLOWUP_POLL_MINUTES |
30 |
Polling interval in minutes |
USER_FIRST_NAME |
Lawrence |
Used to filter action items down to those owned by the user |
GMAIL_WORK_REFRESH_TOKEN |
(required) | Work Gmail OAuth refresh token |
Journal Digest Loop¶
Trigger condition: BRIEFING_CHAT_ID is set
Frequency: Once weekly on JOURNAL_DIGEST_DAY at JOURNAL_DIGEST_TIME local (default Sunday 19:00)
Agent: briefing_agent
Sleeps until the next local-time occurrence using _seconds_until(). Fetches all journal entries from the past 7 days via list_journal_entries(days=7) and runs the agent with _JOURNAL_DIGEST prompt. The digest is delivered to BRIEFING_CHAT_ID. Only fires if entries exist for the period.
The digest typically reflects on themes and patterns across the week's entries — not a simple list, but a short reflective summary the user can read back.
Email Intelligence Loop¶
Trigger condition: BRIEFING_CHAT_ID is set + at least one email credential is configured (GMAIL_REFRESH_TOKEN or OUTLOOK_REFRESH_TOKEN)
Frequency: Once daily at EMAIL_INTEL_TIME local (default 09:30); set EMAIL_INTEL_TIME="" to disable entirely
Agent: briefing_agent via _send_briefing_message()
Sends a proactive email intelligence triage at a fixed daily time — distinct from the morning briefing, focused entirely on email analysis. Uses the system:email_intel context key to prevent duplicate sends on container restart (same dedup pattern as the morning briefing).
What it does: Runs the _EMAIL_INTEL prompt via briefing_agent, which checks both Gmail and Outlook for today's email, categorises messages, surfaces urgent items requiring action, and silently drafts replies for time-sensitive emails. Delivered to BRIEFING_CHAT_ID.
Gate condition: The loop only starts if at least one email integration is configured — it does not start with BRIEFING_CHAT_ID alone. This prevents noisy "no email credentials" failures.
PR Review Loop¶
Trigger condition: BRIEFING_CHAT_ID + GITHUB_TOKEN + PR_REVIEW_ORGS (org allowlist; fail-closed when empty)
Frequency: Once daily at PR_REVIEW_TIME local (default 09:00); set PR_REVIEW_TIME="" to disable
Pipeline: app/pr_review.py (read-only — no writing back to GitHub)
Discovers PRs where Lawrence is tagged as a reviewer via two paths:
- Gmail —
from:notifications@github.com "review requested"over the lastPR_REVIEW_LOOKBACK_DAYS(default 7) - GitHub API —
is:pr is:open review-requested:<login>
Results are deduped by (owner, repo, number) and filtered against PR_REVIEW_ORGS (case-insensitive). For each surviving PR:
- Fetches title + body + per-file diff via PyGithub (
get_pr_full), capped at 8 KB/file and 60 KB total - Runs a tool-less mini-model
Agent[None, PRReview]to produce structuredsummary / risks / questions / description_check / test_observation / verdict - Renders Telegram-friendly markdown deterministically in Python (testable; not LLM-formatted)
- Sends to
BRIEFING_CHAT_ID - Persists the review as a
Notetagged#pr-review #<owner> #<repo>for searchability - Sets
system:pr_review:<owner>/<repo>#<N>in thecontexttable with an 18-hour TTL — updated PRs can be re-reviewed the next day
The same pipeline backs two manual agent tools registered on github_agent: github_review_pr(url) and github_pending_pr_reviews().
To Do Sync Loop¶
Trigger condition: settings.todo_sync_active — TODO_SYNC_ENABLED and Outlook credentials. Independent of Telegram.
Frequency: Every TODO_SYNC_INTERVAL_SECONDS (default 300, floored at 60)
Spec 013 makes the Microsoft To Do app the fast triage surface while Kwasi's tasks table stays the brain. Sync is two-directional but asymmetric:
- Push (Kwasi → To Do) is inline, not in this loop. The four task executors (
create/edit/complete/deleteinapp/agent_tools/tasks.py) mirror to To Do best-effort — exactly like embeddings, a failure never breaks the local task op — and are a no-op whentodo_sync_activeis false. Delete capturestodo_task_idbefore removing the row. - Pull (To Do → Kwasi) is this loop. Microsoft does not push, so we poll
todo_sync.reconcile().
Scope is the default list only (resolved via wellknownListName). The todo_* agent tools still operate on any named list.
Reconcile rules¶
| Rule | Behaviour |
|---|---|
| Conflict (FR-012) | To Do wins |
| Echo guard (FR-014) | Per-task last_synced watermark = the item's lastModifiedDateTime as of our last push/reconcile. A remote item is applied only when its lastModifiedDateTime is strictly newer, so a change we just pushed never round-trips back as a phantom external edit |
| Guarded delete (FR-013) | A failed fetch is never read as "everything was deleted" — reconcile bails out early and touches nothing. Only a linked-but-absent item from a successful fetch is removed |
| Pagination | Reconcile must see the whole list before it can safely delete. A fetch that would exceed the page ceiling raises rather than returning a partial list |
| First-run adoption (FR-018) | An unmatched remote task adopts a same-title unlinked local task; otherwise a new local task is created |
| Push-up (FR-017) | Locals that were never mirrored (create-push failed, or sync was just enabled) are created in To Do and linked |
Field mapping: status done ↔ completed and inProgress → todo; date-only due_date ↔ dueDateTime; To Do importance ⭐ ↔ task priority.
Linkage lives on the tasks row itself — todo_task_id + last_synced, added as additive migrations to both adapters. See Storage → tasks.
reconcile() returns a stats dict (updated / created / adopted / deleted / pushed) for logging and tests, and never raises.
Phone Digest Loop¶
Trigger condition: BRIEFING_CHAT_ID + PHONE_DIGEST_ENABLED (default true)
Frequency: Daily at PHONE_DIGEST_TIME local (default 21:30)
A deterministic, no-LLM summary of yesterday's app screen-time — "screen 4h12m, 42 unlocks; top: …". Restart-safe via the system:phone_digest context key (today's local date).
Its purpose is observability, not insight: it makes the usage signal legible from Telegram without opening Postgres. If there is nothing to report, no message is sent (but the dedup key is still written). Notification ingest was removed on 2026-08-22, so the digest no longer carries a notification line.
Full feature reference: Phone Awareness.
Approval Expiry Loop¶
Trigger condition: Telegram bot is active Frequency: Every 5 minutes
Scans pending_actions for rows where status = 'pending' and expires_at < now. For each:
- Sets
status = 'expired' - If
message_idis set: edits the Telegram preview message to_Expired — no action taken._so the buttons disappear - Logs the expiry to the audit log
Any exception (message already edited, Telegram rate limit) is silently swallowed — expiry is best-effort. The 30-minute window is enforced by the gate itself (expires_at check) even if this loop hasn't run yet.
It is also the retention loop¶
Every 12th tick (hourly) the same loop runs a cleanup pass — this is where all TTL pruning lives, rather than in a dedicated loop:
- old
pending_actionsrows (7 days) - old
audit_logrows (90 days) - behavioural-learning lifecycle — active rules that stop recurring drop to candidate; candidates nobody repeats expire
system:msgtrace:*reaction-feedback mappings (30 days) — one row per sent message, the only key family in thecontextKV that grows without bound. A month-old message will never be reacted to
(The two phone_events* prunes were removed on 2026-08-22 with notification ingest; app_usage_daily is retained indefinitely and never pruned.)
Each cleanup is individually try/excepted, so one failing prune doesn't stop the others.
Shared Helpers (in app/background.py)¶
_seconds_until(settings, hour, minute, weekday=None)— Returns seconds until next local-time occurrence. Handles DST by working inzoneinfolocal time throughout._send_agent_message(deps, telegram_app, settings, prompt)— Runs agent, splits result at 4096 chars, sends all chunks toBRIEFING_CHAT_ID.
Reflection Loop¶
Trigger condition: REFLECTION_SECRET is set
Frequency: Once daily at 02:00 UTC
Sleeps until the next 02:00 UTC, runs ReflectionService.run(), then re-anchors to the wall clock and sleeps until the next 02:00 UTC.
The flat-24h sleep bug
Until 2026-08-19 the loop slept a flat 24 hours after each run. That excludes the run's own duration, so every cycle slipped by however long reflection took (~11 minutes) — in production it had walked from 02:00 to 08:25 UTC over 38 days of uptime. Every other loop already used a _seconds_until-style anchor; this one now does too. If you add a periodic loop, anchor it to the clock, not to sleep(interval) after the work.
Nothing external triggers this. There is no Railway cron and no scheduler — the loop is the only thing that runs reflection in production. POST /reflect exists as a manual/ops trigger and had never been called in prod (zero api-channel audit rows). That mattered: the once-weekly stale-facts nudge originally lived only on the endpoint, so it had never fired while user_facts grew to 760 with 501 untouched in 60 days. maybe_send_stale_facts_nudge() is now shared by both paths and runs from the loop.
ReflectionService.run() produces four outputs from the last 24h of interactions:
1. An updated narrative profile (saved to the context table as UserContext)
2. A JSON list of newly extracted user facts (saved to user_facts with source="reflection")
3. A JSON list of newly extracted personal intentions (saved to pending_intentions)
4. A JSON list of behavioral corrections (saved to agent_learnings; promoted from "candidate" to "active" at ≥2 cycles)
ReflectionService.run() returns facts_added, facts_updated, intentions_added, and learnings_added — the counts of new records saved that cycle — plus a stale_facts list (facts not updated in 180+ days). Both the loop and the POST /reflect response surface this; the stale_facts list feeds the once-weekly Telegram memory nudge.
Full detail on the reflection cycle and parsing is covered in Memory & Reflection.
Loop Activation Summary¶
| Loop | Required env var | Schedule |
|---|---|---|
| Reminder loop | TELEGRAM_TOKEN |
Every 5 minutes |
| Task notifications | TELEGRAM_TOKEN + BRIEFING_CHAT_ID |
Every 5 minutes (inside reminder loop) |
| Morning briefing | BRIEFING_CHAT_ID |
BRIEFING_TIME UTC (default 08:00) |
| Evening recap | BRIEFING_CHAT_ID |
EVENING_RECAP_TIME local (default 21:00) |
| Weekly recap | BRIEFING_CHAT_ID |
WEEKLY_RECAP_DAY/WEEKLY_RECAP_TIME local (default Friday 18:00) |
| Weekly prep | BRIEFING_CHAT_ID |
WEEKLY_PREP_DAY/WEEKLY_PREP_TIME local (default Sunday 18:00) |
| User scheduled tasks | BRIEFING_CHAT_ID |
Every 60 seconds (cron-based evaluation) |
| Read-later digest | BRIEFING_CHAT_ID |
READ_LATER_DIGEST_DAY/READ_LATER_DIGEST_TIME local (default Saturday 09:00) |
| Journal digest | BRIEFING_CHAT_ID |
JOURNAL_DIGEST_DAY/JOURNAL_DIGEST_TIME local (default Sunday 19:00) |
| Email intelligence | BRIEFING_CHAT_ID + email creds |
EMAIL_INTEL_TIME local (default 09:30); "" to disable |
| Alert rules | BRIEFING_CHAT_ID |
Every 5 minutes |
| Meeting prep | BRIEFING_CHAT_ID |
Every 5 minutes (fires MEETING_PREP_LEAD_MINUTES before each meeting) |
| Meeting follow-up | BRIEFING_CHAT_ID + GMAIL_WORK_REFRESH_TOKEN |
Every MEETING_FOLLOWUP_POLL_MINUTES (default 30); MEETING_FOLLOWUP_ENABLED=false to disable |
| PR review | BRIEFING_CHAT_ID + GITHUB_TOKEN + PR_REVIEW_ORGS |
PR_REVIEW_TIME local (default 09:00); "" to disable |
| To Do sync | TODO_SYNC_ENABLED + Outlook credentials |
Every TODO_SYNC_INTERVAL_SECONDS (default 300) |
| Phone digest | BRIEFING_CHAT_ID + PHONE_DIGEST_ENABLED |
PHONE_DIGEST_TIME local (default 21:30) |
| Approval expiry (+ hourly TTL pruning) | Telegram bot active | Every 5 minutes; cleanup pass every 12th tick |
| Reflection loop | REFLECTION_SECRET |
2 AM UTC daily |
All loops fail-safe: an exception in one tick is logged and the loop continues on the next tick.