Skip to content

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

  1. Calendar — today's events in chronological order; flags conflicts and tight transitions
  2. Email — fetches today's emails from Gmail (get_todays_emails_wrapper) and Outlook (outlook_todays_emails_wrapper), then categorises into three tiers:
  3. Urgent (reply needed today): questions, decisions, time-sensitive requests. For each: shows [From] · [Subject] — one-line summary, silently saves a draft reply via draft_email_wrapper (Gmail) or outlook_draft_wrapper (Outlook), and confirms "Draft saved → '[first line]...'" in the briefing. The user can then send, edit, or ignore the draft.
  4. Action required: meeting invites, document reviews, approvals — listed with one-line descriptions, no drafts.
  5. FYI / Skip: newsletters, receipts, automated alerts — collapsed to a single count ("8 FYI (newsletters, receipts, etc.)").
  6. 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.
  7. Tasks — Kwasi native tasks + Microsoft To Do; overdue first, then today's, then upcoming this week
  8. Weather — current conditions and anything notable
  9. Transit — only shown if a disruption is active on the user's usual lines (silent if all clear)
  10. 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:

  1. Done today — completed tasks, meetings attended
  2. Left open — unfinished items, light nudge
  3. Tomorrow — calendar preview and tasks due
  4. 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):

  1. Checks the context table under system:meeting_prep:<event_id> for today's date — skips if already sent
  2. Deduplicates across calendar sources by (summary, start_minute) — the same event in both Google and Outlook fires only one brief
  3. Runs briefing_agent with a _MEETING_PREP prompt containing the event title, start time, and attendees
  4. The agent searches history, notes, tasks, and emails for context about the meeting and attendees
  5. Persists today_str in 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:

  1. Sets status = 'expired'
  2. If message_id is set: edits the Telegram preview message to _Expired — no action taken._ so the buttons disappear
  3. 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 in zoneinfo local time throughout.
  • _send_agent_message(deps, telegram_app, settings, prompt) — Runs agent, splits result at 4096 chars, sends all chunks to BRIEFING_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.