Session Mode is where the prime directive — the user does less — earns its keep. A client should be able to complete a coached strength session with nothing but ✓ taps. Every number on screen was proposed by the system from Nata's program, the client's history, and this morning's recovery from Health Sync; the client's only decisions are exceptions.
1. Program structure: what Nata authors in the Coach Console
Nata builds programs in the Coach Console program builder. A program is structured data, never free text — three reasons:
- The weight-proposal engine needs machine-readable targets (sets, rep ranges, RPE, progression rules) to compute numbers.
- Analytics (volume, adherence, e1RM trends) must be deterministic SQL over events, per locked decision 6 in the brief.
- Substitutions need movement-pattern and equipment tags to offer valid swaps automatically.
The hierarchy:
erDiagram
PROGRAM ||--o{ MESOCYCLE : "contains 1..n"
MESOCYCLE ||--o{ WEEK : "4-6 incl. deload"
WEEK ||--o{ SESSION_TEMPLATE : "e.g. A / B / C"
SESSION_TEMPLATE ||--o{ EXERCISE_SLOT : "ordered slots"
EXERCISE_SLOT }o--|| EXERCISE : "references library"
EXERCISE ||--o{ SUBSTITUTION : "equipment-tagged swaps"
SESSION_TEMPLATE ||--o{ SESSION_INSTANCE : "one per performed day"
SESSION_INSTANCE ||--o{ SET_EVENT : "immutable log"Nata defines session templates once per mesocycle; the builder stamps them into weeks and applies week-level rules (loading weeks progress, the final week auto-deloads). An exercise slot — the atomic unit — looks like this (Marta's Session A, slot 1):
slot:
order: 1
exercise: goblet_squat # from Nata's exercise library
pattern: squat # movement pattern, drives swap logic
is_compound: true
sets: 3
rep_range: [8, 10]
target_rpe: [7, 8]
rest_sec: 90
progression: double_progression # fill the rep range, then +1 load step
load_step_kg: 2 # dumbbell increment at Marta's gym
starting_load_kg: 10 # Nata-set for meso week 1 (postpartum-conservative)
cue: "Chest tall, elbows inside knees."
demo_video: s3://exercises/goblet-squat-20s.mp4
substitutions: [db_box_squat, leg_press, split_squat]
nata_override: null # e.g. "cap at 16 kg until pelvic-floor check-in"
The exercise library (about 120 movements at pilot) carries per-exercise metadata Nata fills in once: form-cue one-liner, 20-second demo clip, equipment requirements, the technique checklist used by Form Check, and equipment-tagged substitutions.
| Level | Owner | Typical values (pilot) |
|---|---|---|
| Program | Nata | "Return to Strength" (Marta), "Recomp Hybrid" (Denys) |
| Mesocycle | Nata | 5 weeks: 4 loading + 1 deload |
| Week | Builder (auto) | week_in_meso 1–5, is_deload flag on week 5 |
| Session template | Nata | Marta: A/B/C full-body ×3; Denys: 2 strength + 2 zone-2 |
| Exercise slot | Nata | 4–6 slots per strength session |
2. Starting a session: the Morning Brief proposes, the user taps
Nobody opens a menu to "log a workout." The Morning Brief (see 02-user-experience.md) already knows today is a training day and proposes the session, pre-adjusted for recovery:
NataCoachBot 07:31
Morning, Marta ☀️ Whoop recovery 78% — green.
Today: Session A (squat, row, press + core), ~35 min.
Everything's set up — weights proposed from last Tuesday.
[ ▶ Start when ready ] [ Move to another day ] [ Preview session ]
Tapping Start (whenever she actually reaches the gym — the button stays live all day) drops her into the chat-based runner: one set card message at a time, edited in place as she progresses. In Phase 2 the same button opens the Mini App runner instead; chat remains the always-working fallback (hotel Wi-Fi, old phones — see 11-roadmap.md).
| Capability | Chat runner (Phase 1) | Mini App runner (Phase 2) |
|---|---|---|
| Set cards, buttons, logging | ✓ inline keyboards | ✓ native UI, swipe between sets |
| Rest timer | message with 1–2 edits (30 s left, "Go") | live countdown + haptic/sound |
| Demo videos | link → opens clip | inline autoplay loop |
| Session overview / jump around | "Preview session" list | full session board |
| Offline tolerance | Telegram's own queueing | local state, syncs on reconnect |
3. The per-set card
One Telegram message per set, four buttons, zero typing in the happy path:
NataCoachBot 18:04
🏋️ Goblet Squat — set 1 of 3
"Chest tall, elbows inside knees." ▶ 20-sec demo
Proposed: 12 kg × 8 reps
[ ✓ Done as proposed ] [ ± Adjust ]
[ ⇄ Swap exercise ] [ ✋ Can't do it ]
- ✓ Done as proposed — logs exactly the proposed load × reps. One tap. This is the designed-for path; in pilot we target ≥ 75% of sets logged this way.
- ± Adjust — quick-tap steppers (
weight −1/+1 step,reps −2…+2) or type10x9. Adjusting affects this set's log and feeds the engine; it does not nag or ask why. - ⇄ Swap exercise — opens the slot's substitution list filtered by the session's equipment profile (§5).
- ✋ Can't do it — one-question triage (§5). Pain always routes to Nata.
Rest timer. After each logged set the bot posts ⏱ Rest 90 s, edits it once at 30 s left…, and again to Go — set 2 👇 alongside the next card. (Telegram rate-limits edits; two edits per rest period is deliberate.)
RPE capture — once per exercise, not per set. After the final set of an exercise, one emoji row:
How did goblet squats feel overall?
[ 🟢 easy ] [ 🟡 solid ] [ 🟠 hard ] [ 🔴 maxed ]
| Tap | Stored RPE | Meaning vs target RPE 7–8 | Engine reaction next time |
|---|---|---|---|
| 🟢 easy | 6 | clear headroom | two 🟢 in a row → skip ahead one progression step |
| 🟡 solid | 7 | on target | progress per rule |
| 🟠 hard | 8 | top of target | progress per rule, watch closely |
| 🔴 maxed | 9–10 | over target | hold load, −1 rep target; two 🔴 in a row → −5% and flag to Nata |
Per-set RPE is a logging chore real lifters skip; per-exercise RPE is one tap and enough signal for autoregulation. (Assumption we'll validate in pilot week 3.)
4. The weight-proposal engine
This is the centerpiece of "the user does less": the client never computes a number. The engine runs when a session instance is created (at Morning Brief time) and re-runs if Health Sync data arrives late.
proposeSlotTarget(slot, user):
# 1. Baseline: last completed performance of this exercise (from SET_EVENTs)
base = lastPerformance(user, slot.exercise) # load, reps, rpe
if none: base = slot.starting_load_kg × bottom of rep_range
# 2. Progression rule vs week-in-mesocycle
target = slot.progression.apply(base, weekInMeso)
# double_progression: last RPE ≤ 6 → jump reps toward top of range;
# all sets at top of range at RPE ≤ 8 → load += load_step, reps → bottom
if week.is_deload: target.load ×= 0.85 ; target.sets −= 1
# 3. RPE autoregulation (table in §3)
if base.rpe ≥ 9: hold load, target.reps −= 1
# 4. Today's recovery modifier (Health Sync readiness 0–100)
r = todayReadiness(user) # Whoop recovery / normalized Garmin, see 04-data-collection.md
if r <= 33: # red day (bands per 02 §2.2 / 04 §5.2: red ≤ 33)
if slot.is_compound: target.load ×= 0.90 ; target.sets = max(1, target.sets − 1)
else: target.sets −= 1
progressionState.freeze() # today doesn't advance the ladder
elif r < 67: # yellow day
progressionState.hold() # repeat last targets, no new load/reps
# 5. Nata override always wins
if slot.nata_override: target = clamp(target, override)
# 6. Round to what physically exists in the user's equipment profile
target.load = roundToEquipment(target.load, user.equipment_profile, slot.exercise)
return target + provenance # every number carries its "why"
Every proposal carries provenance; a Why this weight? button renders it in one sentence ("12 kg because you hit 3×10 @ solid last week — that graduates you up one dumbbell"). Trust is the feature.
roundToEquipment rounds to the nearest available implement or plate combination: Marta's gym has dumbbells in 2 kg steps above 10 kg; barbell work rounds to 2.5 kg total; on red days ties round down.
Worked example: Marta's goblet squat, mesocycle 2
Slot: 3 × 8–10 @ RPE 7–8, double progression, +2 kg step, Nata's starting load 10 kg.
| Date | Readiness | Engine math | Proposal | Result | RPE tap |
|---|---|---|---|---|---|
| Tue Jul 7 (W1) | 71% 🟢 | starting load, bottom of range | 10 kg × 3×8 | as proposed | 🟢 easy (6) |
| Tue Jul 14 (W2) | 68% 🟢 | RPE 6 → jump to top of range | 10 kg × 3×10 | as proposed | 🟡 solid (7) → load increase earned: next target 12 kg × 3×8 |
| Tue Jul 21 (W3) | 29% 🔴 | 12 kg × 0.90 = 10.8 kg → nearest of {10, 12} → 10 kg; sets 3 − 1 = 2; progression frozen | 10 kg × 2×8 | as proposed | 🟢 easy (6) — correct on a red day |
| Tue Jul 28 (W4) | 78% 🟢 | frozen target resumes | 12 kg × 3×8 | as proposed | 🟡 solid (7) |
| Tue Aug 4 (W5, deload) | 74% 🟢 | 12 × 0.85 = 10.2 → 10 kg; sets 3 − 1 = 2 | 10 kg × 2×8 | as proposed | 🟢 easy (6) |
The red-day message makes the adjustment feel like coaching, not a downgrade:
NataCoachBot 07:31 (Tue Jul 21)
Recovery 29% — rough night, I saw. We're not skipping, we're trimming:
goblet squats today at 10 kg × 2×8 instead of the planned 12s.
The 12s aren't going anywhere — they're waiting for a green day. 💛
[ ▶ Start when ready ] [ Rather rest today ]
Five workouts, zero numbers computed by Marta, one tap each morning. That is the product.
5. Substitutions, hotel gym mode, and "Can't do it"
Swap list. Each slot lists 2–4 substitutions tagged by movement pattern and required equipment. Tapping ⇄ Swap exercise shows only the swaps valid for the session's equipment profile (full_gym / hotel_gym / bodyweight_band).
Hotel gym mode (Denys). When Denys taps I'm traveling in the Morning Brief (or his Garmin location/calendar hints at it and the bot asks), the whole session re-resolves against hotel_gym: barbell RDL → dumbbell RDL, lat pulldown → band pulldown, etc. Loads are recomputed with a per-substitution transfer hint (e.g. db_rdl ≈ 35% of barbell load per hand), proposed one notch conservative on first use, then calibrated from his RPE tap. His barbell progression state is frozen, not lost — home-gym numbers resume when he's back. See the travel-week walkthrough in 10-personas.md.
"Can't do it" triage — exactly one question:
What's the issue with this one?
[ 🧰 No equipment ] [ ⚡ Something hurts ] [ 🥵 Too hard today ]
| Answer | System response |
|---|---|
| 🧰 No equipment | show swap list for the slot; remember the gap in this equipment profile |
| ⚡ Something hurts | safety escalation — skip the slot and every remaining slot sharing its movement pattern, log a pain.reported event, notify Nata immediately regardless of autonomy dial (locked decision 7); Nata follows up personally |
| 🥵 Too hard today | −10% load or offer the easiest regression; engine notes it for next proposal |
6. Session summary → events → Wiki Brain
Closing message after the last exercise:
Session done — 34 min 💪
Volume: 2,742 kg across 11 sets (last Tuesday: 2,510 kg)
PR: Goblet squat 12 kg × 8 — your heaviest set since the restart
Effort: mostly 🟡, one 🟠 — exactly where week 4 should sit
Nata: "Strong day. Protein with dinner tonight — Thursday we press."
The coach sentence is generated by the persona layer (Nata's tone guide + this user's Wiki Brain) and follows the Coach-in-the-loop autonomy dial; PRs and volume are deterministic SQL, never LLM arithmetic.
Everything the session produced is immutable events in Postgres:
| Event | Key payload fields |
|---|---|
session.started |
session_instance_id, template, equipment_profile, readiness |
set.logged |
exercise, weight_kg, reps, proposed_weight_kg, proposed_reps, adjusted:bool |
rpe.recorded |
exercise, rpe (6/7/8/9–10), emoji_tap |
exercise.swapped |
from, to, reason |
session.cant_do |
slot, triage_answer |
pain.reported |
slot, free_text if given → escalation |
session.completed |
duration_min, total_volume_kg, pr_flags |
The nightly distillation job (Karpathy ingest workflow, 08-llm-wiki-brain.md) folds these into training-history.md and exercises/*.md — per-exercise pages get new best sets and cue history, log.md gets the dated entry, contradictions (e.g. "says squats easy but adjusted down twice") are flagged for lint.
7. Session state machine and full transcript
stateDiagram-v2 [*] --> Proposed Proposed --> Active: user taps Start Proposed --> Skipped: declined or expires at 22:00 Active --> SetCard: load next slot SetCard --> Logging: Done as proposed or Adjust SetCard --> Swapping: Swap exercise SetCard --> Triage: Cant do it Swapping --> SetCard: substitute chosen Triage --> SetCard: equipment or too-hard resolved Triage --> Escalated: pain reported Logging --> Rest: sets remaining in slot Logging --> RPECapture: final set of exercise Rest --> SetCard: timer done RPECapture --> SetCard: next exercise RPECapture --> Summary: last exercise done Active --> Paused: no input for 15 min Paused --> SetCard: user returns same day Paused --> AutoClosed: end of day, partial session logged Summary --> [*] Escalated --> [*] Skipped --> [*] AutoClosed --> [*]
Transcript — Marta, Tue Jul 28 (W4, readiness 78%), first two exercises of Session A:
Marta 18:03 [taps ▶ Start when ready]
NataCoachBot 18:03 Let's go — Session A, 5 exercises, ~35 min.
NataCoachBot 18:03 🏋️ Goblet Squat — set 1 of 3
"Chest tall, elbows inside knees." ▶ 20-sec demo
Proposed: 12 kg × 8 — the 12s you earned two weeks ago 🙂
[ ✓ Done as proposed ] [ ± Adjust ] [ ⇄ Swap ] [ ✋ Can't do it ]
Marta 18:06 [✓ Done as proposed]
NataCoachBot 18:06 ⏱ Rest 90 s → (edited) 30 s left… → (edited) Go — set 2 👇
NataCoachBot 18:08 🏋️ Goblet Squat — set 2 of 3 · Proposed: 12 kg × 8
Marta 18:10 [✓ Done as proposed]
NataCoachBot 18:12 🏋️ Goblet Squat — set 3 of 3 · Proposed: 12 kg × 8
Marta 18:14 [✓ Done as proposed]
NataCoachBot 18:14 How did goblet squats feel overall?
[ 🟢 easy ] [ 🟡 solid ] [ 🟠 hard ] [ 🔴 maxed ]
Marta 18:14 [🟡 solid]
NataCoachBot 18:14 Logged. That's a rep-for-rep PR at 12 kg 🎉 Next up:
NataCoachBot 18:15 🏋️ One-Arm DB Row — set 1 of 3 (each side)
"Pull the elbow to your hip, not your armpit." ▶ demo
Proposed: 14 kg × 10
[ ✓ Done as proposed ] [ ± Adjust ] [ ⇄ Swap ] [ ✋ Can't do it ]
Marta 18:18 [✓ Done as proposed]
NataCoachBot 18:18 ⏱ Rest 75 s
NataCoachBot 18:20 🏋️ One-Arm DB Row — set 2 of 3 · Proposed: 14 kg × 10
Marta 18:23 [✓ Done as proposed]
NataCoachBot 18:24 🏋️ One-Arm DB Row — set 3 of 3 · Proposed: 14 kg × 10
Marta 18:27 [± Adjust] → taps reps −2 → logs 14 kg × 8
NataCoachBot 18:27 14 × 8 logged — grip goes before back on rows, totally normal.
How did rows feel overall?
Marta 18:27 [🟠 hard]
NataCoachBot 18:27 Noted — I'll hold rows at 14 kg next week and we'll rebuild the last set.
Next: incline push-ups…
[… exercises 3–5, then the session summary from §6 …]
Two exercises, six working sets, one adjustment, two emoji taps — and the engine now knows exactly what to propose next Tuesday.
Related: 02-user-experience.md (Morning Brief, notifications) · 04-data-collection.md (readiness score) · 07-video-feedback.md (Form Check from inside a session) · 08-llm-wiki-brain.md (distillation) · 09-admin-analytics.md (program builder, overrides, escalations).