Self-hosted personal health data lake & dashboard with 75 MCP tools for Claude.ai
React PWA · EN/RU interface · 15 Domains · Weight & BIA · HRT/TRT · GLP-1 · Garmin · Hevy · Nutrition · Labs · Genetics · Skincare · Timeline · Signals & Telegram · AI Digests · Doctor Report
Vitals — персональный дашборд здоровья и полноценное «озеро данных» (data lake) для одного пользователя. Система спроектирована для долгосрочного отслеживания биомаркеров, рекомпозиции тела, контроля терапии GLP-1, прохождения курсов гормональной терапии (ГЗТ / TRT), анализа спортивных показателей и построения еженедельных AI-отчётов с помощью языковых моделей через OpenRouter.
Интерфейс — React-приложение (PWA) с тёплой сливово-угольной темой, а не «клинический терминал»: поверхности никогда не чисто-чёрные и не белые, янтарный акцент (#F5A623) тратится только на «сейчас / ты здесь / главное действие», шрифты — Geologica и Golos Text (кириллица из коробки, без моноширинных). Тревоги идут лесенкой note → info → warn → block, а не стеной красного. Дизайн-система целиком описана в docs/DESIGN_SYSTEM.md.
Главное отличие от фитнес-трекеров — принцип максимального сохранения сырых данных. Vitals — умный навигатор здоровья, который подсвечивает неочевидные взаимосвязи между сном, тренировками, медикаментами и весом, помогая принимать взвешенные решения. Не надзиратель — штурман.
С недавних пор у Vitals есть голос: проактивный слой в Telegram присылает утренний бриф, вечером спрашивает про завтра и ловит свободный текст («голова раскалывается», «кофе в 22») в отдельный домен сигналов. Дашборд больше не ждёт, пока в него зайдут.
Мои данные были разбросаны по многим приложениям: Garmin, Hevy, заметки по GLP-1 и ГЗТ, таблица с анализами, файл с добавками. Ни одно из них не умело сопоставлять сон с восстановлением после тренировки или связывать побочные эффекты терапии с питанием — всё приходилось держать в голове.
Vitals написан с Claude в качестве основного инструмента разработки — но модель данных, архитектура и доменная структура мои. MCP-слой — ключевая часть: Claude может в реальном времени читать и записывать данные по всем 15 доменам, отвечая на вопросы вроде «как мой сон за последнюю неделю влияет на восстановление?» с конкретными цифрами.
Vitals.-.promo.mp4
|
📊 Данные и аналитика
|
🤖 AI и интеграции
|
|
📱 Мобильный опыт
|
🔒 Приватность и контроль
|
- Экраны приложения
- Философия и ключевые принципы
- Домены данных (15 модулей)
- Архитектура системы
- MCP-интеграция с Claude.ai (75 инструментов)
- Быстрый старт (Docker Compose)
- Безопасный деплой (Сетап автора)
- Параметры конфигурации (.env)
- Разработка и тесты
- Поддержать проект
- Лицензия
Приложение живёт на собственных адресах — /today, /weight, /recovery и так далее. Экран выключенного модуля отправляет на «Сегодня»; старые адреса (/app/..., /garmin, /hevy) постоянно перенаправляются, так что закладки и установленное PWA не ломаются.
| Экран | Адрес | Что там |
|---|---|---|
| Сегодня | /today |
Старт дня: ключевые цифры всех включённых доменов и активные предупреждения |
| Вес | /weight, /weight/measures |
Тренд, скользящее среднее, прогноз даты цели, замеры тела, BIA-сканы, прогресс-фото |
| Восстановление | /recovery (+ /sleep/<дата>, /nights, /activities) |
Garmin: сон и его фазы, HRV, пульс покоя, стресс, Body Battery; страница каждой ночи; нормы считаются по твоей же истории |
| Тренировки · Питание | /workouts, /nutrition |
Hevy и КБЖУ с целями |
| GLP-1 · ГЗТ | /glp1, /hrt |
Протокол, карта инъекций, курсы, кривая активного релиза |
| Анализы · Генетика | /labs, /genetics |
Фото/PDF → биомаркеры; VCF → варианты |
| Добавки · Уход · Взаимодействия | /supplements, /skincare, /interactions |
Каталог и рутина, плюс весь каталог правил конфликтов с переключателями |
| Сигналы | /signals |
Всё, что бот поймал из свободного текста |
| Хронология · Отчёты · Графики | /timeline, /reports, /charts |
События, дайджесты и брифы, конструктор графиков |
| Отчёт врача | /share |
Снимок за период по ссылке с паролем |
| Настройки | /settings |
Модули, язык, 2FA, интеграции, проактивный слой, бэкап |
Note
1. Сохранность сырых данных (Raw Payloads Store)
Все интеграции сохраняют исходные ответы API в JSONB-таблицу raw_payloads параллельно со структурированными записями: Garmin, Hevy, анализы, BIA-бланки, исходный VCF и входящие сообщения бота. Если завтра обновится парсер — вся история перепарсится без потерь, и это не обещание на словах: ночная задача raw_payload_sweep (03:30) заново разбирает всё, что помечено «нужен перепарс», по каждому домену независимо.
Tip
2. Движок конфликтов (Conflict Engine)
Данные, не код: 116 курируемых правил с источниками и уровнем доказательности (A/B/C) в vitals/data/conflict_rules.yaml, покрывающих усвоение нутриентов, фармакогенетику, дерматологию, безопасность по анализам, GLP-1, ГЗТ/TRT и противопоказания. Правила понимают не только точное совпадение, но и диапазоны (доза ≥ 2 мг), списки, «любое из», и разносят предупреждение по времени приёма (утро/вечер/с едой), а не только по факту совместности. Несовместимая добавка с генетикой или анализом вернёт 409 Conflict; можно нажать «Записать всё равно (Override)» — причина запишется в лог. Каталог просматривается и включается/выключается на странице /interactions.
Important
3. Локализованное время (Timezone Engine)
Время «сегодня» и границы дат строго в зоне пользователя (VITALS_TIMEZONE). Никаких сбитых графиков при вечерних тренировках. Всё время через vitals/utils/timeutils.
Caution
4. Self-hosted & Single-User Privacy Биометрические данные принадлежат только вам. Деплой на собственный сервер за VPN. Сессионные куки, bcrypt-пароль, CSRF-защита.
Tip
5. Полная переносимость данных (Data Portability) Система не запирает данные. Полная резервная копия БД (включая сырые ответы API) для миграции. Компактный текстовый экспорт (LLM-ready) для вставки в чат с Claude/ChatGPT. Атомарное восстановление из бэкапа в одной транзакции.
Отдельная дорога наружу — отчёт для врача (/share): выбираешь разделы и период, приложение замораживает снимок и отдаёт ссылку с паролем. Ссылка живёт заданное число дней, отзывается в один клик, считает открытия и в бэкап не попадает. Тот же документ скачивается одним самодостаточным HTML-файлом — без скриптов и внешних запросов, открывается офлайн двойным кликом и печатается. Снимок неизменяемый: данные поменялись — создаёшь новый отчёт, а не подменяешь тот, что уже у врача на руках.
Все домены объединены общим интерфейсом InsightsMixin (date, domain, source + составной индекс):
⚖️ 1. Вес и состав тела
- Модели:
WeightLog,BodyMeasurement,NoiseMarker,ProgressPhoto - Расчёт % жира по формуле Navy (шея, талия, бёдра) и безжировой массы тела (LBM)
- 7-дневное скользящее среднее с исключением шумовых периодов (
NoiseMarker) - Линейная регрессия: скорость кг/неделю и прогноз даты цели
- Прогресс-фотографии с привязкой к дате (пакетная загрузка до 5 штук)
- Приоритет источников веса: ручной ввод ≈ BIA-скан > Garmin (Garmin никогда не перекрывает)
📊 2. Состав тела InBody / МедАсс (BIA-сканы)
- Модели:
BodyScan,BodyScanMetric - Загрузка PDF / фото листка анализатора (InBody / МедАсс) → LLM vision-модель распознаёт все доступные метрики (скелетная мускулатура, минералы, белок, висцеральный жир, внеклеточная вода, фазовый угол, сегментарный анализ, общий балл)
- Интерактивная таблица предпросмотра для валидации данных перед сохранением
- Жир% и LBM от BIA-скана и Navy сосуществуют на одном графике как разные источники
- Реестр метрик описан в
vitals/services/analytics/body_metrics.py(не требует БД-каталога)
⚗️ 3. Гормональная терапия (ГЗТ / TRT)
- Модели:
HrtCompound,HrtDose,HrtCycle,HrtCycleItem,HrtCycleTemplate,HrtSideEffect - Курируемый каталог из 73 препаратов (
hrt_compounds.yaml): эфиры тестостерона, 19-норы, оральные ААС, ингибиторы ароматазы / SERM, ХГЧ, ГР / IGF-1 / пептиды — с эфиром, путём введения, периодом полураспада и долей активного гормона - Лог приёмов с пересчётом мл → мг (объём × концентрация) и полями серого рынка (бренд / лаба / партия / фактическая концентрация); ротация мест инъекций
- Полноценные курсы (
HrtCycle): два типа — курс (любой протокол на экзогенных гормонах) и ПКТ (свой каденс анализов), движок расписания (сегменты + линейная рампа, фиксированная сетка, дробные интервалы вроде E3.5D) и server-side кривая активного релиза (сумма экспоненциальных распадов по периоду полураспада × доле активного гормона) - Напоминания: контроль анализов на курсе (гормональная панель в каталоге Labs) и пропущенные инъекции;
soft_warn-правила hrt↔labs (оральные 17-αα + повышенные АЛТ/АСТ, тестостерон + гематокрит, 19-нор + пролактин) через Conflict Engine - Сложные многонедельные протоколы: у каждого препарата своя неделя старта внутри курса (оффсет от начала); шаблоны курсов — сохранение плана как повторно используемого шаблона без дат и обмен шаблонами между инстансами через портируемый JSON (экспорт/импорт со строгой валидацией)
- Только учёт, без рекомендаций по дозам (harm reduction)
💉 4. Протокол GLP-1
- Модели:
Injection,DosePhase,SideEffect - Хронологический лог инъекций (Semaglutide / Tirzepatide) с интерактивной картой точек инъекции (ротация мест для избежания липогипертрофии)
- Линейная шкала фаз дозировок для наложения на графики веса
- Детекция плато: если доза >14 дней и тренд <100 г/неделю → автоматическое предупреждение
⌚ 5. Garmin Connect
- Модели:
GarminDaily,GarminActivity,GarminIntraday,GarminWeightExport - Автосинк: сон, фазы сна, HRV (вариабельность пульса), пульс покоя, стресс, Body Battery, Training Readiness + внутридневные кривые стресса и Body Battery
- Сессия
garminconnect(библиотека пиновая — 0.3.7): токены в Redis + бэкап на диск, том с токенами попадает вbackup.shрядом с дампом БД - Предохранитель логина: вход по паролю рационируется (3 раза в 24 ч, потом пауза 6 ч, счётчики в Redis) — Garmin блокирует аккаунт за частые попытки, и каждый ретрай продлевает блок. Живой токен предохранителя не касается; MFA и «залогинились слишком часто» различаются в алертах
- Расписание опроса — не в
.env, а на карточке «Проактивный слой» в/settings: интервал полного синка + лёгкий пульс (шаги за сегодня) между ними, применяется без перезапуска контейнера - Вес обратно в Garmin (opt-in) — карточка Garmin отправляет только самое свежее прямое измерение (ручное, MCP или BIA), никогда не возвращает импорт Garmin обратно и не переносит историю. Интервал (по умолчанию 15 минут) и окно свежести (не больше 30 дней) настраиваются на живом планировщике без перезапуска; кнопка «Отправить сейчас» запускает явную сверку. Новые логин и пароль начинают действовать в текущем процессе, а без реквизитов фоновая задача тихо ничего не делает
- Консервативная запись веса — перед каждым POST Vitals заново читает день Garmin и пишет только в пустой день. Одна уже существующая запись с тем же весом отмечается как внешнее совпадение, а не как принадлежащая Vitals; другой вес, несколько записей или неполный ответ Garmin дают видимый конфликт без добавления дубля и без удаления чужих данных. Коррекция заменяет только точный
samplePk, подтверждённый для POST Vitals, и лишь после проверки, что день снова пуст - Неопределённость и удаление — до сетевого POST Vitals фиксирует в БД durable-маркер «не проверено» с зарезервированной миллисекундной меткой, поэтому crash/rollback не возвращает операцию в очередь на повторную отправку. Обычно Garmin отвечает
204безsamplePk; тогда владение подтверждается только одной-единственной read-back записью с точным совпадением этой метки, источникаMANUALи веса. Совпадения только по весу недостаточно: планировщик и кнопка «Отправить сейчас» продолжают безопасную сверку и никогда не повторяют неопределённый POST. Удаление локального веса ставит в очередь удаление только подтверждённой записи Garmin, а монотонный курсор не позволяет после удаления или повторного включения выгрузить более старое измерение. Запись идёт через неофициальный web APIgarminconnect, поэтому функция по умолчанию выключена - Полностью переработанный раздел сна с детализированной страницей каждой ночи
- Резервный канал: импорт JSON-экспортов из Health Auto Export
🏋️ 6. Тренировки Hevy
- Модели:
HevyWorkout,HevyExercise,HevySet - Синхронизация силовых тренировок по API (упражнения, веса, подходы, повторения)
- Упражнения сворачиваются через
details/summaryдля максимальной компактности на мобильных - Сопоставление объёма тренировок с показателями восстановления Garmin
🍏 7. Питание
- Модели:
MealLog - Учёт приёмов пищи с калориями и макронутриентами (Б/Ж/У)
- Точное время приёма (
eaten_at) и текстовые заметки - Настраиваемые цели по КБЖУ через настройки с визуализацией прогресса
💊 8. Каталог добавок
- Модели:
Supplement - Каталог витаминов и ноотропов: дозировка, время приёма, уровень доказательности (A/B/C)
- Название автоматически распознаётся в стабильный ключ через словарь синонимов (RU/EN, ~90 добавок) — работает и с кириллицей
- Группировка протокола по времени суток: утро / день / вечер / ночь
- Проверка конфликтов с генетикой, анализами и другими добавками через Conflict Engine
🧴 9. Уход за кожей
- Модели:
SkincareProduct,SkincareLog,SkincareObservation - Дневник утреннего / вечернего ухода с указанием активов (ретиноид, азелаиновая кислота, пилинг, ниацинамид+SPF, витамин C, бензоилпероксид)
- Логирование состояния кожи (жирность, сухость, воспаления, зоны высыпаний)
- Автопредупреждение при конфликте активов (ретиноид+пилинг, бензоилпероксид+вит.C и т.д.)
🧪 10. Анализы и OCR
- Модели:
LabResult,LabMarker - Загрузка PDF / фото анализов → LLM-парсинг биомаркеров (рекомендуется
gemini-2.5-flash) - Расчёт отклонений (Low / High / Critical) и графики изменения показателей
- Возможность прямой записи через MCP панели анализов или отдельных маркеров с тем же дедуплированием и raw-хранилищем
🧬 11. Генетика (VCF)
- Модели:
GeneticVariant - Парсер VCF-файлов → сопоставление SNP с каталогом предрасположенностей (HFE, MTHFR, G6PD, CYP1A2, COMT и др.)
- Источник данных для Conflict Engine — фармакогенетические правила при назначении добавок (например, гемохроматоз блокирует железо)
- Исходные строки VCF (до 50k на импорт) складываются в
raw_payloads— расширили словарь интерпретаций, старый файл перечитывается без повторной загрузки
📈 12. Кастомные графики
- Инструмент-конструктор для гибкого сопоставления любых числовых метрик из разных доменов на одной шкале
- Оверлеи событий из хронологии (Timeline)
📅 13. Хронология (Timeline)
- Модели:
Annotation - Единый кросс-доменный лог событий: ручные аннотации (болезнь, поездка, смена протокола) + автоматически извлекаемые события (смена дозы GLP-1, дозы ГЗТ, сдачи анализов, BIA-сканы, шумные периоды, закрытые цели)
- Отображение событий как флажков-оверлеев прямо на графиках (включая кастомные)
🎯 14. Цели и вехи
- Модели:
Milestone - Интерактивные карточки с целевыми значениями и дедлайнами
- Динамический расчёт % выполнения и прогноз оставшегося времени до цели
💬 15. Сигналы и проактивный канал (Telegram)
- Модели:
Signal,DayContext,NotificationLog(журнал отправок) - Домен, который объясняет цифры остальных четырнадцати: всё, что случилось «в моменте» и не имеет формы — «спать хочу», «голова болит», «кофе в 22». Свободный текст в Telegram → сырое сообщение в
raw_payloads→ разбор в строкиSignalтрёх видов (state/symptom/exposure) с эхо-подтверждением и кнопкой «не то» для отката всей пачки - Ключи сигналов сознательно свободные на период обкатки: хранятся как их написала модель и сворачиваются к каноничному имени на чтении (
KEY_ALIASES) — консолидация словаря будет правкой словаря, а не миграцией - Страница
/signals: лента захваченного, таблица частот ключей с реальными формулировками под каждым (материал для будущего реестра) и точечное удаление строки, которую парсер понял неправильно. Ввод — только через бота: приложение не второй бот, бот не вторая форма ввода - Сообщения, на которых парсер споткнулся (модель упала, кончился баланс), не теряются: утренний бриф перед сборкой делает второй заход — неделя назад, до 20 сообщений
DayContext— одна строка на день: удалёнка/офис, зал, нагрузка. Вечерний блок в 23:45 спрашивает про завтра, шаблон недели заранее заполняет то, что предсказуемо по дню недели; каждая кнопка несёт свою дату, так что ответ после полуночи попадает в нужный день- Утренний бриф: детерминированные блоки собирает код из того же кросс-доменного контекста, что и недельный дайджест, модель добавляет ровно один абзац интерпретации — упала модель, бриф всё равно придёт; пустой день = молчание, а не пустой бриф. Хранится в
weekly_digests(kind='daily_brief'), виден в/reports(там же кнопки «Собрать бриф» и «Отправить тестовое») - Нуджи (подсказки в течение дня) — список спецификаций (условие, текст, кулдаун, категория-переключатель), один движок обходит реестр; добавить подсказку = одна запись. Сейчас три категории: активность, питание, свежесть данных
- Единые ворота отправки: выключенный модуль, дедуп, тихие часы (только для нудджей) и дневной бюджет сообщений; ответы на вопросы владельца из бюджета исключены
- Ответы на вопросы: реплай на сообщение бота или просто «почему hrv просел?» — модель видит то сообщение, на которое отвечают, и контекст последнего брифа, и ничего больше. Глубокие разборы — в Claude.ai через MCP, там 75 инструментов и модель получше
- Сигналы и
day_contextпопадают в контекст и недельного дайджеста, и брифа — с временем суток, потому что «кофе в 22» и «кофе в 9» это разные факты с одним ключом - Канал — за протоколом
Notifier, выше него никто не знает про Telegram. Модульsignalsпо умолчанию выключен и работает как рубильник всего проактивного слоя: выключен — бот молчит совсем. Настройки (время брифа и вечернего блока, тихие часы, бюджет, категории нуджей, частота опроса Garmin, интервал и окно свежести экспорта веса, шаблон недели) — на карточке в/settings, сохранение перевешивает задачи на живом планировщике без перезапуска - MCP:
get_signals,log_signal,get_day_context— Клод видит то, что владелец сказал боту - Настройка:
VITALS_TELEGRAM_BOT_TOKEN,VITALS_TELEGRAM_CHAT_ID,VITALS_TELEGRAM_WEBHOOK_PATH,VITALS_TELEGRAM_WEBHOOK_SECRET(см..env.example) — слушается единственный chat id, вебхук проверяется по секретному заголовку
graph TD
Garmin[Garmin Connect] -->|автосинк| Raw[raw_payloads · JSONB]
Hevy[Hevy REST API] -->|автосинк| Raw
Labs[PDF-анализы / Фото] -->|LLM-парсинг| Raw
BIA[PDF/Фото InBody] -->|LLM-парсинг| Raw
VCF[Генетические VCF] -->|импорт| Raw
TGin[Telegram: свободный текст] -->|вебхук| Raw
Raw --> CE[Conflict Engine]
CE --> IL[InsightsMixin Layer]
Scheduler[APScheduler] -->|Redis-локи| CE
IL --> DB[(PostgreSQL 15)]
IL --> Cache[(Redis)]
DB & Cache --> FastAPI[FastAPI Router + Auth + CSRF]
FastAPI --> UI[JSON API -> React PWA]
FastAPI --> MCP[FastMCP · streamable HTTP]
Scheduler --> PL[Проактивный слой]
PL -->|ворота: бюджет, тихие часы, дедуп| TGout[Telegram: бриф, вечер, нуджи]
UI --> Charts[SVG charts]
UI --> AI[AI-Дайджесты · LLM]
MCP <-->|OAuth 2.0 + PKCE| Claude[Claude.ai]
| Слой | Ответственность |
|---|---|
vitals/ (ядро) |
Модели, сервисы, бизнес-логика. Не знает о FastAPI. Импортируется в скрипты и тесты. |
web/ (доставка) |
FastAPI: JSON API /api/v1, авторизация, CSRF, отдача оболочки React-приложения; немногие серверные страницы (вход, OAuth, отчёт врача). Вызывает сервисы, не содержит бизнес-логику. |
vitals/services/proactive/ |
Проактивный слой четырьмя швами: channels (как сообщение уходит), delivery (можно ли его отправить), compose/brief (что сказано), inbound (что пришло обратно), nudges (когда повод есть), day_plan и prefs. Ничего выше channels не знает слова «telegram». |
| Фронтенд | frontend/: React 19 + TanStack Router/Query, Vite, PWA. Типизированный клиент генерируется из схемы API (npm --prefix frontend run gen). Дизайн-система — DESIGN_SYSTEM.md. Тексты — через vitals/i18n.py (RU/EN), словарь выгружается в приложение. |
Каждая задача идёт под Redis-локом (один исполнитель на всех воркеров) и штампует heartbeat, который видит /health.
| Задача | Расписание | Что делает |
|---|---|---|
keepalive |
каждую минуту | Пульс планировщика — по нему /health понимает, что он жив |
glp1_plateau |
06:00 | Проверка плато на GLP-1, пассивный алерт |
hrt_reminders |
07:00 | Анализы на курсе + пропущенные инъекции |
nutrition_day_end |
23:00 | Итог по КБЖУ за день (предупреждения не считаются с половины дня) |
raw_payload_sweep |
03:30 | Перепарс всего, что помечено «нужен перепарс» (Garmin, Hevy); неподтверждённые анализы и BIA-бланки не трогает |
hevy_sync |
каждые 6 ч | Синк тренировок |
garmin_sync |
каждые N ч (настройка, по умолчанию 6) | Полный синк Garmin |
garmin_pulse |
интервал в активные часы (настройка) | Лёгкий пульс: шаги за сегодня, один запрос без логина |
garmin_weight_export |
каждые N мин (настройка, по умолчанию 15) | Безопасная сверка последнего подходящего локального веса с Garmin; выключенная или ненастроенная интеграция ничего не делает |
daily_brief |
время из настроек (по умолчанию 11:00) | Синк Garmin → сборка брифа → отправка под бюджетом |
evening_block |
время из настроек (по умолчанию 23:45) | Итог дня + вопросы про завтра |
nudges |
ежечасно в :05 | Обход реестра подсказок: условие, кулдаун, категория |
weekly_digest |
понедельник, 08:00 | AI-дайджест |
Время брифа, вечернего блока, частота опроса Garmin, интервал экспорта веса и его окно свежести живут в БД (карточка в /settings), а не в .env — сохранение перерегистрирует задачи на работающем планировщике, перезапуск не нужен.
Встроенный сервер FastMCP server доступен на /mcp/ (транспорт streamable HTTP). Авторизация: OAuth 2.0 с верификацией Bearer-токенов. PKCE (S256) обязателен — запрос на /oauth/authorize без code_challenge отклоняется, а не проходит мимо проверки. Метаданные защищённого ресурса отдаются на /.well-known/oauth-protected-resource (RFC 9728), и ответ 401 указывает на них через WWW-Authenticate: Bearer resource_metadata="...", чтобы клиент сам нашёл, где авторизоваться. Настраивается в веб-интерфейсе (раздел настроек).
75 инструментов — Claude может полноценно читать и записывать данные во все домены. Плюс 2 ресурса (vitals://profile, vitals://digest/latest) и промпт weekly_review. Список инструментов фильтруется по включённым модулям: выключенный домен не показывается в tools/list (он и так отклонял запись), так что контекст разговора не тратится на схемы того, что владелец не отслеживает. Включили модуль обратно — инструменты вернутся при следующем подключении коннектора, без перезапуска.
| Инструмент | Описание |
|---|---|
get_user_profile |
Физические параметры, цели, программа тренировок |
get_weight_logs |
История взвешиваний, замеры тела, периоды шума |
get_glp1_logs |
Инъекции, фазы дозировок GLP-1, побочные эффекты |
get_hrt_logs |
Дозы ГЗТ/TRT, побочные эффекты, активный курс и его план |
get_hrt_cycles |
Список всех курсов ГЗТ с их подетальными планами |
get_garmin_metrics |
HRV, сон, пульс покоя, активность. intraday=true — поминутные кривые дня и ночи, sleep_detail=true — гипнограмма ночи и события дыхания |
get_hevy_workouts |
Силовые тренировки с подходами и весами |
get_supplements_catalog |
Каталог добавок с дозировками и уровнями доказательности |
get_skincare_logs |
Рутина ухода и наблюдения за состоянием кожи |
get_genetics_snps |
Генетические варианты с фильтром по гену и/или rsid («покажи мой rs1801133») |
get_active_alerts |
Нерешённые предупреждения и уведомления о конфликтах |
get_weekly_digests |
Архив AI-дайджестов |
check_supplement_conflicts |
Проверка добавки (по названию, RU/EN) на конфликты с генетикой, анализами, другими добавками и уходом за кожей |
list_conflict_rules |
Список курируемых правил конфликтов, фильтр по домену и/или категории |
check_conflicts |
Проверка произвольного состояния любого домена на активные правила конфликтов (без сохранения) |
get_nutrition_summary |
Суточная сводка питания (КБЖУ) и прогресс по целям |
search_meals |
Поиск приёмов пищи по имени и/или дате |
get_measurements |
Замеры тела (шея, талия, бёдра, % жира, LBM) |
get_notes |
Записи с заметками из всех доменов |
get_body_scans |
Замеры состава тела InBody / МедАсс со всеми метриками |
get_body_scan |
Один замер состава тела с полным листком метрик |
get_body_metric_history |
История одной метрики состава тела (SMM, фазовый угол…) |
get_lab_results |
Результаты анализов (биомаркер, значение, референс, флаг отклонения) |
get_timeline |
Кросс-доменная лента событий (ручные аннотации + авто-события) |
get_full_snapshot |
Кросс-доменный срез за период одним вызовом |
export_everything |
Экспорт истории по всем доменам за один вызов (по умолчанию — последние 90 дней; вся история и отдельные домены — явными аргументами) |
get_data_overview |
Карта данных: по каждому домену число строк, диапазон дат, дата обновления |
get_milestones |
Карточки целей с расчётом прогресса |
get_modules |
Какие опциональные модули включены |
get_trend |
Тренд метрики: наклон (день/неделя), скользящее среднее, прогноз даты до цели |
get_signals |
Сигналы из бота: состояния, симптомы, воздействия (фильтры по виду, ключу, датам) |
get_day_context |
Какой это был день: удалёнка/офис, зал, нагрузка |
get_proactive_state |
Состояние проактивного слоя: включён ли, расписание, шаблон недели, недавние отправки (только чтение — перенастройка бота остаётся за владельцем) |
| Инструмент | Описание |
|---|---|
log_meal |
Запись приёма пищи с калориями и макро (Б/Ж/У) |
update_meal |
Обновление существующей записи о еде |
log_weight |
Запись веса (перекрывает Garmin за ту же дату) |
log_glp1 |
Запись инъекции GLP-1 (препарат, доза, место) |
log_hrt_dose |
Запись приема дозы ГЗТ (расчет мл -> мг; бренд/лаборатория/партия; конфликт-гейт) |
add_hrt_cycle |
Начать новый курс ГЗТ (тип, дата начала, название; закрывает прошлый открытый) |
add_hrt_cycle_item |
Добавить препарат и план его приема в курс ГЗТ (разовые дозы, интервал, неделя старта) |
update_hrt_dose |
Редактировать дозу ГЗТ (непереданные поля сохраняются; конфликт-гейт) |
log_hrt_side_effect |
Записать побочный эффект ГЗТ (тип, тяжесть 1–5) |
close_hrt_cycle |
Закрыть курс ГЗТ датой окончания (по умолчанию — сегодня) |
log_skincare |
Запись/обновление дневного чек-листа ухода (upsert) |
log_measurement |
Запись замеров тела (авто-расчёт Navy % жира) |
log_note |
Добавление заметки к записи любого домена (включая анализы) |
log_body_scan |
Запись замера состава тела из метрик (мост в домен веса) |
log_lab_result |
Запись одного биомаркера (авто-расчёт флага отклонения) |
log_lab_results |
Запись всей панели анализов за раз (список маркеров → результаты + дедупликация) |
update_lab_result |
Редактировать результат анализа (пересчёт флага отклонения, обновление алертов) |
log_event |
Ручная аннотация хронологии (поездка, болезнь, смена протокола) |
update_event |
Редактировать событие хронологии (непереданные поля сохраняются) |
create_milestone |
Создать карточку цели (целевое значение, дедлайн) |
update_milestone |
Обновить цель (в т.ч. статус: достигнута/пропущена/пауза) |
update_glp1 |
Редактировать инъекцию GLP-1 (с конфликт-гейтом) |
log_side_effect |
Записать побочный эффект GLP-1 (тип, тяжесть 1–5) |
add_dose_phase |
Добавить фазу дозировки GLP-1 |
log_skincare_observation |
Записать наблюдение за кожей (воспаление, ПВГ, зона) |
add_supplement |
Добавить добавку в каталог (конфликт-гейт) |
update_supplement |
Обновить добавку в каталоге |
set_supplement_active |
Включить/выключить добавку (конфликт-гейт при включении) |
update_measurement |
Редактировать замер тела (пересчёт Navy % жира / LBM) |
add_noise_marker |
Отметить период как шумовой (исключается из тренда веса) |
upsert_genetic_variant |
Добавить или обновить генетический вариант (по паре ген + rsid) |
set_module |
Включить/выключить опциональный модуль |
log_signal |
Записать сигнал (состояние / симптом / воздействие) — через тот же сервис, что и бот |
mark_signal_misparse |
Пометить разбор сообщения как ошибочный — то же, что кнопка «не то» в боте (по batch_id) |
log_day_context |
Записать, какой это был день — те же ответы, что владелец нажимает в вечернем блоке (догадка шаблона сохраняется рядом с ответом) |
set_week_template |
Задать шаблон недели: чем считается каждый будний день, пока владелец не сказал иначе |
resolve_alert |
Закрыть предупреждение — оно исчезает из get_active_alerts |
override_alert |
Отметить блокирующее предупреждение как «принято, делаю всё равно» |
generate_digest_now |
Сгенерировать свежий еженедельный AI-дайджест сейчас |
delete_record |
Удалить одну запись любого домена по ID (domain + record_id) — вес, замер, шумовой период, анализ, цель, еда, GLP-1 (инъекция / побочка / фаза), ГЗТ (доза / курс / препарат курса), состав тела, событие хронологии, наблюдение за кожей, добавка, генетический вариант, сигнал |
| Инструмент | Описание |
|---|---|
sync_garmin |
Подтянуть свежие данные Garmin прямо сейчас — дневные метрики и активности за последние days (по умолчанию 2, максимум 30) |
sync_hevy |
Подтянуть свежие тренировки из Hevy прямо сейчас |
Note
Лимит — 3 вызова в сутки на каждый. Синхронизация — это исходящий запрос в чужой API (Garmin к тому же троттлит логины), а планировщик и так опрашивает оба источника несколько раз в день. Эти инструменты нужны на случай дыры в данных («последние два дня пустые»), а не перед каждым чтением. Счётчик живёт в Redis и считается по календарному дню; когда лимит исчерпан, инструмент возвращает {"error": ...} и никуда не ходит. Плановая синхронизация от лимита не зависит и продолжает работать.
Tip
Override-флоу. Все записывающие инструменты, проходящие движок конфликтов (вес, GLP-1, ГЗТ, добавки, кожа, замеры, состав тела), принимают override=true. При жёстком блоке инструмент возвращает {"blocked": true, "violations": [...]} вместо сохранения — повтор вызова с override=true сохраняет (аналог кнопки «Записать всё равно» в UI).
Important
Правки частичные. Все update_* меняют только те поля, которые переданы в вызове; остальные берутся из существующей строки, а не обнуляются. Дата не передана — остаётся дата самой записи, а не «сегодня». Веб-формы работают иначе (шлют всё, очистка поля действительно очищает колонку) — расхождение намеренное и живёт на границе MCP.
Периметр записи. Запись в выключенный опциональный модуль отклоняется на общей точке входа, а не в каждом инструменте по отдельности: новый инструмент наследует проверку, а не «не забудь дописать».
Провенанс. Записи через коннектор помечаются источником mcp. В приоритете источников веса mcp равен ручному вводу — то есть перекрывает Garmin и не перекрывается им; между собой решает свежесть. Старые записи задним числом не перекрашиваются.
Note
Ответ платит только за то, о чём спросили. Схемы 75 инструментов пересылаются с каждым сообщением разговора, а чтение по умолчанию отдаёт сотню строк — поэтому в ответах нет ничего, чем модель не может воспользоваться.
- Строка — это данные, а не служебка.
serialize_rowне отдаёт колонки, которые ни один инструмент не принимает обратно (domain,created_at,updated_at,raw_payload_id), и не отдаёт незаполненные поля: отсутствующий ключ иnullчитаются одинаково, аnullстоит токенов на каждой строке.id,dateиsourceостаются — правки и удаления адресуются по id, а провенанс сам по себе ответ. Строка ужимается на 39–59%. - Гипнограмма — по запросу. Поминутная раскладка фаз сна и события дыхания — это ~70% дневной строки Garmin, и раньше они ехали на каждом чтении последней сотни ночей. По умолчанию сворачиваются в счётчик с подсказкой (
"28 entries — call again with sleep_detail=True"), а не исчезают: молчание модель прочитала бы как «фаз сна нет». Флаг отдельный отintraday— вопрос про форму одной ночи не тянет за собой все кривые окна. Чтение за 100 дней: ~96k → ~24k токенов. - Пустая колонка молчит. Ночь без гипнограммы не получает подсказку вовсе — «не измерено» и «свёрнуто» остаются различимы.
Пользователь: Как у меня дела за последние 2 недели? Вес, тренировки, сон — общая картина.
Claude: [вызывает get_weight_logs, get_hevy_workouts, get_garmin_metrics]
Вес: 89.2 → 87.8 кг за 14 дней (−1.4 кг, ~0.7 кг/неделю — в рамках плана).
Тренировки: 4 сессии (2× верх, 2× ноги). Прогрессия: жим лёжа +5 кг,
тяга верхнего блока +7.5 кг.
Сон: в среднем 7ч 12мин, HRV снижается последние 3 дня — вероятно,
накопленная усталость после тренировок. Body Battery восстанавливается нормально.
Рекомендация: recovery выглядит достаточным, следующую тренировку можно проводить.
git clone https://github.com/ilodezis/vitals.git
cd vitals
cp .env.example .env# Ключ для подписи сессионных кук
python -c "import secrets; print(secrets.token_urlsafe(48))"
# Bcrypt-хеш пароля для входа
python -c "import bcrypt; print(bcrypt.hashpw(b'ваш-пароль', bcrypt.gensalt(4)).decode())"VITALS_SESSION_SECRET="результат-первой-команды"
VITALS_AUTH_USERNAME="your-username"
VITALS_AUTH_PASSWORD_HASH="результат-второй-команды"
# Пароль PostgreSQL внутри Docker-сети — обязателен, без него compose не стартует
VITALS_DB_PASSWORD="любой-длинный-пароль"
# Секрет OAuth для подключения Claude.ai по MCP
VITALS_MCP_CLIENT_SECRET="ещё-один-случайный-секрет"docker compose up -d --buildNote
Миграции Alembic применяются автоматически при старте vitals_app. PostgreSQL 15 — внутри Docker-сети.
Дашборд: http://127.0.0.1:8000
curl -s http://127.0.0.1:8000/healthБез этих четырёх переменных приложение работает ровно как раньше: бот молчит, вебхук отвечает 401.
- Создайте бота у @BotFather →
VITALS_TELEGRAM_BOT_TOKEN. - Узнайте свой id у @userinfobot →
VITALS_TELEGRAM_CHAT_ID. Это единственный чат, который бот слушает. - Сгенерируйте секретный сегмент пути и секрет заголовка:
→
python -c "import secrets; print(secrets.token_urlsafe(24))"VITALS_TELEGRAM_WEBHOOK_PATHиVITALS_TELEGRAM_WEBHOOK_SECRET. - Зарегистрируйте вебхук один раз (домен должен быть доступен Telegram снаружи):
curl -X POST "https://api.telegram.org/bot<TOKEN>/setWebhook" -d "url=https://<ваш-хост>/tg/<PATH>" -d "secret_token=<SECRET>"
- Включите модуль Сигналы в
/settings— он же рубильник всего проактивного слоя — и настройте время брифа, тихие часы и бюджет сообщений на карточке «Проактивный слой».
Поскольку Vitals содержит чувствительные медицинские, генетические и персональные биометрические данные, категорически не рекомендуется оставлять приложение открытым для всего интернета. По умолчанию в docker-compose.yml порты привязаны только к локальному адресу (127.0.0.1:8000), чтобы никто извне не мог получить доступ напрямую.
Это самый простой и надежный способ опубликовать приложение с SSL-сертификатом без необходимости открывать порты наружу (ваш сервер остается невидимым для сканеров портов).
- Регистрация домена: Добавьте свой домен в Cloudflare.
- Создание туннеля: В панели Cloudflare Zero Trust перейдите в Access -> Tunnels и создайте новый туннель (например,
vitals-tunnel). - Установка cloudflared: Скопируйте предоставленную Cloudflare команду установки для вашей ОС (Ubuntu/Debian) и выполните ее на VPS. Она установит и запустит демон
cloudflaredкак системную службу. - Маршрутизация: В настройках туннеля во вкладке Public Hostname укажите:
- Subdomain/Domain: Например,
vitals.yourdomain.com - Service Type:
HTTP - URL:
localhost:8000(or127.0.0.1:8000)
- Subdomain/Domain: Например,
- Дополнительная защита (Cloudflare Access):
- Чтобы защитить приложение еще до страницы авторизации, в панели Zero Trust перейдите в Access -> Applications и создайте приложение для вашего домена (например,
vitals.yourdomain.com). - Настройте политику доступа (Access Policy), разрешающую вход только вашему email-адресу через одноразовые коды (One-Time PIN) или социальные провайдеры (Google/GitHub). Теперь перед входом в Vitals нужно будет подтвердить свою личность в Cloudflare.
- Чтобы защитить приложение еще до страницы авторизации, в панели Zero Trust перейдите в Access -> Applications и создайте приложение для вашего домена (например,
Классический подход с веб-сервером Nginx в качестве прокси-сервера.
- Настройка DNS: Направьте A-запись домена (например,
vitals.yourdomain.com) на публичный IP-адрес вашей VPS. - Установка Nginx:
sudo apt update sudo apt install nginx -y
- Конфигурация хоста: Создайте файл
/etc/nginx/sites-available/vitalsсо следующим содержимым:Активируйте конфиг и перезапустите Nginx:server { listen 80; server_name vitals.yourdomain.com; location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # Отключаем ограничение на размер загружаемых PDF-анализов и фото client_max_body_size 20M; } }
sudo ln -s /etc/nginx/sites-available/vitals /etc/nginx/sites-enabled/ sudo nginx -t && sudo systemctl restart nginx - Установка SSL-сертификата (Let's Encrypt):
sudo apt install certbot python3-certbot-nginx -y sudo certbot --nginx -d vitals.yourdomain.com
- Ограничение доступа (на выбор):
- Через VPN (Tailscale/WireGuard): Настройте Nginx так, чтобы он слушал порт
443только на внутреннем IP-адресе VPN-интерфейса (например,listen 100.x.x.x:443 ssl), а не на внешнем интерфейсе сервера. - Через HTTP Basic Auth: Защитите роут паролем. Сгенерируйте файл с паролями:
Добавьте в блок
sudo apt install apache2-utils -y sudo htpasswd -c /etc/nginx/.htpasswd username
location /конфигурации Nginx:auth_basic "Private Vitals Access"; auth_basic_user_file /etc/nginx/.htpasswd;
- Через VPN (Tailscale/WireGuard): Настройте Nginx так, чтобы он слушал порт
Нужен просто публичный доступ, без VPN и Basic Auth? Пропустите шаг 5, поставьте домен под прокси Cloudflare (оранжевое облако — реальный IP сервера скрыт) и включите режим шифрования Full (strict) в SSL/TLS → Overview. Вместо Let's Encrypt можно взять Origin-сертификат Cloudflare (SSL/TLS → Origin Server). Учтите: в этом случае страница входа Vitals — единственное, что отделяет ваши медицинские данные от интернета. Именно для такой установки и стоит включить двухфакторный вход (Настройки → Двухфакторная защита): тогда одного утёкшего пароля будет мало.
Инфраструктура
| Переменная | Описание | Дефолт |
|---|---|---|
VITALS_DATABASE_URL |
PostgreSQL (asyncpg) | postgresql+asyncpg://... |
VITALS_DB_USER |
Пользователь PostgreSQL (для контейнера БД) | vitals |
VITALS_DB_PASSWORD |
Пароль PostgreSQL | Обязательный |
VITALS_DB_NAME |
Имя базы | vitals_db |
VITALS_REDIS_URL |
Redis для кэша и локов | redis://vitals_redis:6379/0 |
VITALS_TIMEZONE |
Зона пользователя | Europe/Chisinau |
VITALS_BACKUP_RETENTION_DAYS |
Сколько дней хранить авто-бэкапы | 7 |
Тонкая настройка пула соединений и таймаутов (VITALS_DB_POOL_SIZE, VITALS_DB_MAX_OVERFLOW, VITALS_DB_POOL_TIMEOUT, VITALS_DB_POOL_RECYCLE, VITALS_DB_STATEMENT_TIMEOUT_MS) — с рабочими дефолтами в .env.example, трогать не обязательно.
Авторизация и безопасность
| Переменная | Описание | Дефолт |
|---|---|---|
VITALS_SESSION_SECRET |
Ключ подписи сессий | Обязательный |
VITALS_AUTH_USERNAME |
Имя пользователя | Обязательный |
VITALS_AUTH_PASSWORD_HASH |
Bcrypt-хеш пароля | Обязательный |
VITALS_SESSION_TTL |
Время жизни сессии (сек) | 2592000 (30 дней) |
VITALS_COOKIE_SECURE |
Флаг Secure для кук | true |
VITALS_COOKIE_SAMESITE |
Политика SameSite | lax |
Двухфакторный вход переменной не имеет: он выключен по умолчанию и включается в настройках (Настройки → Двухфакторная защита). Ключ хранится в базе, а не в .env, и в бэкап не попадает — значит после переезда на новый сервер 2FA нужно включить заново.
Профиль пользователя
| Переменная | Описание | Дефолт |
|---|---|---|
VITALS_HEIGHT_CM |
Рост (см) | 190 |
VITALS_SEX |
Пол (male / female) |
male |
VITALS_USER_AGE |
Возраст | 18 |
VITALS_BODY_FAT_SOURCE |
Главный источник жира%/LBM: latest / navy / bia |
latest |
VITALS_USER_PROGRAM |
Описание программы | Встроено |
VITALS_USER_GOALS |
Цели (через запятую) | снижение жира, сохранение мышц |
Цели питания
| Переменная | Описание | Дефолт |
|---|---|---|
VITALS_NUTRITION_PROTEIN_TARGET_G |
Суточный белок (г) | 150 |
VITALS_NUTRITION_CALORIES_MIN |
Минимум ккал | 1300 |
VITALS_NUTRITION_CALORIES_MAX |
Максимум ккал | 1700 |
Интеграции и AI
| Переменная | Описание | Дефолт |
|---|---|---|
VITALS_OPENROUTER_API_KEY |
API-ключ OpenRouter | Опционально |
VITALS_OPENROUTER_BASE_URL |
Адрес API OpenRouter | https://openrouter.ai/api/v1 |
VITALS_LLM_MODEL_DIGEST |
Модель для дайджестов | anthropic/claude-sonnet-4.6 |
VITALS_LLM_MODEL_PARSER |
Модель для OCR анализов и BIA | google/gemini-2.5-flash |
VITALS_LLM_MODEL_BRIEF |
Модель утреннего брифа (пусто → модель дайджеста) | Опционально |
VITALS_HEVY_API_KEY |
API-ключ Hevy | Опционально |
VITALS_HEVY_BASE_URL |
Адрес API Hevy | https://api.hevyapp.com |
VITALS_GARMIN_EMAIL |
Email Garmin Connect | Опционально |
VITALS_GARMIN_PASSWORD |
Пароль Garmin Connect | Опционально |
VITALS_GARMIN_TOKEN_DIR |
Путь к токенам Garmin | /data/garmin_session |
VITALS_MCP_CLIENT_ID |
OAuth Client ID для MCP | vitals-claude-connector |
VITALS_MCP_CLIENT_SECRET |
OAuth Client Secret для MCP | Обязательно |
VITALS_MCP_REDIRECT_HOSTS |
Разрешённые хосты OAuth-callback'ов (через запятую, только https) | claude.ai,chatgpt.com,oauth-redirect.googleusercontent.com |
VITALS_EXTERNAL_API_TOKEN |
Токен для сервер-сервер Glance API | Опционально |
VITALS_TELEGRAM_BOT_TOKEN |
Токен бота от @BotFather | Опционально |
VITALS_TELEGRAM_CHAT_ID |
Единственный чат, который бот слушает | Опционально |
VITALS_TELEGRAM_WEBHOOK_PATH |
Секретный сегмент URL вебхука (/tg/<path>) |
Опционально |
VITALS_TELEGRAM_WEBHOOK_SECRET |
Секрет из заголовка X-Telegram-Bot-Api-Secret-Token |
Опционально |
Расписаний здесь нет намеренно: время брифа и вечернего блока, тихие часы, дневной бюджет сообщений, категории нуджей, частота опроса Garmin, интервал и окно свежести экспорта веса живут в БД и правятся на карточке «Проактивный слой» в
/settings— эти настройки должны применяться без перезапуска контейнера.
python -m venv .venv
.venv\Scripts\activate # Windows
source .venv/bin/activate # macOS/Linux
pip install -r requirements-dev.txt
npm --prefix frontend ciСначала соберите приложение — сервер отдаёт готовую сборку из web/static/app, без неё на экранах будет frontend not built:
npm --prefix frontend run build
python run_local.pyАвтоматически поднимает SQLite + FakeRedis. Логин по умолчанию: timur / password. Адрес: http://127.0.0.1:8000.
Правите фронтенд — запустите рядом Vite с горячей перезагрузкой (проксирует API на :8000):
npm --prefix frontend run dev # http://127.0.0.1:5173Поменяли API — перегенерируйте типизированный клиент и словарь переводов одной командой: npm --prefix frontend run gen.
python scripts/seed_demo.pyЗасевает локальную базу правдоподобным набором данных по всем доменам (вес, Garmin, тренировки, питание, GLP-1, ГЗТ, анализы, добавки, генетика, уход за кожей). Идемпотентный — можно запускать повторно.
# Юнит-тесты (SQLite, мгновенно)
python -m pytest -q
# Интеграционные (Postgres Docker)
bash scripts/test_postgres.sh
# Фронтенд: типы + тесты + сборка + бюджет размера
npm --prefix frontend run checkTip
Если Vitals помогает вам отслеживать показатели здоровья и облегчает жизнь, вы можете поддержать проект:
- Карты РФ (CloudTips): pay.cloudtips.ru/p/5bb82648
- Международные карты (Ko-fi): ko-fi.com/ilodezis
- USDT (TRC-20):
TV6bMburcEjgkinkZBvt9DonkyiaP7TqHG - USDT (Arbitrum One):
0x3eac15f5d07bba100d4038cad603e420a84753bb
PolyForm Noncommercial License 1.0.0 — использование и модификация разрешены в некоммерческих целях. Коммерческое использование запрещено. Подробности: LICENSE.
Vitals is a personal health dashboard and data lake designed for a single user. Built for long-term tracking of biomarkers, body recomposition, GLP-1 therapy, hormone replacement therapy (HRT / TRT) cycles, athletic performance, and AI-powered weekly analytical digests via OpenRouter LLMs.
The interface is a React app (PWA) with a warm plum-charcoal theme — a health companion, not a clinical terminal: surfaces are never pure black or white, the honey-amber accent (#F5A623) is spent only on "now / you are here / the one action", and type is Geologica + Golos Text (Cyrillic out of the box, no monospace). Alerts climb a ladder, note → info → warn → block, instead of a wall of red. The full design system is documented in docs/DESIGN_SYSTEM.md.
Unlike typical fitness trackers, Vitals prioritizes preserving raw historical data. It serves as a smart wellness navigator — uncovering correlations between sleep, workouts, supplements, and body composition. Not a watchdog — a co-pilot.
Vitals now has a voice: a proactive layer over Telegram delivers a morning brief, asks about tomorrow in the evening, and catches free text ("headache", "coffee at 22:00") into its own signals domain. The dashboard no longer waits to be opened.
My data was scattered across many apps: Garmin, Hevy, GLP-1 and HRT notes, a spreadsheet with lab results, a file with supplements. None of them could cross-reference sleep with training recovery or link therapy side effects to nutrition — I had to keep it all in my head.
Built with Claude as the primary coding tool — but the data model, architecture, and domain design are mine. The MCP layer is the key part: Claude can read and write data across all 15 domains in real time, answering questions like "how did my sleep this week affect my recovery?" with actual numbers.
Vitals.-.promo.mp4
|
📊 Data & Analytics
|
🤖 AI & Integrations
|
|
📱 Mobile Experience
|
🔒 Privacy & Control
|
- App Screens
- Core Philosophy
- Supported Domains (15 modules)
- Technical Architecture
- MCP Integration with Claude.ai (75 tools)
- Quick Start (Docker Compose)
- Secure Deployment (Creator's Setup)
- Configuration (.env)
- Development & Testing
- Support the Project
- License
The app lives on its own addresses — /today, /weight, /recovery, and so on. A screen of a switched-off module sends you to Today; the old addresses (/app/..., /garmin, /hevy) redirect permanently, so bookmarks and an installed PWA keep working.
| Screen | Address | What's there |
|---|---|---|
| Today | /today |
The start of the day: key numbers from every enabled domain and the active warnings |
| Weight | /weight, /weight/measures |
Trend, moving average, goal-date projection, body measurements, BIA scans, progress photos |
| Recovery | /recovery (+ /sleep/<date>, /nights, /activities) |
Garmin: sleep and its stages, HRV, resting heart rate, stress, Body Battery; a page for every night; norms computed from your own history |
| Workouts · Nutrition | /workouts, /nutrition |
Hevy and macros against targets |
| GLP-1 · HRT | /glp1, /hrt |
The protocol, injection-site map, cycles, the active-release curve |
| Labs · Genetics | /labs, /genetics |
Photo/PDF → biomarkers; VCF → variants |
| Supplements · Skincare · Interactions | /supplements, /skincare, /interactions |
Catalog and routine, plus the whole conflict-rule catalog with toggles |
| Signals | /signals |
Everything the bot caught from free text |
| Timeline · Reports · Charts | /timeline, /reports, /charts |
Events, digests and briefs, the chart builder |
| Doctor report | /share |
A snapshot for a period behind a password-protected link |
| Settings | /settings |
Modules, language, 2FA, integrations, the proactive layer, backup |
Note
1. Raw Data Preservation
All API integrations preserve original JSON payloads in a JSONB raw_payloads table alongside normalized records: Garmin, Hevy, lab reports, BIA sheets, the source VCF, and every inbound bot message. If a parser changes — the entire history can be re-processed with zero loss, and that isn't only a promise: a nightly raw_payload_sweep (03:30) re-derives everything flagged for re-parsing, each domain committing independently.
Tip
2. Resilient Conflict Engine
Data, not code: 116 curated rules with sources and an evidence tier (A/B/C) in vitals/data/conflict_rules.yaml, covering nutrient absorption, pharmacogenomics, dermatology, lab safety, GLP-1, HRT, and contraindications. Rules understand more than exact matches — comparisons (dose ≥ 2mg), lists, "any of", and time-of-day separation (morning/evening/with food), not just simultaneous presence. A supplement clashing with genetics or a lab result returns 409 Conflict; users can explicitly override with an audit note. Browse and toggle the whole catalog on /interactions.
Important
3. Localized Time Boundaries
Daily resets and calendar math resolve against the user's timezone (VITALS_TIMEZONE), not the server clock. No more split days from late-night workouts. All time via vitals/utils/timeutils.
Caution
4. Single-User Self-Hosted Privacy Your biometrics belong to you. Designed for deployment behind a VPN. Secure session cookies, bcrypt passwords, CSRF protection, and an optional TOTP second factor for installs that face the open internet.
Tip
5. End-to-End Data Portability Full database backup (including raw API payloads) for migration. Curated, secret-free text export designed for pasting into LLM chats (Claude, ChatGPT). Atomic restore within a single database transaction.
A separate way out is the doctor report (/share): pick the sections and the period, and the app freezes a snapshot behind a password-protected link. The link expires on a schedule you set, is revocable in one click, counts its openings, and never reaches a backup. The same document downloads as a single self-contained HTML file — no scripts, no outside requests — which opens offline on a double-click and prints. The snapshot is immutable: when the data changes you create a new report rather than swapping the one already in a doctor's hands.
All domains share the InsightsMixin interface (date, domain, source + composite index):
⚖️ 1. Weight & Body Composition
- Models:
WeightLog,BodyMeasurement,NoiseMarker,ProgressPhoto - US Navy body fat % formula (neck, waist, hips) + Lean Body Mass (LBM)
- 7-day moving average with noise period exclusion (
NoiseMarker) - Linear regression: weekly rate (kg/week) + goal date projection
- Progress photos with calendar linking (batch upload up to 5 at once)
- Weight source priority: manual ≈ BIA scan > Garmin (Garmin never supersedes)
📊 2. InBody / МедАсс (BIA scans)
- Models:
BodyScan,BodyScanMetric - Upload InBody/МедАсс analyzer sheets (PDF/photo) → LLM vision model extracts every biomarker (skeletal muscle, minerals, protein, visceral fat, extracellular water, phase angle, segmental analysis, score)
- Interactive preview table for validation before final save
- BIA scans and Navy circumference measurements coexist on charts as distinct data sources
- Metric definitions live dynamically in
vitals/services/analytics/body_metrics.py(no DB catalog required)
⚗️ 3. Hormone Therapy (HRT / TRT)
- Models:
HrtCompound,HrtDose,HrtCycle,HrtCycleItem,HrtCycleTemplate,HrtSideEffect - Curated catalog of 73 compounds (
hrt_compounds.yaml): testosterone esters, 19-nors, oral AAS, aromatase inhibitors / SERMs, HCG, GH / IGF-1 / peptides — each with ester, route, half-life and active-hormone fraction - Dose log with ml → mg computation (volume × concentration) and grey-market fields (brand / lab / batch / measured concentration); injection-site rotation
- Full cycles (
HrtCycle): two kinds — course (any exogenous-hormone protocol) and PCT (its own bloodwork cadence), a schedule engine (segment lists + linear ramp, fixed grid, fractional intervals like E3.5D), and a server-rendered active-release curve (sum of exponential decays by half-life × active fraction) - Reminders: bloodwork-due while on cycle (a hormone panel seeded into the Labs catalog) and missed injections;
soft_warnhrt↔labs rules (oral 17-αα + high ALT/AST, testosterone + hematocrit, 19-nor + prolactin) via the Conflict Engine - Complex multi-week protocols: each compound gets its own start week within the cycle (offset from the start); cycle templates — save a plan as a reusable, date-free template and share templates across instances via portable JSON (export/import with strict validation)
- Tracking only — no dosing advice (harm reduction)
💉 4. GLP-1 Protocol
- Models:
Injection,DosePhase,SideEffect - Chronological injection log (Semaglutide / Tirzepatide) with interactive injection site body map
- Dose phase overlays on weight charts for correlation
- Plateau detection: dose >14 days + trend <100g/week → automatic warning
⌚ 5. Garmin Connect
- Models:
GarminDaily,GarminActivity,GarminIntraday,GarminWeightExport - Auto-sync: sleep, sleep stages, HRV, resting HR, stress, Body Battery, Training Readiness + intraday stress / Body Battery curves
garminconnectsession (pinned to 0.3.7): tokens cached in Redis + disk backup, and the token volume is archived bybackup.shnext to the SQL dump- Login breaker: credential logins are rationed (3 per 24h, then a 6h pause, both in Redis) — Garmin rate-limits logins per account and every retry extends the block. A healthy token never touches it; MFA and "throttled" are reported apart from bad credentials
- The poll schedule is not an env var: the full-sync interval and the light pulse (today's steps) between syncs live on the "Proactive layer" card in
/settingsand apply without a container restart - Weight back to Garmin (opt-in) — the Garmin card sends only the latest direct measurement (manual, MCP, or BIA), never echoes a Garmin import, and never backfills history. Its interval (15 minutes by default) and freshness window (at most 30 days) update the running scheduler without a restart; Send now starts an explicit reconciliation. Newly saved credentials take effect in the current process, while the background job quietly no-ops when credentials are absent
- Conservative weight writes — before every POST, Vitals freshly reads the Garmin day and writes only when that day is empty. One equal pre-existing entry is recorded as an external match, not as Vitals-owned; a different value, multiple entries, or an incomplete Garmin response becomes a visible conflict without adding a duplicate or deleting external data. A correction replaces only the exact
samplePkconfirmed for Vitals' own POST, and only after confirming that the day is empty again - Ambiguity and deletion — before the network POST, Vitals commits a durable unverified marker with a reserved millisecond timestamp, so a crash or rollback cannot put the request back into the send queue. Garmin normally answers with
204and nosamplePk; ownership is then established only from one sole read-back record matching that timestamp, theMANUALsource, and the exact weight. Weight equality alone is insufficient: scheduled runs and Send now continue safe reconciliation and never repeat an unverified POST. Deleting a local weight queues removal only for a confirmed Vitals-owned Garmin record, while a monotonic cursor prevents deletion or re-enabling from exposing an older measurement for export. The write path usesgarminconnect's unofficial web API, so it is off by default - Fully reworked sleep analysis with detailed individual night pages
- Fallback: direct Health Auto Export JSON import
🏋️ 6. Hevy Workouts
- Models:
HevyWorkout,HevyExercise,HevySet - API sync: exercises, sets, reps, weights
- Workouts collapse via
details/summaryfor mobile compactness - Cross-reference training volume with Garmin recovery metrics
🍏 7. Nutrition
- Models:
MealLog - Meal logging with calories and macros (protein, fat, carbs)
- Wall-clock consumption time (
eaten_at) + text notes - Configurable macro targets via Settings with dashboard progress gauges
💊 8. Supplements Catalog
- Models:
Supplement - Supplement database with dosages, timing, evidence tiers (A/B/C)
- Free-text names resolve to a stable key via an alias dictionary (RU/EN, ~90 supplements) — works with Cyrillic names too
- Grouped by morning / day / evening / night timing buckets
- Conflict checks against genetics, labs, and concurrent substances via the Conflict Engine
🧴 9. Skincare Log
- Models:
SkincareProduct,SkincareLog,SkincareObservation - Morning / evening routine logs with product actives (retinoid, azelaic acid, peel, niacinamide+SPF, vitamin C, benzoyl peroxide)
- Skin status tracking (hydration, oiliness, breakouts, location mapping)
- Auto-warning for conflicting actives (retinoid+peel, benzoyl peroxide+vitamin C, etc.)
🧪 10. Lab Results & OCR
- Models:
LabResult,LabMarker - Upload PDF / image → LLM parses biomarkers (recommended:
gemini-2.5-flash) - Out-of-range flagging (Low / High / Critical) + interactive history charts
- Directly record complete lab panels or single markers via MCP
🧬 11. Genetics (VCF)
- Models:
GeneticVariant - VCF file parser → health-relevant SNPs (HFE, MTHFR, G6PD, CYP1A2, COMT, and more)
- Feeds pharmacogenomic rules into the Conflict Engine (e.g. hemochromatosis carrier status blocks iron supplementation)
- The source VCF rows (up to 50k per import) are kept in
raw_payloads— extending the interpretation dictionary re-reads the old file without asking for a re-upload
📈 12. Custom Charts
- Chart builder/studio to overlay any metrics across domains on a single timeline
- Overlaid annotations from the Timeline
📅 13. Timeline
- Models:
Annotation - Central event timeline combining manual life logs (trips, sickness, protocols) and automated system events (dose changes, HRT doses, lab draws, BIA scans, achieved goals)
- Renders events as clickable flag-overlays across weight and custom charts
🎯 14. Milestones & Goals
- Models:
Milestone - Interactive goal cards with numeric targets and deadlines
- Real-time progress % based on active weight metrics
💬 15. Signals & Proactive Channel (Telegram)
- Models:
Signal,DayContext,NotificationLog(the send journal) - The domain that explains the numbers in the other fourteen: everything that happens "in the moment" and has no shape — "can't keep my eyes open", "headache", "coffee at 22:00". Free text in Telegram → the raw message into
raw_payloads→ parsed intoSignalrows of three kinds (state/symptom/exposure), echoed back with a "wrong" button that undoes the whole batch - Signal keys stay deliberately free during the shake-out period: stored exactly as the parser wrote them and folded to a canonical name on read (
KEY_ALIASES) — consolidating the vocabulary is a dict edit, not a migration - The
/signalspage: the capture feed, a key-frequency table showing the actual phrasings behind each key (the raw material for a future closed registry), and per-row deletion when the parser got one wrong. Capture happens through the bot only — the app is not a second bot, the bot is not a second input form - Messages the parser choked on (model down, no balance) aren't lost: the morning brief takes a second pass before it assembles — one week back, up to 20 messages
DayContext— one row per day: remote/office, gym, workload. The evening block asks about tomorrow at 23:45; the week template pre-fills what a weekday can actually predict. Every button carries its own date, so a tap after midnight still answers the right day- Morning brief: the deterministic blocks are built by code from the same cross-domain context the weekly digest assembles, and the model contributes exactly one interpretation paragraph — if it fails, the brief still arrives; an empty day is silence, not an empty brief. Stored in
weekly_digests(kind='daily_brief'), visible in/reportsalong with "Build brief" and "Send a test message" buttons - Nudges — a list of specs (condition, text, cooldown, category toggle) walked by one engine; adding a nudge is a single entry. Three categories today: activity, nutrition, data freshness
- One gate for everything outgoing: module off, dedupe, quiet hours (nudges only) and a daily message budget; replies to the owner are deliberately exempt from the budget
- Answering questions: a reply to one of the bot's messages, or just "why is my hrv down?" — the model sees the message being replied to plus the context the last brief was built on, and nothing else. Deep analysis belongs in Claude.ai over MCP, which has 75 tools and a better model
- Signals and
day_contextreach both the weekly digest and the brief, with the hour attached — "coffee at 22:00" and "coffee at 09:00" are opposite facts wearing the same key - The channel sits behind a
Notifierprotocol — nothing above it knows about Telegram. Thesignalsmodule is off by default and doubles as the master switch for the whole proactive layer: off means the bot says nothing at all. Brief and evening times, quiet hours, budget, nudge categories, the Garmin poll rate, the weight-export interval and freshness window, and the week template live on a card in/settings, and saving re-registers the jobs on the running scheduler without a restart - MCP:
get_signals,log_signal,get_day_context— Claude can see what the owner told the bot - Setup:
VITALS_TELEGRAM_BOT_TOKEN,VITALS_TELEGRAM_CHAT_ID,VITALS_TELEGRAM_WEBHOOK_PATH,VITALS_TELEGRAM_WEBHOOK_SECRET(see.env.example) — exactly one chat id is listened to, and the webhook is verified by a secret header
graph TD
Garmin[Garmin Connect] -->|auto-sync| Raw[raw_payloads · JSONB]
Hevy[Hevy REST API] -->|auto-sync| Raw
Labs[Lab PDFs / Images] -->|LLM parsing| Raw
BIA[InBody PDFs / Images] -->|LLM parsing| Raw
VCF[Genetics VCF] -->|import| Raw
TGin[Telegram: free text] -->|webhook| Raw
Raw --> CE[Conflict Engine]
CE --> IL[InsightsMixin Layer]
Scheduler[Scheduler Locks · Redis] --> CE
IL --> DB[(PostgreSQL 15)]
IL --> Cache[(Redis)]
DB & Cache --> FastAPI[FastAPI Router + Auth + CSRF]
FastAPI --> UI[JSON API -> React PWA]
FastAPI --> MCP[FastMCP · streamable HTTP]
Scheduler --> PL[Proactive layer]
PL -->|gate: budget, quiet hours, dedupe| TGout[Telegram: brief, evening, nudges]
UI --> Charts[SVG charts]
UI --> AI[AI Digests · LLM]
MCP <-->|OAuth 2.0 + PKCE| Claude[Claude.ai]
| Layer | Responsibility |
|---|---|
vitals/ (core) |
Models, services, business logic. Zero web dependencies. Importable in scripts and tests. |
web/ (delivery) |
FastAPI: the JSON API /api/v1, auth, CSRF, the React app shell, and the few server-rendered pages (sign-in, OAuth, the doctor report). Calls services — contains no business logic. |
vitals/services/proactive/ |
The proactive layer along four seams: channels (how a message leaves), delivery (whether it may), compose/brief (what is said), inbound (what comes back), nudges (when there's a reason at all), plus day_plan and prefs. Nothing above channels knows the word "telegram". |
| Frontend | frontend/: React 19 + TanStack Router/Query, Vite, PWA. A typed client is generated from the API schema (npm --prefix frontend run gen). Design system: DESIGN_SYSTEM.md. All copy flows through vitals/i18n.py (EN/RU) and its dictionary is exported to the app. |
Every job runs under a Redis lock (one runner across workers) and stamps a heartbeat that /health watches.
| Job | Schedule | What it does |
|---|---|---|
keepalive |
every minute | Scheduler pulse — how /health knows it's alive |
glp1_plateau |
06:00 | GLP-1 plateau check, passive alert |
hrt_reminders |
07:00 | Bloodwork due on cycle + missed injections |
nutrition_day_end |
23:00 | End-of-day macro totals (warnings never fire off a partial day) |
raw_payload_sweep |
03:30 | Re-parse everything flagged pending (Garmin, Hevy); unconfirmed lab and BIA uploads are left alone |
hevy_sync |
every 6h | Workout sync |
garmin_sync |
every N hours (setting, default 6) | Full Garmin sync |
garmin_pulse |
interval, active hours (setting) | Light pulse: today's steps, one request, no login |
garmin_weight_export |
every N minutes (setting, default 15) | Safely reconcile the latest eligible local weight with Garmin; no-op while disabled or unconfigured |
daily_brief |
from settings (default 11:00) | Garmin sync → assemble the brief → send under budget |
evening_block |
from settings (default 23:45) | Day summary + questions about tomorrow |
nudges |
hourly at :05 | Walks the nudge registry: condition, cooldown, category |
weekly_digest |
Mondays, 08:00 | AI digest |
Brief time, evening time, the Garmin poll rate, the weight-export interval and its freshness window live in the database (the /settings card), not in .env — saving re-registers the jobs on the running scheduler, no restart needed.
Built-in FastMCP server at /mcp/ (streamable HTTP transport). Authorization: OAuth 2.0 with signed Bearer token verification. PKCE (S256) is mandatory — an /oauth/authorize request without a code_challenge is rejected rather than skipping the check. Protected-resource metadata is served at /.well-known/oauth-protected-resource (RFC 9728), and a 401 points at it via WWW-Authenticate: Bearer resource_metadata="..." so the client can discover where to authorize. Configured in the web dashboard settings.
75 tools — Claude can fully read and write data across all domains. Plus 2 resources (vitals://profile, vitals://digest/latest) and a weekly_review prompt. The listing is filtered by enabled modules: a switched-off domain is not shown in tools/list at all (it already refused writes), so a conversation does not spend its context on schemas for what the owner does not track. Switch a module back on and its tools return on the connector's next reconnect, no restart needed.
| Tool | Description |
|---|---|
get_user_profile |
Physical profile, goals, training program |
get_weight_logs |
Weight history, body measurements, noise markers |
get_glp1_logs |
GLP-1 injections, dose phases, side effects |
get_hrt_logs |
HRT/TRT doses, side effects, and the active cycle with its plan |
get_hrt_cycles |
All HRT cycles with their per-compound plans |
get_garmin_metrics |
HRV, sleep, resting HR, activities. intraday=true for the per-minute day and night curves, sleep_detail=true for the night's hypnogram and breathing events |
get_hevy_workouts |
Strength workouts with sets, reps, weights |
get_supplements_catalog |
Supplements with dosages and evidence tiers |
get_skincare_logs |
Skincare routine logs and skin observations |
get_genetics_snps |
Genetic SNPs filtered by gene and/or rsid ("show me my rs1801133") |
get_active_alerts |
Unresolved warnings and conflict notifications |
get_weekly_digests |
Historical AI digest archive |
check_supplement_conflicts |
Check a supplement (by name, RU/EN) against genetics, labs, other supplements, and skincare |
list_conflict_rules |
List the curated conflict-rule catalog, filterable by domain and/or category |
check_conflicts |
Check an arbitrary state against active conflict rules for any domain (read-only, no save) |
get_nutrition_summary |
Daily nutrition summary (calories, macros) and goal progress |
search_meals |
Search meals by name and/or date |
get_measurements |
Body measurements (neck, waist, hips, body-fat %, LBM) |
get_notes |
Records with notes across all domains |
get_body_scans |
InBody / МедАсс body-composition scans with every metric |
get_body_scan |
A single body-composition scan with its full metric sheet |
get_body_metric_history |
History of one body-composition metric (SMM, phase angle…) |
get_lab_results |
Lab results (biomarker, value, reference range, out-of-range flag) |
get_timeline |
Cross-domain event timeline (manual annotations + auto-events) |
get_full_snapshot |
One cross-domain snapshot for a period (weight trend, GLP-1, recent labs, activity/recovery, workouts, nutrition, skincare, goals) |
export_everything |
The entire history as one compact, LLM-ready export across all domains |
get_data_overview |
Per-domain map: row count, date range, last-updated timestamp |
get_milestones |
Goal cards with computed progress (filter by status) |
get_modules |
Which optional modules are enabled |
get_trend |
Metric trend: slope (day/week), latest rolling mean, projected date-to-target |
get_signals |
Signals captured by the bot: states, symptoms, exposures (filter by kind, key, dates) |
get_day_context |
What kind of day it was: remote/office, gym, workload |
get_proactive_state |
State of the proactive layer: on/off, schedule, week template, recent sends (read-only — retiming or muting the bot stays with the owner) |
| Tool | Description |
|---|---|
log_meal |
Record a meal with calories and macros |
update_meal |
Update an existing meal entry |
log_weight |
Record weight (overrides Garmin for the same date) |
log_glp1 |
Record a GLP-1 injection (drug, dose, site) |
log_hrt_dose |
Record an HRT dose (ml × concentration → mg; brand/lab/batch; conflict-gated) |
add_hrt_cycle |
Start an HRT cycle (kind; closes the previous open one) |
add_hrt_cycle_item |
Add a compound to a cycle (schedule: segments/ramp, or dose + interval) |
update_hrt_dose |
Edit an HRT dose (fields left out are preserved; conflict-gated) |
log_hrt_side_effect |
Record an HRT side effect (type, severity 1–5) |
close_hrt_cycle |
Close an HRT cycle with an end date (defaults to today) |
log_skincare |
Record/update daily skincare checklist (upsert) |
log_measurement |
Record body measurements (auto-computes Navy body-fat %) |
log_note |
Add a note to any domain record (including lab results) |
log_body_scan |
Record a body-composition scan from metrics (bridges into weight) |
log_lab_result |
Record a single biomarker (auto-computes the out-of-range flag) |
log_lab_results |
Record a whole lab panel at once (a list of markers → results + dedup) |
update_lab_result |
Edit a lab result (recomputes the out-of-range flag, refreshes alerts) |
log_event |
Manual timeline annotation (trip, illness, protocol change) |
update_event |
Edit a timeline event (fields left out are preserved) |
create_milestone |
Create a goal card (target value, deadline) |
update_milestone |
Update a goal (incl. status: achieved/missed/paused) |
update_glp1 |
Edit a GLP-1 injection (conflict-gated) |
log_side_effect |
Record a GLP-1 side effect (type, severity 1–5) |
add_dose_phase |
Add a GLP-1 dose phase (overlaid on the weight chart) |
log_skincare_observation |
Record a skin observation (inflammation, PIH, zone) |
add_supplement |
Add a supplement to the catalog (conflict-gated) |
update_supplement |
Update a catalog supplement |
set_supplement_active |
Toggle a supplement active (conflict-gated on enable) |
update_measurement |
Edit a body measurement (recomputes Navy body-fat % / LBM) |
add_noise_marker |
Mark a range as noise (excluded from the weight trend) |
upsert_genetic_variant |
Add or update a genetic variant (keyed on gene + rsid) |
set_module |
Enable/disable an optional module |
log_signal |
Record a signal (state / symptom / exposure) — through the same service the bot uses |
mark_signal_misparse |
Flag one message's parse as wrong — the bot's "не то" button, by batch_id |
log_day_context |
Record what kind of day it was — the same answers the owner taps in the evening block (the template's guess is kept next to the answer) |
set_week_template |
Set the week template: what each weekday is assumed to be until the owner says otherwise |
resolve_alert |
Resolve an alert — it disappears from get_active_alerts |
override_alert |
Mark a blocking alert overridden — "noted, doing it anyway" |
generate_digest_now |
Generate a fresh weekly AI digest now |
delete_record |
Delete one record from any domain by ID (domain + record_id) — weight, measurement, noise marker, lab result, goal, meal, GLP-1 (injection / side effect / dose phase), HRT (dose / cycle / cycle item), body scan, timeline event, skin observation, supplement, genetic variant, signal |
| Tool | Description |
|---|---|
sync_garmin |
Pull fresh Garmin data now — daily metrics and activities for the last days (default 2, up to 30) |
sync_hevy |
Pull the latest Hevy workouts now |
Note
Capped at 3 calls a day each. A sync is an outbound call to someone else's API (Garmin throttles logins on top of that), and the scheduler already polls both sources several times a day. These tools are for the gap case ("the last two days are empty"), not for use before every read. The counter lives in Redis and resets on the calendar day; once the cap is spent the tool returns {"error": ...} without going anywhere. The scheduled sync is unaffected and keeps running.
Tip
Override flow. Every write tool that runs the conflict engine (weight, GLP-1, HRT, supplements, skincare, measurements, body composition) accepts override=true. On a hard block the tool returns {"blocked": true, "violations": [...]} instead of saving — retry the same call with override=true to save anyway (the equivalent of the UI's "Save anyway" button).
Important
Edits are partial. Every update_* tool changes only the fields the call actually passes; the rest are carried over from the existing row instead of being nulled. Omit the date and the record keeps its own date rather than jumping to today. The web forms behave differently on purpose (they submit everything, and clearing a field there really does clear the column) — the divergence is resolved at the MCP boundary.
Write perimeter. Writing into a disabled optional module is refused at one shared entry point rather than tool by tool: a new tool inherits the check instead of having to remember it.
Provenance. Records written through the connector are stamped source = "mcp". In the weight source priority mcp ranks equal to manual entry — it overrides a Garmin import and is not overridden by one; between equals, recency decides. Existing rows are not relabelled retroactively.
Note
A response pays only for what was asked. The 75 tool schemas are re-sent with every message of a conversation, and a read answers with a hundred rows by default — so responses carry nothing the model cannot act on.
- A row is data, not bookkeeping.
serialize_rowomits the columns no tool accepts back (domain,created_at,updated_at,raw_payload_id) and every unset field: an absent key and anullread the same, and thenullcosts tokens on every row.id,dateandsourcestay — edits and deletes address rows by id, and provenance is an answer in its own right. Rows shrink 39–59%. - The hypnogram is opt-in. The per-minute sleep-stage timeline and breathing events are ~70% of a Garmin daily row, and used to ride along on every read of the last hundred nights. They now fold to a count plus a hint (
"28 entries — call again with sleep_detail=True") rather than vanishing: silence would read as "this night has no stages". The switch is separate fromintraday, so asking about the shape of one night does not pull every curve in the window. A 100-day read drops from ~96k to ~24k tokens. - An empty column stays silent. A night without a hypnogram gets no hint at all — "never measured" and "folded away" remain distinguishable.
User: Summarize my last 2 weeks — weight trend, workouts, sleep quality.
Claude: [calls get_weight_logs, get_hevy_workouts, get_garmin_metrics]
Weight: 89.2 → 87.8 kg over 14 days (−1.4 kg, ~0.7 kg/week — on track).
Workouts: 4 sessions completed (2× upper, 2× lower). Progressive overload:
bench press +5 kg, lat pulldown +7.5 kg.
Sleep: avg 7h 12min, HRV trending down last 3 days — likely accumulated
post-training fatigue. Body Battery recovering normally.
Recommendation: recovery looks sufficient, next session is fine to proceed.
git clone https://github.com/ilodezis/vitals.git
cd vitals
cp .env.example .env# Session cookie signing key
python -c "import secrets; print(secrets.token_urlsafe(48))"
# Bcrypt password hash
python -c "import bcrypt; print(bcrypt.hashpw(b'your-password', bcrypt.gensalt(4)).decode())"VITALS_SESSION_SECRET="your-session-secret"
VITALS_AUTH_USERNAME="your-username"
VITALS_AUTH_PASSWORD_HASH="your-bcrypt-hash"
# PostgreSQL password inside the Docker network — required, compose refuses to start without it
VITALS_DB_PASSWORD="any-long-password"
# OAuth secret for connecting Claude.ai over MCP
VITALS_MCP_CLIENT_SECRET="another-random-secret"docker compose up -d --buildNote
Alembic migrations run automatically when vitals_app starts. PostgreSQL 15 runs inside the Docker network.
Dashboard: http://127.0.0.1:8000
curl -s http://127.0.0.1:8000/healthLeave these four empty and the app behaves exactly as before: the bot never sends, and the webhook fails closed with 401.
- Create a bot with @BotFather →
VITALS_TELEGRAM_BOT_TOKEN. - Get your own id from @userinfobot →
VITALS_TELEGRAM_CHAT_ID. This is the only chat that is listened to. - Generate the secret URL segment and the header secret:
→
python -c "import secrets; print(secrets.token_urlsafe(24))"VITALS_TELEGRAM_WEBHOOK_PATHandVITALS_TELEGRAM_WEBHOOK_SECRET. - Register the webhook once (the host must be reachable by Telegram):
curl -X POST "https://api.telegram.org/bot<TOKEN>/setWebhook" -d "url=https://<your-host>/tg/<PATH>" -d "secret_token=<SECRET>"
- Enable the Signals module in
/settings— it is the master switch for the whole proactive layer — then set brief time, quiet hours and the daily message budget on the "Proactive layer" card.
Since Vitals stores sensitive medical history, genetic profiles, and personal biometric logs, it is strongly advised not to leave the application exposed to the public internet. By default, the port mapping in docker-compose.yml binds only to the loopback interface (127.0.0.1:8000), keeping the application hidden from outside traffic.
The project creator deploys Vitals on an Ubuntu VPS and secures access using one of two methods:
This is the simplest and most secure method to publish the app with a valid SSL certificate without exposing any ports on your firewall or router (your server's IP remains hidden).
- Add Domain: Set up your domain in Cloudflare.
- Create Tunnel: In the Cloudflare Zero Trust Dashboard, navigate to Access -> Tunnels and create a new tunnel (e.g.,
vitals-tunnel). - Install cloudflared: Copy the installation command provided by Cloudflare for your server's OS (Ubuntu/Debian) and run it on the VPS. This installs the
cloudflareddaemon as a system service. - Route Traffic: In the tunnel's configuration under the Public Hostname tab, add:
- Subdomain/Domain: e.g.,
vitals.yourdomain.com - Service Type:
HTTP - URL:
localhost:8000(or127.0.0.1:8000)
- Subdomain/Domain: e.g.,
- Additional Protection (Cloudflare Access):
- To secure the app before the user even sees the Vitals login screen, go to Access -> Applications in the Zero Trust dashboard and add a self-hosted application for your domain (e.g.,
vitals.yourdomain.com). - Define an Access Policy allowing access only to your email address via One-Time PINs (OTP) or authentication providers like Google/GitHub.
- To secure the app before the user even sees the Vitals login screen, go to Access -> Applications in the Zero Trust dashboard and add a self-hosted application for your domain (e.g.,
A traditional setup using Nginx as a reverse proxy.
- DNS Setup: Point your domain's A-record (e.g.,
vitals.yourdomain.com) to your VPS's public IP. - Install Nginx:
sudo apt update sudo apt install nginx -y
- Configure virtual host: Create a file at
/etc/nginx/sites-available/vitalswith the following configuration:Activate the configuration and restart Nginx:server { listen 80; server_name vitals.yourdomain.com; # Allow uploading larger lab reports and photos client_max_body_size 20M; location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } }
sudo ln -s /etc/nginx/sites-available/vitals /etc/nginx/sites-enabled/ sudo nginx -t && sudo systemctl restart nginx - Provision SSL Certificate (Let's Encrypt):
sudo apt install certbot python3-certbot-nginx -y sudo certbot --nginx -d vitals.yourdomain.com
- Restrict Access (Pick one):
- Via VPN (Tailscale/WireGuard): Configure Nginx to listen for SSL traffic only on your private VPN IP interface (e.g.,
listen 100.x.x.x:443 ssl), rather than the public interface. - Via HTTP Basic Auth: Generate a password file:
Add the following directives to your Nginx
sudo apt install apache2-utils -y sudo htpasswd -c /etc/nginx/.htpasswd username
location /block:auth_basic "Private Vitals Access"; auth_basic_user_file /etc/nginx/.htpasswd;
- Via VPN (Tailscale/WireGuard): Configure Nginx to listen for SSL traffic only on your private VPN IP interface (e.g.,
Just want plain public access, without VPN or Basic Auth? Skip step 5, put the domain behind the Cloudflare proxy (orange cloud — your server's real IP stays hidden) and set the encryption mode to Full (strict) under SSL/TLS → Overview. Instead of Let's Encrypt you can use a Cloudflare Origin Certificate (SSL/TLS → Origin Server). Keep in mind: in that case the Vitals login page is the only thing between your medical data and the internet. That is exactly the setup worth turning two-factor sign-in on for (Settings → Two-factor authentication) — a leaked password alone is then not enough.
Infrastructure
| Variable | Description | Default |
|---|---|---|
VITALS_DATABASE_URL |
PostgreSQL (asyncpg) | postgresql+asyncpg://... |
VITALS_DB_USER |
PostgreSQL user (for the DB container) | vitals |
VITALS_DB_PASSWORD |
PostgreSQL password | Required |
VITALS_DB_NAME |
Database name | vitals_db |
VITALS_REDIS_URL |
Redis for cache and locks | redis://vitals_redis:6379/0 |
VITALS_TIMEZONE |
User timezone | Europe/Chisinau |
VITALS_BACKUP_RETENTION_DAYS |
How many days of automatic backups to keep | 7 |
Connection-pool and timeout tuning (VITALS_DB_POOL_SIZE, VITALS_DB_MAX_OVERFLOW, VITALS_DB_POOL_TIMEOUT, VITALS_DB_POOL_RECYCLE, VITALS_DB_STATEMENT_TIMEOUT_MS) ships with working defaults in .env.example — you don't need to touch it.
Authentication & Security
| Variable | Description | Default |
|---|---|---|
VITALS_SESSION_SECRET |
Session cookie signing key | Required |
VITALS_AUTH_USERNAME |
Dashboard username | Required |
VITALS_AUTH_PASSWORD_HASH |
Bcrypt password hash | Required |
VITALS_SESSION_TTL |
Session duration (seconds) | 2592000 (30 days) |
VITALS_COOKIE_SECURE |
Secure cookie flag | true |
VITALS_COOKIE_SAMESITE |
SameSite policy | lax |
Two-factor sign-in has no variable: it is off by default and switched on in the dashboard (Settings → Two-factor authentication). The key lives in the database rather than .env, and backups never carry it — so after moving to a new server, 2FA has to be turned on again.
User Profile
| Variable | Description | Default |
|---|---|---|
VITALS_HEIGHT_CM |
Height (cm) | 190 |
VITALS_SEX |
Sex (male / female) |
male |
VITALS_USER_AGE |
Age | 18 |
VITALS_BODY_FAT_SOURCE |
Primary body-fat%/LBM source: latest / navy / bia |
latest |
VITALS_USER_PROGRAM |
Program description | Embedded |
VITALS_USER_GOALS |
Goals (comma-separated) | снижение жира, сохранение мышц |
Nutrition Targets
| Variable | Description | Default |
|---|---|---|
VITALS_NUTRITION_PROTEIN_TARGET_G |
Daily protein target (g) | 150 |
VITALS_NUTRITION_CALORIES_MIN |
Minimum daily kcal | 1300 |
VITALS_NUTRITION_CALORIES_MAX |
Maximum daily kcal | 1700 |
Integrations & AI
| Variable | Description | Default |
|---|---|---|
VITALS_OPENROUTER_API_KEY |
OpenRouter API key | Optional |
VITALS_OPENROUTER_BASE_URL |
OpenRouter API base URL | https://openrouter.ai/api/v1 |
VITALS_LLM_MODEL_DIGEST |
Digest model | anthropic/claude-sonnet-4.6 |
VITALS_LLM_MODEL_PARSER |
Lab OCR model | google/gemini-2.5-flash |
VITALS_LLM_MODEL_BRIEF |
Morning-brief model (empty → the digest model) | Optional |
VITALS_HEVY_API_KEY |
Hevy API key | Optional |
VITALS_HEVY_BASE_URL |
Hevy API base URL | https://api.hevyapp.com |
VITALS_GARMIN_EMAIL |
Garmin Connect email | Optional |
VITALS_GARMIN_PASSWORD |
Garmin Connect password | Optional |
VITALS_GARMIN_TOKEN_DIR |
Garmin session token path | /data/garmin_session |
VITALS_MCP_CLIENT_ID |
MCP OAuth Client ID | vitals-claude-connector |
VITALS_MCP_CLIENT_SECRET |
MCP OAuth Client Secret | Required |
VITALS_MCP_REDIRECT_HOSTS |
Allowed OAuth callback hosts (comma-separated, https only) | claude.ai,chatgpt.com,oauth-redirect.googleusercontent.com |
VITALS_EXTERNAL_API_TOKEN |
Token for server-server Glance API | Optional |
VITALS_TELEGRAM_BOT_TOKEN |
Bot token from @BotFather | Optional |
VITALS_TELEGRAM_CHAT_ID |
The only chat the bot listens to | Optional |
VITALS_TELEGRAM_WEBHOOK_PATH |
Secret segment of the webhook URL (/tg/<path>) |
Optional |
VITALS_TELEGRAM_WEBHOOK_SECRET |
Secret in the X-Telegram-Bot-Api-Secret-Token header |
Optional |
No schedules here, on purpose: brief and evening times, quiet hours, the daily message budget, nudge categories, the Garmin poll rate, and the weight-export interval and freshness window live in the database and are edited on the "Proactive layer" card in
/settings— those settings have to apply without restarting the container.
python -m venv .venv
.venv\Scripts\activate # Windows
source .venv/bin/activate # macOS/Linux
pip install -r requirements-dev.txt
npm --prefix frontend ciBuild the app first — the server serves the finished bundle from web/static/app, and without it the screens answer frontend not built:
npm --prefix frontend run build
python run_local.pyAuto-provisions SQLite + FakeRedis. Default login: timur / password. Address: http://127.0.0.1:8000.
Working on the frontend? Run Vite beside it for hot reload (it proxies the API to :8000):
npm --prefix frontend run dev # http://127.0.0.1:5173After changing the API, regenerate the typed client and the translation dictionary in one go: npm --prefix frontend run gen.
python scripts/seed_demo.pyPopulates the local database with realistic data across all domains (weight, Garmin, workouts, nutrition, GLP-1, HRT, labs, supplements, genetics, skincare). Idempotent — safe to re-run.
# Unit tests (SQLite, instant)
python -m pytest -q
# Integration tests (Postgres Docker)
bash scripts/test_postgres.sh
# Frontend: types + tests + build + bundle-size budget
npm --prefix frontend run checkTip
If Vitals helps you track your health markers and makes your life better, consider supporting its development. Contributions go toward keeping the creator caffeinated:
- Russian Cards (CloudTips): pay.cloudtips.ru/p/5bb82648
- International Cards (Ko-fi): ko-fi.com/ilodezis
- USDT (TRC-20):
TV6bMburcEjgkinkZBvt9DonkyiaP7TqHG - USDT (Arbitrum One):
0x3eac15f5d07bba100d4038cad603e420a84753bb
PolyForm Noncommercial License 1.0.0 — free for personal and private use. Commercial use prohibited. See LICENSE.

