Background Loops¶
Fifteen 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). diagnose_self reads a fixed list of these keys to surface a loop that's stuck or erroring without trawling logs. The key list in diagnose_self is hardcoded — a new loop that adds a heartbeat should be added there too.
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]
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)"]
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]
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
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.
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().
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 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 ~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) |
| 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 |
| 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.