Background Loops¶
Thirteen asyncio tasks start inside the FastAPI lifespan and run for the lifetime of the process. They are cancelled cleanly on shutdown.
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]
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"]
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]
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
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):
- 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
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.
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.
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.
Shared Helpers (in main.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 ~2 AM UTC
Sleeps until 2 AM UTC, runs ReflectionService.run(), then sleeps 24 hours.
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)
The response from POST /reflect includes facts_added, facts_updated, intentions_added, and learnings_added — the counts of new records saved that cycle. It also includes a stale_facts list (facts not updated in 180+ days) used to trigger 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) |
| Approval expiry | Telegram bot active | Every 5 minutes |
| 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.