Coach
The AI Coach is an autonomous agent that provides personalized, real-time feedback and encouragement to users. Coach is configured per-user through Meet Coach preferences and optional personality assessment, then injected into the system prompt at runtime.
Overview
Coach is powered by the AI Workforce (agent_runner/) and runs on two main triggers:
- Scheduled check-ins — User-configured rhythm (morning, evening, bookends, when-needed)
- Event-driven — streak breaks, missed days, goal completion, critical life events
Coach response is contextual to the active page: dashboard (brief pulse), goal-detail (focused advice), communities (accountability framing), etc.
See coach-surfaces.md for the full map of every surface where Coach appears, its source (real-AI, client-template, static, push), and fallback strategy.
Configuration & Personalization
Meet Coach Wizard
Users configure Coach through the onboarding and Settings → Personalization. The wizard captures:
- Persona: captain (accountability), spark (inspiration), mirror (reflection), rival (challenge), sage (wisdom), analyst (data-driven)
- Tone: two-axis model (brevity 0–4, warmth 0–4)
- Focus: primary + secondary areas (streaks, milestones, reflection, accountability, energy)
- Rhythm: frequency (morning, evening, bookends, when-needed)
See docs/architecture/data-models.md → User Detail — coaching preferences for the field layout and defaults.
Taking the Enneagram Assessment
Coach can integrate deeper personality insight via an optional Enneagram assessment. The assessment is strictly opt-in and offered as a self-discovery moment, not a requirement.
Assessment Flow
- Discoverability: A dismissible card appears on the dashboard after 7+ days of user activity. Users can also access the assessment from Settings → Personalization at any time.
- Format: 40 forced-choice question pairs (one per screen, auto-advance on selection).
- Resumable: Partial progress persists in localStorage; users can return to finish.
- Result: Dominant type (1–9), wing (adjacent type with higher score), tritype (one type per Enneagram center: body 8/9/1, heart 2/3/4, head 5/6/7).
Result Display
After completion, users see:
- Dominant type + wing prominently
- Short, non-clinical description (e.g., "Type 1: the Reformer — perfection-seeking, principle-driven")
- Full 9-type score breakdown (expandable)
- Option to retake or dismiss
Coach Integration
When a user has a completed assessment:
- System prompt injection — The assessment result (dominant type, wing, and a curated framing) is injected into Coach's system prompt as a "User personality profile" block (~250 tokens). Coach uses this to tailor motivation-based framings without labeling.
- Framings example: A Type 1 receives "I know you hold yourself to high standards; it's OK to be imperfect here" vs. a Type 7 who hears "I know you love novelty; let's finish this before starting the next thing." Framings are authored per type and stored in
app/services/ai/prompts/enneagram_framings.rb. - History retention — Each retake creates a new
EnneagramAssessmentrecord. Settings shows assessment history as a timeline.
No Impact on Core Experience
- Coach quality for users who skip the assessment is unaffected.
- No notifications nudge the assessment.
- Enneagram is additive context only — it does not replace
coach_personaor tone preferences.
Cold-Start Warmup Context (OBJ-2165)
A new user has no habit history and hasn't necessarily set coaching preferences yet — Coach has nothing to personalize against. Two flag-gated surfaces address the first two weeks:
Warmup card (CoachWarmupCard.vue)
Shown once, immediately after the onboarding wizard exits (gated on route.query.source === 'wizard' and the coach-warmup-card PostHog flag, dev override objectuve_coach_warmup_card_override). A 3-question preference card:
- Time of day — Morning / Afternoon / Evening / It varies
- Missed-day response — Gentle nudge / Honest check-in / Just move on / Depends on the day
- Feedback style — Very direct / Balanced / Mostly encouraging / Mix it up
Answers persist via the updateCoachWarmupContext mutation, humanized — the chip label shown ("Gentle nudge") is the exact string stored and later injected into the system prompt, not an enum slug. Storage is UserDetail#coach_warmup_context, a store_accessor on the data jsonb column (rails_api/app/models/user_detail.rb) holding { time_of_day, missed_day_response, feedback_style } — additive, and independent of the sibling coach_* coaching-preference accessors and data['onboarding'].
The card enters the Dashboard's post-wizard interstitial stack at tier 5, deferring to Streak Repair, Welcome Back, and Better Together — see Onboarding § Post-Wizard Interstitials. It renders at most once per session (dismissed on skip or completion).
The success state cannot render without a confirmed write: a failed or errored mutation shows a retry state (role="alert", "Couldn't save that. Tap to retry.") with the selected answer and chip grid still live, never a silent discard.
System prompt injection
Ai::Prompts::Coaching.warmup_block (rails_api/app/services/ai/prompts/coaching.rb, VERSION 7) renders coach_warmup_context, when present, as a ## Coach warmup context block appended after the existing ## Coach preferences block (preferences_block) — preferences first, warmup second, each independently omitted when its data is blank. A UserDetail with neither produces a prompt byte-identical to the pre-OBJ-2165 output.
Data-thin strip (CoachDataThinStrip.vue)
A dismissible, no-flag notice — "Your Coach is still getting to know you — it gets sharper after a week or two." — mounted once in CoachChatPanel.vue, pinned above the scroll region so it isn't scrolled past by the panel's own scrollToBottom() calls. Covers both AiCoachModal and the full-page /coach view (see coach-surfaces.md) from that single mount point.
Visible while signupAgeDays < 14; disappears permanently once the user crosses that boundary, and dismisses per-browser-session (sessionStorage) before then. Resolves signupAgeDays itself from USER_QUERY rather than through useAiCoach().context, since views/Coach.vue never calls setContext() — gating through the coach context would have hidden the strip on a direct /coach link.
Goal Motivation Snapshot Telemetry
Coach uses Goal Motivation Snapshot data to tailor recommendations and track onboarding engagement. Three PostHog events capture the assessment lifecycle, and one user property tracks completion status.
Events
goal_motivation_snapshot_shown — Onboarding slide mounts (Phase 93 T1)
- Fire site:
ionic_frontend/src/views/welcome/slides/GoalMotivationSnapshotSlide.vue:45(onMountedhook) - Trigger: Slide component mounts in the onboarding flow
- Properties:
cohort(string): Always'flagged'— marks users who see the slide as onboarding flagged cohort
- Condition: Fires only in production mode (
import.meta.env.MODE !== 'development')
goal_motivation_snapshot_completed — Assessment submitted with non-empty answers (Phase 93 T1)
- Fire site:
ionic_frontend/src/views/welcome/slides/GoalMotivationSnapshotSlide.vue:195(handleContinuefunction) - Trigger: User taps Continue button and has answered at least one question (
hasAnyAnswerguard) - Properties: None (empty payload)
- Condition: Fires only in production mode; also calls
posthog.people.set({ has_goal_motivation_profile: true })immediately after (line 196)
goal_motivation_snapshot_skipped — Entire slide skipped without answering (Phase 93 T1)
- Fire site:
ionic_frontend/src/views/welcome/slides/GoalMotivationSnapshotSlide.vue:216(handleSkipAllfunction) - Trigger: User taps "Skip all" button to dismiss all four questions without providing answers
- Properties: None (empty payload)
- Condition: Fires only in production mode
User Property: has_goal_motivation_profile
Tracks whether a user has completed the Goal Motivation Snapshot assessment.
Population paths:
Boot sync —
ionic_frontend/src/posthog.ts:45-46(setPostHogUser function)- Frontend calls
setPostHogUser()withhasGoalMotivationProfileparameter - Property set from cached goal motivation profile (computed from Rails GraphQL query)
- Returns boolean based on whether
goalMotivationProfile?.completedAtexists
- Frontend calls
On completion —
ionic_frontend/src/views/welcome/slides/GoalMotivationSnapshotSlide.vue:196- Immediately after
goal_motivation_snapshot_completedfires - Calls
posthog.people.set({ has_goal_motivation_profile: true })
- Immediately after
Boot-time backfill —
ionic_frontend/src/components/SideMenu.vue(watcher on USER_QUERY resolve)- On returning user's first page load, watches
latestGoalMotivationProfile - Syncs latest state to PostHog via
posthog.people.set()
- On returning user's first page load, watches
Type: boolean
Lifecycle: Once true, always true (reflects whether user has ever completed the assessment; never reset to false even if assessments are deleted)
Daily-load trigger (Phase 115)
Starting with Phase 115 (v1.23), the coach-home synopsis is generated on-demand on the user's first authenticated Coach view each local day, instead of relying solely on the 06:00 UTC pre-bake.
How it works
When CoachSynopsisCard.vue mounts, it fires the ensureTodaysCoachMessage GraphQL mutation before calling fetchInsight(). The mutation delegates to the Ai::EnsureTodaysCoachMessage interaction (rails_api/app/interactions/ai/ensure_todays_coach_message.rb):
- The interaction computes today's cache key:
ai:insight:v1:<user_public_id>:coach-home:<YYYY-MM-DD>(user's local date viauser.timezone_or_default). - Cache hit — if the key exists, it returns the cached insight immediately. No LLM call.
generated: false. - Cache miss — it calls
Ai::CoachService#generate_synopsisto produce a fresh synopsis, writes it to the cache (24-hour TTL), and returnsgenerated: true. - On any error,
Ai::Fallbacks::Insight.coach_homeis returned and the error is captured in Sentry.
In all paths the interaction fires AiCoaching::DailyTriggerEvent with source: :daily_load for PostHog telemetry.
Frontend loading state
CoachSynopsisCard.vue adds a mutationPending ref and an effectiveLoading = computed(() => mutationPending.value || loading.value) computed. All three template branches (insight / skeleton / empty state) gate on effectiveLoading, so the skeleton renders immediately on mount while the mutation is in-flight — no empty-state flash.
Idempotency
Calling ensureTodaysCoachMessage twice on the same user-local day is safe: the second call returns the cached result without a second LLM call. The frontend fires the mutation on every mount (including page revisits), but the backend no-ops on cache hit.
Phase 117 update
The 06:00 UTC coach-home pre-bake was retired in Phase 117 (v3.12.5). The coach-home surface is now fully event-driven: AiCoaching::GenerateDailyInsightPackJob continues to pre-bake the other five pages (dashboard, goals, achievements, communities, goal-detail), but coach-home is no longer in its PAGES constant. All coach-home cache writes now come exclusively from the two event triggers documented above.
Completion follow-up trigger (Phase 116)
Starting with Phase 116 (v1.23), once a user completes all required check-ins for the day, a background job overwrites the coach-home cache entry with a fresh synopsis that reflects their completed day.
How it works
When any goal_tracking.habit_checked_in event fires, Ai::Subscribers (registered at boot via Ai::Subscribers.register) enqueues AiCoaching::GenerateFollowUpCoachMessageJob. The subscriber call is wrapped in a rescue boundary — check-in mutation latency is unaffected if the enqueue fails.
The job calls Ai::EnsureCompletionFollowUpCoachMessage.call(user: user) (rails_api/app/interactions/ai/ensure_completion_follow_up_coach_message.rb), which:
- Re-evaluates
User#required_checkins_complete_today?(Phase 114 signal). If stillfalse, the interaction returns immediately — no LLM call, no cache touch. - Reads the existing cache entry for the same
DailyInsightPackKey(ai:insight:v1:<user_public_id>:coach-home:<YYYY-MM-DD>). If the entry already hassource: 'completion_followup', the interaction returns — idempotent on re-flip. - Calls
Ai::CoachService#generate_synopsis(user)to produce a fresh synopsis, then writes it to the same cache key (24-hour TTL) withsource: 'completion_followup', overwriting the morning message. - On any error: captures to Sentry, leaves the existing morning message in cache (no fallback write — better UX than overwriting with a generic placeholder).
- Fires
AiCoaching::DailyTriggerEventwithsource: :completion_followupin all paths (PostHog event:ai_coaching.daily_trigger).
Frontend pickup
No frontend change was required. The next time the user opens the Coach surface, CoachSynopsisCard.vue onMounted re-fires the Phase 115 ensureTodaysCoachMessage mutation (cache hit → returns the follow-up entry the job wrote) and then fetchInsight() reads the follow-up copy.
Cache value shape (Phase 116 addition)
Phase 116 added a source discriminator field to the cache value written by both interactions:
source value | Written by | Meaning |
|---|---|---|
'daily_load' | Ai::EnsureTodaysCoachMessage | Morning synopsis on first daily load |
'completion_followup' | Ai::EnsureCompletionFollowUpCoachMessage | Follow-up after all check-ins logged |
The follow-up interaction uses this field to stay idempotent: if a re-flip event arrives after the follow-up has already been written (e.g. user unchecks then rechecks the last habit), no second LLM call is made.
Idempotency
The follow-up fires once per user-local day, on the false → true transition only. Both parallel check-in events and user retaps are safe: the source: 'completion_followup' guard in the cache value prevents double-generation.
At-risk-goal nudge (Phase 117)
Starting with Phase 117 (OBJ-1099), Coach gets a third proactive trigger: a once-per-goal-per-period nudge when a recurring (habit) goal is measurably behind its own cadence with little time left in the current period.
Recurrence-aware "at risk" definition
"At risk" and "period" are both defined per recurrence_type, via the new AtRiskCadence concern (rails_api/app/models/concerns/at_risk_cadence.rb, mixed into Goal):
| Recurrence | Period | At risk when |
|---|---|---|
daily | Today | due_today? and local hour ≥ AT_RISK_EVENING_HOUR (18:00) |
weekly / custom_days | Current ISO week | Today is the last expected weekday in recurrence_days, not yet checked in today, and an earlier expected day this week was missed |
interval | [anchor, anchor + recurrence_interval], anchor = last check-in date (or created_at if never checked in) | Period ends within 1 day and the goal hasn't been checked in since the period began |
Each goal's stable period_key (e.g. 2026-07-08 for daily, 2026-W28 for weekly/custom_days, the period-end date for interval) is the dedup key described below.
How it works
Driven by an hourly Crono sweep (AiCoaching::GenerateAtRiskCoachMessageJob, rails_api/config/cronotab.rb) rather than a domain-event subscriber — "little time left in the period" is itself time-based, so no check-in event can drive it. At each user's local AT_RISK_EVENING_HOUR, the job calls Ai::EnsureAtRiskGoalCoachMessage.call(user:) (skipping users on a when_needed rhythm or with no active habit goals), which:
- Selects the single most-at-risk goal (
Goal#at_risk?), ranked by least time left in its period, then lowest completion rate. - Checks
CoachTriggerDispatchfor an existing(goal_id, trigger_kind: 'at_risk_goal', period_key)row — a durable, DB-unique-indexed dedup table (not cache-only, since a weekly/interval period must survive a Redis flush). Bails if one already exists. - Guards against overwriting a same-day
completion_followuporat_risk_goalcache entry (it may overwrite adaily_loadsynopsis) — the same precedence pattern Phase 116 established. - Generates copy via
Ai::CoachService#generate_at_risk_nudge, naming the goal once, noting the time left without urgency/scarcity/streak-loss language, and ending with one small next action (Ai::Prompts::AtRisk). - Writes the
coach-homecache entry (source: 'at_risk_goal') and records theCoachTriggerDispatchrow. - Fires
AiCoaching::DailyTriggerEventwithsource: :at_risk_goalin all paths.
On any error, the existing cache entry is left untouched (fail-open, same as Phase 116) and the error is captured in Sentry.
Cache value shape (Phase 117 addition)
The source discriminator from Phase 116 gains a third value:
source value | Written by | Meaning |
|---|---|---|
'daily_load' | Ai::EnsureTodaysCoachMessage | Morning synopsis on first daily load |
'completion_followup' | Ai::EnsureCompletionFollowUpCoachMessage | Follow-up after all check-ins logged |
'at_risk_goal' | Ai::EnsureAtRiskGoalCoachMessage | Nudge for a habit goal falling behind its own cadence |
Idempotency
Dedup here is durable rather than cache-only: CoachTriggerDispatch's unique index on (goal_id, trigger_kind, period_key) guarantees at most one nudge per goal per period even if the coach-home cache is flushed mid-period. The hourly sweep re-evaluates every eligible user every hour, but a goal that already has a dispatch row for its current period_key is skipped.
Known gap
A weekly/custom_days habit with exactly one expected day per week can never satisfy "an earlier expected day this week was missed" — its only due day has no earlier day to compare against — so it can never trigger the nudge. This is a faithful implementation of the definition above, not a bug; tracked as a follow-up if product wants once-a-week habits covered too (OBJ-1202).
Last updated: 2026-08-07 (OBJ-2165: documented the cold-start warmup card, data-thin strip, and Ai::Prompts::Coaching's VERSION 6 → 7 warmup_block)
Previous update — 2026-07-08 (Phase 117: deprecated the coach-home pre-bake; documented the at-risk-goal nudge trigger, AtRiskCadence concern, and the at_risk_goal cache source value)