Appearance
Freemium Psychology & Engagement Design
How and why every freemium gate, trial mechanic, and consent flow in LucidPal is designed the way it is. This document exists so future contributors understand the intent behind these product decisions — not just what the code does, but why it works.
Guiding Principles
Three psychological models govern all freemium decisions in LucidPal:
Fogg Behavior Model (B = MAP): Behavior happens only when Motivation × Ability × Prompt align simultaneously. Gate users at the moment of peak motivation — after they've experienced value — not at the door before they've felt anything.
Loss Aversion (Kahneman): Users feel the pain of losing something 2× more intensely than the pleasure of gaining something equivalent. A gate framed as "you're about to lose this session" converts better than "upgrade to save."
Reciprocity (Cialdini): Give first, ask second. Let users experience the full feature. Ask for the upgrade after they've received real value.
Live Notes — Freemium Trial Design
Why the tab is free to enter
Live Notes has no paywall on the tab itself. Free users land directly on the feature.
Rationale: A hard gate at the tab (e.g. upgrade sheet on tap) creates zero motivation to upgrade because the user has experienced zero value. They don't know what they're missing. Removing the entry barrier lets motivation build organically.
The 30-minute lifetime trial cap
Free users get 1,800 seconds (30 minutes) of live transcription, tracked server-side by DeviceIdentityService.deviceId (a Keychain-backed UUID that survives reinstalls).
Why 30 minutes:
- Long enough for one real meeting — users experience the full feature in a meaningful context.
- Short enough that active users hit the cap and face the upgrade decision at peak utility.
- Cost ceiling: Deepgram charges ~$0.0043/min. 30 min = $0.13 max per user lifetime.
Why server-side tracking:
- Prevents abuse from reinstalls (Keychain UUID survives app deletion).
- Enables accurate remaining-balance display without trusting the client.
- Server balance is the source of truth; local
LiveTrialManageris a cache.
Trial balance pill
SessionRecordView and TranscriptionView both show a 🕐 MM:SS free remaining capsule pill at all times for free users.
| Remaining | Colour |
|---|---|
| > 6 min | Primary / neutral |
| 1 s – 6 min | Amber (Color.orange) |
| 0 s (exhausted) | Red (Color.red) |
Why always visible: Scarcity in real time. The ticking counter creates urgency throughout the session — not just at the end. This is the same mechanic used by parking meters, countdown timers, and limited-time offers. Seeing "04:23 free remaining" mid-meeting makes the value concrete and the upgrade decision easy.
Why seconds precision: A user recording a 2-minute memo needs to see whether they have enough time. "4 minutes" is ambiguous; "04:23" is not.
First-session trial modal (LiveNotesTrialModal)
Shown exactly once, before the first session starts, when !liveTrialManager.hasShownModal.
Copy intent: "You have 30 minutes of free transcription. Enough for a real meeting." This sets expectations transparently (no bait-and-switch surprise), anchors the value ("a real meeting"), and frames the trial as a gift rather than a restriction. The primary CTA is "Start Session" — the user is moving forward, not being blocked.
Why one modal and not a persistent banner: The information needs to be delivered once, clearly. Repeating it on every session would be friction. After the first session, the balance pill does the ongoing communication.
Auto-stop at cap
When elapsed >= sessionBudget during a free session, TranscriptionView automatically stops recording and shows the upgrade sheet.
Why auto-stop instead of letting them keep recording: The server closes the session at the cap regardless (Durable Object enforces limitMs). Stopping client-side first gives the iOS app control over the UX — clean stop, upgrade sheet, no abrupt disconnect.
"Stop & Save" paywall gate
The primary upgrade moment is the Stop & Save action, not the tab entry.
Flow for free user with budget exhausted:
Tap "Stop & Save"
→ session exhausted → UpgradeView (full screen)
→ CTA: "Upgrade to save unlimited sessions"Why this moment works: The user has just transcribed a meeting. They have value in hand. They want to save it. The upgrade sheet appears at the exact moment motivation (saving the note) + ability (one tap) + prompt (the sheet) align — the Fogg B=MAP ideal.
The copy uses loss aversion: "Your session is ready — upgrade to save it" is more effective than "This feature requires Pro."
Upgrade flow states
| User state | Tap "Stop & Save" | Outcome |
|---|---|---|
| Pro/Ultimate | — | Save immediately, dismiss |
| Free, has budget | — | Record usage → save |
| Free, exhausted | — | showSaveUpgrade sheet |
| Free, cap hit mid-session | Auto | showCapUpgrade sheet |
Analytics Consent — Transparent Opt-In
Why and when
Shown on the next app foreground after the user's first completed Agent response.
Why after first value, not at launch: Asking for analytics consent at first launch has near-zero context — the user doesn't know the app yet and defaults to "No." Asking after the first successful agent interaction means:
- The user has experienced value (motivation is high).
- They have a positive association with the app.
- Reciprocity applies: the app just helped them — they're more inclined to help back.
The 1.5-second delay on foreground prevents the sheet appearing on top of the active agent response.
Trigger implementation
swift
// ContentView.swift
.onChange(of: agentViewModel.taskState) { _, state in
if case .done = state { analyticsConsentManager.markFirstValueDelivered() }
}
.onChange(of: scenePhase) { _, phase in
if phase == .active && analyticsConsentManager.shouldShowPrompt {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
showAnalyticsConsent = true
}
}
}shouldShowPrompt is only true when consent == .unknown && hasDeliveredFirstValue. Once the user answers, it never shows again.
Consent sheet psychology (AnalyticsConsentSheet)
Header: "Help shape LucidPal" — identity-based framing. The user is a contributor, not a data source.
What we collect (green checkmarks):
- iPhone model and iOS version
- Country (not GPS location)
- Which features you use most
- App crashes and errors
What we never collect (red X marks):
- Conversations or notes
- Calendar events
- Anything that identifies you personally
Why list both: Explicitly listing what will not be collected is more persuasive than listing only what will be collected. It removes the user's biggest fear before they have to articulate it. The red/green visual contrast makes the "never" list feel like a promise.
Fine print: "Anonymous. Never sold. Helps us prioritize features for your device." — "for your device" makes the benefit personal, not abstract.
CTA hierarchy:
- Primary (orange button, full width): "Yes, help improve LucidPal" — positive identity action
- Secondary (grey text link): "No thanks" — small, non-threatening, no guilt
Why orange CTA: Per conversion psychology, CTA buttons must contrast with the background. Orange on the sheet's light/dark background provides maximum contrast and matches the LucidPal accent color already associated with positive actions throughout the app.
Data collected on consent
AnalyticsConsentManager.sendTelemetry() fires once (guarded by telemetrySent UserDefaults flag) on POST /analytics/telemetry:
| Field | Source |
|---|---|
deviceId | DeviceIdentityService.deviceId (Keychain UUID) |
deviceModel | sysctlbyname("hw.machine") — e.g. iPhone17,2 |
osVersion | UIDevice.current.systemVersion |
appVersion | CFBundleShortVersionString + CFBundleVersion |
country | Locale.current.region?.identifier |
locale | Locale.current.identifier |
Stored server-side as telemetry:{deviceId} in Cloudflare KV (permanent, no expiry). One record per device.
Device Identity (DeviceIdentityService)
A stable UUID generated on first launch and stored in Keychain with kSecAttrAccessibleAfterFirstUnlock.
Why Keychain, not UserDefaults: Survives app deletion and reinstallation. Only a factory reset + manual Keychain wipe would generate a new UUID — acceptable edge case.
Uses:
X-Device-IDheader onPOST /transcription/session— server-side trial balance key (trial_secs:{deviceId})- Analytics telemetry payload (
deviceIdfield)
swift
// One-time UUID generation
private static func createAndStore() -> String {
let id = UUID().uuidString
KeychainHelper.save(id, forKey: key, accessibility: kSecAttrAccessibleAfterFirstUnlock)
return id
}Email Integrations — 30-Day Unified Trial
Why a date-based trial (not seconds-based like Live Notes)
Email integrations are not consumption features. Usage doesn't map to seconds — a user might ask one inbox question or ten. A 30-day calendar window gives enough time for the integrations to become part of their workflow, which is the goal. Live Notes gates on consumption (API cost); email integrations gate on habit formation.
Trial start: first account connection
The 30-day clock starts the moment the user connects their first Gmail or Exchange/Microsoft 365 account — tracked by EmailTrialManager.startTrial().
Why connection, not app install:
- A user who discovers the feature on day 14 gets a full 30 days of actual use.
- Starting at install would penalise late discoverers and feel arbitrary.
- Connection is a deliberate act (OAuth flow, browser sign-in) — high-intent moment, ideal trial anchor.
EmailTrialManager stores emailTrialStartDate in UserDefaults. This is intentionally client-side only — no server-side enforcement. The cost of a trial extension via reinstall is acceptable; the complexity of server-side date tracking is not justified for this feature.
Gmail + Exchange: unified trial clock
Connecting either Gmail or Microsoft 365 & Exchange starts the shared 30-day clock. Both integrations are unlocked simultaneously for the trial window — the user does not get two separate 30-day trials by connecting each integration separately.
Why unified:
- Single mental model: "You have a 30-day email trial" — not two separate countdowns.
- Prevents gaming: connecting Exchange on day 1, Gmail on day 15 doesn't extend the clock.
- Hint cards and warning modals are context-aware — they adapt copy based on which integrations are connected.
Post-trial tier requirements differ:
- Gmail: requires Starter (lower tier)
- Microsoft 365 & Exchange: requires Pro
The warning modals surface this difference explicitly: "After 30 days, Gmail requires Starter ($X/mo) and Exchange requires Pro ($Y/mo)."
Cloud AI (Gemini) during the trial
For the duration of the active trial, PremiumManager.canUseCloudAI returns true and canSendCloudMessage bypasses the credit-based gate. This routes the user through CloudLLMService (Gemini 2.5 Flash) for all chat sessions.
Why cloud AI for a feature trial:
Speed as a wow factor. On-device inference takes 2–8 seconds to produce the first token. Cloud (Gemini) responds in under 1 second. For inbox queries ("do I have anything urgent?"), the latency difference is the entire experience. A slow first impression doesn't create addiction; a fast one does.
No model-loading gate. Free users without a GGUF downloaded would otherwise get a model-download prompt before answering an email question. Removing that barrier means the path from "connect account" to "first useful response" is three taps.
Preview of a paid benefit. Pro subscribers get cloud AI. Trial users experiencing cloud AI during Exchange trial are previewing what Pro feels like — not just Exchange. This broadens the upgrade motivation.
Transparency: EmailTrialOnboardingSheet shows a "Powered by LucidPal AI" badge. The fine print is context-aware: when both integrations are connected it reads "After 30 days, Gmail requires Starter and Exchange requires Pro." No bait-and-switch.
Onboarding sheet (one-time, fires on first connect)
Shown immediately after the OAuth flow completes and either exchangeService.isConnected or gmailService.isAuthenticated transitions to true. Fires once (emailTrialManager.onboardingShown). The sheet is context-aware: title and example prompts adapt based on which integrations are active.
Three example prompts (not a feature list):
- "Do I have anything urgent from my manager today?"
- "Summarize my unread threads from this week"
- "Add a meeting with Sarah tomorrow at 2pm"
Why prompts, not features: A list of features ("read email subjects", "access Exchange calendar") describes what the integration does. Prompts show what the user can accomplish. The Fogg Ability factor is maximised when users know exactly what to type — no cognitive effort required to start.
Why exactly three: Paradox of choice. More than three options at this moment increases decision anxiety. Three creates curiosity without overwhelm. Each prompt covers a different domain (query, summarisation, action) to demonstrate breadth without listing exhaustively.
Progressive hint cards (in-chat, day-paced)
EmailHintCard appears in the chat view, one hint per time window, dismissed and never re-shown. The card is context-aware — prompts adapt based on which integrations are active:
| Day | Gmail only | Exchange only | Both connected |
|---|---|---|---|
| 1 | "Try: 'Do I have anything urgent in Gmail today?'" | "Try: 'Do I have anything urgent from my manager today?'" | Exchange prompt (work-critical) |
| 3 | "Try: 'Summarize my unread Gmail threads'" | "Try: 'Summarize my email thread with [contact]'" | Gmail prompt |
| 7 | "Try: 'Draft a reply to [Gmail thread]'" | "Try: 'Flag emails from my manager this week'" | Exchange prompt |
Why day-paced and not all at once: Showing all hints on day 1 is a feature dump. Spacing them creates progressive discovery — each hint feels like a new capability unlocked, not a tutorial to complete. By day 7 the user has used the integration across three different workflows. That's habit formation, not onboarding.
Query count tracking
EmailTrialManager.recordQuery() is called from both ExchangePromptSection.build() and GmailPromptSection.build() — every time email context (Gmail or Exchange) is injected into a chat system prompt. This count feeds the warning modals.
Why track query count: The warning modals surface the number ("You've queried your inbox 34 times this month"). This activates two mechanisms simultaneously:
- Sunk cost / investment: The user has built something into their workflow. Losing it requires actively undoing that.
- Concrete loss framing: "34 queries" is real. "Exchange integration" is abstract. Real numbers convert better than abstract feature names.
Warning modals (loss-framed, day-triggered)
Three variants, controlled by EmailTrialWarningModal based on daysRemaining. The body copy dynamically lists which integrations the user will lose access to:
Day 25 — soft warning
- Trigger:
daysRemaining == 5,!warningDismissed - Frame: "5 days left on your Exchange trial"
- Body: "You've used Exchange (queryCount) times. Keep it by upgrading to Pro."
- CTA: "See Plans" + "Dismiss"
- Intent: Plant the loss seed before urgency. First mention of the end date.
Day 28 — loss-framed
- Trigger:
daysRemaining <= 2, warning not yet shown at this level - Frame: "2 days left — here's what you'll lose"
- Body: "On day 31, Exchange access and LucidPal AI both go away. You've built (queryCount) queries into your workflow."
- CTA: "Upgrade to Pro" (orange gradient) + "Remind me later"
- Intent: Lead with what disappears (loss aversion 2×). "Built into your workflow" acknowledges the investment they've made. Orange CTA = high contrast on dark background, matches loss-urgency tone.
Day 30 / exhausted — hard gate
- Trigger:
isExhausted - Frame: "Your Exchange trial has ended"
- Body: "You used Exchange (queryCount) times this month. Upgrade to Pro to keep it."
- CTA: "Upgrade to Pro" (orange, no dismiss option)
- Secondary: "Not now" (grayed, exits without dismissing underlying feature gate)
- Intent: No escape hatch at exhaustion — the decision must be made. Past-tense framing ("you used", "this month") reinforces what existed and is now gone.
Settings badge (trial in progress)
SettingsView+Exchange.swift and IntegrationsSettingsView.swift both show a days-remaining badge on the Exchange card and Gmail card respectively:
| State | Badge | Colour |
|---|---|---|
| > 5 days remaining | "27d left" | Blue |
| ≤ 5 days remaining | "3d left" | Orange |
| Exhausted | "Trial ended" | Red |
| Pro subscriber (Exchange) | — | Hidden |
| Starter subscriber (Gmail) | — | Hidden |
Why orange at 5 days: Colour change reinforces the approaching deadline without a modal. Passive scarcity signal — the user doesn't have to be interrupted to feel the pressure.
UpgradeContext — Context-Driven Loss Aversion Copy
Why one sheet, six copy variants
Every upgrade prompt in the app routes through a single UpgradeView. A single upgrade sheet simplifies maintenance (one place to update tier cards, pricing, CTA logic) but previously showed the same generic copy regardless of where it was triggered. This destroyed loss aversion: a user interrupted mid-recording and a user browsing settings both saw "LucidPal AI takes it further."
UpgradeContext fixes this. It is an enum passed into UpgradeView that drives the eyebrow, headline, and sub-copy. The tier cards, pricing, and CTA logic remain shared.
The six contexts
| Case | Trigger | Eyebrow | Headline | Sub-copy |
|---|---|---|---|---|
.default | Generic settings / plans card tap | "Your AI is already free." | "LucidPal AI takes it further." | — |
.taskBadge | First Settings tap after 10 completed tasks (free user, one-time) | "You've made LucidPal yours." | "Don't lose it." | "Your AI lives only on this phone. Drop it in a pool and it's gone." |
.liveNotesEntry | User tries to start Live Notes but trial is exhausted | "Out of free minutes." | "Your free minutes are used up." | "Pro gives you unlimited Live Notes — and backs them up." |
.liveNotesCap | Session hit the lifetime budget mid-recording | "Out of free minutes." | "Don't lose what you just captured." | "Pro gives you unlimited Live Notes — and backs them up." |
.liveNotesSave | Stop & Save tapped while trial is exhausted | "Almost there." | "This session only exists here." | "Upgrade to keep it safe across all your devices." |
.sync | Sync upgrade card tapped in Settings | "This phone only." | "Lose it, lose everything." | "Pro syncs your notes, chats, and habits across all your devices." |
Copy principle
Free tier is unlimited on-device AI forever — there are no task caps, no time limits on agentic features. Pro value is sync + multi-device + cloud integrations. All six copy variants frame the risk of not having Pro as data loss or device lock-in, never as artificial scarcity:
- ❌ "Unlock unlimited tasks with Pro" (false — tasks are already unlimited)
- ✅ "Your AI lives only on this phone. Drop it in a pool and it's gone." (real data risk)
CTAs say "Get [Tier]" not "Unlock [Tier]" — "unlock" implies something is being withheld; "get" describes an acquisition.
Implementation
UpgradeContext is declared at the top of UpgradeView.swift. UpgradeView has a var context: UpgradeContext = .default property (default argument) so all 15+ existing call sites that pass no context remain source-compatible and correctly show .default copy.
The three computed vars (eyebrow, headline, subCopy) are private and live inside UpgradeView. The subCopy var returns String? — nil for .default means no sub-copy row renders.
Any new upgrade surface must pass an explicit context that matches the moment of interception.
Task-10 Gold Badge — One-Time Upgrade Sheet
What it is
After a free user completes their 10th agentic task, the next time they tap the Settings tab they see UpgradeView(context: .taskBadge) — once, never again.
Why task 10
Task 10 is the investment threshold. A user who has completed 10 tasks has:
- Learned the agentic workflow (ability is high)
- Integrated LucidPal into real work (motivation is high)
- Accumulated personal context and chat history on this device (something to lose)
The combination of high motivation and something concrete to lose is exactly when loss aversion lands hardest. Showing the sheet on task 1 would be pure friction; task 10 is earned.
Why Settings tab, not inline
The sheet is deferred to the next Settings tap rather than appearing immediately after task 10 completes. Interrupting the user mid-task with an upgrade sheet undermines the very engagement signal we're rewarding. Settings is a natural pause — the user has consciously shifted away from task execution.
One-time gate
UserDefaultsKeys.didShowTaskBadgeUpgrade ("didShowTaskBadgeUpgrade") — a Bool stored via @AppStorage. The flag is set in onDismiss of the sheet (not before presentation) to ensure it only records "shown" after the user actually sees and dismisses it.
The onChange(of: selectedTab) guard checks five conditions in sequence: tab is .settings, entitlement is .free, completedTaskCount >= 10, !didShowTaskBadgeUpgrade, !showTaskBadgeUpgrade. The last guard prevents a double-trigger if onChange fires twice before the sheet has rendered.
swift
// ContentView.swift
if newTab == .settings,
premiumManager.entitlement == .free,
completedTaskCount >= 10,
!didShowTaskBadgeUpgrade,
!showTaskBadgeUpgrade {
showTaskBadgeUpgrade = true
}Copy intent
"You've made LucidPal yours." / "Don't lose it." / "Your AI lives only on this phone. Drop it in a pool and it's gone."
The eyebrow acknowledges the investment ("made it yours"). The headline is a pure loss frame. The sub-copy makes the risk visceral and specific — not "data may be at risk" but a concrete scenario the user can picture.
Settings Sync Row — Tappable Upgrade Card
What changed
syncUpgradeCard in SettingsView+Sync.swift was previously a static display card — visually present but not interactive. Free users could see it but tapping did nothing.
It is now wrapped in a Button that sets upgradeContext = .sync and presents UpgradeView via the shared showUpgradeSheet binding on SettingsView.
The card copy was updated to match the .sync loss frame:
| Field | Before | After |
|---|---|---|
| Title | "Device Sync" | "This phone only" |
| Subtitle | "Keep your data in sync across devices" | "Lose it, lose everything. Pro syncs your notes, chats, and habits across every device." |
Pro users
syncSection gates on premiumManager.canUseDeviceSync. Pro users see the real sync controls (syncCard + wifiOnlyToggle). Free users see syncUpgradeCard. The loss-aversion copy is never shown to users who already have Pro.
Context isolation
SettingsView has a shared @State var upgradeContext: UpgradeContext = .default that all upgrade-triggering paths within settings mutate before setting showUpgradeSheet = true. Every code path resets the context explicitly:
- Sync card:
upgradeContext = .sync - Upgrade-gated nav rows (
appNavRow):upgradeContext = .default
This prevents stale context bleed when the sheet is dismissed and re-opened from a different path in the same Settings session.
Summary: Gate Placement Rationale
| Feature | Where gated | Why |
|---|---|---|
| Live Notes tab | Not gated | Zero value experienced = zero motivation to upgrade |
| First session | Trial modal (once) | Set expectations before commitment; transparency > surprise |
| Session duration | Real-time countdown | Scarcity during use; ticking clock makes value concrete |
| Stop & Save (exhausted) | UpgradeView(context: .liveNotesSave) | Peak motivation moment — value in hand, one tap from saving |
| Cap hit mid-recording | UpgradeView(context: .liveNotesCap) | Value just captured; loss is immediate and concrete |
| Trial fully exhausted entry | UpgradeView(context: .liveNotesEntry) | No trial left; no point in entering without upgrade decision |
| Task-10 badge | UpgradeView(context: .taskBadge) — once | Investment threshold; something real to lose (chat history) |
| Settings sync row | UpgradeView(context: .sync) — tappable card | Device lock-in framing at the exact UI surface that names it |
| Analytics consent | After first Agent response | Reciprocity + positive association; consent at value peak |
| Gmail/Exchange (free users) | Not gated — unified trial starts on first connect | Entry barrier = zero motivation; let them try before asking |
| Email trial (active) | Day-paced hints + passive badge (both cards) | Progressive discovery; habit formation before the ask |
| Email trial (day 25+) | Loss-framed warning modals (context-aware copy) | Lead with what disappears; quantified usage activates sunk cost |
| Email trial (expired) | Hard gate, no dismiss | Decision must be made; past-tense framing reinforces real loss |
The pattern is consistent: let users experience value first, gate at the moment of commitment, frame every gate as ongoing loss — not a one-time unlock.