PRD: Richer, More Personable Coach Responses
Product: Objectuve (codename: Enkidu) Feature: Depth-aware reply length, deeper personalization, persisted conversation memory, and rich response rendering for the consumer Coach Status: Approved / Ready for planning Date: June 2026 Author: Josh Lockhart
1. Context — why this change
Relationship to prior work
This PRD builds on the Coach UDG PRD (docs/product/coach-udg-prd.md, status: Proposed), which established server-side context assembly — goals, habits, and recent activity — as the foundation for richer coaching. The UDG PRD's architecture (upfront injection via BuildCoachContext, token-budgeted context block) is the platform this work extends.
This PRD deliberately revisits three decisions the UDG PRD deferred or left in place:
- The 2-sentence cap — UDG explicitly preserves it ("No change to the 2-sentence response cap"). We remove it, replacing it with a depth-gated length budget tied to the user's
coach_depthslider (see Workstream A). - Mood logs — UDG excludes them pending a privacy decision. We make that decision here: include, scoped to the user's own coach (see §6 and Workstream B).
- Gamification — UDG deems badge/rank details "not relevant to coaching advice unless the user asks." We include them as a light, occasional acknowledgement — not a focus area — when the coach naturally references the user's progress (see §6 and Workstream B).
Once this PRD is greenlit, the UDG PRD's "Excluded (Phase 1)" list should be updated: mood logs and gamification move from Excluded to Included under this PRD's scope.
Coach is Objectuve's signature surface, but live replies feel bare compared to the copy users are shown during onboarding. The onboarding wizard (MeetCoach.vue:598-625) and coachPersonaDetails.ts:12-54 set a high bar — persona-true, references specific streak numbers, action-forward, textured:
Captain: "Twelve days of practice — that's a real signal, not a coincidence. Two days off changes the count, not the capacity. Pick today's session and we move."
Live Coach can't reach that bar today for three structural reasons:
- A hard
2 sentences maxcap appears in bothcoaching.rb:12andbase.rb:94, flattening every reply and silently overriding the user's own Brief→Detailed depth slider (collected in onboarding, stored ascoach_depth1–5, never used in prompts). - No conversation memory. Each
getAdvicecall sends only the current message as a single user-role turn (coach_service.rb:80-92). The coach can't follow a thread or remember the user across days. Frontend chat history is local-only and cleared on context change. - Underused user data.
BuildCoachContextalready loadsGoalEvents but only counts them (build_coach_context.rb:33,64) — the journal text is never read. Mood (MoodLog), gamification (User.xp/level/longest_streak, ranks, badges), and goalcategoryare never passed. The frontend renders replies as plain<p>text only (CoachChatPanel.vue:41).
The machinery is actually sophisticated (per-persona "soul" .md files, tone/warmth/focus instruction maps, enneagram framing) — the gap is what data we select, how long we let replies be, whether the coach remembers, and how richly the UI renders the reply.
Intended outcome
Live Coach replies match the onboarding gold standard: persona-true, grounded in the user's real streaks/mood/journal/progress, length-appropriate to their depth setting, conversational across turns, and rendered with light emphasis plus tappable next-step actions.
Decisions locked in this session
- Length: Honor the depth slider — terse default, but the Brief→Detailed setting unlocks longer multi-part coaching. Remove the hard 2-sentence cap.
- Personalization: Weave in all four underused sources — mood logs, gamification, journal-entry text, goal categories.
- Memory: Persist across sessions — a lightweight message store so the coach recalls past chats and prior days.
2. Goals / Non-goals
Goals
- Replies match the onboarding persona-copy quality bar, grounded in real user data.
- Reply length scales to the user's
coach_depthsetting (no more flat 2-sentence cap). - Coach follows multi-turn threads and remembers across sessions.
- Replies render with light markdown emphasis + 0–2 tappable action chips (log progress, update mood, view goal, etc.).
Non-goals
- Not changing the persona set, enneagram assessment, or the tone/warmth/focus controls.
- Not building a general-purpose chatbot — Coach stays goal/habit-scoped and brief-by-default (anti-social brand: ~10 min/day).
- Not LLM-emitted JSON for actions (brittle/costly) — action chips are derived deterministically in Rails from existing context signals.
- Not the agent_runner / AI Workforce path — consumer Coach is the Rails
Ai::CoachServicepath.
3. Current-state reference (files this PRD touches)
| Concern | File |
|---|---|
| Response generation | rails_api/app/services/ai/coach_service.rb (#get_advice, ~80–92) |
| Prompt template + hard cap | rails_api/app/services/ai/prompts/coaching.rb (cap at line 12) |
| Persona preamble + hard cap + instruction maps | rails_api/app/services/ai/prompts/base.rb (cap at line 94; BREVITY/WARMTH/FOCUS maps; build_personality_preamble) |
| Persona "soul" exemplars | rails_api/app/services/ai/prompts/souls/{captain,spark,mirror,rival,sage,analyst}.md |
| Context assembly | rails_api/app/interactions/ai/build_coach_context.rb |
| GraphQL entry | rails_api/app/graphql/mutations/ai/get_advice.rb (returns advice: String) |
| Coach prefs (depth/tone) | rails_api/app/models/user_detail.rb; rails_api/app/models/user.rb (~218–230) |
| Audit/cost log | rails_api/app/models/coach_interaction.rb (tracks prompt/completion tokens) |
| FE chat + plain render | ionic_frontend/src/components/coach/CoachChatPanel.vue |
| FE mutation | ionic_frontend/src/constants/graphql/ai.js (GET_ADVICE_MUTATION) |
| FE state | ionic_frontend/src/composables/useAiCoach.ts |
| Gold-standard copy (design reference) | ionic_frontend/src/views/MeetCoach.vue:598-625; src/constants/coachPersonaDetails.ts:12-54 |
4. Workstreams
Workstream A — Depth-aware length + persona-true quality
Prompt/context only. No schema, no FE structural change. Highest impact / lowest risk.
- Remove the hard cap in
coaching.rb:12andbase.rb:94. - Add a
DEPTH_INSTRUCTIONSmap inbase.rb(parallel toBREVITY_INSTRUCTIONS), keyed bycoach_depth1–5, mapping to a length budget:- 1 Brief → 1–2 sentences
- 2 → 2–3 sentences
- 3 (default) → a short paragraph (3–4 sentences)
- 4 → two short paragraphs
- 5 Detailed → multi-part, may include a short ordered list of next steps
- Clarify depth vs. brevity:
depthgoverns how much content/length;tone_brevitygoverns terseness of style. Wirecoach_depththroughbuild_personality_preamble(it's already stored, just unused). Document the interplay in the prompt. - Add few-shot exemplars to the soul files so live output matches the onboarding bar. Each
souls/*.mdgains 1–2 example outputs mirroring itsstreakMessage/ wizard-preview copy. The onboarding TS copy stays the design reference; the Ruby soul files are the single source for the LLM.
Workstream B — Deeper personalization from user data (all four sources)
Enrich BuildCoachContext. Reuse existing models; respect a token budget with prioritized ordering.
- Mood →
build_mood_summary: recentMoodLogtrend (last 7–14 days) + most recent mood; lets the coach connect emotional state to progress (thereflectionfocus already hints at this). - Gamification →
build_progress_summary: rank name,current_streak,longest_streak, recently earned badges, plus XP signal. Reuse, don't rebuild (capability-first): a server-side rank service already exists —rails_api/app/services/achievements/compute_user_rank_service.rb(Achievements::ComputeUserRankService) derivescurrent_rank_namefromuser.xp(the canonical XP column; it deliberately ignores the drift-proneuser.levelinteger) and is already exposed over GraphQL viaTypes::AchievementStatsType— reachable atme { achievementStats { currentRankName } }(a sub-field ofUserType.achievement_stats, not a standalone query). Workstream B should call that service directly fromBuildCoachContextrather than replicating any ladder. There is no dedicatedBadgeorRankActiveRecord model and no stored rank field. Pre-existing risk to surface: the frontendionic_frontend/src/components/SideMenu.vue:366-384computes rank names from a different source — a badge-count proxy (unlockedCount * 100) — and its ladder diverges from the server's (e.g. rank 3 is"Foundation"in the UI vs"Path Maker"inComputeUserRankService). Once the coach cites rank names, a user could see two different ranks; planning must decide whether to align the UI ladder to the server before Workstream B ships. Badges are tracked asUserActionenum actions (~49 distinct actions such ascomplete_first_goal) inrails_api/app/models/user_action.rb; "recently earned badges" means recent relevantUserActionrecords. Challenge badges (badge_name/badge_icononCommunityChallenge) are a separate domain and out of scope here. - Journal entries → read recent
GoalEvent.contenttext (last N), include verbatim snippets in the focused-goal detail and a global "recent reflections" block. The events are already eager-loaded (includes(:events)) — selectcontent, not justcreated_at. - Goal categories → include each goal's
categoryname in the goals summary so advice respects life-area context (Health/Career/etc.). - Budgeting: cap total context tokens; order by relevance (focused goal > recent activity > mood > gamification).
CoachInteractionalready records token counts for monitoring.
Workstream C — Persisted conversation memory
Biggest scope: new models + migration + retrieval + FE history load. Do last.
- New models (
PublicRecord,acts_as_paranoid):CoachConversation— one rolling thread per user (optionally scoped pergoal_id), columns:user_id,goal_id(nullable),last_message_at.CoachMessage—conversation_id,role(user/assistant),content,created_at.- Migration + regenerate
schema.rbin the same CI cycle (per CLAUDE.md gotcha).
- Retrieval: in
CoachService#get_advice, load the last K turns (e.g. 10, token-budgeted) and pass them as a real multi-message array toServiceClient.chatinstead of a single user-role message. Persist both the user message and the assistant reply per turn. - Frontend: on Coach open, load prior history via a new query (e.g.
coachConversation(goalId, page)) so reopening shows past messages instead of a blank slate.useAiCoach.tshistory becomes hydrated from the server. - Retention & privacy: define a retention policy (e.g. 90 days), honor
acts_as_paranoiddeletion, and include coach messages in user data export/delete. Mood + journal text are sensitive — document that this is the user's own data fed to their own coach, already transiting LiteLLM. (See §6.) - Coach opt-out interaction: the Coach opt-out PRD (
docs/product/coach-opt-out-prd.md) gatesGetAdvicebehindcoach_modal_enabled?and explicitly left chat history out of scope for its v1. Since this workstream is what introduces persisted history, planning must define behavior when a user has Coach disabled: do not persist newCoachMessagerecords while disabled, and retain (but never read or surface) any prior history rather than hard-deleting it. Re-enabling restores the thread.
Important — data availability at launch: CoachConversation and CoachMessage are net-new tables introduced by this workstream. Conversation memory begins collecting at the moment this feature ships — there is no historical chat history to backfill, so the "coach remembers past chats" capability applies only to conversations that occur after this ships. Users will experience a one-time fresh start when Workstream C launches.
Workstream D — Rich response rendering
Extend the GraphQL surface + FE render. Prose stays short; richness comes from emphasis + chips.
- Extend
getAdviceresponse fromadvice: Stringto:advice: String— prose, may include light markdown (bold/italic only).suggestedActions: [CoachAction!]— each{ label, kind, targetId }wherekind∈log_event | log_mood | view_goal | create_milestone | open_meet_coach.
- Derive
suggestedActionsdeterministically in Rails from the sameBuildCoachContextsignals (e.g. goal stalled → "Log progress"; no mood today → "Update mood"). No extra LLM call, reliable, cheap. Avoids brittle LLM JSON. - Frontend (
CoachChatPanel.vue):- Render markdown safely.
dompurify^3.4.0is already installed inionic_frontend/package.jsonand already used for sanitization across the app:ChangelogModal.vuepairs it with a bold/italic regex (/\*\*(.+?)\*\*/g) — the exact pattern to reuse here — whileBadgeImage.vueuses it for SVG sanitization.markedandmarkdown-itare not installed. Reuse the existing DOMPurify + bold/italic pattern — no new dependency needed. - Render
suggestedActionsas tappable chips reusing the existing suggestion-chip pattern (CoachChatPanel.vue:16-25), deep-linking via the router /useAiCoach. - Add Storybook stories for the rich message states.
- Render markdown safely.
5. Phasing (recommended)
- Phase 1 — A + B (prompt + context). Immediate richness and personability, no schema, no FE structural change. Ship first.
- Phase 2 — D (markdown + deterministic action chips). FE + additive GraphQL fields.
- Phase 3 — C (persisted memory). New models/migration, retrieval, FE history hydration.
6. Privacy, cost & guardrails
- Token/cost: more context + history + longer replies all raise cost — across three new vectors (richer context, K-turn history, depth-gated longer prose). The Coach UDG PRD pinned a ~400-token context baseline; this work will exceed it, so planning must set a concrete envelope (suggested starting target: context ≤ ~700 tokens, history ≤ 10 turns / ~600 tokens, depth-5 replies capped to a hard output ceiling). Monitor against UDG's baseline via
CoachInteractiontoken columns; keep the existing 10 req/min rate limit. - Mood & journal text inclusion — deliberate decision: The Coach UDG PRD excluded mood logs pending a privacy decision. That decision is made here: include them. Mood logs and journal entry text are the user's own data, already collected with their agency, and already transiting LiteLLM (no new third-party exposure). They are scoped solely to that user's own coach — never shared with other users, never used for training. Those properties make inclusion appropriate despite UDG's earlier deferral. Add retention policy, ensure deletion/export coverage, and keep all sensitive data (mood, journal text, persisted chat history) scoped to the user's own coach.
- Gamification — deliberate decision: The Coach UDG PRD deemed badge/rank details "not relevant to coaching advice unless the user asks." We include gamification as a light, occasional acknowledgement: the coach may naturally reference a level-up, rank name, or longest streak when relevant to the user's progress narrative. Gamification is not a focus area or a prompt topic — this scoping directly addresses UDG's "not relevant unless asked" concern by limiting it to opportunistic acknowledgement, not a dedicated coaching thread.
- Brand guardrail: longer replies cut against the anti-social/~10-min ethos — mitigated by depth defaulting to brief; detail is opt-in via the slider.
7. Success metrics
- Qualitative: live replies match the onboarding gold standard (persona-true, cite specific user data) — spot-check across all 6 personas × depth levels.
- Quantitative (PostHog —
coach_chat_opened/coach_advice_sentalready exist):- Messages per session (multi-turn depth) ↑
- Return-to-Coach rate ↑
- New
coach_action_chip_tappedevent rate - Depth-slider adoption
- Guardrails: reply latency, tokens/reply (CoachInteraction), rate-limit headroom.
8. Verification / testing (for the implementation phases)
- Backend RSpec:
spec/services/ai/coach_service_spec.rb— depth→length budget, history passed as multi-message array, no hard cap.spec/interactions/ai/build_coach_context_spec.rb— mood / gamification / journal-text / category inclusion + budgeting.- New specs:
CoachConversation/CoachMessagemodels, persistence + retrieval;get_advicerequest spec for newsuggestedActionsfield.
- Frontend Vitest + Storybook:
CoachChatPanelmarkdown render, action-chip render + tap deep-link, server history hydration. - Manual / preview: drive Coach across personas and depth settings; confirm replies cite real streaks/mood/journal and that chips route correctly.
- Migration discipline: run
bin/rails db:migrateand commitschema.rbin the same cycle (per CLAUDE.md gotcha).
9. Open questions / risks to resolve in planning
- Depth vs. brevity interplay — finalize the exact interplay so the two controls don't contradict (depth governs content length; brevity governs stylistic terseness — document this clearly in the prompt).
- Memory threading — one rolling thread per user vs. per-goal threads (PRD assumes one rolling thread, optionally goal-scoped).
- Retention window — confirm the exact policy with stakeholder before Phase 3.
- UserAction badge query/caching — confirm the query/caching approach for surfacing recent
UserActionbadge records within the context token budget (sources confirmed; approach TBD in planning). - Rank-name ladder divergence — the frontend
SideMenu.vuebadge-count ladder and the serverAchievements::ComputeUserRankServiceXP ladder disagree (e.g."Foundation"vs"Path Maker"at rank 3). Decide whether to align the UI to the server before the coach starts citing rank names. (The coach should reuse the servercurrentRankNameviame { achievementStats { currentRankName } }, not rebuild a ladder.) - Update the Coach UDG PRD's exclusion list after greenlight: mood logs and gamification move from the UDG "Excluded (Phase 1)" list to Included under this PRD's scope.
10. Next step
Route to planning via the GSD workflow (/gsd-new-milestone, then /gsd-plan-phase). Implement in the §5 phase order (A+B → D → C), each phase gated by the §8 tests. Phase 1 (Workstreams A+B) is the recommended first slice — highest impact, no schema or FE structural change.
Last updated: 2026-06-15