Skip to content

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:

  1. Scheduled check-ins — User-configured rhythm (morning, evening, bookends, when-needed)
  2. 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.mdUser 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:

  1. 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.
  2. 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.
  3. History retention — Each retake creates a new EnneagramAssessment record. 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_persona or 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 (onMounted hook)
  • 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 (handleContinue function)
  • Trigger: User taps Continue button and has answered at least one question (hasAnyAnswer guard)
  • 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 (handleSkipAll function)
  • 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:

  1. Boot syncionic_frontend/src/posthog.ts:45-46 (setPostHogUser function)

    • Frontend calls setPostHogUser() with hasGoalMotivationProfile parameter
    • Property set from cached goal motivation profile (computed from Rails GraphQL query)
    • Returns boolean based on whether goalMotivationProfile?.completedAt exists
  2. On completionionic_frontend/src/views/welcome/slides/GoalMotivationSnapshotSlide.vue:196

    • Immediately after goal_motivation_snapshot_completed fires
    • Calls posthog.people.set({ has_goal_motivation_profile: true })
  3. Boot-time backfillionic_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()

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):

  1. The interaction computes today's cache key: ai:insight:v1:<user_public_id>:coach-home:<YYYY-MM-DD> (user's local date via user.timezone_or_default).
  2. Cache hit — if the key exists, it returns the cached insight immediately. No LLM call. generated: false.
  3. Cache miss — it calls Ai::CoachService#generate_synopsis to produce a fresh synopsis, writes it to the cache (24-hour TTL), and returns generated: true.
  4. On any error, Ai::Fallbacks::Insight.coach_home is 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:

  1. Re-evaluates User#required_checkins_complete_today? (Phase 114 signal). If still false, the interaction returns immediately — no LLM call, no cache touch.
  2. Reads the existing cache entry for the same DailyInsightPackKey (ai:insight:v1:<user_public_id>:coach-home:<YYYY-MM-DD>). If the entry already has source: 'completion_followup', the interaction returns — idempotent on re-flip.
  3. Calls Ai::CoachService#generate_synopsis(user) to produce a fresh synopsis, then writes it to the same cache key (24-hour TTL) with source: 'completion_followup', overwriting the morning message.
  4. On any error: captures to Sentry, leaves the existing morning message in cache (no fallback write — better UX than overwriting with a generic placeholder).
  5. Fires AiCoaching::DailyTriggerEvent with source: :completion_followup in 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 valueWritten byMeaning
'daily_load'Ai::EnsureTodaysCoachMessageMorning synopsis on first daily load
'completion_followup'Ai::EnsureCompletionFollowUpCoachMessageFollow-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):

RecurrencePeriodAt risk when
dailyTodaydue_today? and local hour ≥ AT_RISK_EVENING_HOUR (18:00)
weekly / custom_daysCurrent ISO weekToday 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:

  1. Selects the single most-at-risk goal (Goal#at_risk?), ranked by least time left in its period, then lowest completion rate.
  2. Checks CoachTriggerDispatch for 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.
  3. Guards against overwriting a same-day completion_followup or at_risk_goal cache entry (it may overwrite a daily_load synopsis) — the same precedence pattern Phase 116 established.
  4. 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).
  5. Writes the coach-home cache entry (source: 'at_risk_goal') and records the CoachTriggerDispatch row.
  6. Fires AiCoaching::DailyTriggerEvent with source: :at_risk_goal in 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 valueWritten byMeaning
'daily_load'Ai::EnsureTodaysCoachMessageMorning synopsis on first daily load
'completion_followup'Ai::EnsureCompletionFollowUpCoachMessageFollow-up after all check-ins logged
'at_risk_goal'Ai::EnsureAtRiskGoalCoachMessageNudge 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)

Loading…