Skip to content

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_dismissed flag on UserDetail
  • 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 EnneagramAssessment record; 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 scores
  • dominant_type (integer, 1–9): computed from max score
  • wing (integer, 1–9): adjacent type with second-highest score
  • tritype (string, e.g. "489"): one type per center, encoded as 3-digit string
  • completed_at (datetime): when the user submitted the assessment
  • deleted_at (datetime): soft-delete timestamp

Relationship:

ruby
user.has_many :enneagram_assessments, dependent: :destroy
user.enneagram_assessments.where(deleted_at: nil).order(completed_at: :desc).first
  # → latest completed assessment for Coach context

UserDetail — Dismiss Persistence

The enneagram_card_dismissed flag lives on UserDetail as a JSONB store accessor:

ruby
# app/models/user_detail.rb
store_accessor :data, :enneagram_card_dismissed  # boolean, defaults to false/nil

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

ruby
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
end

Mutations & Queries

Queries:

  • enneagramAssessment(userId: ID!) → latest EnneagramAssessment for that user
  • enneagramAssessmentHistory(userId: ID!, limit: Int, offset: Int) → paginated history

Mutations:

  • submitEnneagramAssessment(input: { userId, answers: [answer_pairs] }) → creates new row, returns computed scores + dominant type + wing + tritype
  • dismissEnneagramCard(userId: ID!) → sets user.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:

ruby
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 → call DismissEnneagramCard mutation

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:

  1. No assessment → heading + body + "Start your Enneagram assessment" CTA
  2. Partial progress → same, CTA = "Continue your assessment"
  3. Has assessmentEnneagramResultCard + EnneagramHistoryList + "Retake assessment" link

Retake behavior:

  • Clears enneagram_partial_answers from localStorage (via useEnneagramAssessment.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:

typescript
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 (onMounted hook)
  • Trigger: Card component mounts on dashboard (once per session, prevented by hasFiredShown flag)
  • Properties:
    • days_active (number): User's signup age in days, passed as prop from Dashboard
    • source (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, inside navigateToAssessment(source) — single call site; source is 'settings' or 'retake' depending on which CTA the user tapped
  • 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 response
    • wing (number): Adjacent type with second-highest score
    • time_to_complete_ms (number, optional): Milliseconds from first answer to submission; null if user resumed from prior session
    • questions_answered (number): Always 40
    • source (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 both onBeforeRouteLeave and onBeforeUnmount guards)
  • Trigger: User leaves the assessment route before submitting; guards prevent double-firing (check abandonedFired flag, 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 DismissEnneagramCard mutation to persist dismissal in user.details.enneagram_card_dismissed

enneagram_retake_started — User initiates assessment retake from Settings

  • Fire site: ionic_frontend/src/views/Settings.vue:704 (inside navigateToAssessment('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_answers from localStorage before navigation

User Property: has_enneagram_assessment

Population paths:

  1. Boot syncionic_frontend/src/composables/useClerkSync.ts (initial user load from GraphQL)

    • Fetches user.latestEnneagramAssessment in GET_CURRENT_USER query
    • If assessment exists, calls setPostHogUser(userId, email, true) to set user property
  2. Mutation successionic_frontend/src/composables/useEnneagramAssessment.ts:176 (after enneagram_completed event)

    • Calls posthog.people.set({ has_enneagram_assessment: true })
  3. SideMenu syncionic_frontend/src/components/SideMenu.vue (reactive watcher)

    • Watches latestEnneagramAssessment on user object
    • On change, calls posthog.people.set({ has_enneagram_assessment: !!val.user.latestEnneagramAssessment })

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:

typescript
// 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 in UserDetail.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 AiEmployeeMemory in system prompt (stable trait context before session memory).


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)

Loading…