Пятьдесят дней назад планирование моих тренировок переехало из головы и переписки в телеграм-бота. Он читает восстановление из WHOOP и форму из TrainingPeaks, рассуждает через Claude и сам пишет структурированные тренировки обратно в календарь TrainingPeaks — оттуда они уезжают в Garmin Connect и на часы. За эти пятьдесят дней он записал в календарь 72 тренировки, а весь расход на модели составил 16,45 доллара по учёту расходов в базе. Быстрее я пока не побежал: до Шанхайского марафона, где я иду за sub-3, сто дней, и обещать спортивный результат нечестно. Зато планирование недели стало занимать примерно втрое меньше моего времени.
Официального доступа к TrainingPeaks у меня нет: партнёрская программа отказала без объяснения причин. Дальше — как я всё равно научился писать в чужой календарь, во что это обошлось и в каких местах оно ломалось. Все числа в статье — срез базы на 28 августа 2026 года.
Тренер недостаточно смотрел на мои данные
Я бегаю около десяти лет, в триатлоне семь: двадцать с лишним марафонов с личником 3:02, десяток половинок Ironman, полная железная дистанция в Швейцарии за двенадцать часов, три ультратрейла со стокилометровым UTMB в Кьянти. Держу 80–120 км и 10–15 часов в неделю уже несколько лет подряд. Это к тому, о каком объёме данных дальше речь: пятьсот с лишним часов тренировок в год, и каждая пишется секундными отсчётами пульса, темпа, мощности и каденса.
Начинал я, как все, с живым тренером. Их было несколько, платил от 15 до 50 тысяч рублей в месяц, последние три года не работаю ни с кем — разошлись спокойно, просто сказал, что дальше сам.
Претензия у меня была ровно одна. Тренер недостаточно смотрел на мои данные. По цифрам восстановления было видно, что сегодняшнюю тренировку надо править, а её никто не правил. Программа приходила на неделю вперёд, базовая, не учитывающая, что в среду у меня перелёт, а в четверг весь день на ногах. Ощущение было, что я общаюсь с роботом, который движется по программе и не адаптируется под меняющуюся ситуацию. Забавно, что робота у меня тогда как раз и не было.
Сначала я решил, что дело в деньгах, посчитал по тренерской ставке и выбросил эту версию: позволить себе я мог. Правда скучнее. Ежедневный разбор чужих цифр — работа, которую никто не заказывал: в договоре план на неделю и обратная связь, а не ежеутренний просмотр вариабельности. Тренер продавал программу, а я хотел внимания. Претензия была не к нему, а к формату, и никакая доплата его бы не изменила: человек не встанет в шесть утра, чтобы посмотреть на мой сон.
Отсюда тезис, который я дальше проверяю цифрами: живой тренер проигрывает агенту в частоте взгляда и в правах доступа, а вовсе не в квалификации. Читать чужие цифры каждый день человек не станет — а агент только этим и занят.
Что такое TrainingPeaks и зачем в нём структурированные тренировки
Дальше будет много про TrainingPeaks, так что короткий словарь — он же объясняет, почему циклические виды спорта вообще живут внутри одного приложения.
TrainingPeaks — это календарь тренировок и хранилище того, что с них приехало. Слева план, справа факт. Тренер кладёт в календарь, что делать в четверг; часы забирают это перед выходом; после тренировки файл возвращается в тот же день и ложится рядом с планом. Дальше всё считается по обоим столбцам сразу.
Ключевая вещь для этой статьи — структурированная тренировка. Это не «пробеги 10 километров», а последовательность шагов с целевыми коридорами: разминка 10 минут в 60–70 % от порога, восемь повторов по 800 метров в 100–105 %, между ними полторы минуты трусцы, заминка. Часы ведут по такой тренировке сами: пикают на смене шага, показывают, попадаешь ли в коридор, и не дают уехать вперёд на первом же отрезке. Именно это делает автоматическую запись в календарь осмысленной: тренировка, которую агент придумал, но не положил в календарь, до часов не доедет и останется текстом в мессенджере.
Порог, от которого считаются проценты, — это темп, пульс или мощность, которые ты способен держать примерно час. Все зоны строятся от него, и у каждого вида спорта он свой: бег, велосипед и плавание не сравнимы напрямую.
Чтобы их всё-таки сравнивать, придуман TSS (training stress score) — стоимость тренировки в условных единицах: час строго на пороге даёт ровно 100. Из ежедневных TSS собирается PMC (performance management chart), три кривые, на которых дальше держится всё:
-
CTL — среднее TSS за 42 дня. Накопленная тренированность, «форма» в бытовом смысле.
-
ATL — то же за 7 дней. Свежая усталость.
-
TSB — свежесть: насколько накопленная форма опережает свежую усталость. Положительная перед стартом, отрицательная в загрузочном блоке. Считает её сам TrainingPeaks, и с простой разностью сегодняшних CTL и ATL она не сходится.
Год в чате: транспортным протоколом работал я
Первого AI-тренера я сделал себе задолго до всякого агента: просто открыл чат и начал разговаривать, сначала ChatGPT, потом Claude. Это работало на удивление хорошо примерно год, и всё это время узким местом был я сам.
Цикл выглядел так. Утром я открывал WHOOP, смотрел восстановление и вариабельность сердечного ритма (HRV) и пересказывал их в чат. Открывал TrainingPeaks, смотрел форму, пересказывал и её. Получал ответ, что сегодня стоит сделать. Открывал TrainingPeaks заново и вбивал тренировку руками — не текстом, а по шагам, с коридорами, чтобы она доехала до часов. Вечером после тренировки открывал файл, смотрел, что получилось, и пересказывал результат обратно в чат.
Так это выглядело в идеале. На практике весь цикл я проходил далеко не каждый день: носил в чат то, что казалось важным, раза два-три в неделю, а в командировке не носил вообще. И выбирал плохо — пересказывал вчерашнюю тренировку, если она вышла показательной, и молчал про три подряд посредственные, хотя форма едет как раз в таких сериях. Модель видела не мои данные, а мою выжимку из них, сделанную уставшим человеком в шесть утра. Остальное терялось.
Четыре из пяти шагов делал человек, и человеком был я. Модель умела думать, но у неё не было ни глаз, ни рук. Я работал транспортным протоколом между двумя приложениями и одним чатом — причём протоколом ненадёжным, с потерей пакетов.
Отсюда и берётся «втрое меньше времени» из первого абзаца. Самое дорогое — сборка недельного плана в понедельник: шесть-семь тренировок, каждую надо вбить шагами, с коридорами и повторами, минут сорок. Дальше правки в течение недели, когда тренировка переезжала и сессию приходилось перебивать заново, — ещё пятнадцать-двадцать минут. И минут двадцать на перенос данных в чат и раскопки в старых диалогах в те дни, когда я этим занимался. Итого около полутора часов в неделю. Сейчас уходит примерно полчаса: прочитать, поспорить, нажать кнопку. Секундомера я не включал и журнал «до» не вёл, так что это оценка по шагам, а не измерение. Всё остальное в статье считается из базы.
Были и проблемы поинтереснее бытовых. Контекст в чате перегружался: после нескольких месяцев ежедневного общения модель начинала терять нить, и приходилось заводить новый диалог, пересказывая туда выжимку историю. Найти что-то в прошлом было почти невозможно — попробуйте вспомнить, в каком из сорока диалогов вы обсуждали проваленную темповую в марте. И, что хуже всего, модель иногда путалась в цифрах, которые я же ей и принёс, и уверенно выдавала на их основе неверные вводные.
Год этой возни привёл меня к простому выводу. Модель в чате умеет думать, но не умеет дотянуться. Разница между чатом и агентом — не ум, а руки.
Шесть контуров, четыре системы и одна кнопка
Что в итоге собралось.
Агент живёт процессом на дроплете DigitalOcean с двумя ядрами и четырьмя гигабайтами памяти, под systemd — там же крутятся ещё два моих агента, не про спорт, оркестратор и планировщик задач. Единственный интерфейс — телеграм-бот. Мозг — Claude, разложенный по задачам: свободный чат, недельный план и воскресный разбор идут на Opus, утренний чек-ин и разбор тренировки — на Sonnet 5, свёртка памяти и извлечение уроков — на Haiku. Вся память — один файл SQLite. Планировщик — очередь задач самого телеграм-фреймворка, никакого Airflow: каждое расписание здесь один вызов, и всего их четыре.
Данные приходят с двух сторон. WHOOP отдаёт восстановление, вариабельность, пульс покоя и сон — по официальному OAuth, заявку одобрили без вопросов. TrainingPeaks отдаёт план, факт, кривые формы, зоны и раскладку по кругам — неофициально, и об этом дальше два раздела.
Отдельно стоит сказать, чего в схеме нет: прямой работы с Garmin. Я к ней и не подступался. TrainingPeaks сам синхронизирует структурированные тренировки в Garmin Connect и на часы, а выполненное приезжает обратно тем же путём. Одна интеграция вместо трёх.
На этом работают шесть контуров: утренний чек-ин, разбор после каждой тренировки, воскресная пара «разбор недели → план», ежедневный аудит зон, свободный чат и гейт подтверждения, через который проходит всё, что уходит в календарь. Дальше по тексту каждый разбирается отдельно.
Точка входа при этом занимает двадцать строк: вся загрузка живёт в общем раннере, а агент описывается одним значением.
from shared.lib.agent import Agent
from shared.lib.settings import Settings
def build_tolyan_agent(settings: Settings) -> Agent:
return Agent(
name="tolyan",
persona_path=_PERSONA_PATH, # персона чата; у джобов свои промпты
chat_enabled=True, # раннер сам поставит обработчик чата
chat_tier="opus", # чат — 75 % всего расхода, и он на Opus
chat_coach_tools=True, # чтение TP/WHOOP и черновики под кнопку
chat_web_search=True, # из-за серверного поиска ниже и появится pause_turn
chat_fetch_url=True,
whoop_webhook_enabled=True,
setup=_setup, # регистрация команд и джобов
internal_port=DEFAULT_INTERNAL_PORT,
)
Теперь про масштаб. Толян — это 10 928 строк кода и 10 175 строк тестов, 396 тест-функций. От первого коммита до первой записи в реальный календарь прошло пять дней, с 6 по 11 июля. Моего личного времени на это ушло два-три часа.
Строки писал Claude Code. Мои два-три часа — это постановка задач, чтение диффов, споры о том, что считать правильным поведением, и приёмка. Кроме того, Толян встал не на пустое место: у меня уже была самописная инфраструктура — общий класс агента, чат с инструментами, память, механика подтверждений, журнал расходов на модели. С нуля это была бы неделя-две, а не вечер. Дальше по тексту я пишу «я сделал» в том же смысле, в каком архитектор говорит «я построил дом».
Партнёрская программа отказала без объяснения причин
Дальше всё упиралось в одно: агент должен уметь класть тренировку в календарь сам. Иначе он остаётся умным собеседником, а вбивать шаги руками по-прежнему буду я.
У TrainingPeaks есть партнёрский API. Я написал в партнёрскую программу и заполнил форму на приложение. Отказали. Без объяснения причин.
Контраст с WHOOP получился поучительный: там я зарегистрировал приложение и получил доступ к своим данным за полчаса. Права дали не все — один скоуп не одобрили, и позже это дорого обошлось, — но дали. Один вендор в 2026 году считает нормальным дать пользователю доступ к его собственным данным, другой — нет.
Перед тем как идти в обход, я перебрал легальные варианты, и стоит объяснить, почему ни один не подошёл:
|
Путь |
Почему не сработал |
|---|---|
|
Garmin Training API |
Партнёрский, для производителей и платформ; частному лицу не выдают |
|
Strava API |
Отдаёт факт, но не принимает структурированный план — писать некуда |
|
intervals.icu |
Отличный сервис с открытым API, но мой план, история и тренерская логика уже десять лет живут в TrainingPeaks |
|
Файлы .FIT напрямую на часы |
Технически возможно, но выпадает календарь, планирование и PMC — то есть всё, ради чего это делается |
Так что дальше — неофициальный маршрут. Скажу прямо: он в серой зоне пользовательского соглашения, работает на моём личном аккаунте с моими личными данными, и готового скрапера в этой статье не будет. Механика, которую я описываю, восстанавливается по публичным клиентам сообщества, на них и опираюсь: trainingpeaks-mcp, tp2intervals, trainingpeaks_bot.
Кука на 49 дней, bearer на 827 символов и один повтор на 401
Приём, которым вскрывается закрытый API, банален до обидного: посмотреть, что делает веб-приложение самого вендора. Если у сервиса есть работающий личный кабинет в браузере, значит, где-то есть и API, который его кормит.
У TrainingPeaks это tpapi.trainingpeaks.com. Браузерная сессия держится в куке Production_tpAuth, а сам веб-клиент меняет её на короткоживущий bearer-токен одним запросом:
async def refresh_token(self) -> TPTokens:
"""Обменять куку веб-сессии на bearer-токен."""
headers = {"Cookie": f"Production_tpAuth={self._cookie}", "Accept": "application/json"}
resp = await self._client.get("/users/v3/token", headers=headers)
if resp.status_code == 401:
# Кука протухла — сама по себе она не обновляется, нужен человек.
raise TPAuthError("TrainingPeaks cookie expired or invalid; re-authenticate.")
if resp.status_code != 200:
raise TPError(f"Token exchange failed: HTTP {resp.status_code}")
token = resp.json().get("token") or {}
if not token.get("access_token"):
raise TPError("Token exchange response missing token.access_token")
return TPTokens(
access_token=str(token["access_token"]),
expires_at=time.time() + float(token.get("expires_in", 3600)),
)
Дальше всё обычно: Authorization: Bearer на каждый запрос, обновление токена при истечении, один повтор на 401, если токен умер прямо в полёте.
Две детали из этого куска стоят отдельного упоминания.
Первая: bearer приходит непрозрачной строкой на 827 символов, и срок её жизни известен только со слов сервера — expires_in: 3600. Проверить нечем, токен не JWT, настоящий exp не вскрыть. Поэтому защита двухслойная: обновляем за минуту до заявленного конца и держим один повтор на 401, чтобы умерший в полёте токен молча переигрался на свежем.
Вторая, более неприятная: кука сама себя не продлевает, и умершую должен принести человек — открыть браузер, забрать значение, отправить боту командой. Я закладывался, что это будет происходить регулярно, и написал целый контур: подтверждённые записи, которые не смогли уйти в TrainingPeaks, ждут в состоянии pending и дописываются после свежей куки, а протухшие гасятся, чтобы не приземлиться в календарь неделей позже нужной даты.
За пятьдесят дней контур не пригодился ни разу: работает та кука, что легла в переменные окружения 10 июля. Сорок девять дней на одной сессии — не знаю, повезло мне или так и задумано, и механику на всякий случай оставил.
PATCH не существует, structure требует JSON внутри JSON, а IF и TSS считаю я сам
Читать из TrainingPeaks оказалось скучно: пара запросов, и есть история, форма и раскладка по кругам. Кривые формы, кстати, отдаются по POST с телом, а не по GET, хотя ничего не меняют. Интересное началось, когда я захотел писать.
Первое, обо что я споткнулся: в /fitness/v6 нет PATCH. Совсем. Чтобы поменять одно поле, есть только PUT, и он перезаписывает объект целиком: забрать тренировку, подменить своё, отправить обратно всё вместе.
Звучит безобидно ровно до момента, когда понимаешь, чем рискуешь. В объекте лежат вещи, которых ты не писал: комментарии, данные о выполнении, ключи вложений, поля, появившиеся в API уже после тебя. Собрать payload с нуля — значит снести всё это первым же обновлением. GET здесь не оптимизация, а страховка.
async def update_structured_workout(self, workout_id: str, spec: StructuredWorkout) -> str:
family_id, type_id = _validate_spec(spec)
athlete_id = await self._ensure_athlete_id()
# PATCH в /fitness/v6 нет: берём объект целиком и перезаписываем только свои поля.
payload = dict(await self.get_workout_raw(workout_id))
payload.update(_plan_payload_fields(spec, family_id, type_id))
# Протухший startTimePlanned удержит тренировку на старой дате,
# даже если workoutDay уже поменялся. Сохраняем время суток, меняем день.
start = payload.get("startTimePlanned")
if isinstance(start, str) and start:
time_part = start.split("T", 1)[1] if "T" in start else "00:00:00"
payload["startTimePlanned"] = f"{_to_iso_date(spec.date)}T{time_part}"
await self._request(
"PUT", f"/fitness/v6/athletes/{athlete_id}/workouts/{workout_id}", json_body=payload
)
return workout_id
Вторая ловушка сидит внутри поля structure — того самого, где описаны шаги. На чтении оно приходит нормальным разобранным объектом. На запись требует строку: JSON, сериализованный внутрь другого JSON, руками, через json.dumps(...).
Асимметрия неприятна, но убивает не она. Убивает то, что на неправильный формат TrainingPeaks отвечает 200. Тренировка появляется в календаре, называется как надо, стоит в нужном дне — и внутри пустая. Ни шагов, ни целевых коридоров. Прилетел бы 400 — поправил бы за минуту. Вместо этого я двое суток был уверен, что всё работает. Худший вид отказа — тот, при котором API говорит «да».
Схема внутри — два уровня, и на них легко запутаться. Я запутался.
Верхний уровень — блоки: type (step или repetition) и length, которая всегда меряется в повторах — {"value": 6, "unit": "repetition"} для серии из шести. Никаких секунд и метров здесь нет.
Нижний уровень — листья внутри блока, и вот у них length настоящая: {"value": 900, "unit": "second"} или {"value": 800, "unit": "meter"}. Там же имя, intensityClass и массив targets с границами коридора.
И два режима отличаются не флажком, а формой. В режиме длительностей у блоков есть накопленные begin и end, у листьев — openDuration: false, а рядом лежит polyline, по которой рисуется столбчатая картинка тренировки. В дистанционном нет ни того, ни другого, ни третьего — зато появляется visualizationDistanceUnit: "meter": ось меряется метрами, и секундная полилиния нарисовала бы график, противоречащий собственным шагам.
Полное тело приводить не буду — оно длинное и слишком легко превращается в готовый скрапер, — но формы выше хватит, чтобы собрать своё, а сверить есть с чем: публичные клиенты пишут ровно это.
А дальше выяснилось, что самое важное TrainingPeaks за меня не считает вообще.
У плановой тренировки есть два числа, ради которых всё затевалось: IF (intensity factor — отношение интенсивности сессии к пороговой) и уже знакомый TSS. Для выполненной тренировки TP считает их сам, по факту. Для плановой — не считает. Хочешь, чтобы завтрашняя работа попала в прогноз формы, принеси числа сам.
Формула не сложная, но и не «среднее по коридорам»: время-взвешенное среднее четвёртой степени от середины целевого диапазона каждого шага.
def _compute_if_tss(steps, threshold_speed_mps=None) -> tuple[float, float]:
"""IF = (Σ dt·mid⁴ / Σ dt)^0.25 / 100 ; TSS = Σdt·IF²·100 / 3600."""
weighted_sum = 0.0
total_seconds = 0
for step in _expanded_steps(steps): # повторы разворачиваются в плоский список
midpoint = (step.target_low + step.target_high) / 2.0
seconds = _step_seconds(step, threshold_speed_mps)
weighted_sum += seconds * (midpoint**4)
total_seconds += seconds
if total_seconds == 0:
return 0.0, 0.0
intensity_factor = (weighted_sum / total_seconds) ** 0.25 / 100.0
tss = (total_seconds * intensity_factor**2 * 100.0) / 3600.0
return round(intensity_factor, 3), round(tss, 1)
Четвёртая степень здесь воспроизводит логику нормализованной мощности: десять минут на пороге стоят дороже двадцати минут трусцы, и линейное усреднение этого не ловит. Посчитаешь среднее арифметическое — разминка утянет интенсивность вниз, интервальная работа приедет с заниженным TSS, PMC покажет недобор, и агент на следующий день предложит добавить нагрузку, которую ты уже набрал.
Отдельная возня — с шагами в дистанции. «6×800 метров» не имеет длительности сама по себе: чтобы посчитать вклад во время и в TSS, нужен пороговый темп. Без него либо выкидывать эти шаги из расчёта, и тогда среднее уедет к интенсивности разминки, либо не писать тренировку вовсе. Я выбрал второе: нет надёжного порога — агент отказывается конвертировать метры в секунды и говорит об этом вслух. Как он научился не доверять порогам, которые ему выдал сам TrainingPeaks, — история ниже.
И последнее: totalTimePlanned нужно отправлять всегда и явно. TrainingPeaks выведет его сам, если структура задана длительностями, но у дистанционной выводить не из чего. Поле остаётся пустым, тренировка встаёт в календарь с длительностью 0:00 и не даёт в PMC ничего — при том что внешне всё в порядке: сессия на месте, шаги внутри, коридоры правильные.
6×800: от аргументов инструмента до экрана часов
Чтобы всё это перестало быть набором приёмов, проследим одну настоящую тренировку целиком. Беру её из базы как есть, четвёртое августа.
Разговор был обычный: я сказал, что хочу в этот день короткие отрезки. Агент сходил за профилем, за формой и за тем, что уже стоит в календаре, и вызвал инструмент. Вот аргументы вызова — дословно, как они лежат в базе:
{
"date": "2026-08-04",
"sport": "run",
"title": "6×800 по 4:10",
"intensity_metric": "percentOfThresholdPace",
"replaces_workout_id": "3883294186",
"description": "Разминка 15' Z1. Затем 6×800м в темпе ~4:10/км через 400м трусцы. Заминка 10' Z1. Если первые 2-3 отрезка улетают на 185+ или разваливаются — режь до 5×800 или сбавь до 4:15. Отрезки быстрее порога (3:49), задача — скоростной/VO2 стимул, не пороговый объём.",
"steps": [
{"name": "Разминка Z1", "duration_seconds": 900,
"target_low": 60, "target_high": 75, "intensity_class": "warmUp"},
{"repeat": 6, "steps": [
{"name": "800м @4:10", "duration_seconds": 200, "distance_m": 800,
"target_low": 92, "target_high": 95, "intensity_class": "active"},
{"name": "трусца 400м", "duration_seconds": 360, "distance_m": 400,
"target_low": 55, "target_high": 65, "intensity_class": "rest"}
]},
{"name": "Заминка Z1", "duration_seconds": 600,
"target_low": 55, "target_high": 70, "intensity_class": "coolDown"}
]
}
Три вещи здесь стоят внимания.
Коридоры заданы процентами от порога, а не абсолютным темпом — тренировка переживёт изменение формы: подтянется порог, подтянутся и все тренировки.
replaces_workout_id указывает на сессию, которая уже стояла в календаре — агент не создал новую, а переписал существующую тем самым GET-merge-PUT. Из 72 записей 51 создана с нуля, 21 переписана поверх.
А теперь неприятное: у шагов внутри повтора стоят обе длины сразу — и duration_seconds, и distance_m. Модель подстраховалась: раз я прошу отрезки по 800 метров в темпе 4:10, она посчитала, что это примерно 200 секунд, и отправила оба числа. По сегодняшней схеме инструмента так нельзя — шаг несёт ровно одну длину.
А четвёртого августа было хуже, чем «валидатор не проверял». Дистанционных шагов тогда просто не существовало: distance_m разбирался только на уровне всей сессии, а у шага парсер знал одно поле — секунды. Восемьсот метров он не отверг и не применил. Он их не заметил. Поле молча ушло в никуда, а на часы уехало то, что стояло в секундах.
И вот тут становится интересно, потому что из двух чисел уцелело худшее. Отрезок модель пересчитала точно: 4:10 на километр — это 250 секунд, 800 метров — ровно 200, сходится. А соседний шаг она посчитала мимо: трусца, 400 метров за 360 секунд, то есть 15:00 на километр — медленнее, чем я хожу пешком, — при заданном тут же коридоре 55–65 % от порога, который на моём настоящем пороге 4:15/км требует от 157 до 185 секунд.
Что это ошибка, а не замысел, видно по следующему черновику: четырнадцатью минутами позже у той же трусцы стоит уже 200 секунд. Модель молча переписала сама себя и нигде об этом не сказала.
Здесь мне повезло, и я это выяснил, только когда полез в базу ради статьи: шестиминутная трусца простояла двадцать восемь минут вечером третьего августа и была переписана до того, как тренировка уехала на часы. Утром я бежал правильную. Безопаснее механика от этого не стала — метры молча игнорировались, секунды были неверными, а заметил я всё это через три недели.
Проверка появилась 26 августа вместе с настоящими дистанционными шагами, и в коде рядом с ней написано, зачем она нужна:
# Ровно одна длина на шаг: секунды ИЛИ метры. Отбрасывать «оба» так же важно,
# как отбрасывать «ни одного» — модель, которая подстраховалась и прислала оба,
# иначе получит одно из двух молча проигнорированным на проводе.
duration = raw.get("duration_seconds")
distance = raw.get("distance_m")
if (duration is None) == (distance is None):
raise SpecError(
f"{where}: give exactly one of 'duration_seconds' (timed step) or "
"'distance_m' (distance step)"
)
И четвёртое, замеченное только при подготовке статьи. В описании агент пишет: «Отрезки быстрее порога (3:49)». Это мой прошлогодний порог: год назад настоящий, потом форма просела, а число в настройках осталось. Рассуждение вышло само себе противоречащим: 4:10 на километр — на двадцать одну секунду МЕДЛЕННЕЕ этого порога, а коридор агент поставил 92–95 %, честно ниже порогового, и сам же назвал это «быстрее порога». Тренировка получилась нормальной по чистой случайности: я просил конкретный темп, и в него он попал.
Дальше моя часть кода разворачивает повторы в плоский список, считает IF и TSS, выводит плановую дистанцию всей сессии — разминка и трусца включительно, потому что TrainingPeaks сравнивает с ней одометр выполненной, — сериализует структуру строкой и складывает в объект. В TrainingPeaks пока ничего не уходит.
Уходит в Telegram — черновик с кнопками:
И только после нажатия строка меняет состояние с proposed на pending, а обработчик кнопки выполняет запись. Через несколько секунд тренировка стоит в календаре TrainingPeaks со всеми шагами и коридорами, через несколько минут она в Garmin Connect, а к вечеру — на часах. Утром я выхожу на дорожку и вижу на экране «800м @4:10, отрезок 1 из 6» — числа, которые модель посчитала накануне вечером, а я подтвердил одним тапом, не открывая ноутбук.
По-другому эта система работать и не умеет: инструмент, которым модель ставит тренировку, в календарь не пишет. Он кладёт строку в базу в состоянии proposed и отправляет мне сообщение с кнопками, а запись выполняет обработчик нажатия, и он один. За пятьдесят дней ни одна тренировка не уехала в TrainingPeaks без моего тапа.
Одна дырка в этом правиле есть, и она сознательная: удалить тренировку из календаря модель может сама, без кнопки, — но только ту, что создала с нуля. Гоночные файлы и мои ручные тренировки не тронет. За пятьдесят дней не воспользовалась ни разу, в базе ноль удалённых строк, но изображать герметичный гейт было бы враньём.
Инструменты, цикл и память: что делает это агентом, а не cron с промптом
Всё, что было выше, — это водопровод. Агентом систему делает другое: модель сама решает, какие данные ей нужны, ходит за ними и только потом отвечает.
Механика — обычный цикл вызова инструментов. Модели отдаётся девять функций: профиль атлета и его правка, восстановление из WHOOP, форма за период, тренировки за диапазон, раскладка по кругам, недельный объём, черновик и удаление тренировки. Ответ обрабатывается по причине остановки:
for _iter in range(max_tool_iters):
response = await llm.complete(tier=agent.chat_tier, system=system,
messages=messages, max_tokens=cap, extra={"tools": tools})
stop = response.stop_reason
if stop == "tool_use":
# Модель попросила данные: проигрываем её ход, исполняем каждый вызов,
# возвращаем результаты и идём на следующий круг.
messages.append({"role": "assistant", "content": raw_content})
results = [await chat_tools.execute_tool_use(block, ...)
for block in raw_content if block.type == "tool_use"]
messages.append({"role": "user", "content": results})
continue
if stop == "pause_turn":
# Длинный серверный инструмент приостановил ход — проигрываем, чтобы продолжить.
messages.append({"role": "assistant", "content": raw_content})
continue
if stop == "max_tokens" and not escalated:
escalated, cap = True, 8192 # один раз поднимаем потолок и переспрашиваем
continue
break # end_turn — модель договорила
Как это выглядит со стороны — вот дословная выдержка из лога за одно утро:
Разница с обычным cron, который зовёт модель по расписанию, видна на живом примере. На «Толян, дай инструкции по силовой на ноги сегодня» он не отвечает сразу: сначала лезет за профилем, потом за сегодняшним восстановлением, потом за тем, что стоит в плане. И ответ может оказаться «не сегодня», хотя я просил инструкции, а не мнение.
Второе, что отличает агента от промпта, — память. Она устроена в три слоя.
Первый — история диалога: старые сообщения периодически сжимаются в конспект. Лечение ровно той болезни, от которой я страдал в эпоху чата.
Второй — уроки. Агент сам вытаскивает из разговора устойчивые правила и предлагает их мне той же кнопкой, что и тренировки: из восемнадцати кандидатов я одобрил девять, четыре отклонил, пять до сих пор висят. Вот те девять, дословно, как записаны:
1. Recover faster after races—2-3 days without running work instead of 5 days.
2. Align all training mesocycles to calendar weeks (Monday–Sunday) with Monday as rest day.
3. Align recovery periods after races to 2-3 days maximum instead of 5, based on owner's proven capacity.
4. No longer include swimming in training plans.
5. Don't include swimming in training plans since the owner is no longer preparing for triathlon.
6. Remove swimming from training plans—triathlon is no longer the goal.
7. Set interval workouts by distance (e.g., 800m repeats) not duration in TrainingPeaks.
8. Add planned kilometer targets to all running workouts for the week.
Записаны они по-английски, хотя разговор шёл по-русски, — так уж вышло, и я оставил как есть.
Восемь строк, но правил на самом деле пять. Строки 4, 5 и 6 — это одно и то же правило про плавание, записанное трижды: триатлон в сезоне отменился, а агент раз за разом ставил бассейн в план, и я объяснял заново, пока не закрепилось. Строки 1 и 3 — то же самое про восстановление после гонок. Дедупликация у меня есть, но тупая: точное совпадение и вхождение одной строки в другую. Три формулировки про плавание разошлись словами достаточно, чтобы проскочить мимо неё, — смысла система не понимает. Зато по числу дублей видно, что именно приходилось повторять.
Третий слой — профиль и сезонный план: цели, старты с приоритетами, фазы, пороги. Сводка подмешивается в каждый разговор, поэтому агент всегда знает, сколько дней осталось до Шанхая и что ноябрьский ультратрейл бежится как тренировочный.
И нюанс, на котором я обжёгся отдельно: агенту надо явно сообщать сегодняшнюю дату. Без этого модель считает её по памяти и уверенно предлагает поменять тренировку, которая была вчера. Теперь в каждый разговор подмешивается блок «Сейчас», и в промпте написано, что это единственный источник правды про время.
Сколько стоит месяц работы этого тренера
Начнём с той строки, которая измерена точно. За пятьдесят дней агент сжёг моделей на 16,45 доллара — это сумма из таблицы расходов, куда пишется каждый вызов:
|
Контур |
Расход |
|---|---|
|
Свободный чат |
$12,29 |
|
Утренний чек-ин, 50 дней |
$2,07 |
|
Недельный план, 7 штук |
$0,81 |
|
Разбор тренировки, 37 дней |
$0,59 |
|
Свёртка памяти и извлечение уроков |
$0,69 |
|
Итого |
$16,45 |
Одна деталь про сам подсчёт: 35 центов из этой суммы лежат в базе Толяна с ярлыком другого агента. Извлечение уроков намеренно записывается на моего SMM-бота — у них общий дневной лимит, — а одна ранняя свёртка памяти получила чужой ярлык на второй день жизни тренера и так и осталась. Сумма верна, но воспроизводится только запросом без фильтра по агенту, и я это заметил, когда сверял числа для статьи.
Дальше складываем месяц целиком, и вот тут начинаются оценки, а не замеры. Каждую называю вслух.
Инференс — 9,87 доллара. 16,45 за 50 дней, пересчитанные на 30. Три четверти этой суммы съедает свободный чат, потому что он один живёт на самой дорогой модели.
Дроплет — 8 долларов. Сама машина стоит 24 доллара в месяц за два ядра и четыре гигабайта, но Толян на ней не один: там же крутятся ещё два моих агента, не имеющих отношения к спорту. Делю на три. Если бы вы поднимали такое с нуля и только под тренера, у вас в этой строке стояло бы 24.
Claude Code Max 20x — 7 долларов. Подписка стоит 200 долларов в месяц и обслуживает всю остальную мою работу, так что вешать её сюда целиком было бы враньём. Сколько съел этот проект, видно по истории коммитов: 19 коммитов за семь вечеров — пять дней на первую рабочую версию в июле и четыре вечера правок за следующие полтора месяца. Примерно четыре часа в месяц против моих ста двадцати с лишним, то есть около 3,5 % подписки.
WHOOP и TrainingPeaks — 30 долларов. Годовой WHOOP Peak стоит 239 долларов, годовой TrainingPeaks Premium — 125. Без них ничего из описанного не существует: восстановление агент читает из первого, а план пишет во второй. Я платил обе подписки и в эпоху живого тренера, но это не повод прятать их из счёта — ниже я прибавлю их и ему.
|
Строка |
В месяц |
Откуда число |
|---|---|---|
|
WHOOP Peak |
$19,92 |
$239 в год |
|
Инференс |
$9,87 |
замер, таблица расходов |
|
TrainingPeaks Premium |
$10,42 |
$125 в год |
|
Дроплет, доля |
$8 |
$24 на трёх агентов |
|
Claude Code, доля |
$7 |
4 ч из 120+ ч в месяц |
|
Итого |
≈ $55 |
≈ 4 500 ₽ |
Четыре с половиной тысячи рублей по среднему августовскому курсу ЦБ 82,2 рубля за доллар. Теперь честное сравнение: ремешок и календарь были нужны и живому тренеру, значит, к его гонорару они прибавляются тоже. Тренер за 30 тысяч плюс те же 2 500 подписками — 32 500 рублей в месяц против 4 500. Разница в семь раз. По краям диапазона, который я платил, — от четырёх раз до почти двенадцати.
И даже так сравнение неполное. Тридцать две с половиной тысячи покупают человека, который несёт ответственность за план. Четыре с половиной покупают инструмент, который надо было уметь написать, — а семь вечеров, которые на него ушли, в таблице не стоят, потому что я не знаю, во сколько честно оценить собственный вечер.
Где агент проигрывает живому тренеру
Прежде чем перейти к поломкам — про то, где человек выигрывает.
Он меня не видит. Тренер на дорожке за пять секунд замечает, что у меня валится корпус на последних отрезках; агент видит только цифры и никогда не скажет, что дело в технике, а не в форме. Всё, что не оцифровано, для него не существует. Он и не трогает руками: когда в августе потянуло бедро, агент мог только осторожничать по косвенным признакам, а физиотерапевт пощупал бы и сказал за минуту.
Дальше — про ответственность. Тренер, поставивший провальный план, теряет клиента и репутацию. Агент не теряет ничего, и вся ответственность за его решения остаётся на мне: я одновременно заказчик, приёмщик и единственный пострадавший.
Меня такая конструкция устраивает, потому что я десять лет читаю собственные цифры и вижу, когда предложение выглядит странно. Новичку она не подходит совсем. У него нет ни базы для сравнения, ни насмотренности, которая подскажет, что неделя перегружена, ни человека, который скажет «нет» вместо него, — а ошибку он оплатит травмой, а не испорченным графиком. Всё, что здесь написано, работает для того, кто и без тренера доехал бы до финиша, просто хуже и дольше.
И то, что беспокоило меня больше всего: он не спорил по-настоящему. Живой тренер говорит «нет» и стоит на своём. Агента я продавливал всегда — он подстраивался под тон, и мой напор читался им как сигнал, а не как аргумент. Второе мнение, которое всегда соглашается, уже не второе.
Готовя статью, я полез искать, где эта черта заложена, и нашёл её у себя. Единственная инструкция про несогласие в системном промпте звучала так: «Тон дозируется: если Никита скажет жёстче/мягче — подстройся и запомни через урок». Про то, что делать, когда я просто давлю, не было ни строчки. 30 августа я дописал отдельный блок: держись вывода, пока не появятся новые данные, а напор данными не считается; уступил под давлением — скажи вслух, на чём настаивал и какое число против; спорь один раунд, а не десять. Как это поведёт себя вживую, не знаю — блок в проде младше этой статьи.
А один раз ниже вы увидите, чем кончается несогласие, которое я всё-таки продавил: три дня подряд я был уверен, что он зря меня бережёт, а данные говорят, что прав был он.
Где это ломается
За пятьдесят дней в проде набралось три настоящих отказа, и ни один не был виной модели: ломались данные, чужие настройки и мой код. Ещё три пункта ниже — не отказы, но без них картина неполная.
Зоны в TrainingPeaks устарели, и половина проблемы была моей. Пороговый темп в аккаунте стоял 3:49/км: год назад он был настоящим, потом форма просела, а обновить число должен был я. Настоящий, выставленный 26 августа, — 4:15/км. TSS растёт как квадрат доли от порога, так что весь беговой TSS считался с коэффициентом (3,92 / 4,37)² ≈ 0,81 и занижался примерно на 19 %. От того же устаревшего числа строились все зоны.
Но, начав разбираться, я нашёл и свой баг, который был хуже: клиент брал первую попавшуюся группу зон вместо группы нужного вида спорта, и плавание считалось по беговым порогам. Теперь порядок жёсткий, а наружу вместе с зоной уходит признак, найдена ли она точно: для пульсового коридора сгодится и общая группа, для перевода метров интервала в секунды — нет.
Аудит зон нашёл проблему и промолчал — но в прод это не уехало. Проверку расхождения я написал молчащей: она честно отказывалась считать по кривым числам, а сказать об этом вслух не умела, и агент просто переставал использовать зоны. Поймал я это сам, читая собственный дифф, и починил через восемнадцать минут вторым PR. В список настоящих отказов пункт поэтому не идёт, а сюда идёт: падение видно сразу, а молчаливое «я больше не считаю по этим данным» не видно никогда.
Утренний чек-ин обрывался на полуслове двенадцать раз. Все двенадцать — ровно на 2000 токенах, все на одной модели. Испорченных утр при этом два: десять обрывов пришлись на одно 21 июля, когда джоб пытался заново каждые полчаса с 06:48 до 11:13, ещё два — на 30 июля. Первопричина: я не передавал параметр thinking, а «по умолчанию» у разных моделей означает разное. Одна рассуждает перед ответом, другая нет, и у первой рассуждение съедало весь бюджет ответа.
Потолок подняли с 2000 до 20 000 токенов — и цену я тут заплатил, а не сэкономил. Медиана ответа у доехавших вызовов выросла втрое, с 494 до 1539 токенов, но втрое выросло и остальное: вызов подорожал с 1,7 до 3,6 цента, латентность — с 10,9 до 24,9 секунды. Зато средний расход на день упал с 4,5 до 3,9 цента, и вот в этом выигрыш: из выборки ушли дни вроде 21 июля, когда джоб сжёг 44 цента, долбясь одиннадцать раз подряд.
Агент ослеп по восстановлению на ровном месте. В регистрации приложения WHOOP не был отмечен один из запрашиваемых scope — так в OAuth называется отдельное право доступа, — а один невалидный scope отбивает весь запрос согласия целиком, вместе с разрешёнными правами. Дефект пролежал шестнадцать дней незамеченным — первый токен я выпустил вручную с более узким набором — и рванул ровно тогда, когда истёк refresh-токен: перевыдать согласие стало нельзя.
Воскресная пара «разбор недели → план» не запускалась ни разу. Написана, покрыта тестами, смержена 26 августа — и до конца окна, по которому считалась статья, в проде не отработала. Первый разбор она сделала 30 августа в 22:00, ровно когда я дописывал этот раздел: одна строка в базе, девять с половиной центов. «Сделано» и «работает» — разные вещи, и расстояние между ними я тут наблюдал лично.
Он слишком меня бережёт — и, кажется, я тут не прав. Видит восстановление ниже 30 % — предлагает отдыхать. За 48 дней с данными это случилось трижды, и каждый раз меня раздражало: я предпочитаю выжимать и учить организм работать не в идеальном состоянии.
Готовя статью, я полез в те три дня. 22 июля: восстановление 20 %, вариабельность 22,5 при моей обычной 35, пульс покоя 73 против обычных 62, сон 5:17. 24 июля: 29 %, вариабельность 23,7, пульс покоя 69. 28 августа: 22 %, вариабельность 24,0, пульс покоя 74. Три дня, когда организм по всем трём независимым показателям лежал в яме. Он был прав, а раздражало меня ровно это.
Первопричину я нашёл в собственном системном промпте:
- **Восстановление — часть тренировки.** Адаптация происходит в отдыхе. Сон,
HRV, recovery — это входные данные плана, а не мелочи. Лучше недобрать, чем
перебрать: недотренированный выйдет на старт, перетренированный — нет.
Эту строку написал я. Самая раздражающая черта моего тренера оказалась точной копией моей собственной осторожности, которую я в него и заложил. Живые тренеры, кстати, вели себя так же — и я тогда тоже злился.
Данные, которым я сам не до конца верю
Вот кривые формы за все пятьдесят дней.
Июльская болезнь уронила CTL с 55,5 до 45,4 к 20 июля; на пике 23 августа он дошёл до 58,1 — выше, чем был до неё. TSB за тот же период ушёл с +20 до −19 к 17 августа: так и выглядит загрузочный блок, свежесть в нём отрицательная по определению.
А теперь оговорка, без которой этот график читать нельзя. Пороговый темп я исправил 26 августа, а TrainingPeaks не пересчитывает уже сохранённый TSS. Вся кривая до 25 августа лежит на заниженной шкале: форма валидна, абсолютные значения нет, шов проходит в самом конце графика. Ближайшие полтора месяца CTL будет расти сам по себе без единой тренировки, а TSB — показывать усталость, которой нет. Насколько — не знаю: меньше девятнадцати процентов, потому что плавание и велосипед считались по своим порогам, а разбивки TSS по видам спорта в базе нет. Агенту про это сказано отдельной константой с датой, после которой её надо удалить. Историю я не пересчитывал: это правка нескольких сотен тренировок ради красивого графика.
Что забрать, если вы не бегаете
Про спорт здесь была только предметная область. Всё остальное переносится.
Главное, что я понял: узкое место было не в интеллекте, а во вводе-выводе. Год я жаловался, что модель не помнит контекст, хотя настоящая проблема была в том, что данные носил руками я — и носил выборочно. Прежде чем улучшать промпт, стоит посчитать, сколько раз в вашем цикле человек работает транспортным протоколом — агентизации подлежат именно эти шаги, а не рассуждение.
Дальше — про стену, которой не оказалось. Если у сервиса есть работающий личный кабинет в браузере, значит, есть и API, который его кормит. Это серая зона, и решать вам, но «нам отказали» и «это невозможно» — разные утверждения, и я почти год их путал.
Самое дорогое, что я вынес: худший отказ — тот, при котором система отвечает «да». Пустая тренировка с кодом 200 стоила мне двух суток, а модель, тихо переписавшая собственную трусцу с 360 секунд на 200, не сказала об этом вообще ничего — и я узнал об этом только через три недели. Громкий сбой — подарок. Проектируйте так, чтобы отказ было слышно.
Отдельно про гейт. Инструмент, которым модель ставит тренировку, у меня в календарь не пишет — он кладёт черновик и просит кнопку. За пятьдесят дней это ни разу не помешало работе и один раз спасло. Если ваш агент трогает внешний мир, разделение «модель предлагает, код решает, человек подтверждает» стоит того, чтобы написать его до первого инцидента, а не после.
Вернусь с цифрой после 6 декабря — тогда и станет ясно, помогло ли всё это добежать из 3:02 в 2:59 или я собрал очень аккуратный способ приходить к тому же результату с меньшими усилиями. Второе, впрочем, тоже результат.
Источники и код, на которые я опирался
-
JamsusMaximus/trainingpeaks-mcp — клиент, по которому восстанавливается механика обмена куки на токен и формат записи тренировок
-
freekode/tp2intervals — перенос тренировок между TrainingPeaks и intervals.icu, независимое подтверждение формата структуры
-
kotovaleksandr/trainingpeaks_bot — ещё одна независимая реализация того же маршрута
-
Документация WHOOP для разработчиков — официальный OAuth, скоупы и вебхуки
-
Normalized Power, Intensity Factor and Training Stress Score — откуда берутся IF и TSS и почему в формуле четвёртая степень
-
GoldenCheetah — открытая реализация тех же метрик: если не верите формуле выше, её можно сверить по исходникам
Примечание об инструментах. Код Толяна преимущественно писал Claude Code, я ставил задачи, читал диффы и принимал результат. Эту статью он тоже помогал мне писать, готовя артефакты, сниппеты и прочие визуализации. Все числа в тексте вынуты из работающей прод-базы и журнала вызовов моделей запросами, которые я запускал сам.
Автор: nikitahudov


