Live config loaded — values shown in teal reflect what is currently deployed on this server.
AGS Documentation
Back to Dashboard

Participant Journey src/api/participants.py

A participant interacts with AGS through the PWA at /ags. They never see the researcher dashboard. Their entire journey — from first visit to session completion — is designed to be as low-friction as possible for someone who may never have used a smartphone app before.

Enrollment

1. Language selection
Kiswahili (default), Kiluhya, or English. All subsequent screens and help content are delivered in the selected language. This choice is stored and used for every session.
2. What AGS does — privacy notice
A plain-language screen explains that AGS watches how they interact (not what they type) and may offer help. This is not the consent form — it's preparation for it.
3. About You
Phone number (validated E.164 format — must match +254… or equivalent), age group, digital literacy level, prior web use (yes/no), font size preference (normal / large), and a self-efficacy score. These feed directly into the readiness model. The phone number is the only thing they need to remember.
4. Consent — hard gate
Participants must scroll the consent text and explicitly accept. consent_recorded = true is written to the database at this point. No telemetry row may be written before this — not even the self-efficacy score from step 3.
5. Study ID reveal
A 2.2-second fullscreen overlay shows the assigned study ID: AGS001, AGS002, and so on. This is generated by a PostgreSQL sequence (ags_participant_seq) so it is race-condition safe even under concurrent enrollments. Participants are encouraged to note it for reference, though they don't need it to return.
6. JWT issued
A 30-day participant token is stored in localStorage. Why 30 days? Older participants may take days or weeks between sessions. Requiring re-login after two hours — standard for web apps — would mean they'd need to re-enter their phone number every time. Thirty days covers a study cohort comfortably while remaining recoverable: if the token expires, they just enter their phone again.

Why phone number, not account credentials

The target population frequently doesn't remember passwords, email addresses, or usernames they set up months ago. A phone number is something they always have with them and already know by heart. There's no password to forget, no email to check, no account recovery flow.

Privacy note. The raw phone number is never stored. On enrollment and reauth, the phone is converted to an HMAC-SHA256 token using PHONE_HMAC_SECRET before the database write. This is one-way: a phone cannot be recovered from its token. The PHONE_HMAC_SECRET must be a separate key from JWT_SECRET — if you rotate the JWT secret (e.g. after a security event), participants' phone-based reauth should not suddenly stop working.

Returning participants

The router guard checks the JWT expiry client-side before any screen renders. If the token has expired and a phone number is stored locally, it attempts a silent reauth — POST /v1/participants/reauth — in the background. The participant sees nothing; they go straight to service selection. Only if reauth fails (wrong phone, unknown number) do they see the sign-in form.

Session lifecycle

Service selection
Participant chooses a task (eCitizen login, SHA registration, etc.). The session is created server-side: study condition is resolved from StudyConditionConfig, the task template is validated for schema_version, and a Redis context is initialised. The client never sends or sees the study condition.
Baseline calibration (3 taps)
The first three interactions are baseline calibration — they establish the participant's personal interaction rhythm. No governance decision fires during this window. After the third tap, calibration_cleared = true and the full pipeline activates. Why 3? One tap is too noisy (nerves, unfamiliarity with the device). Three gives enough variance to establish a meaningful personal baseline without delaying the session.
Live session
Each event posts to /v1/sessions/{id}/events. The full pipeline runs synchronously (≤ 100ms): signal normalisation → readiness estimation → governance decision → adaptation content resolution. The adaptation decision is returned in the response. Database and Redis writes for history happen asynchronously off the critical path.
Explicit help
The Help button always returns Level ≥ 2, regardless of readiness state, cooldown, or study condition. This is a hard rule — a participant who asks for help gets meaningful help, always. For Level 1 and 2 responses, help comes from the scripted content library (step-specific first, then generic). For Level 3, the help is generated by Mistral LLM directly — the scripted library serves only as a fallback if Mistral is unavailable.
Episode 1 — adaptive scenario
After calibration clears, SessionView calls GET /starting-scenario. The server computes a starting difficulty from the participant's persistent ability estimate, time since last session (gap decay), and today's calibration delta (IKI ratio, error rate, help rate during baseline). The starting difficulty is capped at D3 — D4/D5 risk scenarios are never the first episode. The sim receives an ags-next-scenario postMessage and loads the matching scenario.
Episodes 2 and 3 — ZPD targeting
When the participant completes the scenario goal (sim fires ags-practice-goal-complete), SessionView calls POST /episodes/complete. The server scores the episode (completion × 0.50 + independence × 0.25 + efficiency × 0.25, with governance penalties for worsened outcome or L4 HANDOFF). The ability estimate is updated via an ELO-inspired formula, and the next scenario is selected from the Zone of Proximal Development (±1 difficulty from current ability). If end-of-episode readiness was LOW or OVERLOAD (below 40), the next difficulty shifts down by 1. The sim receives the next ags-next-scenario. A transition screen appears showing the completion confirmation, then the next task's title and a plain-language difficulty signal ("↑ A bit more challenging" / "Similar difficulty" / "↓ A little easier"). The participant taps Continue, which reveals the scenario intro card for the next episode before the sim becomes interactive.
Post-session survey
After episode 3, session_done: true is returned. The survey is triggered. An optional survey asks about confidence, how the session felt, and perceived difficulty. Every item is skippable. The confidence score from this survey feeds into the next session's readiness model as the germane load component (GL). For the first session, GL is null and its weight redistributes across IL and EL.
Session end
PATCH /v1/sessions/{id} writes ended_at in UTC. If the participant abandons, the abandonment step ID and reason are recorded. If they're offline when the session ends, the end event is queued locally and replayed when connectivity returns.

Multi-episode session flow

  1. Session created → Redis context initialised → calibration window (3 events)
  2. GET /starting-scenario → ability estimate read → starting difficulty selected → sim loads Episode 1
  3. Pipeline events fire normally throughout the episode (readiness, governance, adaptation all active)
  4. Sim fires ags-practice-goal-completePOST /episodes/complete → performance scored → ability updated → next scenario selected
  5. Sim loads Episode 2 via ags-next-scenario
  6. Repeat for Episode 3
  7. session_done: true → survey triggered → PATCH /sessions/{id} closes session

Offline queue

If the participant loses connectivity mid-session, events are queued in localStorage by a service-worker-backed queue. A banner notifies them they're offline and how many events are pending. When connectivity returns, the queue replays automatically. The API accepts queued events idempotently via client_event_id — the same event submitted twice produces only one row.

Token summary

TokenTTLRefreshEnv var
Participant JWT30 daysSilent — stored phone triggers reauth automaticallyJWT_SECRET
Researcher JWT8 hoursManual re-loginRESEARCHER_JWT_SECRET