---
title: "User flows"
description: "Nine flows as Mermaid and step tables: screen, trigger, API, success, failure, exit."
source: "design/flows.md"
---

> Part of [Brand and design](/design/llms.txt). Every page: [llms.txt](/llms.txt).

# User flows

Screen ids refer to files in `screens/` (`<group>/<screen>--<state>[.en].html`). Every API call is in [`openapi.yaml`](/api/openapi.yaml). Every request carries `Authorization` (after `/auth/anonymous`) and `Accept-Language: <UI locale>` (ADR-001 D13).

Contents: [1 First launch → first session](#1-first-launch--diagnostic--paywall--first-real-session) · [2 Daily quest](#2-daily-quest) · [3 Free practice](#3-free-practice-from-the-library) · [4 Resume after kill](#4-resume-after-the-app-was-killed) · [5 Permission denied](#5-microphone-permission-denied) · [6 Offline mid-upload](#6-offline-mid-upload) · [7 Expired subscription](#7-expired-subscription) · [8 Delete data](#8-delete-training-data--account) · [9 Change languages](#9-change-ui-language--practice-languages)

---

## 1. First launch → diagnostic → paywall → first real session

```mermaid
flowchart TD
  A([App launch]) --> B{Keychain install_id?}
  B -- no --> B1[create install_id] --> C
  B -- yes --> C[POST /auth/anonymous]
  C --> D[GET /config]
  D -->|app < min_app_version| FU[global/force-update]
  D --> E{me.onboarding.status}
  E -- not_started --> W[onboarding/welcome]
  W --> L[language: practice language<br/>default = device locale]
  L --> G[goal] --> S[scenario + topics] --> SL[self-level] --> CM[commitment + reminder time] --> NP[notification-priming]
  NP -->|POST /onboarding| DI[diagnostic/diag-intro]
  DI --> DR[diag-read] -->|POST reading-complete| DRC[diag-record idle]
  DRC -->|first tap| MIC[mic-purpose sheet] --> SYS{system mic prompt}
  SYS -- denied --> DEN[diag-record permission-denied]
  SYS -- granted --> REC[diag-record recording]
  REC -->|submit / 90 s auto-stop| UP[diag-processing]
  UP -->|assessed| RES[diag-result reveal]
  UP -->|unreliable| UNR[diag-processing unreliable] -->|re-record| DRC
  UP -->|failed retryable| FL[diag-processing failed] -->|POST /attempts/id/retry| UP
  RES --> PL[plan]
  PL --> PW[paywall default]
  PW -->|purchase / trial| TX[POST /subscriptions/apple/transactions]
  TX -->|entitlement trial/active| H[home first-time]
  PW -->|restore| RS[paywall restored] --> H
  PW -->|cancel / fail| PF[paywall purchase-failed] --> PW
  H -->|Start today's session| R1[read normal]
```

| # | Screen | Trigger | API call | Success | Failure | Exit |
|---|---|---|---|---|---|---|
| 1 | (splash) | Launch | `POST /auth/anonymous {install_id, timezone, locale, app_version, platform}` | tokens stored | retry with backoff; offline → cached tokens or "connect to start" | — |
| 2 | (splash) | tokens ok | `GET /config` | limits, flags, `supported_ui_locales`, `supported_practice_languages` | use bundled defaults | `force-update` if version too low |
| 3 | `onboarding/welcome` | `onboarding.status = not_started` | — | — | — | Get started |
| 4 | `onboarding/language--selected` | Get started | — | practice language held locally (default device locale) | — | back to welcome |
| 5–8 | goal → scenario → self-level → commitment | Continue (disabled until a choice) | `GET /topics` (scenario) | answers held locally | topics hidden on error | back to the previous step |
| 9 | `onboarding/notification-priming` | Continue | `POST /onboarding` (all answers incl. `practice_language`); if allowed: `PUT /me/push-token` | `{diagnostic_session, article}` | inline retry; answers kept | "Not now" also submits |
| 10 | `diagnostic/diag-intro` | — | — | — | — | Start the diagnostic |
| 11 | `diagnostic/diag-read--normal` | — | `POST /sessions/{id}/reading-complete` | stage → round1 | queued offline | back = intro |
| 12 | `diagnostic/mic-purpose--sheet` | first tap on mic | — | system prompt | — | Not now → idle |
| 13 | `diagnostic/diag-record--*` | tap | `POST /sessions/{id}/attempts` → PUT → `POST /attempts/{id}/submit` | processing | upload error → processing failed | leave → leave-recording alert |
| 14 | `diagnostic/diag-processing--*` | submitted | poll `GET /attempts/{id}` every 2 s → 5 s | `assessed` | `failed` (retry), `unreliable` (re-record), >120 s (check later) | — |
| 15 | `diagnostic/diag-result--reveal` | assessed | — | count-up | — | See my plan |
| 16 | `plan/plan--default` | — | `GET /plan` | 14 days | 404 → spinner + retry (plan job not done) | Continue |
| 17 | `paywall/paywall--default` | — | `GET /paywall`; StoreKit `Product.products` | interactive | `loading-products` until StoreKit returns | **no close** (hard) |
| 18 | paywall | Start trial | `Product.purchase()` → `POST /subscriptions/apple/transactions {signed_transaction}` | `Entitlement.status = trial` → home | `purchase-failed` (not charged) | — |
| 19 | `home/home--first-time` | entitlement ok | `GET /home` | quest = plan day 1 | `home--error` | — |

## 2. Daily quest

```mermaid
flowchart LR
  N[Reminder 08:30<br/>max 1/day] --> H[home normal]
  H -->|Start| S[POST /sessions kind=daily]
  S --> R[read] -->|reading-complete| R1[record round 1]
  R1 -->|submit| P1[processing]
  P1 -->|assessed| F[feedback]
  P1 -->|unreliable| R1
  F -->|Try again with this advice| R2[compress round 2]
  R2 -->|submit| P2[processing round 2]
  P2 -->|assessed| C[POST /sessions/id/complete]
  C --> CP[complete improved/same/shorter-lost]
  CP -->|level up| LU[complete level-up overlay]
  CP -->|achievement| AC[achievement sheet]
  CP --> G[growth ready] & H2[home completed]
```

| # | Screen | Trigger | API call | Success | Failure | Exit |
|---|---|---|---|---|---|---|
| 1 | `home/home--normal` | open app / notification | `GET /home` | quest in a practice language | cached + offline banner / error | tabs |
| 2 | `read/read--normal` | Start | `POST /sessions {article_id, kind: daily}` (Idempotency-Key); 200 returns an existing in-progress session | session | 402 → paywall expired-return | back keeps stage `reading` |
| 3 | `record/record--*` | reading-complete | attempts → PUT → submit | processing | see flows 5 & 6 | leave alert |
| 4 | `processing/processing--*` | submit | poll `GET /attempts/{id}` | `assessed` → haptic success | failed / unreliable / long-wait | "check later" → home resume |
| 5 | `feedback/feedback--full` | assessed | `GET /attempts/{id}/exemplar` (poll while pending), `GET /attempts/{id}/audio` on evidence tap, `POST /assessments/{id}/feedback` | — | exemplar failed → text + retry | Try again → round 2 |
| 6 | `compress/compress--*` | CTA | attempts {round 2} → PUT → submit | processing round 2 | 409 stage precondition → refetch session | leave alert |
| 7 | `complete/complete--*` | round 2 assessed | `POST /sessions/{id}/complete` (Idempotency-Key = session_id) | Settlement | 409 → show feedback for missing round; network → retry (idempotent) | See progress / Back to Home |
| 8 | `home/home--completed` | back | `GET /home` (invalidate cache) | streak +1, quest done | — | Free practice → library |

## 3. Free practice from the library

| # | Screen | Trigger | API call | Success | Failure | Exit |
|---|---|---|---|---|---|---|
| 1 | `library/library--list` | Practice tab | `GET /topics`, `GET /articles?language=&topic=&status=` | list | `library--error` | — |
| 2 | `library/library--language-en`, `--filtered` | filter chip | `GET /articles?language=en` / `?topic=` | filtered list | `--filter-empty` | Clear filter |
| 3 | row tap (unlocked) | — | `POST /sessions {article_id, kind: free}` | read | locked row → toast with `unlock_hint` (CCR-3) | — |
| 4–8 | same as daily quest steps 2–7 | — | — | XP per D4 (50 % if repeated within 7 days; `xp.repeat_discount`) | — | — |

## 4. Resume after the app was killed

```mermaid
flowchart TD
  K([Relaunch]) --> Q{Local unsent recording?}
  Q -- yes --> U[Upload queue resumes<br/>attempts → PUT → submit] --> H
  Q -- no --> H[GET /home]
  H --> RS{home.resume_session or today_quest.in_progress?}
  RS -- yes --> SH[global/resume-session sheet]
  SH -->|Continue| ROUTE{session.stage}
  ROUTE -- reading --> RD[read]
  ROUTE -- round1 / round2 --> REC[record / compress idle<br/>or interrupted sheet if partial file]
  ROUTE -- *_processing --> PR[processing]
  ROUTE -- completed --> CP[complete replayed]
  SH -->|Abandon| AB[POST /sessions/id/abandon] --> H2[home normal]
  RS -- no --> H2
```

| # | Screen | Trigger | API call | Success | Failure | Exit |
|---|---|---|---|---|---|---|
| 1 | launch | partial file on disk (`local:recorder.fileURL`) | — | interrupted sheet on the record screen | file unreadable → "recording couldn't be recovered", re-record | — |
| 2 | `global/resume-session--sheet` | `resume_session != null` | `GET /sessions/{id}` | route by `stage` | 404 → drop silently | Continue / Abandon |
| 3 | `complete/complete--replayed` | stage `completed` but settlement not seen | `POST /sessions/{id}/complete` (same Idempotency-Key) | `replayed = true`, no animations | — | — |

## 5. Microphone permission denied

| # | Screen | Trigger | API call | Success | Failure | Exit |
|---|---|---|---|---|---|---|
| 1 | `diagnostic/mic-purpose--sheet` | first tap | — | system prompt | — | — |
| 2 | `record/record--permission-denied` | denied (now or earlier) | none — **no attempt is created** | — | — | Open Settings (deep link) / Back to the article |
| 3 | return from Settings | `scenePhase = active` + permission granted | — | record idle | still denied → same screen | — |

## 6. Offline mid-upload

```mermaid
sequenceDiagram
  participant U as User
  participant A as App
  participant Q as Upload queue (disk)
  participant API
  U->>A: Submit recording
  A->>Q: persist file + attempt metadata
  A->>API: POST /sessions/{id}/attempts
  API-->>A: presigned PUT
  A--xAPI: PUT (network lost)
  A->>U: processing--offline (+ toast)
  Note over A,Q: NWPathMonitor satisfied
  A->>API: PUT (resume, same sha256)
  A->>API: POST /attempts/{id}/submit (idempotent)
  API-->>A: 202 → poll → assessed
  A->>U: feedback
```

| # | Screen | Trigger | API call | Success | Failure | Exit |
|---|---|---|---|---|---|---|
| 1 | `processing/processing--offline` | path unsatisfied during PUT | — | file persisted | — | leave allowed; home shows resume |
| 2 | `global/offline--toast` | connectivity lost anywhere | — | non-blocking | — | — |
| 3 | auto | path satisfied | re-request presign if `expires_at` passed (`POST attempts` with same Idempotency-Key), PUT, submit | processing → feedback | `400 upload_checksum_mismatch` → re-PUT once, then processing failed | — |

## 7. Expired subscription

| # | Screen | Trigger | API call | Success | Failure | Exit |
|---|---|---|---|---|---|---|
| 1 | launch | `GET /me → entitlement.status = expired` (or 402 on `POST /sessions`) | `GET /paywall`, `GET /me` | `paywall/paywall--expired-return` | offline within `grace_until` → app usable from cache | — |
| 2 | paywall expired-return | Subscribe | `Product.purchase()` (no intro offer) → `POST /subscriptions/apple/transactions` | home normal (history intact) | purchase-failed | Restore |

## 8. Delete training data / account

| # | Screen | Trigger | API call | Success | Failure | Exit |
|---|---|---|---|---|---|---|
| 1 | `profile/settings--default` | Delete all training data | — | `settings--delete-data-confirm` | — | Cancel |
| 2 | confirm | Delete | `DELETE /me/training-data` → 202 `{job_id, purge_backups_by}` | toast "Training data deleted"; caches cleared; growth → empty; home first-time-like (streak 0) | error alert, nothing deleted | — |
| 3 | `profile/settings--delete-account-confirm` | Delete account | `DELETE /me` → 204, then `POST /auth/anonymous` with a **new** install_id | back to onboarding welcome | error alert | "Manage subscription first" → App Store |
| 4 | session row (growth recent / history) | swipe → delete | `DELETE /sessions/{id}` → 204 | row removed, stats refetched | — | — |

## 9. Change UI language / practice languages

| # | Screen | Trigger | API call | Success | Failure | Exit |
|---|---|---|---|---|---|---|
| 1 | `profile/settings--language` | App language row | `PATCH /me {settings.ui_locale}` (null = follow device) | UI re-renders immediately; all caches invalidated; refetch with the new `Accept-Language` | revert toggle + error toast | back |
| 2 | same | Practice language toggle | `PATCH /me {settings.practice_languages}` (≥ 1, first = primary) | `GET /home` refetched → quest/quick picks in the new set; library default filter updates | revert | back |
| 3 | in-progress session | any language change | — | the session keeps its own `session.language` (content never switches mid-session) | — | — |
