This document is the technical companion to the product vision and the UX flows. It locks the runtime shape of the system defined in the brief §6: TypeScript monorepo, grammY webhook gateway, Next.js Mini App, PostgreSQL, Redis + BullMQ, S3-compatible object storage, Claude API.
1. Context diagram
Everything user-facing flows through one bot (@NataCoachBot) and one Mini App. Everything intelligent flows through the Orchestrator's pipelines. Everything durable lands in Postgres (events, structured data), S3 (media + Wiki Brain files), or both.
flowchart TD
subgraph Clients["Telegram clients"]
U1["Marta's chat"]
U2["Denys's chat"]
NAPP["Nata — Coach Console Mini App"]
end
TG["Telegram Bot API"]
U1 <--> TG
U2 <--> TG
NAPP <--> TG
subgraph App["NataCoach app (Fly.io, single region)"]
TGS["Self-hosted Bot API server (telegram-bot-api, 2 GB downloads)"]
GW["Bot Gateway (grammY, webhook mode)"]
MINI["Next.js Mini App server (Session Mode UI, Weekly Review, Coach Console)"]
HS["Health Sync webhook receivers (Whoop, Garmin)"]
ORCH["Orchestrator"]
subgraph Pipelines["LLM pipelines (BullMQ workers)"]
ML["Meal Lens"]
SM["Session Mode engine"]
FC["Form Check"]
MB["Morning Brief generator"]
DIST["Nightly distillation"]
end
Q["Redis + BullMQ queues"]
end
PG[("PostgreSQL — events, structured data")]
S3[("S3-compatible storage — media + per-user wiki files")]
CLAUDE["Claude API (Opus-tier + Haiku-tier)"]
WHOOP["Whoop API v2"]
GARMIN["Garmin Health API"]
NATA["Nata (coach)"]
TG <--> TGS
TGS -->|"webhook POST /telegram"| GW
GW --> ORCH
MINI --> ORCH
WHOOP -->|"OAuth + webhooks"| HS
GARMIN -->|"push webhooks"| HS
HS --> Q
ORCH --> Q
Q --> Pipelines
Pipelines --> CLAUDE
Pipelines --> PG
Pipelines --> S3
ORCH --> PG
ORCH --> S3
GW -->|"sendMessage / sendPhoto"| TGS
NATA --> NAPP
NAPP -->|"approvals, program edits, takeover"| ORCHKey properties:
- One inbound door per source. Telegram traffic hits the grammY gateway; wearable traffic hits Health Sync receivers. Both acknowledge fast (< 1 s) and defer real work to BullMQ. Telegram retries undelivered webhooks and Garmin retries failed pushes, so handlers must be — and are — idempotent (§6).
- The Orchestrator is a library, not a service. It is the shared TypeScript layer (context builder, autonomy dial, persona wrapper, safety rails) that the gateway, Mini App, and workers all call. At pilot scale it runs in-process; it can be split out later without changing contracts.
- LLM output is never the source of truth. Pipelines write proposals (macros, feedback drafts, briefs) that become facts only when confirmed (propose-confirm) — and analytics are computed from the
eventstable with deterministic SQL, per brief §4.6.
2. Container / module breakdown
| Container / module | Tech | Responsibilities | Talks to |
|---|---|---|---|
| Self-hosted Bot API server | official telegram-bot-api, own container + small volume |
Local Bot API root for @NataCoachBot (grammY apiRoot points here): proxies all Bot API traffic to Telegram's datacenters and lifts the hosted API's 20 MB download cap to 2 GB — required for Form Check video clips. Downloaded files land on its volume; the gateway streams them to S3 |
Telegram datacenters, Bot Gateway |
| Bot Gateway | grammY, webhook mode | Verify Telegram secret token; dedupe by update_id; route messages, photos, voice, callback taps; render propose-confirm keyboards; downloads media to S3; instant ACK |
Self-hosted Bot API server, Orchestrator, S3, Redis |
| Mini App server | Next.js (Telegram Mini App) | Session Mode runner UI, Weekly Review charts, Coach Console (roster, approval queue, program builder, chat takeover); validates Telegram initData for auth |
Orchestrator, Postgres |
| Health Sync receivers | HTTP handlers | Whoop/Garmin OAuth callback + push webhook endpoints; signature verification; store raw payload; enqueue normalize jobs | Whoop, Garmin, S3, BullMQ |
| Orchestrator | TS library | Per-user context assembly (program + wiki excerpts + recent events); autonomy dial (draft → auto-send with audit → autonomous per message type); safety keyword escalation; persona layer (Nata tone guide + user Wiki Brain) around every coach-voice call |
Postgres, S3, Claude API |
| Meal Lens worker | BullMQ worker | Photo → vision LLM → meal_items + macros → deterministic comparison vs. active food program → coach feedback draft → approval check → reply. Detail: 05-food-analysis.md |
Claude, Postgres, S3 |
| Session Mode engine | TS module + worker | Weight proposals from set history + today's recovery (deterministic progressive-overload rules, no LLM); LLM only for free-text parsing and coach-voice answers. Detail: 06-training-experience.md | Postgres, Claude |
| Form Check worker | BullMQ worker | Video → ffmpeg frame extraction (12 frames) → vision LLM against per-exercise checklist → cue draft → approval queue (always draft mode at pilot). Detail: 07-video-feedback.md | Claude, S3, Postgres |
| Morning Brief generator | BullMQ repeatable job | Per-user pre-dawn build job (06:30 local); merges overnight recovery + today's program day + wiki flags; proposes adjustments; delivery 30–45 min after detected wake, falling back to the user's chosen time (default 07:15) — contract in 04 §6; sends per autonomy dial | Postgres, Claude, Gateway |
| Distillation worker | BullMQ nightly job | Karpathy ingest workflow per user: day's events → raw/ file → update wiki/ pages, index.md, log.md; weekly lint pass. Detail: 08-llm-wiki-brain.md |
Postgres, S3, Claude (Batches) |
| Postgres | Managed (Fly Postgres) | Source of truth: users, programs, sessions, meals, metrics, append-only events, approvals, alerts, wiki page metadata |
— |
| Redis + BullMQ | Managed Redis | Queues: meal-lens, form-check, health-sync, briefs, distillation, outbox; retries, rate limiting, dead-letter |
— |
| Object storage | S3-compatible (Tigris/R2) | media/{userId}/… (photos, videos, voice), wiki/{userId}/… (raw/, wiki/, index.md, log.md — versioned bucket for git-style history) |
— |
3. Data model
Postgres holds structured truth; wiki_pages holds only pointers and version metadata — page bodies live in S3. The events table is append-only and is the single feed for both analytics and nightly distillation.
erDiagram
users ||--o{ integrations : "connects"
users ||--o{ metrics : "has daily"
users ||--o{ programs : "assigned"
programs ||--o{ program_days : "contains"
program_days ||--o{ exercises : "prescribes"
users ||--o{ sessions : "trains"
program_days ||--o{ sessions : "instantiates"
sessions ||--o{ sets : "logs"
exercises ||--o{ sets : "performed as"
users ||--o{ meals : "photographs"
meals ||--o{ meal_items : "decomposes into"
users ||--o{ events : "emits"
users ||--o{ wiki_pages : "owns"
users ||--o{ approvals : "generates"
users ||--o{ alerts : "triggers"
users {
uuid id PK
bigint tg_user_id UK
text display_name
text timezone
text goals
text contraindications
text status
timestamptz created_at
}
integrations {
uuid id PK
uuid user_id FK
text provider
bytea access_token_enc
bytea refresh_token_enc
text scopes
text status
timestamptz connected_at
}
metrics {
uuid id PK
uuid user_id FK
date day
text source
text kind
numeric value_num
jsonb value_json
text dedupe_key UK
timestamptz ingested_at
}
programs {
uuid id PK
uuid user_id FK
text program_type
int version
text status
date starts_on
text coach_notes
}
program_days {
uuid id PK
uuid program_id FK
int day_index
text focus
jsonb targets_json
}
exercises {
uuid id PK
uuid program_day_id FK
int slot
text name
int sets_planned
text rep_range
int rest_sec
text cues
text substitution_group
}
sessions {
uuid id PK
uuid user_id FK
uuid program_day_id FK
timestamptz started_at
timestamptz completed_at
text state
numeric readiness_score
numeric volume_kg
numeric rpe_avg
}
sets {
uuid id PK
uuid session_id FK
uuid exercise_id FK
int set_index
numeric proposed_weight_kg
numeric actual_weight_kg
int reps
numeric rpe
text flag
}
meals {
uuid id PK
uuid user_id FK
timestamptz eaten_at
text photo_s3_key
text status
int kcal_est
int protein_g
int carbs_g
int fat_g
text confidence
text score
}
meal_items {
uuid id PK
uuid meal_id FK
text name
int portion_est_g
int kcal
int protein_g
int carbs_g
int fat_g
}
events {
bigint seq PK
uuid user_id FK
timestamptz ts
text event_type
jsonb payload
text source
text dedupe_key UK
}
wiki_pages {
uuid id PK
uuid user_id FK
text path
text title
text s3_key
int version
text updated_by
text summary
timestamptz updated_at
}
approvals {
uuid id PK
uuid user_id FK
text draft_type
text draft_content
text status
text decided_by
int latency_s
timestamptz created_at
timestamptz decided_at
}
alerts {
uuid id PK
uuid user_id FK
text severity
text kind
text message
text status
timestamptz created_at
timestamptz ack_at
}Notes on intent:
eventsis the spine. Every fact —set.logged,meal.scored,sleep.recorded,brief.confirmed,pain.reported— is one immutable row with adedupe_key(e.g.garmin:sleep:2026-08-09:denys) enforcing exactly-once semantics under webhook retries. Structured tables (sets,meals,metrics) are convenient projections; if they ever disagree withevents, events win.metricsis normalized wearable truth. One row per user × day × source × kind (recovery_score,sleep_minutes,hrv_ms,rhr_bpm,strain,steps,weight_kg), so Morning Brief and Weekly Review queries never touch provider-specific payloads. Mapping rules live in 04-data-collection.md.approvalsis the Coach-in-the-loop ledger. Every LLM draft that requires review is a row;latency_sfeeds the "< 30 s per item" target in 09-admin-analytics.md.wiki_pagesstores pointers, not prose. The S3 bucket is versioned, so(s3_key, version)gives git-style history; Nata's manual edits setupdated_by = 'nata'and are treated as ground truth by the distiller.
4. Sequence diagrams
4.1 Meal photo, end to end (draft-mode autonomy)
sequenceDiagram participant M as Marta (Telegram) participant T as Telegram Bot API participant G as Bot Gateway (grammY) participant Q as BullMQ meal-lens participant W as Meal Lens worker participant P as Postgres participant S as S3 participant C as Claude API participant N as Nata (Coach Console) M->>T: sends lunch photo (13:42) T->>G: webhook POST (update_id 8812031) G->>G: verify secret token, dedupe update_id G->>S: store photo media/marta/2026-08-09/meal-1342.jpg G->>Q: enqueue meal-lens job G->>T: "Looking at your lunch... " (ack < 1 s) Q->>W: job picked up W->>P: load active food program + today's meal totals W->>S: load wiki excerpt (nutrition-patterns.md, coaching-playbook.md) W->>C: Opus-tier vision call (photo + context) C-->>W: items + macros JSON + feedback draft W->>P: insert meal (status proposed), meal_items, event meal.analyzed W->>W: deterministic compare vs day targets (protein gap 48 g) W->>P: autonomy dial says draft mode - insert approval (pending) P-->>N: approval appears in queue (push) N->>P: approve with 1-word edit (14 s) P->>Q: enqueue outbox send Q->>G: deliver approved feedback G->>T: message + buttons "✓ Looks right" / "Adjust" T->>M: coach feedback with proposed macros M->>T: taps "✓ Looks right" T->>G: callback query G->>P: meal status confirmed, event meal.scored
4.2 Garmin webhook ingest → normalize → Morning Brief adjustment
sequenceDiagram participant GA as Garmin push service participant H as Health Sync receiver participant Q as BullMQ health-sync participant W as Normalizer worker participant P as Postgres participant B as Morning Brief generator participant C as Claude API participant G as Bot Gateway participant D as Denys (Telegram) GA->>H: POST /webhooks/garmin (sleep summary, 05:58) H->>H: verify signature, check dedupe_key H->>Q: enqueue normalize job (raw payload attached) H-->>GA: 200 OK (< 300 ms) Q->>W: job picked up W->>P: upsert metrics (sleep_minutes 348, hrv_ms 41, rhr_bpm 56) W->>P: append event sleep.recorded (dedupe_key garmin:sleep:2026-08-09:denys) W->>W: rule check - sleep 5h48m below 6h30m threshold W->>Q: promote briefs job for Denys (flag low_recovery) Q->>B: 06:30 local - build Morning Brief B->>P: load metrics, today program_day (Zone 2 40 min), wiki flags B->>C: Opus-tier persona call (propose cut to 30 min easy) C-->>B: brief draft B->>P: autonomy dial - auto-send with audit (log approval auto_sent) B->>G: send brief 30-45 min after detected wake (fallback 07:15) G->>D: "Short night (5h48) - let's cut Zone 2 to 30 min today. OK?" ✓ / keep 40 D->>G: taps ✓ G->>P: event brief.confirmed, session plan adjusted
4.3 Nightly distillation (events → Wiki Brain update)
sequenceDiagram participant CR as BullMQ cron (02:30 per user) participant W as Distillation worker participant P as Postgres participant S as S3 wiki bucket participant C as Claude API (Batches) participant N as Nata (alerts) CR->>W: distill job (user marta, watermark seq 48211) W->>P: fetch events where seq > 48211 (37 events today) W->>S: write raw/2026-08-09-events.md (immutable) W->>S: read index.md + affected pages (training-history.md, recovery-patterns.md, nutrition-patterns.md) W->>C: Opus-tier ingest call - update pages, fix cross-refs, flag contradictions C-->>W: revised page bodies + index summaries + contradiction list W->>W: validate - no raw/ edits, page size caps, links resolve W->>S: write new page versions (bucket versioning keeps history) W->>P: bump wiki_pages versions, updated_by distiller W->>S: append log.md entry "[2026-08-09] ingest - 37 events, 3 pages updated" W->>S: update index.md W->>P: advance watermark to seq 48248, event wiki.distilled alt contradiction found W->>P: insert alert (kind wiki_contradiction, severity low) P-->>N: shows in Console next morning end alt LLM or validation failure after 3 retries W->>W: move job to dead-letter queue W->>P: alert (kind pipeline_failure) - wiki untouched, last good version stands end
5. LLM model routing and cost
Per brief §6: Opus-tier (frontier, vision) for coach reasoning, Meal Lens, Form Check, distillation; Haiku-tier for routing/classification. Concretely at pilot: claude-opus-5 ($5 / $25 per MTok in/out) and claude-haiku-4-5 ($1 / $5 per MTok). Nightly distillation runs through the Message Batches API at 50% of standard price — it is the definition of a non-latency-sensitive workload.
Assumptions (marked, per style rules): an engaged pilot user sends ~25 messages/day, logs 3–4 meals, trains ~3.5×/week, uploads ~2 form videos/week. Input token counts include the persona layer + wiki excerpt each call carries.
| # | Task | Model tier | Calls/user/day | Tokens in/out per call | $/call | $/user/day |
|---|---|---|---|---|---|---|
| 1 | Inbound routing + safety triage (every message) | Haiku | 25 | 1,000 / 60 | $0.0013 | $0.03 |
| 2 | Meal Lens: vision + macro estimate + feedback draft | Opus | 3.5 | 8,000 / 600 | $0.055 | $0.19 |
| 3 | Session Mode: free-text RPE/comment parsing | Haiku | 4 | 1,200 / 100 | $0.0017 | $0.01 |
| 4 | Session Mode: coach-voice answers, substitutions | Opus | 1.1 | 6,000 / 350 | $0.039 | $0.04 |
| 5 | Form Check: 12 frames vs. technique checklist | Opus | 0.3 | 20,000 / 800 | $0.12 | $0.04 |
| 6 | Morning Brief generation | Opus | 1 | 5,000 / 250 | $0.031 | $0.03 |
| 7 | Free-chat coaching replies | Opus | 5 | 6,000 / 300 | $0.038 | $0.19 |
| 8 | Nightly distillation (Batches, 50% off) | Opus | 1 | 40,000 / 4,000 | $0.15 | $0.15 |
| 9 | Weekly Review + Nata digest (Sunday, amortized) | Opus | 0.14 | 30,000 / 2,000 | $0.20 | $0.03 |
| Total (nominal engaged day) | ≈ $0.71 |
The math behind a row, so the table is auditable — row 2: 8,000 in × $5/1M = $0.040, 600 out × $25/1M = $0.015 → $0.055/call × 3.5 calls = $0.19. Row 8 at list price would be 40,000 × $5/1M + 4,000 × $25/1M = $0.30; batched, $0.15.
Budget picture:
- Nominal day ≈ $0.71, heavy day (Denys travelling: 5 restaurant meals, a form video, long chats) ≈ $1.60 — under the < $2/user/day cap from brief §6 with margin.
- Pilot fleet (10 users): ~$7–16/day, ~$210–480/month. Cheap enough that we do not optimize prematurely.
- Buffers, not baseline: prompt caching on the stable prefix (Nata's tone guide + wiki core, cache reads at ~0.1× input price) cuts chat-heavy Opus input by ~40%; if spend trends high, rows 4 and 7 are the first candidates to route simple cases to Haiku after the classifier scores them "low-stakes."
- Never downgraded: anything safety-adjacent (pain/injury language, medical-adjacent questions) always goes to Opus-tier and always escalates to Nata regardless of cost.
6. Deployment and reliability
Topology (pilot, 10 users): one Fly.io app in waw/fra running two process groups — web (grammY gateway + Health Sync receivers + Next.js Mini App) and worker (all BullMQ consumers) — plus a third small process running the official self-hosted telegram-bot-api server with a persistent volume (the bot is logged into it instead of the hosted API; getFile returns local paths the gateway streams to S3 before job pickup — required by 07 §1 for Form Check clips over the hosted 20 MB cap), plus managed Postgres (Fly Postgres, daily snapshots + WAL archiving), managed Redis (Upstash), and Tigris (Fly's S3-compatible storage) with bucket versioning on for the wiki prefix. A single VPS (Hetzner) is an equivalent fallback; nothing in the design assumes more than one region or one node.
Webhook TLS and auth. Fly terminates TLS with a valid cert (Telegram requires HTTPS on 443/8443). The Telegram webhook is registered with a secret_token; the gateway rejects any request missing the matching X-Telegram-Bot-Api-Secret-Token header. Whoop deliveries are verified via HMAC signature; Garmin via its consumer-key verification. All provider OAuth tokens are stored app-layer encrypted (§7).
Reliability basics:
| Concern | Mechanism |
|---|---|
| Duplicate webhooks | Idempotency everywhere: Telegram update_id dedupe in Redis (24 h TTL); provider payloads deduped by events.dedupe_key unique index; normalizers UPSERT metrics |
| Transient failures | BullMQ retries: 3 attempts, exponential backoff 10 s / 60 s / 5 min; Claude API 429/5xx additionally retried by the SDK |
| Poison jobs | After final retry → per-queue dead-letter queue; job payload preserved for replay after a fix |
| Human fallback | Any job reaching the DLQ inserts an alerts row (pipeline_failure) and pings Nata's Console. The bot never leaves the user hanging — see transcript below |
| Ordering | Per-user BullMQ job groups so a user's meal analyses and brief sends process in order; cross-user work runs in parallel |
| Distillation safety | Wiki writes are all-or-nothing per night: validation failure leaves the last good page versions untouched; raw/ files are write-once |
| Monitoring | Fly health checks on web; queue-depth and DLQ-size gauges; a daily self-check event (system.heartbeat) that Nata's digest surfaces if missing |
Failure UX (Meal Lens worker dead-lettered after 3 attempts):
[14:03] Marta: 📷 (salmon + rice bowl)
[14:03] NataCoachBot: Looking at your lunch... 🔎
[14:07] NataCoachBot: Hmm, I'm having trouble reading this one — I've sent
it to Nata and we'll get back to you within the hour. Nothing lost! 🙌
[14:07] → Coach Console: alert "Meal Lens failed 3x for Marta (photo attached)"
[14:31] Nata (via takeover): "That bowl looks great — I'd call it ~620 kcal,
38g protein. Logged it for you ✍️"
Manual entry through the Console writes the same meal.scored event as the pipeline would, so downstream analytics and distillation never know the difference.
7. Privacy and security
Health data is the most sensitive thing this system touches, and it is treated as special-category data (GDPR Art. 9) from day one — explicit consent is captured during onboarding (02-user-experience.md) before any Health Sync connection or meal photo processing.
| Area | Policy |
|---|---|
| Classification | Tier 1 (health): metrics, meals, sessions, wiki pages, form videos, contraindications. Tier 2 (account): Telegram IDs, timezones, integration status. Tier 1 gets every control below; Tier 2 gets encryption at rest and access control |
| Encryption at rest | Postgres volume encryption + S3 server-side encryption as baseline; OAuth tokens and contraindication notes additionally app-layer encrypted (AES-256-GCM, key in Fly secrets, rotated on personnel change) |
| Encryption in transit | TLS 1.2+ on every hop: Telegram, providers, Claude API, Postgres, Redis, S3 |
| Per-user LLM isolation | The Orchestrator's context builder is the only code path that assembles prompts. It takes a single userId, loads only rows/objects scoped to it (WHERE user_id = $1, S3 prefix wiki/{userId}/), and asserts at runtime that no context fragment carries another user's ID. Cross-client comparisons exist only as aggregate SQL in Nata's Console — never in a user-facing prompt |
| LLM data handling | Claude API calls carry no Telegram handles or real surnames — users are referenced by first name and internal ID; API data-retention terms reviewed at pilot start; no training on our data |
| Media retention | Meal photos: 90 days, then deleted (derived macros/events retained). Form Check videos: 30 days after review, then deleted (written cues + extracted key-frame retained only with user opt-in). Voice notes from Nata: kept (they're the product). Deletion is a nightly S3 lifecycle job that also writes a media.expired event |
| Export | /export in chat → within 24 h the user gets a zip: full Wiki Brain (their own raw/ + wiki/ + index.md + log.md), CSVs of metrics/sessions/meals, media still in retention. The wiki is markdown — the export is genuinely readable, which is part of the product's trust story |
| Delete | /delete_my_data → confirm → account paused immediately, provider tokens revoked, hard delete of Postgres rows + S3 prefixes after a 30-day grace window (a signed deletion event and invoice-relevant aggregates are the only residue) |
| Access model | Nata sees everything; users see only their own data. Console access is Nata-only at pilot (Telegram initData auth pinned to her tg_user_id + second factor for the web console). Users can never query another user through any surface — the bot's context isolation (above) enforces this at the same layer for the LLM. Engineers access production via break-glass only, logged |
| Safety escalation | Pain/injury/medical keyword hits (Haiku triage, row 1 in §5) bypass every autonomy setting: programming pauses, Nata is alerted, the bot deflects medical questions to professionals — per brief §4.7 |
The full autonomy-dial and approval-queue design that these controls plug into lives in 09-admin-analytics.md; the phased hardening plan (e.g. moving app-layer encryption keys to a KMS post-pilot) is in 11-roadmap.md.