About Vitals
Vitals is an MCP server published by ilodezis in the Healthcare category: self-hosted health data lake & AI wellness navigator. Tracks Garmin, Hevy, GLP-1, nutrition, labs with MCP tools.
The repository has 12 stars and 2 forks, with the last commit 5 days ago. Recent commit activity is the strongest signal that reported issues will get attention.
Install
git clone https://github.com/ilodezis/vitalsThis server has no ConduID identity, so agent calls to it are not receipted. Pin the version you install and review the source before granting it credentials.
Ask AI
Ask AI about Vitals
Powered by Claude · Grounded in docs
Security checks
Static checks derived from the repository 12 hours ago. Heuristics on the source, not an audit.
- ✓README presentThe repository ships documentation.
- ✓License declaredUsable under a stated license.
- ✓Tests presentA test suite exists in the repository.
- ✓Dependencies pinnedA lockfile pins dependency versions.
- ✓No dynamic code executionSource scan found no eval, dynamic Function, or shell execution.
1 check has not been run yet.
Releases
README
Vitals - Личный кабинет здоровья и data lake
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 доменам, отвечая на вопросы вроде «как мой сон за последнюю неделю влияет на восстановление?» с конкретными цифрами.
Что умеет
https://github.com/user-attachments/assets/171de17f-538b-4395-a9b6-656670380811
📊 Данные и аналитика
- 15 независимых доменов здоровья, терапии и хронологии
- Автосинк Garmin Connect + Hevy API
- OCR-парсинг анализов через LLM (Gemini)
- Vision-парсинг бланков состава тела InBody / МедАсс (BIA)
- Движок конфликтов: 116 курируемых правил (усвоение, фармакогенетика, дерматология, безопасность анализов, GLP-1, ГЗТ, противопоказания)
- 7-дневное скользящее среднее + линейная регрессия
- Детекция плато на GLP-1
- Кросс-доменная хронология событий (Timeline) + флажки-аннотации на графиках
- Кастомные графики: наложение любых метрик друг на друга
🤖 AI и интеграции
- Еженедельные AI-дайджесты (Claude / GPT) из версионированного контекста за закрытый период: Garmin и Hevy, GLP-1 и ГЗТ, состав тела, анализы, питание и остальные включённые домены
- Проактивный канал в Telegram: утренний бриф, вечерний блок, подсказки по условию
- 75 MCP-инструментов для Claude.ai (чтение + запись + override)
- OAuth 2.0 + PKCE авторизация MCP
- Полный экспорт данных для LLM (copy-paste ready)
- Атомарный бэкап и восстановление БД
- Настраиваемые модели через OpenRouter
📱 Мобильный опыт
- PWA с установкой на Home Screen (iOS/Android)
- Двуязычный интерфейс (русский / английский) — переключение в настройках
- Адаптация под все размеры (Mobile / Tablet / Desktop)
- React + TanStack Router — мгновенные переходы между экранами, жесты назад на телефоне
- Офлайн-кэш последних данных и установка как приложение (Service Worker)
- Собственные SVG-графики, отрисованные под ширину экрана
🔒 Приватность и контроль
- Self-hosted, single-user, behind VPN
- Bcrypt-авторизация + подписанные сессии
- Двухфакторный вход (TOTP) — по желанию, включается в настройках
- CSRF + Origin Guard + CSP
- Модульный дашборд — включай/выключай домены в настройках
- Никаких внешних CDN — шрифты и стили полностью локальные
- Отчёт для врача: снимок за период по ссылке с паролем и сроком жизни
📖 Содержание
- Экраны приложения
- Философия и ключевые принципы
- Домены данных (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 + составной индекс):
- Модели:
WeightLog,BodyMeasurement,NoiseMarker,ProgressPhoto - Расчёт % жира по формуле Navy (шея, талия, бёдра) и безжировой массы тела (LBM)
- 7-дневное скользящее среднее с исключением шумовых периодов (
NoiseMarker) - Линейная регрессия: скорость кг/неделю и прогноз даты цели
- Прогресс-фотографии с привязкой к дате (пакетная загрузка до 5 штук)
- Приоритет источников веса: ручной ввод ≈ BIA-скан > Garmin (Garmin никогда не перекрывает)
- Модели:
BodyScan,BodyScanMetric - Загрузка PDF / фото листка анализатора (InBody / МедАсс) → LLM vision-модель распознаёт все доступные метрики (скелетная мускулатура, минералы, белок, висцеральный жир, внеклеточная вода, фазовый угол, сегментарный анализ, общий балл)
- Интерактивная таблица предпросмотра для валидации данных перед сохранением
- Жир% и LBM от BIA-скана и Navy сосуществуют на одном графике как разные источники
- Реестр метрик описан в
vitals/services/analytics/body_metrics.py(не требует БД-каталога)
- Модели:
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)
- Модели:
Injection,DosePhase,SideEffect - Хронологический лог инъекций (Semaglutide / Tirzepatide) с интерактивной картой точек инъекции (ротация мест для избежания липогипертрофии)
- Линейная шкала фаз дозировок для наложения на графики веса
- Детекция плато: если доза >14 дней и тренд <100 г/неделю → автоматическое предупреждение
- Модели:
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
- Модели:
HevyWorkout,HevyExercise,HevySet - Синхронизация силовых тренировок по API (упражнения, веса, подходы, повторения)
- Упражнения сворачиваются через
details/summaryдля максимальной компактности на мобильных - Сопоставление объёма тренировок с показателями восстановления Garmin
- Модели:
MealLog - Учёт приёмов пищи с калориями и макронутриентами (Б/Ж/У)
- Точное время приёма (
eaten_at) и текстовые заметки - Настраиваемые цели по КБЖУ через настройки с визуализацией прогресса
- Модели:
Supplement - Каталог витаминов и ноотропов: дозировка, время приёма, уровень доказательности (A/B/C)
- Название автоматически распознаётся в стабильный ключ через словарь синонимов (RU/EN, ~90 добавок) — работает и с кириллицей
- Группировка протокола по времени суток: утро / день / вечер / ночь
- Проверка конфликтов с генетикой, анализами и другими добавками через Conflict Engine
- Модели:
SkincareProduct,SkincareLog,SkincareObservation - Дневник утреннего / вечернего ухода с указанием активов (ретиноид, азелаиновая кислота, пилинг, ниацинамид+SPF, витамин C, бензоилпероксид)
- Логирование состояния кожи (жирность, сухость, воспаления, зоны высыпаний)
- Автопредупреждение при конфликте активов (ретиноид+пилинг, бензоилпероксид+вит.C и т.д.)
- Модели:
LabResult,LabMarker - Загрузка PDF / фото анализов → LLM-парсинг биомаркеров (рекомендуется
gemini-2.5-flash) - Расчёт отклонений (Low / High / Critical) и графики изменения показателей
- Возможность прямой записи через MCP панели анализов или отдельных маркеров с тем же дедуплированием и raw-хранилищем
- Модели:
GeneticVariant - Парсер VCF-файлов → сопоставление SNP с каталогом предрасположенностей (HFE, MTHFR, G6PD, CYP1A2, COMT и др.)
- Источник данных для Conflict Engine — фармакогенетические правила при назначении добавок (например, гемохроматоз блокирует железо)
- Исходные строки VCF (до 50k на импорт) складываются в
raw_payloads— расширили словарь интерпретаций, старый файл перечитывается без повторной загрузки
- Инструмент-конструктор для гибкого сопоставления любых числовых метрик из разных доменов на одной шкале
- Оверлеи событий из хронологии (Timeline)
- Модели:
Annotation - Единый кросс-доменный лог событий: ручные аннотации (болезнь, поездка, смена протокола) + автоматически извлекаемые события (смена дозы GLP-1, дозы ГЗТ, сдачи анализов, BIA-сканы, шумные периоды, закрытые цели)
- Отображение событий как флажков-оверлеев прямо на графиках (включая кастомные)
- Модели:
Milestone - Интерактивные карточки с целевыми значениями и дедлайнами
- Динамический расчёт % выполнения и прогноз оставшегося времени до цели
- Модели:
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), словарь выгружается в приложение. |
Фоновые задачи (APScheduler)
Каждая задача идёт под 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 — сохранение перерегистрирует задачи на работающем планировщике, перезапуск не нужен.
🔗 MCP-интеграция с Claude.ai
Встроенный сервер 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 (он и так отклонял запись), так что контекст разговора не тратится на схемы того, что владелец не отслеживает. Включили модуль обратно — инструменты вернутся при следующем подключении коннектора, без перезапуска.
Чтение (33 инструмента)
| Инструмент | Описание |
|---|---|
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 |
Состояние проактивного слоя: включён ли, расписание, шаблон недели, недавние отправки (только чтение — перенастройка бота остаётся за владельцем) |
Запись (40 инструментов)
| Инструмент | Описание |
|---|---|
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 (инъекция / побочка / фаза), ГЗТ (доза / курс / препарат курса), состав тела, событие хронологии, наблюдение за кожей, добавка, генетический вариант, сигнал |
Синхронизация (2 инструмента)
| Инструмент | Описание |
|---|---|
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 токенов.- Пустая колонка молчит. Ночь без гипнограммы не получает подсказку вовсе — «не измерено» и «свёрнуто» остаются различимы.
Пример диалога с Claude
Пользователь: Как у меня дела за последние 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 выглядит достаточным, следующую тренировку можно проводить.
🚀 Быстрый старт
1. Клонируйте и настройте окружение
git clone https://github.com/ilodezis/vitals.git
cd vitals
cp .env.example .env
2. Сгенерируйте секреты
# Ключ для подписи сессионных кук
python -c "import secrets; print(secrets.token_urlsafe(48))"
# Bcrypt-хеш пароля для входа
python -c "import bcrypt; print(bcrypt.hashpw(b'ваш-пароль', bcrypt.gensalt(4)).decode())"
3. Заполните .env
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="ещё-один-случайный-секрет"
4. Запустите
docker compose up -d --build
[!NOTE] Миграции Alembic применяются автоматически при старте
vitals_app. PostgreSQL 15 — внутри Docker-сети.
5. Проверьте
Дашборд: http://127.0.0.1:8000
curl -s http://127.0.0.1:8000/health
6. Проактивный слой в Telegram (опционально)
Без этих четырёх переменных приложение работает ровно как раньше: бот молчит, вебхук отвечает 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), чтобы никто извне не мог получить доступ напрямую.
Вариант А: Cloudflare Tunnel (Рекомендуется автором)
Это самый простой и надежный способ опубликовать приложение с 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 реверс-прокси + SSL + VPN/Basic Auth
Классический подход с веб-сервером 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 usernamelocation /конфигурации 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 |
| Переменная | Описание | Дефолт |
|---|---|---|
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
Dev-сервер (без Docker)
Сначала соберите приложение — сервер отдаёт готовую сборку из 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 check
☕ Поддержать проект
[!TIP] Если 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 - Personal Health Dashboard and Data Lake
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.
Why I built this
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.
What it does
https://github.com/user-attachments/assets/171de17f-538b-4395-a9b6-656670380811
📊 Data & Analytics
- 15 independent health, therapy and event domains
- Auto-sync Garmin Connect + Hevy API
- OCR lab report parsing via LLM (Gemini)
- Vision-parsing of InBody / МедАсс (BIA) body composition reports
- Conflict engine: 116 curated rules (absorption, pharmacogenomics, dermatology, lab safety, GLP-1, HRT, contraindications)
- 7-day moving average + linear regression
- GLP-1 plateau detection
- Cross-domain event timeline (Timeline) + chart annotation flags
- Custom charts: overlay any metrics across domains on a single chart
🤖 AI & Integrations
- Weekly AI digests (Claude / GPT via OpenRouter) from versioned closed-period context: Garmin and Hevy, GLP-1 and HRT, body composition, labs, nutrition, and the other enabled domains
- Proactive Telegram channel: morning brief, evening block, condition-driven nudges
- 75 MCP tools for Claude.ai (read + write + override)
- OAuth 2.0 + PKCE authorization for MCP
- Full data export for LLM (copy-paste ready)
- Atomic database backup & restore
- Configurable models via OpenRouter
📱 Mobile Experience
- PWA with Home Screen install (iOS/Android)
- Bilingual interface (English / Russian) — switchable in Settings
- Fully responsive across Mobile / Tablet / Desktop
- React + TanStack Router — instant screen changes, swipe-back on the phone
- An offline copy of the latest data and install-as-app (Service Worker)
- Hand-drawn SVG charts laid out at the screen's width
🔒 Privacy & Control
- Self-hosted, single-user, behind VPN
- Bcrypt auth + signed session cookies
- Optional two-factor sign-in (TOTP), switched on in Settings
- CSRF + Origin Guard + CSP
- Modular dashboard — toggle domains on/off in Settings
- No external CDNs — fonts and assets are hosted locally
- Doctor report: a snapshot for a period, behind a password-protected link that expires
📖 Table of Contents
- 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
🧭 App Screens
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 |
🧠 Core Philosophy
[!NOTE] 1. Raw Data Preservation All API integrations preserve original JSON payloads in a JSONB
raw_payloadstable 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 nightlyraw_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 returns409 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 viavitals/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.
📊 Supported Domains
All domains share the InsightsMixin interface (date, domain, source + composite index):
- 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)
- 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)
- 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)
- 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
- 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
- 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
- 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
- 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
- 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.)
- 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
- 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
- Chart builder/studio to overlay any metrics across domains on a single timeline
- Overlaid annotations from the 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
- Models:
Milestone - Interactive goal cards with numeric targets and deadlines
- Real-time progress % based on active weight metrics
- 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
🏗️ Technical Architecture
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. |
Background jobs (APScheduler)
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.
🔗 MCP Integration with Claude.ai
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.
Read (33 tools)
| 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) |
Write (40 tools)
| 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 |
Sync (2 tools)
| 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 withoverride=trueto 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 prioritymcpranks 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.
Example interaction
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.
🚀 Quick Start
1. Clone and configure
git clone https://github.com/ilodezis/vitals.git
cd vitals
cp .env.example .env
2. Generate secrets
# 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())"
3. Update .env
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"
4. Launch
docker compose up -d --build
[!NOTE] Alembic migrations run automatically when
vitals_appstarts. PostgreSQL 15 runs inside the Docker network.
5. Verify
Dashboard: http://127.0.0.1:8000
curl -s http://127.0.0.1:8000/health
6. Proactive Telegram layer (optional)
Leave 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.
🛡️ Secure Deployment (Creator's Setup)
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:
Option A: Cloudflare Tunnel (Recommended)
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.,
Option B: Classic Nginx Reverse Proxy + SSL + VPN/Basic Auth
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 Nginxsudo apt install apache2-utils -y sudo htpasswd -c /etc/nginx/.htpasswd usernamelocation /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.
⚙️ Configuration
| 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.
| 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.
| 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) | снижение жира, сохранение мышц |
| 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 |
| 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.
🛠️ Development & Testing
Local environment
python -m venv .venv
.venv\Scripts\activate # Windows
source .venv/bin/activate # macOS/Linux
pip install -r requirements-dev.txt
npm --prefix frontend ci
Dev server (no Docker)
Build 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.py
Auto-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:5173
After changing the API, regenerate the typed client and the translation dictionary in one go: npm --prefix frontend run gen.
Demo data
python scripts/seed_demo.py
Populates 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.
Tests
# 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 check
☕ Support the Project
[!TIP] 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
📄 License
PolyForm Noncommercial License 1.0.0 — free for personal and private use. Commercial use prohibited. See LICENSE.
README fetched from the repository 12 hours ago. The original is authoritative.