Enneagram Assessment
An optional personality assessment that gives Coach deeper insight into how users are motivated. The Enneagram maps nine motivation patterns and core fears/desires; Coach uses this profile to tailor feedback and framing.
Scope: Strictly opt-in, non-blocking, and additive to existing Coach configuration (persona, tone, focus, rhythm).
Overview
What It Is
A 40-question forced-choice assessment (5–10 minutes) that resolves to:
- Dominant type (1–9): the core motivation pattern
- Wing: the adjacent type with the highest score (influences expression)
- Tritype: one type per Enneagram center (body 8/9/1, heart 2/3/4, head 5/6/7) — internal Coach context only
Users see their dominant type + wing prominently, with a short non-clinical description (e.g., "Type 1: the Reformer — perfection-seeking, principle-driven"). The full 9-type score breakdown is available on an expandable card.
Why It Matters
Existing Coach configuration (persona, tone, focus, frequency) captures how users want to be coached. The Enneagram captures why — the underlying motivation structure. A Type 1 needs permission to be imperfect; a Type 7 needs help finishing rather than starting. Coach can use this to frame advice more aptly without relying on users to articulate their patterns themselves.
User Journeys
Discovery & Invitation
Dashboard Card (Phase 80)
A dismissible card appears on the dashboard after a user has been active for 7+ days:
┌─────────────────────────────────────┐
│ Know yourself better │
│ │
│ 10 minutes. 40 questions. Coach │
│ gets to know how you actually move │
│ toward your goals. │
│ │
│ [Start assessment] or [Continue...] │
│ [×] dismiss │
└─────────────────────────────────────┘- Visibility: Shown after 7 days of activity, unless dismissed or assessment already completed
- CTA text: "Start assessment" (no prior progress) or "Continue assessment" (partial localStorage state exists)
- Dismiss: Users can permanently dismiss the card. Dismissal persists via
enneagram_card_dismissedflag onUserDetail - Placement: Dashboard, above the branch conditionals (first-week invitation, getting started card, sparse state), below streak-repair section
- Telemetry:
enneagram_card_shown,enneagram_started(with source: 'card'),enneagram_dismissed
Settings → Personalization Tab (Phase 80)
An entry point available in Settings on the new Personalization tab:
No assessment yet:
Your Coach, calibrated to you
The Enneagram is a map of how you're motivated. Ten
minutes of honest questions, and Coach stops giving
advice that was written for someone else.
[Start your Enneagram assessment]Partial progress (incomplete in localStorage): Same heading and body, CTA changes to "Continue your assessment"
Assessment exists:
- Latest assessment displayed via
EnneagramResultCard(collapsed by default) - Full history as a timeline via
EnneagramHistoryList - "Retake assessment" link (low-emphasis, not a primary CTA)
Assessment Flow
See EnneagramAssessment.vue and Phase 79 feature docs for the assessment runner and result display.
Key mechanics:
- 40 question pairs: Each pair is a forced-choice statement selection (no "neutral" option). Auto-advances on selection; back button allowed.
- Progress indicator: "12 / 40" at top of screen
- Resumable: Partial answers persist in localStorage under
enneagram_partial_answers; the dashboard card offers "Continue assessment" if partial state exists - Single result display: After submission, one result screen shows dominant type + wing, short description, and expandable 9-type breakdown
Result lifetime:
- User sees result immediately on completion
- Each retake creates a new
EnneagramAssessmentrecord; prior results are visible as history in Settings - Coach always uses the latest assessment (most recent
completed_at)
Backend Implementation
Data Model
Table: enneagram_assessments (soft-deletable via acts_as_paranoid)
Columns:
user_id(belongs_to :user)scores(JSONB): string-keyed object mapping "1"–"9" to 0–100 normalized scoresdominant_type(integer, 1–9): computed from max scorewing(integer, 1–9): adjacent type with second-highest scoretritype(string, e.g. "489"): one type per center, encoded as 3-digit stringcompleted_at(datetime): when the user submitted the assessmentdeleted_at(datetime): soft-delete timestamp
Relationship:
user.has_many :enneagram_assessments, dependent: :destroy
user.enneagram_assessments.where(deleted_at: nil).order(completed_at: :desc).first
# → latest completed assessment for Coach contextUserDetail — Dismiss Persistence
The enneagram_card_dismissed flag lives on UserDetail as a JSONB store accessor:
# app/models/user_detail.rb
store_accessor :data, :enneagram_card_dismissed # boolean, defaults to false/nilWhen set: The dashboard card's visibility gate checks this flag; if true, the card is hidden for that user permanently. Users can still start the assessment from Settings → Personalization even if they've dismissed the dashboard card.
Interaction: DismissEnneagramCard (rails_api/app/interactions/dismiss_enneagram_card.rb) sets this flag:
class DismissEnneagramCard < Interaction::Base
input :user, required: true
def call
detail = user.details # Note: plural! See CLAUDE.md "Common Gotchas"
if detail.nil?
result.fail(error: 'User detail record not found', code: :not_found)
return
end
detail.update!(enneagram_card_dismissed: true)
result.details = { dismissed: true }
rescue ActiveRecord::RecordInvalid => e
result.fail(error: e.message, code: :invalid_input)
end
endMutations & Queries
Queries:
enneagramAssessment(userId: ID!)→ latestEnneagramAssessmentfor that userenneagramAssessmentHistory(userId: ID!, limit: Int, offset: Int)→ paginated history
Mutations:
submitEnneagramAssessment(input: { userId, answers: [answer_pairs] })→ creates new row, returns computed scores + dominant type + wing + tritypedismissEnneagramCard(userId: ID!)→ setsuser.details.enneagram_card_dismissed, returns dismissed flag
Coach Integration
System Prompt Injection
This entire pipeline lives in rails_api — the Node agent_runner/ service has no Enneagram references and is not involved.
When a user has a completed assessment, Mutations::Ai::GetAdvice (rails_api/app/graphql/mutations/ai/get_advice.rb) looks up the user's latest assessment (context[:current_user].enneagram_assessments.order(completed_at: :desc).first) and passes it into Ai::CoachService#get_advice as enneagram_assessment:. CoachService#get_advice forwards it to Ai::Prompts::Base.build_personality_preamble(coaching_preferences, enneagram_assessment:), which calls the private build_enneagram_block method to append a personality block, and the combined preamble is passed into Ai::Prompts::Coaching.build to produce the system prompt:
## User personality profile
**Enneagram type:** Type X (Y wing)
**Top-3 type scores:** Type X (score%), Type Y (score%), Type Z (score%)
**Coaching note:** [1–2 sentence framing]This block is built by Ai::Prompts::Base.build_enneagram_block (rails_api/app/services/ai/prompts/base.rb), which looks up the framing from Ai::Prompts::EnneagramFramings::FRAMINGS (rails_api/app/services/ai/prompts/enneagram_framings.rb):
- One 1–2 sentence framing per dominant type (1–9)
- No clinical language; framed as coaching insight, not diagnosis
- Stored as frozen constants to prevent accidental drift
Example framing (Type 7), trimmed:
FRAMINGS = {
7 => 'This person is energized by possibility and forward motion, but can scatter across too ' \
'many directions or avoid completing things when they stop feeling fresh. Coach toward ' \
"depth and follow-through on the options they've already chosen — they have the energy, " \
'they just need a reason to stay.'
}No Impact on Non-Opted-In Users
Coach quality for users who skip the assessment is unaffected. The system prompt injection only occurs if an assessment exists; without it, Coach falls back to existing persona/tone/focus configuration. No shared prompt changes assume Enneagram presence.
Frontend Implementation
Components
Enneagram-specific components:
EnneagramAssessment.vue— Full-screen question runner with localStorage resumption (Phase 79)EnneagramQuestionCard.vue— Single question-pair display with binary choice (Phase 79)EnneagramResultCard.vue— Result display with collapsible 9-type breakdown (Phase 79)EnneagramHistoryList.vue— Timeline of prior assessments (Phase 79)EnneagramPromptCard.vue— Dashboard card inviting eligible users (Phase 80)
New surface: Dashboard card
Location: ionic_frontend/src/components/dashboard/EnneagramPromptCard.vue
Props:
ctaLabel(string): "Start assessment" or "Continue assessment"daysActive(number): user's signup age in days (for telemetry)
Emits:
@start→ navigate to/enneagram-assessment@dismiss→ callDismissEnneagramCardmutation
New surface: Settings tab
Location: ionic_frontend/src/views/Settings.vue (modified)
Adds "Personalization" tab (icon: Compass from lucide-vue-next) between Privacy and Supporter tabs.
Panel states:
- No assessment → heading + body + "Start your Enneagram assessment" CTA
- Partial progress → same, CTA = "Continue your assessment"
- Has assessment →
EnneagramResultCard+EnneagramHistoryList+ "Retake assessment" link
Retake behavior:
- Clears
enneagram_partial_answersfrom localStorage (viauseEnneagramAssessment.reset()) - Navigates to
/enneagram-assessment - Fires PostHog event:
enneagram_started: { source: 'retake' }
Composables
useEnneagramAssessment() (Phase 79)
Manages assessment state, localStorage resumption, and result caching.
useEnneagramCardGate() (Phase 80)
Determines if the dashboard card should render:
const shouldShow = computed(() =>
currentUser.signupAgeDays >= 7 &&
!userDetail.enneagramCardDismissed &&
!latestAssessment?.completed_at
)Gating & Visibility
Dashboard Card Gate
Shown when all of these are true:
- User has been active for ≥ 7 days (
signupAgeDays >= 7) - User has not dismissed the card (
!userDetail.enneagram_card_dismissed) - User has no completed assessment (
!latestAssessment)
Settings Tab
Always visible to every user, regardless of assessment status.
Telemetry
Events & Properties
enneagram_card_shown — Dashboard card renders (Phase 80)
- Fire site:
ionic_frontend/src/components/dashboard/EnneagramPromptCard.vue:26(onMountedhook) - Trigger: Card component mounts on dashboard (once per session, prevented by
hasFiredShownflag) - Properties:
days_active(number): User's signup age in days, passed as prop from Dashboardsource(string): Always'dashboard'
- Condition: Fires only in production mode (
import.meta.env.MODE !== 'development')
enneagram_started — Assessment flow begins
- Fire sites:
- Dashboard card:
ionic_frontend/src/components/dashboard/EnneagramPromptCard.vue:34(source:'card') - Settings (both retake and initial start):
ionic_frontend/src/views/Settings.vue:710, insidenavigateToAssessment(source)— single call site;sourceis'settings'or'retake'depending on which CTA the user tapped
- Dashboard card:
- Trigger: User taps "Start assessment" or "Continue assessment" CTA
- Properties:
source(string):'card'(from dashboard),'settings'(from Settings tab), or'retake'(retaking from Settings)
- Condition: Fires only in production mode
enneagram_completed — Assessment successfully submitted
- Fire site:
ionic_frontend/src/composables/useEnneagramAssessment.ts:167(submit()method) - Trigger: User completes all 40 questions and mutation resolves successfully
- Properties:
dominant_type(number): Resolved type (1–9) from submission responsewing(number): Adjacent type with second-highest scoretime_to_complete_ms(number, optional): Milliseconds from first answer to submission; null if user resumed from prior sessionquestions_answered(number): Always40source(string): Where assessment was started —'card','settings', or'retake'
- Condition: Fires only in production mode; also calls
posthog.people.set({ has_enneagram_assessment: true })
enneagram_abandoned — User navigates away mid-assessment
- Fire site:
ionic_frontend/src/views/EnneagramAssessment.vue:300(fireAbandonedIfNeeded, called from bothonBeforeRouteLeaveandonBeforeUnmountguards) - Trigger: User leaves the assessment route before submitting; guards prevent double-firing (check
abandonedFiredflag,answers.length > 0,result === null,!isSubmitting) - Properties:
questions_answered(number): Number of questions answered before leaving (1–39)source(string): Where assessment was started —'card','settings', or'retake'
- Condition: Fires only in production mode
enneagram_dismissed — Dashboard card dismissed
- Fire site:
ionic_frontend/src/components/dashboard/EnneagramPromptCard.vue:41(dismiss button click) - Trigger: User taps the × dismiss button on the dashboard card
- Properties: None (empty payload
{}) - Condition: Fires only in production mode; also triggers
DismissEnneagramCardmutation to persist dismissal inuser.details.enneagram_card_dismissed
enneagram_retake_started — User initiates assessment retake from Settings
- Fire site:
ionic_frontend/src/views/Settings.vue:704(insidenavigateToAssessment('retake'), retake link click) - Trigger: User taps "Retake assessment" link in Settings → Personalization tab
- Properties:
prior_dominant_type(number): Dominant type from the latest existing assessment (1–9)prior_wing(number): Wing type from the latest existing assessment (1–9)
- Condition: Fires only in production mode; clears
enneagram_partial_answersfrom localStorage before navigation
User Property: has_enneagram_assessment
Population paths:
Boot sync —
ionic_frontend/src/composables/useClerkSync.ts(initial user load from GraphQL)- Fetches
user.latestEnneagramAssessmentinGET_CURRENT_USERquery - If assessment exists, calls
setPostHogUser(userId, email, true)to set user property
- Fetches
Mutation success —
ionic_frontend/src/composables/useEnneagramAssessment.ts:176(afterenneagram_completedevent)- Calls
posthog.people.set({ has_enneagram_assessment: true })
- Calls
SideMenu sync —
ionic_frontend/src/components/SideMenu.vue(reactive watcher)- Watches
latestEnneagramAssessmenton user object - On change, calls
posthog.people.set({ has_enneagram_assessment: !!val.user.latestEnneagramAssessment })
- Watches
Type: boolean
Lifecycle: Once true, always true (never reset to false even if assessments are deleted; reflects whether user has ever completed one)
Phase 80 Carry-Forward: Dashboard Card Visibility
signupAgeDays field (DOCS-DEBT-1 closure)
The dashboard card gate uses signupAgeDays (computed on the user object from created_at) to determine if the card should be shown:
// ionic_frontend/src/composables/useEnneagramCardGate.ts
const shouldShow = computed(() =>
currentUser.signupAgeDays >= 7 &&
!userDetail.enneagramCardDismissed &&
!latestAssessment?.completed_at
)Where it lives:
- Frontend:
currentUser.signupAgeDays(computed from user.created_at in Apollo cache or GraphQL query result) - Backend: No explicit field; calculated at query time as
(Date.now() - user.created_at.getTime()) / (1000 * 60 * 60 * 24)in the user type resolver
Visibility conditions (all must be true):
signupAgeDays >= 7— User must have been active for at least 7 days!userDetail.enneagramCardDismissed— User has not dismissed the card (stored inUserDetail.data['enneagram_card_dismissed'])!latestAssessment?.completed_at— User has no completed assessment yet
Access & Permissions
- Backend queries/mutations: No admin gate; all users can submit, query their own, and view history. Cross-user queries (
enneagramAssessment(userId: "other_user_id")) should be scoped to the current user or admin. - Frontend: No role gates; feature available to all users.
Known Constraints
- Single-user scope: No social comparison, type-based discovery filters, or "find your Type 4 friends" features. The assessment is for self-knowledge and Coach context only.
- No export: Results are not downloadable or shareable.
- Closing questions from Phase 80 planning:
- Tritype presentation: Tritype is internal Coach context only; not surfaced in user-visible results. Future versions may expose it for deeper self-knowledge.
- Prompt block placement: Currently sits above
AiEmployeeMemoryin system prompt (stable trait context before session memory).
Related Documentation
- Enneagram Assessment PRD — original product rationale and design exploration
- Meet Coach — Existing persona/tone/focus configuration
- Coach Surfaces Map — Comprehensive Coach surface inventory and fallback strategy
- Data Models — Full schema reference
- Phase 79 Planning — Question bank, result computation, and Phase 79 acceptance criteria
Last updated: 2026-07-13 (doc audit: corrected drifted telemetry line-number citations — enneagram_started/enneagram_retake_started were swapped in Settings.vue (now 710/704 respectively), and useEnneagramAssessment.ts/EnneagramAssessment.vue citations for enneagram_completed, the has_enneagram_assessment population path, and enneagram_abandoned were re-verified against source)