Proactive Outreach
Vita can reach out proactively via Telegram with scheduled messages. The system combines a SQLite message queue, dynamic message builders, voice composition, explicit admission and delivery gates, plus Adaptive Telegram Outreach (ATO) response tracking and opt-in frequency decisions.
IMPORTANT: Autonomous Capabilities
You CAN and SHOULD do these things without asking permission:
- Queue reminders - When bio-Zack says "remind me to X" or "ping me about Y", just queue it
- Check calendar - Use
get_calendar_events()to see meeting end times and schedule around them - Schedule follow-ups - After conversations, queue relevant follow-up messages
- Home reminders - "remind me when I get home" queues a presence-triggered reminder
Do NOT: - Say you "need approval" to queue messages - Track reminders only in scratch when you could queue them - Ask permission to use these core capabilities
Example - Handling "remind me after my meeting":
from scripts.integrations.google_api import get_calendar_events
from scripts.proactive import enqueue_message
from datetime import datetime, timedelta
# Check calendar for meeting end time
events = get_calendar_events(days=1)
meeting = next((e for e in events if "team sync" in e["title"].lower()), None)
if meeting and meeting.get("end_dt"):
end_time = datetime.fromisoformat(meeting["end_dt"])
remind_at = end_time + timedelta(minutes=5)
else:
remind_at = datetime.now() + timedelta(hours=1)
enqueue_message(
trigger="user_reminder",
message="Reminder: follow up on search improvements",
send_at=remind_at,
dedup_key=f"reminder-search-{datetime.now().date()}",
)
Note:
get_calendar_eventslives atscripts.integrations.google_apiβ confirm against the source if the import path drifts.
Example - Home reminder:
from scripts.proactive.triggers import schedule_when_home
schedule_when_home(
message="Check the mailbox",
dedup_key="mailbox-2026-01-28",
)
# Auto-detects if user is already home (fires immediately)
# Otherwise waits for presence detection (Linux or iOS geofence)
Architecture
βββββββββββββββββββββββββββββββββββββββββββ
β Queue (SQLite) β
β data/proactive/queue.db β
β Tables: messages, thread_mapping, β
β reminder_acks, presence_reminders, β
β alert_emissions β
ββββββββββββββββββ¬βββββββββββββββββββββββββ
β polls every 30s
ββββββββββββββββββΌβββββββββββββββββββββββββ
β Scheduler (6 async loops) β
β - Message processor (30s) β
β - ATO response timeouts (15min) β
β - Goal enrichment (15min) β
β - Reminder ack checker (15min) β
β - Presence monitor (30s) β
β - Event funnel (5s) β
ββββββββββββββββββ¬βββββββββββββββββββββββββ
β build + compose
ββββββββββββββββββΌβββββββββββββββββββββββββ
β Registered Builders + Composer β
β - Raw data β composable dict β
β - Claude composition for natural voice β
β - Fallback templates on failure β
ββββββββββββββββββ¬βββββββββββββββββββββββββ
β send via Telegram
ββββββββββββββββββΌβββββββββββββββββββββββββ
β ATO Coordinator β
β - Response/backoff state + status β
β - Opt-in gating, burst mode, analytics β
ββββββββββββββββββ¬βββββββββββββββββββββββββ
β
ββββββββββββββββββΌβββββββββββββββββββββββββ
β Conversation Log β
β data/proactive/conversations.jsonl β
βββββββββββββββββββββββββββββββββββββββββββ
Note: The proactive delivery scheduler (
scripts/proactive/scheduler.py) runs inside the Telegram bot process. It is distinct from the API-sideVitaScheduler(see Scheduling) β the bot does not restart daily, so recurring triggers are reconciled by a separate daily-trigger pass rather than at startup alone.
Scheduling Messages
Static Messages
from scripts.proactive import enqueue_message
from datetime import datetime, timedelta
enqueue_message(
trigger="prep_reminder",
message="Dentist appointment in 1 hour",
send_at=datetime.now() + timedelta(hours=1),
dedup_key="dentist-2026-01-02", # prevents duplicates
)
enqueue_message returns the new row id, or raises EnqueueRejected when admission control / dedup blocks the insert if you pass on_reject="raise" (see Queue Admission Control).
Dynamic Messages (Built at Send Time)
from scripts.proactive.triggers import schedule_morning_kickoff, schedule_evening_checkin
# Schedule for 7am with dedup
schedule_morning_kickoff()
# Schedule for 8pm with dedup
schedule_evening_checkin()
The queue stores builder_key and context, not frozen text. At delivery time,
build_message() reads current data and returns either a direct string, a composable
dict, or None. None is a terminal "nothing to send" decision: the scheduler
cancels the row on its first evaluation, increments its attempt count, and records an
empty_builder_result reason in queue context. A raised builder exception is a real
failure and goes through delivery retry/backoff instead.
Deferred Prompts (Claude Executes at Scheduled Time)
enqueue_prompt schedules a full Claude invocation β not just a text message, but a prompt that Claude will execute with access to all tools and context at the specified time. These rows carry delivery_class="prompt" and are budgeted on a separate SYSTEM tier (see admission control below).
from scripts.proactive import enqueue_prompt
from datetime import datetime, timedelta
enqueue_prompt(
prompt="Check email for anything urgent and summarize in Telegram",
send_at=datetime.now() + timedelta(hours=2),
dedup_key="check-email-2026-01-28",
)
Use enqueue_prompt when the work to do at a future time requires reasoning or tool use (e.g., "in 30 minutes, look up X and send results"), not just sending a pre-written message.
Built-in Triggers
All times are PT. Recurring triggers are scheduled by ensure_daily_triggers() (see below), which reconciles both today's and tomorrow's set on each pass.
| Trigger | Time | Builder | Content |
|---|---|---|---|
microgrit_morning |
6:00 AM | microgrit_morning |
Daily commitment prompt |
morning_kickoff |
7:00 AM | morning_kickoff |
Sleep, HRV, training, tasks, commitment context |
training_suggestion |
9:00 AM | training_suggestion |
Recovery-aware workout suggestion |
goal_prompt |
10:00 AM (Wed/Sun) | goal_prompt |
Weekly training goal progress |
gap_filler |
10:30 AM (Mon/Wed/Fri) | gap_filler |
Identity knowledge gap questions |
relationship_nudge |
10:30 AM (Mon/Thu/Sat) | relationship_nudge |
Relationship-goal recency nudge |
openclaw_pi_innovation_scan |
11:30 AM (Mon) | (deferred prompt) | Weekly innovation scan |
identity_interrupt_1 |
1:30 PM (weekdays) | identity_interrupt_1 |
Observer perspective |
habit_nudge |
2:00 PM | habit_nudge |
Midday habit progress check |
identity_interrupt_2 |
5:00 PM (weekdays) | identity_interrupt_2 |
Surface avoidance patterns |
ceo_reflection |
5:30 PM (weekdays) | ceo_reflection |
End-of-day reflection prompt |
sleep_gate_warning |
7:30 PM | sleep_gate_warning |
Predictive sleep-gate warning (see Sleep Guardian) |
evening_checkin |
8:00 PM | evening_checkin |
Steps, habits, reflection prompt |
microgrit_evening |
8:15 PM | microgrit_evening |
Commitment completion check |
ride_mode_prompt |
9:00 PM | ride_mode_prompt |
Pick tomorrow's ride mode |
winddown_alert |
9:45 PM | winddown_alert |
Sleep protocol wind-down |
openclaw_pi_innovation_scan is scheduled as a deferred prompt (executed by Claude at send time), not a static-text builder.
On-Demand Triggers
| Trigger | Builder | Content |
|---|---|---|
meeting_started |
meeting_started |
Internal "board" deliberation start notification |
meeting_concluded |
meeting_concluded |
Internal "board" decisions summary |
followup |
followup |
Contextual follow-up questions |
user_submit_summary |
user_submit_summary |
Processed URL summary + reflection |
goal_enrichment |
goal_enrichment |
Fills a goal's missing fields via Q&A |
objectives |
objectives |
Daily/weekly objective prompt |
heartbeat_nudge |
heartbeat_nudge |
Surfaces a queued discussion topic |
voice_meeting_invitation |
voice_meeting_invitation |
Invite to a voice deliberation session |
boot_sequence_orient |
boot_sequence_orient |
Orientation message on session boot |
user_reminder |
(static text) | User-requested reminders |
Note:
meeting_started/meeting_concludedrefer to the internal Board construct (silicon-zack's deliberation council), not any real-world meeting or named people.Warning:
commitment_reminderandcommitment_escalationare retired channels (0% engagement, removed fromBUILDERSandensure_daily_triggers). The builder functions still exist for reference but are no longer routed. Commitment context now surfaces insidemorning_kickoffandceo_reflection. See Capture & Commitments.
Daily Trigger Reconciliation
ensure_daily_triggers() (in scripts/proactive/triggers.py) reconciles the full recurring set for today and tomorrow. It is invoked by the API-side VitaScheduler's _daily_triggers_loop at 00:10 PT (after the 00:05 nightly rollup, in api/src/scheduler.py), so outreach stays scheduled even though the bot never restarts. Dedup keys make it idempotent if called more than once.
Beyond the message triggers above, the same pass wires the agent-contract framework:
ensure_agent_schedule_triggers(target)β DST-safe per-agent schedule triggers (each registered agent's recurring schedule is materialized as a queued trigger for the target date).ensure_agent_sentinels()β upserts the sentinel registry rows so agent-watchers stay current.
Both are wrapped in try/except and log warnings on failure rather than aborting the rest of the daily pass.
Queue Management
from scripts.proactive import (
cancel_message,
reschedule_message,
revise_message,
list_pending
)
# View pending messages
pending = list_pending()
# [{"id": 1, "trigger": "...", "send_at": "...", "message": "..."}, ...]
# Cancel a message
cancel_message(msg_id)
# Reschedule to new time
reschedule_message(msg_id, new_datetime)
# Revise text (static messages only - dynamic uses builder)
revise_message(msg_id, "New message text")
Queue Admission Control
Normal inserts into queue.db pass through _check_admission
(scripts/proactive/queue.py), a rate-limit gate that prevents queue flooding. There
are three knobs, four rolling-24-hour delivery tiers, a hard denylist, and narrow
bypass contracts:
| Knob | Constant | Behavior |
|---|---|---|
| A β same-trigger coalesce | TRIGGER_COALESCE_SECONDS=60 |
Drop if the same trigger was enqueued within 60s |
| B β global ceiling | GLOBAL_CEILING_COUNT=10 / GLOBAL_CEILING_WINDOW_SECONDS=600 |
Max 10 non-exempt messages per rolling 10 min |
| C β per-tier daily ceiling | DAILY_CEILING_* (rolling 24h) |
Apply the tier policy below |
Knob C keys first on delivery classβwhat the row is, not merely what its trigger isβand uses a rolling 24-hour window rather than a calendar day:
| Tier | Cap | Counting policy | Typical members |
|---|---|---|---|
| HIGH | 6/24h |
Per trigger; one high-value source cannot starve another | morning_kickoff, sleep_gate_warning, goal_enrichment, gapcloser_nudge, leverage_watch, feed_agent_reply, ecosystem_scan, exec_finding |
| FAILURE | 3/24h |
Per trigger; distinct infrastructure failures do not compete | vdb_failure, scheduler_health, stepwise_fail, rss_feed_down, curated *_failure triggers |
| OTHER | 4/24h |
One shared ambient-outreach budget | Every non-exempt message trigger not curated above |
| SYSTEM | 12/24h |
Shared by prompt_text IS NOT NULL rows |
Non-exempt deferred agent prompts and internal pipeline continuations |
The tier check does not replace the 60-second coalesce or 10-per-10-minute global ceiling; HIGH and FAILURE rows still pass those first. SYSTEM is separate so outreach noise cannot starve code-executing deferred prompts, and prompts cannot consume the outreach floor.
RATE_LIMIT_EXEMPT(user_reminder,user_request,interactive,deferred_prompt, email approval/arrival triggers, etc.) bypasses all limits because it is user-initiated or time-critical.ADMISSION_BYPASS_TRIGGERSgivesmeeting_brief,meeting_ingest, andmeeting_startedfull per-event bypass. Back-to-back meetings must not coalesce-drop one another. A correctness-critical call site can use the private_skip_admission=Trueescape hatch for the same effect.ENQUEUE_DENYLISThard-drops pure-noise triggers before they ever hit the queue.
On rejection, every enqueue path logs a WARNING and follows a structured caller-selected
contract: on_reject="return" returns the legacy -1 sentinel;
on_reject="raise" raises EnqueueRejected, whose .reason includes the denylist,
coalesce, global, tier, or state-dedup reason. Correctness-critical callers use the
raising form so they do not persist downstream state for a message that never landed.
Fire-and-forget notification sites retain the sentinel form so an admission drop does
not crash their host scheduler or ingestion loop.
Vacation mode and delivery class
Vacation mode lives in frequency_state.json and is exposed at
GET|POST /api/v1/scheduler/vacation-mode. At delivery time:
- user-initiated triggers bypass suppression, vacation, timing, and frequency gates;
- a curated
VACATION_ALLOWEDset still delivers genuinely useful outreach; morning_kickoffremains allowed, but its builder strips work/training content and keeps only the light sleep, HRV, readiness, and date context;- every other Telegram-message row is cancelled with a forensic
context.cancelled_reasonrather than disappearing silently; - every row with
prompt_textis system execution, not Telegram outreach, so it still runs even when its trigger is not allowlisted. This delivery-class exemption also prevents vacation mode from suppressing its own future clear/off-switch prompt.
This gate changes delivery, not admission: rows can be admitted before vacation mode is evaluated, then cancelled or executed when due.
Alert State-Dedup
A separate dedup contract suppresses identical-content alerts on recurring schedules until the underlying state changes. This is distinct from dedup_key (queue-level uniqueness) and from the in-memory event_funnel.fingerprint (10s).
_compute_state_hash/_check_state_deduphash the alert payload; identical content within the resend window is dropped.TRIGGER_STATE_RESEND_DEFAULTSgives per-trigger windows out of the box (e.g.rwgps_smoke_fail4h,vdb_failure6h,sleep_failure24h,scheduler_health4h). Other triggers opt in by passingstate_resend_seconds.- The
alert_emissionstable is the dedup ledger backing this contract.
Built 2026-05-06 after a single smoke-test incident emitted 9 identical alerts.
Informed Pre-Send Gate
Before a message asserts that bio-zack still owes something, the obligation is
re-checked against live done-ness. Shipped 2026-07-26
(scripts/proactive/presend_gate.py, issue 86c97664).
The rule comes from bio-zack directly, at 7:01am on 2026-07-18, after the morning kickoff told him he still owed two Will-Eckenroth follow-ups he had actually completed during the previous day's call:
"Automated messages must be informed pre-send."
The mechanism is not staged-snapshot staleness. Builders already run at
send time (see Architecture). The real problem is that
done-ness lands in more places than the store the item came from: a commitment
closed in conversation, in Precedent, or noted in the daily scratch stays
status: pending in data/assistant/commitments.json until something
reconciles it. So a builder reading perfectly fresh data still reads a stale
claim.
The gate is wired in scheduler.py immediately after build_message(), so
every builder inherits it rather than morning_kickoff alone. It walks the
obligation-bearing payload keys (commitments_due, overdue_items,
commitments, small_tasks) and assigns each item one of three states:
| State | Meaning | Effect |
|---|---|---|
OPEN |
nothing says it's done | assert it normally |
RESOLVED |
found done in Precedent or the scratch notes | drop it from the message |
UNVERIFIED |
the check itself failed | keep it, tagged (status unverified) |
That third state is the point of the design. Dropping a real obligation because Precedent was unreachable would be a worse failure than the one being fixed; silently asserting it would be the original bug. Render "unknown", never swallow and never fabricate β the same discipline the freshness contract enforces on numbers, applied to claims.
If gating empties an obligation key, the key is removed; if nothing is left to say at all, the message is cancelled rather than sent hollow.
Matching is deliberately conservative
A false RESOLVED silently drops a real obligation bio-zack never sees and
cannot correct. A false OPEN produces one annoying message he can push back
on. The asymmetry dictates the tuning.
Text matching uses asymmetric containment β what fraction of the item's distinctive words the evidence covers β with a 0.75 floor and a minimum of 2 shared tokens, plus a symmetric-Jaccard fast path at 0.65.
The first implementation used symmetric Jaccard alone and missed the actual
Jul-18 pair: send Will Eckenroth the waiver links vs sent Will Eckenroth
the waiver links after the call scores 0.43, because "send"/"sent" differ and
"will" is a stopword. Containment scores it 0.75. The fix is regression-tested
against that pair plus four near-misses it must not drop (sent Brittney the
waiver links, replied to Helene about the offsite agenda, call with Nick
done, and an unrelated errand).
Known gap: the gate reads resolutions but does not reconcile them back
into commitments.json, so the same item is re-checked on every send. That
inflow fix belongs to the commitment pipeline
(2026-07-22-commitment-counterparty-attribution-ingest.md).
Voice Composition
Builders can return either:
- A string (sent as-is, for system notifications)
- A dict with {type, data, compose: True} (routed through Claude for natural voice)
The composer (scripts/proactive/composer.py) takes the structured data dict and routes it through scripts.claude.run_batch with an agent profile β the empty-system-prompt simple agent by default, or the tool-enabled coaching.composer agent for coaching_batch (which verifies stagnation claims before composing). No model name is hardcoded; routing is owned by the agent profile (see Model Guidance).
The composer has bespoke prompt templates for these message_types; anything else falls through to a generic template:
morning_kickoff, coaching_batch, followup, identity_interrupt_observer, identity_interrupt_avoidance, boot_sequence_orient, habit_nudge.
When composition fails, a fallback template is used.
Thread Linking
When user replies to a proactive message:
- Outbound messages store:
telegram_msg_id -> thread_idinthread_mappingtable - Inbound replies look up thread via
get_thread_for_reply(reply_to_msg_id) - Fallback:
get_recent_thread_id()for non-reply messages within 30 min - Special thread formats:
microgrit-YYYY-MM-DD-am/pmfor micro-grit trackingenrich-*for goal enrichmenthome_*for home reminder threads
Context Injection
data/proactive/conversations.jsonl remains the analytics and thread-exchange log, and
its helpers can still render a daily history block:
from scripts.proactive.conversation_log import get_todays_session_context, format_session_context
entries = get_todays_session_context(limit=10, since_ts=reset_ts)
context_block = format_session_context(entries)
# Output:
# [Previous messages in this Telegram session today:]
# [07:00] VITA (morning_kickoff): Good morning!...
# [07:02] USER: Focus on code review today
# [End of previous messages]
Interactive Telegram continuity now uses the per-topic append-only ledger instead of
relying on this daily block. Every successful out-of-session scheduler send is recorded
in data/telegram/topic_ledger.jsonl; the resident topic session injects only unseen
entries and advances its watermark after successful inference. Deferred-prompt results
skip that ledger write because the prompt already ran inside the destination topic
session. See Telegram β Topic ledger and context continuity
for the active injection and failure contract.
Home Presence Reminders
Location-triggered reminders that fire when the user returns home:
from scripts.proactive.triggers import schedule_when_home
schedule_when_home(
message="Take out the trash",
dedup_key="trash-2026-01-28",
)
Detection methods:
- iOS: Geofence entry via fire_from_ios_geofence(place_id) β the primary path (see Voice & Location)
- Linux: Presence monitor checks for known devices on the local network
The presence monitor loop polls every 30s with a 60s arrival debounce; place_id maps through profile/places.json + PLACE_TO_TRIGGER aliases. Auto-detects if the user is already present and fires immediately when appropriate.
Creating Custom Builders
Builders generate dynamic content at send time:
# scripts/proactive/builders.py
def my_custom_builder() -> str:
"""Build message with fresh data."""
from scripts.utils import safe_read_json
health = safe_read_json("memory/views/health-snapshot.json", {})
lines = ["Good morning!"]
if health.get("sleep_hours"):
lines.append(f"You slept {health['sleep_hours']} hours")
return "\n".join(lines)
Register in BUILDERS dict:
BUILDERS = {
"morning_kickoff": morning_kickoff,
"evening_checkin": evening_checkin,
"my_custom": my_custom_builder,
}
For ATO-aware sources, also register with the decorator:
from scripts.proactive.adaptive import register_message_source
@register_message_source(
"my_source",
"information_gathering", # category
priority=0.5,
max_per_week=7,
min_hours_between=2,
)
def build_my_message(context: dict) -> str:
return "Your message here"
max_per_week and min_hours_between become inputs to ATO's opt-in
should_send(source_name) decision. Registering a builder does not make the main
delivery scheduler enforce them automatically; see the ATO runtime note below.
For /sleep Analysis
from scripts.proactive.conversation_log import get_proactive_summary
summary = get_proactive_summary(days=7)
# Returns:
# {
# "period_days": 7,
# "total_outbound": 14,
# "total_inbound": 8,
# "triggers": {"morning_kickoff": 7, "evening_checkin": 7},
# "response_rate": 57.1,
# "threads_started": 14,
# "threads_with_response": 8,
# "recent_threads": [...]
# }
Additional analysis functions:
- get_recent_exchanges(since, limit) - Raw exchange history
- get_thread_exchanges(thread_id) - All messages in a thread
- has_activity_since(since_ts) - Check for any activity after timestamp
Adaptive Telegram Outreach (ATO)
ATO records send/response/timeout state, computes adaptive intervals, exposes status and manual controls, and supports opt-in send gating based on responsiveness.
Current runtime policy: since 2026-03-23, the main bot delivery scheduler does not call
should_send(). Bio-zack explicitly disabled that blanket frequency limiter after it silently suppressed wanted messages. Queue admission, dead-trigger suppression/quarantine, vacation mode, and the scheduler's 8amβ10pm productive-window gate are the active delivery controls. ATO still records every sent source, processes one-hour response timeouts, updates backoff state, drives burst behavior, and remains available to callers that explicitly invokeshould_send().
How It Works
βββββββββββββββββββββββββββββββββββββββββββ
β Message source sends β
β main scheduler, burst manager, or caller β
ββββββββββββββββββ¬βββββββββββββββββββββββββ
β optional should_send(source_name)
ββββββββββββββββββΌβββββββββββββββββββββββββ
β ATO decision library β
β 1. Avoid hours (10pm-6am PT) β
β 2. Category limits (per day/week) β
β 3. Per-source adaptive backoff β
ββββββββββββββββββ¬βββββββββββββββββββββββββ
β if allowed
ββββββββββββββββββΌβββββββββββββββββββββββββ
β Send β Track β Monitor Response β
β record_sent() β check_response_timeouts β
βββββββββββββββββββββββββββββββββββββββββββ
Library features
- Exponential Backoff: On non-response, wait time increases (15 -> 30 -> 60 -> 120 -> 240 -> 480 min max)
- Category Limits: Prevents spam by limiting messages per category per day/week
- Avoid Hours: No messages between 10pm-6am PT
- Burst Mode: When user responds, rapid Q->A->Q->A without Claude processing (5 min timeout)
- Source Registry: Decorator pattern for registering builders with category and priority metadata
- Thread Cache: LRU cache (500 entries) for fast thread->source lookups without JSONL scans
- Response Log Pruning: Amortized pruning (~1% of writes) keeps log to 14 days
Backoff Formula
interval = min(15 * 2^(streak//2), 480)
| Streak | Interval |
|---|---|
| 0-1 | 15 min |
| 2-3 | 30 min |
| 4-5 | 60 min |
| 6-7 | 120 min |
| 8-9 | 240 min |
| 10+ | 480 min (8 hours max) |
Backoff resets to 0 when user responds. Can be manually reset via /proactive reset <source>.
Category Limits
These reflect the 2026-05-14 emergency tuning that crashed volume by ~75% to recover signal quality (the code comment labels the prior numbers β info 12/60, reflection 10/50, notification 15/75, reminder 10/50 β as "Previous values"):
These values are enforced only where a caller opts into should_send(); queue admission
tiers are the blanket scheduler-side volume control.
| Category | Per Day | Per Week |
|---|---|---|
| information_gathering | 3 | 21 |
| reflection | 2 | 14 |
| notification | 4 | 28 |
| reminder | 2 | 14 |
Daily counters reset at midnight PT. Weekly counters reset on week boundary. Unknown categories fall back to a 5/25 default.
Avoid Hours
The ATO decision library blocks 10pmβ6am PT
(HARD_AVOID_HOURS = {22,23,0,1,2,3,4,5}), and burst mode auto-exits if avoid hours
begin. The main scheduler's separate active timing gate is stricter: it reschedules
non-exempt sends outside 8amβ10pm PT to the next 8am window.
API Functions
from scripts.proactive.adaptive import (
should_send, # Check if source can send now
record_sent, # Track sent message
record_response, # Handle user response (triggers burst)
get_ato_status, # Get full status for display
set_source_enabled, # Enable/disable source
reset_source_backoff, # Reset backoff for one source
reset_all_backoffs, # Reset all sources
check_response_timeouts, # Check for timed-out threads
)
should_send(source_name) -> bool
Checks avoid hours, category limits, and per-source backoff. Returns True for unknown sources (backward compatibility).
if should_send("gap_filler"):
# OK to send
else:
# Blocked by avoid hours, category limit, or backoff
record_response(thread_id, text) -> dict
Records user response, resets backoff, enters/refreshes burst mode. Detects busy intent.
result = record_response(thread_id, user_text)
# Returns:
# {
# "source": "gap_filler",
# "burst_active": True,
# "skip_claude": True, # Don't invoke Claude - send next Q directly
# "is_busy": False,
# "resume_in_minutes": None,
# "next_thread_id": "abc12345"
# }
get_ato_status() -> dict
Returns full status including per-source state, category counts, burst status.
status = get_ato_status()
# {
# "avoid_hour_active": False,
# "burst_active": False,
# "burst_source": None,
# "sources": {
# "gap_filler": {
# "category": "information_gathering",
# "enabled": True,
# "consecutive_no_response": 2,
# "current_interval_minutes": 30,
# "can_send": True
# }
# },
# "category_counts": {"information_gathering": {"today": 3, "week": 15}}
# }
Burst Mode
When user responds to an ATO message:
1. System enters burst mode (or refreshes if already active)
2. skip_claude=True tells telegram_bot to NOT invoke Claude
3. Next queued ATO question sent directly (2 sec delay)
4. Cycle continues until:
- User says "busy/brb/gtg/not now" -> exits, optionally schedules resume
- User says "back in X hours/minutes" -> exits + schedules resume in X time
- 5 min timeout (BURST_TIMEOUT_SECONDS=300) -> auto-exit
- Avoid hours -> auto-exit
- No more queued questions -> exit
State file: data/proactive/burst_state.json
{
"active": true,
"started_at": "2026-01-28T10:30:00",
"source_name": "gap_filler",
"messages_sent": 3,
"current_thread_id": "abc12345"
}
Busy Detection
Regex patterns detect busy intent: busy, brb, gtg, gotta go, not now, hold off, talk later, I'm busy, I'm leaving, etc.
Availability parsing: "back in 2 hours" or "free in 30 minutes" extracts the duration for resume scheduling.
Source Registry
Sources are registered via decorator at import time:
from scripts.proactive.adaptive import register_message_source
@register_message_source(
"gap_filler",
"information_gathering",
priority=0.7,
max_per_week=3,
min_hours_between=4,
)
def build_gap_filler_message(context: dict) -> str:
...
Each source gets independent backoff state stored in frequency_state.json.
/proactive Command
/proactive # Show status (avoid hours, burst, per-source state)
/proactive enable <source> # Enable a source
/proactive disable <source> # Disable a source
/proactive reset <source> # Reset backoff to base interval
Warning: Only
enable | disable | reset <source>are implemented (cmd_proactivedispatches onargs[0]/args[1]). There is no special-cased "reset all" path β/proactive reset allwould just try to reset a source literally named "all". To clear every backoff, callreset_all_backoffs()from Python.
Dead-Trigger Suppression (Feedback Learning Loop)
Beyond ATO backoff, a source-level probabilistic suppression layer (scripts/proactive/trigger_suppression.py) handles engagement-expecting sources with chronic zero/low response. It tracks a per-source CNR (consecutive-no-response) counter and, once a source crosses the threshold, drops it probabilistically β 1 in SUPPRESSION_RATIO (=50) sends still passes through, so a dead channel keeps a faint heartbeat (~1/week) rather than being killed outright. A single response auto-reactivates the source to normal frequency (auto_reactivate_on_response).
ENGAGEMENT_SOURCESis the allowlist of sources subject to this check; a few critical sources are exempt even within it.- The suppression list is curated nightly during
/sleep. It is part of the broader Feedback Learning Loop (fll_learning.py,fll_experiments.py). - A parallel
commitment_suppression.pyapplies the same idea to commitment surfacing.
State: data/proactive/trigger_suppression_state.json; audit trail: data/feedback/suppression_log.jsonl.
Portfolio Audit and Reversible Quarantine
Suppression intentionally leaves a faint recovery heartbeat. The newer portfolio layer answers a different question: which sources are healthy, declining, intentionally silent, or genuinely dead, and which dead sources should be turned off visibly?
scripts/proactive/portfolio_audit.py reads every source in
frequency_state.json, changes no source behavior, and writes
data/proactive/portfolio-audit.json unless --no-write is passed:
python -m scripts.proactive.portfolio_audit
python -m scripts.proactive.portfolio_audit --json
python -m scripts.proactive.portfolio_audit --no-write
The classification order is deliberate because safety and intent override raw response rates:
| Class | Current rule | Actuation policy |
|---|---|---|
safety-critical |
Name matches health, gate, monitor, crash, outage, or delivery-insurance patterns | Never quarantine; value is not measured by replies |
system-silent |
Intentional sweep/digest/watchdog/recheck, or both user-pulled and self-terminating | Never quarantine; silence is expected |
dormant |
No sends in the observed window | Leave alone; it is not making noise |
dead |
At least 3 sends, zero recorded responses, and CNR β₯ 20 | Eligible for quarantine after both safety guards pass |
decaying |
Had recorded responses, but now has CNR β₯ 20 over at least 3 sends | Review only; prior engagement makes automatic death too aggressive |
healthy |
Observed engagement β₯ 30% | Keep enabled |
low-value |
Any remaining active source below healthy engagement | Review, especially if it also fails consent + self-termination |
The audit also breaks out engaged_but_unwanted: low-value recurring push sources that
have some replies but fail the consent-and-self-termination test. Reply rate alone is
not treated as proof that recurring outreach is wanted.
scripts/proactive/quarantine.py is the conservative actuator. Its default is a dry
run; --apply flips only eligible dead sources to enabled=false through the locked
set_source_enabled() path. It repeats the safety-critical/system-silent guards,
refuses explicitly named ineligible sources with a reason, skips already-disabled
sources idempotently, and never auto-quarantines decaying sources.
# Preview all current dead-class candidates; changes nothing.
python -m scripts.proactive.quarantine
# Apply all eligible candidates, or a reviewed subset.
python -m scripts.proactive.quarantine --apply
python -m scripts.proactive.quarantine --apply --only source_a,source_b
# Inspect and reverse.
python -m scripts.proactive.quarantine --list
python -m scripts.proactive.quarantine --restore source_a
Each apply/restore appends a forensic record to
data/proactive/quarantine-log.jsonl. Quarantine is therefore permanent until the
source is re-enabled, zero-leak, visible in the live enabled flag, and reversibleβunlike
probabilistic dead-trigger suppression, where one in 50 sends still passes.
Response Tracking
File: data/proactive/response_log.jsonl
Tracks three event types per line:
{"id": "20260128-103000-t_abc123", "source": "gap_filler", "sent_at": "...", "telegram_msg_id": 12345, "thread_id": "t_abc123", "responded": false}
{"thread_id": "t_abc123", "responded": true, "response_at": "..."}
{"thread_id": "t_abc123", "timeout": true, "timeout_at": "..."}
Timeout detection runs on the ATO timeout loop (every 15 min). Threads without response after 60 minutes increment the source's consecutive_no_response counter (backoff).
The ThreadCache (LRU, 500 entries) prevents repeated JSONL scans and tracks which threads have already been processed for timeout (preventing double-counting).
Frequency State
File: data/proactive/frequency_state.json
{
"sources": {
"gap_filler": {
"last_sent": "2026-01-28T10:30:00",
"consecutive_no_response": 2,
"enabled": true,
"reschedule_count": 0,
"recent_send_ids": ["20260128-103000-t_abc123"],
"recent_response_ids": []
}
},
"global": {
"category_counts": {
"information_gathering": {"today": 3, "week": 15}
},
"last_daily_reset": "2026-01-28",
"last_weekly_reset": "(2026, 5)"
}
}
Reminders + Ack/Resend
User reminders ("remind me to X") are enqueued with trigger=user_reminder (rate-exempt). The delivery scheduler creates a reminder_acks row; _reminder_ack_loop re-fires unacknowledged reminders when there is fresh conversation activity β but never during quiet hours (REMINDER_QUIET_HOURS_START=22 to REMINDER_QUIET_HOURS_END=8, in the active/travel-aware timezone). Skipped resends don't burn retry_count, so reminders resume after 8am. abandon_maxed_reminders drops reminders that hit max retries.
Tip: The quiet-hours backoff is travel-tz aware β it uses bio-zack's active timezone, not a hardcoded PT. Overnight unacked re-fires skip without incrementing
retry_count(shipped 2026-06-10).
Scheduler Background Loops
The proactive scheduler (scripts/proactive/scheduler.py) starts 6 concurrent async
tasks in start_scheduler() (called from telegram_bot.py post-init). Five live in
the scheduler module; start_funnel() owns the sixth:
| Loop | Constant | Interval | Purpose |
|---|---|---|---|
| Message processor | POLL_INTERVAL |
30s | Poll queue for due messages, build, compose, send |
| ATO timeout checker | TIMEOUT_CHECK_INTERVAL |
15 min | Detect threads without response, increment backoff |
| Goal enrichment | ENRICHMENT_CHECK_INTERVAL |
15 min | Send next enrichment question if due |
| Reminder ack checker | REMINDER_ACK_CHECK_INTERVAL |
15 min | Check for unacknowledged reminders, resend if needed |
| Presence monitor | PRESENCE_CHECK_INTERVAL |
30s | Check if user returned home, fire pending reminders |
| Event funnel | event_funnel.POLL_INTERVAL |
5s | Debounce eligible events per topic, deduplicate fingerprints, and dispatch a context-aware batch through the Telegram request path |
The message processor pulls due messages (get_due_messages), applies suppression,
vacation/timing/meeting gates, runs builders/composer, and sends through Telegram's rich
message path. Successful out-of-session sends are recorded in the topic ledger. Builder
or mechanical-delivery failures get three exponential reschedules (60s, 240s, 960s);
the next failure marks the row failed. The timeout loop warns when any row remains
pending more than one hour. An empty builder result is terminal and is cancelled on the
first evaluation instead of being re-polled every 30 seconds.
Note: Daily reconciliation (
ensure_daily_triggers()at 00:10 PT) is owned by the API-sideVitaScheduler, not this proactive scheduler (see Daily Trigger Reconciliation).
Data Files
| File | Purpose |
|---|---|
data/proactive/queue.db |
SQLite message queue (tables: messages, thread_mapping, reminder_acks, presence_reminders, alert_emissions) |
data/proactive/conversations.jsonl |
All exchanges for /sleep analysis |
data/proactive/frequency_state.json |
ATO per-source frequency + category counters |
data/proactive/burst_state.json |
Burst mode state |
data/proactive/response_log.jsonl |
ATO send/response/timeout tracking |
data/proactive/trigger_suppression_state.json |
Dead-trigger suppression state (CNR per source) |
data/proactive/portfolio-audit.json |
Latest read-only source classification report |
data/proactive/quarantine-log.jsonl |
Append-only quarantine/restore audit trail |
data/feedback/suppression_log.jsonl |
Probabilistic suppression/pass-through/reactivation events |
data/telegram/topic_ledger.jsonl |
Out-of-session per-topic context injected into resident Telegram sessions |
profile/places.json |
Place definitions for presence reminders |
Best Practices
- Always use dedup_key for recurring messages to prevent duplicates
- Use builders for messages that need fresh data at send time
- Keep messages concise - Telegram is for quick interactions
- Track engagement via
/sleepto optimize timing and content - Register sources with ATO to benefit from adaptive frequency
- Use appropriate categories to respect daily limits
- Monitor /proactive status to see backoff levels
- Use composable dicts for messages that should sound natural (not robotic)
- Use
schedule_when_homefor location-triggered reminders instead of time-based guesses - Check
has_activity_since()before resending reminders to avoid spam
Known Limitations
- Failures are log-visible, not user-alerted - Builder/send failures use bounded retry/backoff and stale-queue warnings, but a permanently failed row does not create a separate user-facing alert. Deferred-prompt execution failures are a sharper edge: that branch logs and leaves the row pending for the next scheduler poll rather than consuming the bounded delivery retry counter.
- Composition latency - Voice composition adds a Claude invocation per message (~5-15 seconds). Non-composable messages send instantly.
- The OTHER admission floor is shared - A noisy uncategorized source can consume the four-message ambient budget. Curated HIGH and FAILURE sources have isolated per-trigger budgets, but new important triggers must be explicitly tiered.
- Burst mode is queue-dependent - Burst only works if there are messages already in the queue. No on-the-fly question generation.
- Presence detection delay - Home presence checks run every 30 seconds with a 60s debounce, so reminders may fire shortly after arrival.
- Past-due message flood - If bot is offline for hours, all past-due messages send on restart with only 2-second spacing.
- Portfolio labels use heuristics - Safety, silence, consent, and self-termination
are name-pattern allowlists, while engagement comes from the bounded state retained in
frequency_state.json. New trigger names need deliberate classification review.
Where to Go Next
- Scheduling β the API-side
VitaSchedulerand recurring jobs - Telegram β the bot that hosts the proactive scheduler
- Board β the internal deliberation construct behind
meeting_*triggers - Sleep Guardian β source of
sleep_gate_warning - Voice & Location β the iOS geofence side of presence reminders
- Capture & Commitments β commitment surfacing (now folded into kickoff/reflection)
- Model Guidance β how the composer's agent profiles route to models