Skip to content

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:

  1. Queue reminders - When bio-Zack says "remind me to X" or "ping me about Y", just queue it
  2. Check calendar - Use get_calendar_events() to see meeting end times and schedule around them
  3. Schedule follow-ups - After conversations, queue relevant follow-up messages
  4. 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_events lives at scripts.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-side VitaScheduler (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_concluded refer to the internal Board construct (silicon-zack's deliberation council), not any real-world meeting or named people.

Warning: commitment_reminder and commitment_escalation are retired channels (0% engagement, removed from BUILDERS and ensure_daily_triggers). The builder functions still exist for reference but are no longer routed. Commitment context now surfaces inside morning_kickoff and ceo_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_TRIGGERS gives meeting_brief, meeting_ingest, and meeting_started full per-event bypass. Back-to-back meetings must not coalesce-drop one another. A correctness-critical call site can use the private _skip_admission=True escape hatch for the same effect.
  • ENQUEUE_DENYLIST hard-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_ALLOWED set still delivers genuinely useful outreach;
  • morning_kickoff remains 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_reason rather than disappearing silently;
  • every row with prompt_text is 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_dedup hash the alert payload; identical content within the resend window is dropped.
  • TRIGGER_STATE_RESEND_DEFAULTS gives per-trigger windows out of the box (e.g. rwgps_smoke_fail 4h, vdb_failure 6h, sleep_failure 24h, scheduler_health 4h). Other triggers opt in by passing state_resend_seconds.
  • The alert_emissions table 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:

  1. Outbound messages store: telegram_msg_id -> thread_id in thread_mapping table
  2. Inbound replies look up thread via get_thread_for_reply(reply_to_msg_id)
  3. Fallback: get_recent_thread_id() for non-reply messages within 30 min
  4. Special thread formats:
  5. microgrit-YYYY-MM-DD-am/pm for micro-grit tracking
  6. enrich-* for goal enrichment
  7. home_* 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 invoke should_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

  1. Exponential Backoff: On non-response, wait time increases (15 -> 30 -> 60 -> 120 -> 240 -> 480 min max)
  2. Category Limits: Prevents spam by limiting messages per category per day/week
  3. Avoid Hours: No messages between 10pm-6am PT
  4. Burst Mode: When user responds, rapid Q->A->Q->A without Claude processing (5 min timeout)
  5. Source Registry: Decorator pattern for registering builders with category and priority metadata
  6. Thread Cache: LRU cache (500 entries) for fast thread->source lookups without JSONL scans
  7. 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_proactive dispatches on args[0]/args[1]). There is no special-cased "reset all" path β€” /proactive reset all would just try to reset a source literally named "all". To clear every backoff, call reset_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_SOURCES is 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.py applies 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-side VitaScheduler, 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

  1. Always use dedup_key for recurring messages to prevent duplicates
  2. Use builders for messages that need fresh data at send time
  3. Keep messages concise - Telegram is for quick interactions
  4. Track engagement via /sleep to optimize timing and content
  5. Register sources with ATO to benefit from adaptive frequency
  6. Use appropriate categories to respect daily limits
  7. Monitor /proactive status to see backoff levels
  8. Use composable dicts for messages that should sound natural (not robotic)
  9. Use schedule_when_home for location-triggered reminders instead of time-based guesses
  10. Check has_activity_since() before resending reminders to avoid spam

Known Limitations

  1. 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.
  2. Composition latency - Voice composition adds a Claude invocation per message (~5-15 seconds). Non-composable messages send instantly.
  3. 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.
  4. Burst mode is queue-dependent - Burst only works if there are messages already in the queue. No on-the-fly question generation.
  5. Presence detection delay - Home presence checks run every 30 seconds with a 60s debounce, so reminders may fire shortly after arrival.
  6. Past-due message flood - If bot is offline for hours, all past-due messages send on restart with only 2-second spacing.
  7. 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 VitaScheduler and 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