Coaching System
Vita's coaching system generates contextual, identity-grounded coaching messages for active goals. It maps each goal to relevant identity data (observations, quotes), determines the coaching context (progress, stagnation, setback, etc.), and delivers nudges via Telegram. The system uses bio-zack's own words and philosophy rather than generic motivation.
A second, separate module under scripts/coaching/ handles communication-pattern coaching β it parses processed meeting transcripts, computes behavioral metrics, and queues a qualitative debrief. That module is documented in its own section near the end.
Status (dormant): The proactive goal-coaching loop is currently not running. The
coaching-nudgesentinel that drove it is disabled ("enabled": falseindata/sentinel/sentinels.json); its state shows it auto-disabled after 5 consecutive errors, last successful trigger 2026-04-22,trigger_count130. The goal-coaching data files (data/coaching/cooldowns.json,effectiveness.jsonl,goal-identity-map.json) have been frozen since ~March 2026. Treat the goal-coaching subsystem below as reference architecture β the engine is intact and re-enableable, but it is not delivering nudges today. The/coachslash command still works on demand, and the communication-coaching module (a different pipeline) is still active.
Why This Design?
-
Identity-grounded, not generic - Coaching messages draw from actual observations and quotes captured during identity interviews. When the system references a stated belief, it's quoting a real statement, not inventing motivation.
-
Cooldown-protected - A 12-hour cooldown per goal prevents nagging. If a goal was coached at 10am, it won't be coached again until at least 10pm.
-
Batched delivery - Instead of spamming one message per stagnating goal, the system consolidates multiple goals into a single Telegram message composed in natural voice.
-
LLM with template fallback - The primary path uses Claude to generate Socratic, direct coaching messages. If the API fails, template-based messages fill in seamlessly.
Architecture
+--------------------+ +--------------------+ +--------------------+
| Identity Data | | Goal Files | | Occurrence Data |
| observations.jsonl | | goals/active/*.json| | occurrences-*.jsonl|
| quotes.jsonl | | | | |
+--------+-----------+ +--------+-----------+ +--------+-----------+
| | |
v v v
+--------+-----------+ +--------+-----------+ +--------+-----------+
| identity_loader.py | | determine_context()| | _load_occurrence_ |
| load observations | | classify goal state| | stats() |
| load quotes | | | | last_occurrence |
+--------+-----------+ +--------+-----------+ | occurrences_7d |
| | +--------+-----------+
v | |
+--------+-----------+ | |
| mapper.py | | |
| keyword extraction |<-------------+--------------------------+
| relevance scoring |
| cached mapping |
+--------+-----------+
|
v
+--------+-----------+
| generator.py |
| LLM generation |
| template fallback |
+--------+-----------+
|
v
+--------+-----------+ +--------------------+
| proactive.py |---->| Telegram Queue |
| check_stagnating | | (proactive system) |
| queue_coaching_batch| | coaching.composer |
+--------+-----------+ +--------+-----------+
| |
v v
+--------+-----------+ +--------+-----------+
| effectiveness.py | | Bio-zack receives |
| log events | | composed message |
| manage cooldowns | | via Telegram |
+--------+-----------+ +--------------------+
Context Types
The system classifies each goal into one of five coaching contexts based on progress data:
| Context | Trigger | Message Style |
|---|---|---|
progress |
Recent occurrences within expected frequency | Reinforcement, acknowledge momentum |
stagnation |
No progress for 2x expected interval | Socratic challenge using own words |
setback |
Declining trend in occurrences | Reframe difficulty as growth |
celebration |
Goal completed or target hit | Acknowledge achievement |
new |
Goal created within last 7 days, no occurrences | Ask what success looks like |
Context Determination Logic
def determine_context(goal, progress_data):
# 1. Completed? -> celebration
# 2. No occurrences + new goal (<7 days) -> new
# 3. No occurrences + old goal -> stagnation
# 4. Days since last > 2x expected gap -> stagnation
# 5. Trend == declining -> setback
# 6. Default -> progress
Expected gap is derived from goal frequency (in generator.py):
- Weekly goals: 7 // times_per_week (e.g., 2x/week = 3-day expected gap)
- Interval goals: interval_days from frequency config
- Default: 7 days
Message Templates
Templates are organized by whether they incorporate a quote from identity data. All templates below match generator.py verbatim.
With Quote (50% chance when quotes available)
The template path rolls rng.random() < 0.5 to decide whether to use a quote-bearing template (when relevant quotes exist).
Stagnation:
- You said: "{quote}" - {days} days without progress on {goal_title}. What would regret-minimizing Zack do?
Progress:
- Consistent with {goal_title}. The "{quote}" trajectory.
- {occurrences} this week on {goal_title}. You mentioned: "{quote}"
Without Quote
Stagnation:
- The hard middle - where you said the most joy lives. Still in it with {goal_title}?
- {days} days. You've said failure is a choice. Is {goal_title} still chosen?
Progress:
- Progress on {goal_title}. Eyes wide open - that's the mode.
Setback:
- Setback on {goal_title}. You said difficulty means growth. Where's the growth here?
- {goal_title} slipped. Failure is a choice, not an event - worth reclaiming?
Celebration:
- Hit your {goal_title} target. The direction you wanted.
- {goal_title} complete. What you said about progress over destination - this is that.
New:
- New goal: {goal_title}. What does success look like?
LLM Generation
When available, the system uses Claude (default claude-sonnet-4-6) to generate messages that are more contextual than templates. See Model Guidance for why sonnet-4-6 is the balanced default for proactive composers even though the frontier models are claude-opus-4-8 and claude-fable-5. The LLM prompt includes:
- Goal title, purpose, and hypothesis
- Current context (stagnation, progress, etc.)
- Up to 3 relevant identity observations
- Up to 3 relevant quotes
LLM rules (quoted verbatim from the prompt in generate_coaching_message_llm()):
- Use their own words/philosophy, don't invent new motivation
- Be direct, not flowery
- One Socratic question is good
- No exclamation points or "you can do it" energy
- Sound like them talking to themselves
The smart generator (generate_coaching_message_smart) tries LLM first, falls back to templates on failure. LLM can be disabled with COACHING_DISABLE_LLM=1 (accepts 1/true/yes).
Goal-Identity Mapping
The mapper (scripts/coaching/mapper.py) connects each goal to relevant identity data through keyword matching and caching.
How Mapping Works
- Extract keywords from goal metadata (title, domain, tags, purpose)
- Score observations by counting keyword matches in observation text (top 5 kept)
- Score quotes with weighted matching: tag match (2x), theme match (1x), quote text match (0.5x) (top 5 kept)
- Fallback quotes from universal themes (
regret,progress,discipline,growth) when no goal-specific matches found - Cache result in
data/coaching/goal-identity-map.jsonwith a 24-hour TTL (CACHE_TTL_HOURS = 24)
Identity Coverage Checking
The mapper also detects gaps in identity data relevant to a goal. DOMAIN_GOAL_MAP maps a goal-keyword to the identity domains that goal draws on:
DOMAIN_GOAL_MAP = {
"training": ["values_beliefs", "self_knowledge"],
"skateboard": ["creativity_passions", "self_knowledge"],
"body": ["self_knowledge", "values_beliefs"],
"date": ["relationships"],
"<family-member>": ["relationships", "legacy_mortality"], # real key is a name (PII-scrubbed here)
"family": ["relationships", "childhood_family"],
"work": ["work_purpose"],
"claude": ["work_purpose", "creativity_passions"],
}
Note: One real key in the source code is a family member's first name and is redacted above as
<family-member>. If you regenerate this page from source, scrub that key β do not copy the raw value.
If a goal's relevant identity domains have coverage below the threshold (LOW_THRESHOLD = 0.4), suggest_interview_domain() recommends which domain to explore next in an identity interview.
Identity Data Sources
The identity loader (scripts/coaching/identity_loader.py) reads from:
- data/identity/observations.jsonl - Personality, value, and preference observations
- data/identity/quotes.jsonl - Memorable quotes with tags and themes
Only observations of type value, personality, or preference are used for coaching (filtered by get_coaching_observations()). See Identity for how this data is captured.
Effectiveness Tracking
Every coaching message is logged to data/coaching/effectiveness.jsonl. Example (sanitized placeholders):
{
"event_id": "goal-id-20260110180000",
"ts": "2026-01-10T18:00:00.029319+00:00",
"goal_id": "<goal-uuid>",
"message": "Batched nudge with 2 goals",
"context": "stagnation",
"source": "proactive_batch",
"template_used": null,
"quote_used": null
}
Fields
| Field | Purpose |
|---|---|
event_id |
{goal_id}-{timestamp} for dedup |
ts |
UTC timestamp |
goal_id |
Which goal was coached |
message |
Truncated message text (max 200 chars, via _safe_truncate) |
context |
One of: progress, stagnation, setback, celebration, new |
source |
One of: manual, checkin, proactive, proactive_batch, test |
template_used |
Which template was selected (if template path) |
quote_used |
Which quote was included (truncated to 50 chars) |
Stats
from scripts.coaching.effectiveness import get_effectiveness_stats
stats = get_effectiveness_stats(days=30)
# {
# "total": 42,
# "by_context": {"stagnation": 38, "progress": 4},
# "by_source": {"proactive_batch": 40, "manual": 2}
# }
Cooldown Mechanism
Each goal has an independent 12-hour cooldown to prevent over-messaging.
How it works:
1. When a coaching message is sent, log_coaching_event() writes the current timestamp to data/coaching/cooldowns.json
2. Before coaching a goal, is_on_cooldown(goal_id) checks if fewer than 12 hours have passed
3. Goals on cooldown are skipped during proactive coaching runs
Cooldowns file (data/coaching/cooldowns.json), sanitized:
{
"<goal-uuid-1>": "2026-02-24T04:20:19.204171+00:00",
"<goal-uuid-2>": "2026-02-24T16:02:18.724346+00:00"
}
Manual reset: clear_cooldown(goal_id) removes a goal's cooldown entry.
The cooldown constant is COOLDOWN_HOURS = 12, defined in scripts/coaching/effectiveness.py.
Proactive Coaching Integration
Reminder: This loop is currently dormant (see the status banner at the top). The mechanism below is accurate but not firing.
Scheduling
Proactive coaching was migrated from a direct scheduler loop to a sentinel detector/responder pair, driven by the coaching-nudge sentinel (now disabled):
- Detector:
scripts/sentinel/checks/stagnating_goals.py- Runscheck_stagnating_goals(stagnation_days=7)and outputs sorted goal IDs for hash comparison - Responder:
scripts/sentinel/checks/run_coaching.py- Callsrun_proactive_coaching()(fromscripts/coaching/proactive.py) to queue nudges
The legacy scheduler loop (_proactive_coaching_loop, designed to run at COACHING_HOUR, default 10am PT) still exists as a method in api/src/scheduler.py, but it is not registered in the scheduler's task list (self._tasks), so it never starts. (Note: that dormant loop's manual trigger run_proactive_coaching_now() routes through a separate followup implementation in api/src/services/scheduler_followups.py, not the scripts/coaching/ path.)
The supported invocation paths today are:
- The sentinel responder scripts/sentinel/checks/run_coaching.py (when the coaching-nudge sentinel is enabled)
- Running python scripts/coaching/proactive.py directly (CLI, for manual/testing use)
- from scripts.coaching.proactive import run_proactive_coaching in Python
See Sentinel for the detector/responder pattern and the auto-disable-at-5-errors behavior that took this sentinel offline.
Proactive Run Flow
run_proactive_coaching()
-> check_stagnating_goals(stagnation_days=7)
-> load all goals from goals/active/
-> load occurrence stats from occurrences-*.jsonl
-> for each goal: skip if on cooldown
-> determine_context() for each
-> collect goals where context == "stagnation" and days >= 7
-> sort by staleness (most stagnant first)
-> take top MAX_NUDGES_PER_RUN (default 2, env COACHING_MAX_NUDGES)
-> queue_coaching_batch()
-> get identity mappings for each goal
-> enqueue single message with builder_key="coaching_batch"
-> log coaching event for each goal (updates cooldown)
Return shape:
{"checked": 9, "stagnating": 2, "nudged": 2, "nudged_goals": ["Goal A", "Goal B"]}
Telegram Delivery
The queued message flows through the proactive outreach pipeline (see Proactive):
-
Builder (
build_coaching_batchinscripts/proactive/builders.py): Registered as an ATO source in thereflectioncategory. Returns a composable dict with goal data (orNoneif nothing to send). -
Composer (
scripts/proactive/composer.py): Unlike most message types (which use the lightweightsimpleagent),coaching_batchis composed by a dedicated tool-enabled agent namedcoaching.composer. Because stagnation detection keys on specific sub-components (e.g. a sub-component going stale even when the parent goal has activity), the composer is instructed to verify the stagnation claims against primary source before composing β it readsdata/scheduling/occurrences-*.jsonl(andgoals/active/*.jsonif needed) to confirmdays_stagnantmatches reality, then weaves the goals into a single conversational nudge ending in one reflective question. This avoids sending a "you've stalled" nudge that the occurrence data contradicts. -
Telegram: Delivered as a single message to bio-zack's Telegram chat.
ATO Integration
The coaching batch builder is registered with Adaptive Telegram Outreach via @register_message_source in scripts/proactive/builders.py:
| Property | Value |
|---|---|
| Source name | coaching_batch |
| Category | reflection |
| Priority | 0.7 |
| Max per week | 7 |
| Min hours between | 20 |
This means coaching nudges respect quiet hours, category limits, and backoff if bio-zack doesn't respond.
Dead-Trigger Suppression
Beyond ATO category limits, coaching_batch is also listed in ENGAGEMENT_SOURCES in scripts/proactive/trigger_suppression.py β the set of response-expecting sources eligible for dead-trigger suppression. If engagement (the response rate) drops to zero, the source is suppressed even when ATO would otherwise allow it. coaching_batch was at one point promoted to the never-suppress ALWAYS_SEND set, but was moved back to normal suppression (historical note: CNR=7) during an engagement-collapse tuning pass. See Proactive for the suppression and CNR (consecutive-no-response) mechanics.
Python API
Quick Reference
# Generate a coaching message for a goal
from scripts.coaching import (
generate_coaching_message_smart,
determine_context,
get_or_create_mapping,
is_on_cooldown,
log_coaching_event,
get_effectiveness_stats,
clear_cooldown,
find_relevant_observations,
find_relevant_quotes,
invalidate_cache,
check_identity_coverage,
suggest_interview_domain,
)
# proactive.py is NOT re-exported from the package (circular-import guard):
from scripts.coaching.proactive import run_proactive_coaching
# Check context
context, ctx_vars = determine_context(goal, progress_data)
# Get identity mapping (cached 24h)
mapping = get_or_create_mapping(goal_id, goal)
# Generate message (LLM first, template fallback)
message = generate_coaching_message_smart(
goal, context, ctx_vars,
mapping["observations"], mapping["quotes"]
)
# Run proactive coaching manually
result = run_proactive_coaching()
# {"checked": 9, "stagnating": 2, "nudged": 2, "nudged_goals": ["Goal A", "Goal B"]}
CLI / Slash Command
/coach [goal-name] # coach one goal by name (resolves via scripts.scheduling.goals.resolve_goal)
/coach # coach all goals with drift, skipping any on cooldown
The /coach skill loads goal progress from memory/views/goal-progress.json, checks the 12h cooldown, calls the coaching engine, and logs the event. This on-demand path works regardless of whether the proactive loop is running.
Environment Variables
| Variable | Default | Purpose | Defined in |
|---|---|---|---|
COACHING_LLM_MODEL |
claude-sonnet-4-6 |
Model for LLM-generated messages | scripts/coaching/generator.py |
COACHING_DISABLE_LLM |
(unset) | Set to 1/true/yes to force template-only |
scripts/coaching/generator.py |
COACHING_MAX_NUDGES |
2 |
Max goals per proactive run (MAX_NUDGES_PER_RUN) |
scripts/coaching/proactive.py |
COACHING_HOUR |
10 |
Hour (PT) for the dormant legacy scheduler loop only | api/src/scheduler.py |
Note:
COACHING_HOURstill exists in code but only configures_proactive_coaching_loop, which is not started β so it currently has no effect.
Communication-Pattern Coaching (separate module)
A distinct pipeline under scripts/coaching/ coaches how bio-zack communicates in meetings, independent of the goal-coaching engine above. Unlike goal coaching, this module is still active (data/coaching/communication_metrics.jsonl updates with each processed meeting).
It runs automatically after a meeting transcript is ingested and processed:
- Analyze (
scripts/coaching/communication.py) β parses the transcript and computes behavioral metrics for bio-zack's turns versus baselines from prior good meetings: - Talk ratio (share of total words)
- Monologue count β segments longer than 150 words (~2 minutes of speech), unless they're interactive (β₯2 questions per 150 words exempts them)
- Framework-lecture count β monologues containing structural language (framework / structure / approach / process / pipeline)
- Question rate β questions per 1000 words, excluding rhetorical
- Max listening run β longest stretch where bio-zack doesn't speak
- Log (
log_meeting_metrics) β appends the metrics todata/coaching/communication_metrics.jsonlfor trend tracking. Baselines live indata/coaching/communication_baselines.json. - Debrief (
scripts/coaching/post_meeting_debrief.py,queue_communication_debrief) β compares the meeting against baselines, collects the flagged segments, and queues a Claude deferred prompt (enqueue_prompt) that reads the transcript and writes a qualitative, identity-grounded communication debrief, deduped per meeting. The debrief is delivered to the Meetings Telegram topic (or, as a fallback, viaenqueue_messagewithtrigger='communication_debrief').
Scope: The metrics and baselines are derived from real meetings. This page documents the mechanism only β meeting content is never surfaced here. The
communication_debrieftrigger is itself anENGAGEMENT_SOURCEsubject to dead-trigger suppression.
Data Structures and File Locations
| File | Purpose |
|---|---|
scripts/coaching/__init__.py |
Module exports (goal coaching) |
scripts/coaching/generator.py |
Template and LLM message generation |
scripts/coaching/mapper.py |
Goal-to-identity keyword mapping with cache |
scripts/coaching/effectiveness.py |
Event logging, cooldowns, stats |
scripts/coaching/proactive.py |
Proactive coaching run logic, batching |
scripts/coaching/identity_loader.py |
Load and filter observations/quotes |
scripts/coaching/communication.py |
Meeting communication-metric analysis |
scripts/coaching/post_meeting_debrief.py |
Post-meeting debrief, deferred-prompt queueing |
scripts/sentinel/checks/stagnating_goals.py |
Sentinel detector for stagnating goals |
scripts/sentinel/checks/run_coaching.py |
Sentinel responder that runs coaching |
scripts/proactive/builders.py |
build_coaching_batch ATO builder |
scripts/proactive/composer.py |
coaching.composer voice composition agent |
scripts/proactive/trigger_suppression.py |
Dead-trigger suppression eligibility |
data/coaching/cooldowns.json |
Per-goal cooldown timestamps (frozen) |
data/coaching/effectiveness.jsonl |
Coaching event log (frozen) |
data/coaching/goal-identity-map.json |
Cached goal-to-identity mappings (frozen) |
data/coaching/communication_baselines.json |
Communication-metric baselines |
data/coaching/communication_metrics.jsonl |
Communication-metric trend log (active) |
data/identity/observations.jsonl |
Source identity observations |
data/identity/quotes.jsonl |
Source identity quotes |
data/sentinel/sentinels.json |
coaching-nudge sentinel config (enabled: false) |
api/src/scheduler.py |
Dormant _proactive_coaching_loop + COACHING_HOUR |
Known Limitations
-
Currently dormant - The proactive goal-coaching loop is offline because its sentinel auto-disabled after repeated errors. Re-enabling requires fixing the responder error and flipping
enabledback to true indata/sentinel/sentinels.json. The/coachcommand and communication-coaching module remain functional. -
No response-based effectiveness - The system logs that messages were sent but doesn't track whether bio-zack responded or took action. Effectiveness stats are volume-only, not outcome-based.
-
Stagnation dominates - In practice, most coaching messages were stagnation nudges for the same set of goals. Progress and celebration contexts rarely trigger because goals either have occurrences (no coaching needed) or don't (stagnation).
-
Mapping is keyword-only - The goal-to-identity mapping uses simple keyword matching. A goal won't match a semantically related observation unless overlapping words appear.
-
Universal themes are static - The fallback quote themes (
regret,progress,discipline,growth) are hardcoded. As identity data grows, these may become less representative. -
Cooldown is per-goal, not per-message - If two different coaching pathways (manual + proactive) try to coach the same goal, the first one blocks the second via cooldown. There's no global "don't send more than N coaching messages today" limit beyond ATO category limits and dead-trigger suppression.
Where to go next
- Sentinel β the detector/responder framework and auto-disable behavior that gates proactive coaching
- Proactive β the outreach queue, ATO categories, voice composition, and dead-trigger suppression
- Goals β goal files, frequency config, and occurrence tracking that feed
determine_context() - Identity β how observations and quotes are captured
- Model Guidance β model routing for composers and why
sonnet-4-6is the proactive default