NataCoach / product & system design Wiki Brain Personas Coach Console ↗

03 — System Architecture

How NataCoach is built: one Telegram bot, one orchestrator, event-sourced Postgres truth, per-user Wiki Brains in object storage, and a small set of LLM pipelines that fit under $2/user/day at pilot.

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"| ORCH

Key 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 events table 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:

  • events is the spine. Every fact — set.logged, meal.scored, sleep.recorded, brief.confirmed, pain.reported — is one immutable row with a dedupe_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 with events, events win.
  • metrics is 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.
  • approvals is the Coach-in-the-loop ledger. Every LLM draft that requires review is a row; latency_s feeds the "< 30 s per item" target in 09-admin-analytics.md.
  • wiki_pages stores pointers, not prose. The S3 bucket is versioned, so (s3_key, version) gives git-style history; Nata's manual edits set updated_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.