AI cycling coaching agent. Bring your own LLM API key or sign in with a ChatGPT Plus subscription, connect intervals.icu for real athlete data, chat via Telegram or CLI.
Want the bot running 24/7 without keeping your computer on? Deploy the Railway template: https://railway.com/deploy/cycling-coach
Follow @yerzhansa on X.com for updates, or drop a question/feedback anytime.
- Analyzes fitness, fatigue, and form from your real rides
- Builds periodized plans toward your goal event
- Pushes structured workouts to your intervals.icu calendar (auto-syncs to Garmin, Wahoo, Hammerhead, COROS, Suunto, Zwift)
┌─────────────────────────────────────────────────────────┐
│ You │
│ Telegram / CLI │
└────────────────────────────┬────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Cycling Coach Agent │
│ │
│ ┌──────────────┐ ┌─────────────┐ ┌───────────────┐ │
│ │ Coaching │ │ Cycling │ │ Memory │ │
│ │ persona & │ │ logic │ │ goals, notes, │ │
│ │ domain skills│ │ zones, plans│ │ preferences │ │
│ └──────────────┘ └─────────────┘ └───────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Intervals.icu API │ │
│ │ fitness · fatigue · form · rides · push workouts │ │
│ └──────────────────────────────────────────────────┘ │
└────────────────────────────┬────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ LLM (Claude / GPT / Gemini) │
│ Interprets data + coaching knowledge → response │
└─────────────────────────────────────────────────────────┘
- You send a message — via Telegram or the command line ("Build me a 12-week gran fondo plan", "What should I ride today?")
- The coach reads your history — goals, past conversations, injury notes, and preferences stored locally on your machine
- It pulls your real data — current fitness, fatigue, form, recent rides, FTP, and zones from intervals.icu
- It runs cycling logic — zone calculations, periodization models, feasibility checks, workout structure — all deterministic, no guessing
- An LLM puts it together — Claude, GPT, or Gemini interprets everything and responds like a knowledgeable coach
- Workouts land on your calendar — structured intervals pushed to intervals.icu, which syncs to Garmin, Wahoo, Hammerhead, COROS, Suunto, and Zwift
Requires Node.js 22+ (comes with npm).
Supported on macOS and Linux. Windows is not supported — the secrets backends and process handling are POSIX-shaped (use WSL2 if you must run on Windows).
Open Terminal (Mac/Linux) or PowerShell (Windows) and run:
npm install -g cycling-coach
cycling-coach setup
cycling-coachThe setup wizard asks for your LLM provider — an API key for Anthropic / OpenAI / Google / DeepSeek / Qwen / MiniMax / Kimi / Z.AI / OpenRouter, or OAuth sign-in with your ChatGPT subscription (no API key needed). Then optionally connects intervals.icu and Telegram. After setup, cycling-coach starts in CLI mode — or Telegram mode if you provided a bot token.
Cycling Coach (CLI mode). Type your message:
> Calculate my zones for FTP 280
> Build me a 12-week plan for a gran fondo
> What should I do today?
> /quit
LLM provider options:
- Anthropic (Claude) — console API key from Anthropic Console. Recommended default.
- OpenAI (GPT) — console API key from OpenAI Platform.
- Google (Gemini) — console API key from Google AI Studio.
- DeepSeek — API key from DeepSeek Platform.
- Qwen — API key from Alibaba Cloud DashScope.
- MiniMax — API key from MiniMax Platform.
- Kimi — API key from Moonshot AI.
- Z.AI (GLM) — API key from Z.AI.
- OpenRouter — API key from OpenRouter.
- OpenAI Codex (ChatGPT subscription) — experimental — browser OAuth sign-in with your ChatGPT Plus / Pro / Business / Edu / Enterprise account. No API key needed; the bot uses your subscription quota. Minimum tier: ChatGPT Plus ($20/mo). Select it in
cycling-coach setupto start the OAuth flow. Models offered in the wizard:gpt-5.4(balanced, recommended) andgpt-5.4-mini(faster, smaller context). Cost is covered by the subscription regardless of which model you pick — the choice is speed vs capability, not price. On hard rate-limit failures the bot retries up to 4× with backoff (~35s total) before reporting the error to the chat.
Anthropic's Claude Pro/Max subscription does not support OAuth for third-party tools (per Anthropic ToS) — the only supported Anthropic path here is the console API key.
Where to get other keys:
- intervals.icu: intervals.icu/settings > Developer Settings
- Telegram: Message @BotFather >
/newbot
git clone git@github.com:yerzhansa/cycling-coach.git
cd cycling-coach
npm install
npm run build
# Dev loop (auto-reload, reads .env)
npm run devNote: npm run dev runs TypeScript directly (via tsx). npm run build produces dist/ for running via Node / the published npm package.
| Command | What it does |
|---|---|
/start |
Welcome message + available commands |
/plan |
Fetches your intervals.icu data, builds a periodized plan, asks to push to calendar |
/workout |
Checks your current fitness, fatigue, and form — suggests today's workout with structured intervals |
/status |
Shows fitness, fatigue, form, and coaching notes |
/sync |
Pushes next 1-2 weeks of planned workouts to intervals.icu calendar |
/whatsnew |
Shows what changed in the latest release |
/update |
npm installs: updates the bot to the exact verified registry version. Managed container deploys: explains that updates happen by image redeploy. |
Free-form chat works too — ask anything about training, report an injury, request plan adjustments.
Cycling Coach restricts Telegram interactions to a configured allowlist of user IDs. Only senders in ~/.cycling-coach/allowed-senders.json (or set via the CYCLING_COACH_OPERATOR_ID env var, single ID) can interact with the bot. Random Telegram users who discover your bot's username are dropped at the middleware layer.
To check whether a newer version exists, Cycling Coach makes one background HTTPS request to ping.enduragent.icu. That endpoint returns the latest published version (the same answer registry.npmjs.org gives) and records an anonymous usage count so the project can see roughly how many instances are running across install channels. The count contains no personal information.
The check runs on Telegram-mode startup and again every 24 hours (so a long-running deployment learns about new releases without a restart). The same request powers the "update available" notification and the /whatsnew and /update commands. In development and test the bot talks to registry.npmjs.org directly instead; if the ping.enduragent.icu endpoint is ever unreachable, the bot silently falls back to registry.npmjs.org, so update checks keep working either way.
Set CYCLING_COACH_NO_UPDATE_CHECK=1 to disable the automatic background checks (both the startup check and the daily re-check). The operator-initiated /update and /whatsnew commands still query the endpoint — those are explicit requests, not background checks (and /whatsnew also reaches the GitHub Releases API for release notes). In managed container deploys, /update does not run npm; image hosts update by redeploying the container image.
The setup wizard prompts you to send /start from your Telegram account; the wizard captures your user ID and writes the allowlist file automatically — you never type your numeric ID.
When you run cycling-coach on an upgraded install with no allowlist yet:
- Interactive shell (TTY available): the bot prompts you to send
/startfrom your Telegram app. It captures your user ID, asks you to confirm, and writes the allowlist. Total friction: one Telegram message + one keypress. You never type your user ID anywhere. - Non-interactive (Docker, systemd, fly.io,
cycling-coach &): the bot starts in pairing mode. DM it once — the reply contains your Telegram user ID and the exact CLI command to run. Copy-paste from the reply:cycling-coach add-sender <id>. Total friction: about 30 seconds.
cycling-coach add-sender <userId>
A friend's user ID appears in the bot's pairing-challenge reply when they first try to message it.
cycling-coach remove-sender <userId>
If you remove the only allowed sender, the bot drops back into pairing mode automatically.
cycling-coach list-senders
Prints the current policy, allowed senders with their addedAt timestamps, and any session files left on disk from prior conversations (useful for identifying who's been DM-ing the bot).
Pre-upgrade, the bot accepted DMs from anyone who knew its username. Their session files (~/.cycling-coach/sessions/telegram:<chat-id>.jsonl) remain on disk after migration — they're no longer queryable through the bot, but they still exist. Manual deletion is safe (rm of any chat-ID that isn't yours). A cycling-coach prune-sessions CLI is tracked as a follow-up.
- Zone calculator — 6 power zones from FTP (Z1 Recovery through Z6 VO2max)
- Plan builder — periodized training plan from athlete profile (linear, block, reverse linear, polarized, pyramidal models)
- Feasibility check — assesses whether FTP or W/kg targets are realistic
- Sample weeks — generates weekly workout templates by volume tier with hard session spacing
- Fetch athlete profile (FTP, weight, max HR, zones)
- Fetch recent activities (load, intensity, duration)
- Fetch wellness data (fitness, fatigue, form, HRV, resting HR, sleep)
- Push workouts to calendar → auto-syncs to Garmin, Wahoo, Hammerhead, COROS, Suunto, and Zwift
File-based at ~/.cycling-coach/:
memory/MEMORY.md— long-term: goals, injury history, preferencesmemory/2026-04-13.md— daily conversation notesplans/current-plan.json— active training plan
The agent reads memory at the start of each conversation and writes to it when significant decisions are made (new goal, plan change, injury).
Conversation transcripts live at ~/.cycling-coach/sessions/<chatId>.jsonl. A session reset renames the transcript to a timestamped archive (<chatId>.jsonl.reset.<timestamp>) rather than deleting it, and archives are kept indefinitely by default. To opt into age-based cleanup, set session.resetArchiveRetentionDays in config.yaml (or the SESSION_RESET_ARCHIVE_RETENTION_DAYS env var) to the number of days to keep archives; 0 — the default — keeps them forever. Archive files are owner-readable only (mode 0600).
Instead of env vars, you can create ~/.cycling-coach/config.yaml:
llm:
provider: anthropic
model: claude-opus-4-6
api_key: sk-ant-...
flush_model: claude-haiku-4-5-20251001 # optional: cheaper model for memory tidy-up
intervals:
api_key: your-intervals-api-key
athlete_id: "0"
telegram:
bot_token: "123456:ABC..."For the Codex OAuth path, the config has no api_key — tokens live in ~/.cycling-coach/auth-profiles.json (mode 0600) and rotate automatically:
llm:
provider: openai-codex
model: gpt-5.4
auth_profile: openai-codexEnv vars take precedence over YAML.
The bot periodically summarizes a conversation into structured memory ("memory tidy-up"). This runs on your chat model by default, but it is a mechanical extract-the-facts task that a cheaper model handles just as well. Set llm.flush_model in config.yaml (or the LLM_FLUSH_MODEL env var) to route only the tidy-up through a cheaper model while your chat replies keep the default model. When unset, the tidy-up reuses your chat model (no change). Suggested cheap models per provider: Anthropic — a haiku-class model such as claude-haiku-4-5-20251001; OpenAI — a mini-class model such as gpt-5.4-mini or gpt-4o-mini; Google — a flash-class model such as gemini-2.5-flash.
If you don't want API keys to live as plaintext in ~/.cycling-coach/config.yaml, any secret field (llm.api_key, intervals.api_key, telegram.bot_token) can be replaced with a SecretRef. Two shapes are supported:
source: exec— runs an external command (1Password CLI, Vault,age, etc.) and reads its stdout. Best for local desktop use with a password manager.source: env— reads a process env var directly (no spawn). Best for cloud / Docker / Railway / Kubernetes where the platform injects secrets as env vars.
# exec — local with 1Password
llm:
provider: anthropic
model: claude-sonnet-4-6
api_key:
source: exec
command: op
args: [read, "op://Personal/Anthropic/credential"]
# env — cloud / Docker
llm:
provider: anthropic
model: claude-sonnet-4-6
api_key:
source: env
var: ANTHROPIC_API_KEYPrecedence: env var (the legacy ANTHROPIC_API_KEY / INTERVALS_API_KEY / TELEGRAM_BOT_TOKEN keys) > SecretRef > plain YAML. Setting ANTHROPIC_API_KEY in your shell still wins — useful for debugging a vault issue without touching YAML.
exec requirements:
- The
commandmust print only the secret to stdout. JSON blobs, labels, or extra output will be stored verbatim and downstream APIs will reject them. - A single trailing
\nor\r\nis trimmed; all other whitespace is preserved. - Empty output, non-zero exit, a 30s timeout, or output over 64KB is a fatal startup error with a clear stderr message.
shell: false—commandandargsare passed directly to the OS.~,$HOME, globs, and shell operators are not expanded. Use absolute paths.
env requirements:
varmust name an env var that is set and non-empty at startup. Unset →ENOENT; empty string →EMPTY. Both are fatal startup errors.- The value is used verbatim — no trimming, no shell interpretation. If your platform's secret manager appends a newline, set the env var without it.
If you have the 1Password CLI (op) or you're on macOS, cycling-coach setup can create the backend items for you — no YAML hand-editing, no manual op item create / security add-generic-password calls.
When the wizard reaches the secrets step it asks "Where to store secrets?". The available options depend on what it detects:
- Plain config.yaml — the pre-existing behavior; secrets are written as plain strings.
- 1Password CLI — offered when
opis on your$PATH. Ifopis installed but not signed in, the wizard offers an "1Password CLI — sign in first" option that runsop signininline, then re-detects and continues. If no account is configured, the option is hidden and an INFO log explains why. - macOS Keychain — offered on macOS (Darwin) only.
Pick one and the wizard handles every subsequent secret prompt (llm.api_key, intervals.api_key, telegram.bot_token) against that backend. For 1Password, the first write triggers Touch ID / system auth. The resulting config.yaml stores only a SecretRef pointing at the backend — your actual secret value never lands in YAML.
Re-running the wizard is idempotent. Hit Enter at any password prompt to keep the existing value; YAML is unchanged and no new backend item is created. If a 1Password item with the same title already exists, the wizard prompts [Update | Keep existing | Cancel] instead of overwriting blindly.
Switching backends on a re-run (e.g. you picked Keychain last time, now want 1Password): if you type a new value, the wizard writes to the new backend and leaves the old item alone — the old SecretRef is replaced in YAML but the old Keychain/1Password item is not deleted (clean it up manually if you want). If you hit Enter without typing a new value, the wizard shows an explicit [Paste to migrate to <new backend> | Keep in <old backend> (YAML unchanged)] prompt — it never silently reads a secret from one backend and writes it to another.
Pasted keys are trimmed. The wizard strips leading and trailing whitespace from pasted secrets and logs
Trimmed whitespace from pasted <field>.at INFO when it does. This catches trailing newlines that clipboard managers commonly add (a frequent cause of "key works in curl, fails in the bot"). If your secret legitimately needs surrounding whitespace — rare, but real for some token formats — set it via env var instead; the env-var path bypasses trim.
Run setup from one terminal at a time. Concurrent
cycling-coach setupruns may create duplicate backend items or race on the YAML write; the wizard does not lock against this in v1. If you accidentally start two, complete one and re-run the other — the re-run UX (Update / Keep / Cancel) handles duplicates cleanly.
Keychain scope (macOS). The Keychain backend uses your login keychain (per-Mac, unlocked automatically when you log in, not synced via iCloud). The full keychain path is pinned in the SecretRef so a later
security default-keychain -s …won't silently break cycling-coach. If you want cross-device sync, pick 1Password in the wizard instead. Custom keychains and iCloud Keychain targeting are planned for v2.
Ctrl+C during a "1Password: creating item…" step may leave orphans. The wizard tracks items it creates in-run and prints
op item delete "…"cleanup commands on cancellation (Ctrl+C, SIGTERM). There is an unclosable sub-second race whereopcommits the new item server-side but the child is killed before it can report success — the wizard has no way to record it, so it can't list it for cleanup. After a forced cancel, runop item list | grep cycling-coachto check for stray items. This is a fundamental limitation of child-process write-then-ack semantics, not specific to cycling-coach.
Non-TTY invocations are rejected. Running cycling-coach setup from a non-interactive context (CI, Docker build, systemd post-install, piped stdin) exits with code 2 and a stderr pointer to the Non-interactive setup section below. Zero side effects are performed before the TTY check.
| Backend | command |
args |
Caveat |
|---|---|---|---|
| 1Password CLI | op |
["read", "op://Vault/Item/field"] |
GUI session required for Touch ID; not for headless/launchd. |
| macOS Keychain | security |
["find-generic-password", "-w", "-s", "cycling-coach", "-a", "anthropic_api_key", "/Users/you/Library/Keychains/login.keychain-db"] |
-w is mandatory — without it the whole record is dumped. The keychain path is passed as the last positional argument (pins the keychain so a later security default-keychain -s … doesn't break cycling-coach). macOS's security does not support a -k flag on *-generic-password subcommands. |
| Bitwarden | bw |
["get", "password", "anthropic-api-key"] |
Requires BW_SESSION env from bw unlock before cycling-coach starts. |
| HashiCorp Vault | vault |
["kv", "get", "-field=key", "secret/anthropic"] |
-field= is mandatory — raw vault kv get prints JSON. Needs VAULT_ADDR + VAULT_TOKEN. |
| AWS Secrets Manager | aws |
["secretsmanager", "get-secret-value", "--secret-id", "my/secret", "--query", "SecretString", "--output", "text"] |
--query SecretString --output text is mandatory. Without both flags the output is JSON and the bot fails with "invalid API key". |
| GCP Secret Manager | gcloud |
["secrets", "versions", "access", "latest", "--secret=anthropic"] |
Requires gcloud auth application-default login in the environment cycling-coach runs under. |
| age-encrypted file | age |
["-d", "-i", "/Users/you/.age/key.txt", "/Users/you/secrets/anthropic.age"] |
Absolute paths only — shell: false does not expand ~ or $HOME. |
- Use absolute paths in
command:— macOSlaunchdstarts processes with a minimalPATHthat excludes/usr/local/binand Homebrew paths. Put/usr/local/bin/op(the output ofwhich op) instead of bareop. - 1Password Touch ID won't work headless — no GUI to prompt against. For daemon use, pick a backend with a pre-unlocked session (Vault, cloud secret managers,
age-encrypted files), or supply the key via env var. - Stderr from the resolver command is shown on non-zero exit (last 200 chars). Stick to well-behaved CLIs — a buggy resolver that prints the secret on error will leak it to logs.
If you can't run cycling-coach setup in an interactive terminal, hand-edit ~/.cycling-coach/config.yaml directly. A minimal YAML with env-supplied secrets:
llm:
provider: anthropic
model: claude-sonnet-4-6
# api_key, intervals, telegram sourced from env vars:
# ANTHROPIC_API_KEY, INTERVALS_API_KEY, INTERVALS_ATHLETE_ID, TELEGRAM_BOT_TOKENOr fully SecretRef-driven:
llm:
provider: anthropic
model: claude-sonnet-4-6
api_key:
source: exec
command: /usr/local/bin/op
args: [read, "op://Personal/Anthropic/credential"]
intervals:
api_key:
source: exec
command: /usr/local/bin/vault
args: [kv, get, -field=key, secret/intervals]
athlete_id: "i12345"
telegram:
bot_token:
source: exec
command: /usr/bin/security
args: [find-generic-password, -w, -s, cycling-coach, -a, telegram_bot_token, /Users/you/Library/Keychains/login.keychain-db]SecretRef support was added in a recent release. Downgrading cycling-coach while config.yaml contains SecretRef blocks will fail at startup — older versions treat non-string secret values as malformed. Keep plain strings or env vars if you need to roll back.
Cycling Coach is a single Node process holding a long-polling connection to Telegram, with state on a local volume at $CYCLING_COACH_HOME (defaults to ~/.cycling-coach). It needs:
- An always-on container or VM (no scale-to-zero — long-polling stops when the process stops).
- A persistent volume mounted at
/data(or wherever you pointCYCLING_COACH_HOME). - Secrets injected as env vars, referenced from
config.yamlviasource: env(see Storing secrets outside config.yaml). - One instance only — sessions are sharded by Telegram chat ID on local disk; do not enable autoscaling.
- A BYOK API-key provider (
anthropic/openai/google/deepseek/qwen/minimax/kimi/zai/openrouter).LLM_PROVIDER=openai-codexis not supported in containers — it depends on an interactive OAuth flow that writes to the data dir, which can't run headless.
The official image is published to GHCR:
docker pull ghcr.io/yerzhansa/cycling-coach:stableRun it with a persistent /data volume:
docker run -d --name cycling-coach \
-v cycling-coach-data:/data \
--env-file .env \
ghcr.io/yerzhansa/cycling-coach:stableOr build the same image locally from this repo:
docker build -f packages/cycling-coach/Dockerfile -t cycling-coach .Use --env-file rather than inline -e KEY=value flags — inline values land in shell history and are visible to other users via ps. Your .env should contain LLM_PROVIDER, LLM_API_KEY, INTERVALS_API_KEY, INTERVALS_ATHLETE_ID, TELEGRAM_BOT_TOKEN, and CYCLING_COACH_OPERATOR_ID for unattended cloud/container starts. Provider-specific LLM env vars still work and take precedence over LLM_API_KEY: ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_GENERATIVE_AI_API_KEY, DEEPSEEK_API_KEY, ALIBABA_API_KEY, MINIMAX_API_KEY, MOONSHOT_API_KEY, ZAI_API_KEY, or OPENROUTER_API_KEY. Restrict the file to the user that invokes docker: chmod 600 .env. Note that env vars passed to a container remain visible via docker inspect to anything with Docker-socket access — where available, prefer Docker Compose secrets: or podman secrets instead. For non-container installs, the config-based secret backends (macOS Keychain, 1Password SecretRef — see Storing secrets outside config.yaml) keep keys out of the environment entirely.
The image mounts /data for state and reads /data/config.yaml if present. The image sets CYCLING_COACH_MANAGED_DEPLOY=1, so /update is disabled and updates happen by pulling/redeploying a newer image. The container starts as root only long enough to fix managed-platform volume ownership, then drops to the non-root node user (uid 1000). With the env vars above, no config.yaml is required — LLM_PROVIDER + LLM_API_KEY covers all API-key LLM providers.
For finer control (custom model, idle timeout, etc.) drop a config.yaml into the volume:
# /data/config.yaml
llm:
provider: anthropic
model: claude-sonnet-4-6
api_key:
source: env
var: LLM_API_KEY
intervals:
api_key:
source: env
var: INTERVALS_API_KEY
athlete_id: "i12345"
telegram:
bot_token:
source: env
var: TELEGRAM_BOT_TOKENRailway templates are one-click deploy recipes. The Cycling Coach template runs your own private bot on Railway 24/7, so your computer does not need to stay on.
Deploy: https://railway.com/deploy/cycling-coach
Railway hosts the container and volume in your Railway account. The bot reads your Railway variables, talks to Telegram, intervals.icu, and your chosen LLM provider from inside your Railway project. We do not host a shared backend, store your secrets, store your athlete data, or receive your Telegram messages. Your hosting and billing relationship is with Railway. Railway currently lists Hobby as the practical minimum for always-on apps: $5 minimum usage/month, including $5 monthly usage credits.
The template uses ghcr.io/yerzhansa/cycling-coach:stable, mounts persistent state at /data, and has image auto-updates enabled.
Before you click Deploy, prepare three accounts: one LLM provider account, intervals.icu, and Telegram.
Fill these Railway variables:
| Variable | What to enter | Where to get it |
|---|---|---|
LLM_PROVIDER |
One lower-case provider id: anthropic, openai, google, deepseek, qwen, minimax, kimi, zai, or openrouter. Start with anthropic if unsure. |
Pick the provider that issued your LLM_API_KEY. ChatGPT Plus login is not supported in Railway because it needs an interactive browser login. |
LLM_API_KEY |
API key for the provider in LLM_PROVIDER. |
Anthropic Console, OpenAI Platform, Google AI Studio, DeepSeek Platform, Alibaba Cloud DashScope, MiniMax Platform, Moonshot AI, Z.AI, or OpenRouter. |
INTERVALS_API_KEY |
Your intervals.icu API key. | intervals.icu/settings > Developer Settings. |
INTERVALS_ATHLETE_ID |
Your intervals.icu athlete id, usually like i12345. Include the leading i when intervals.icu shows one. |
Open your intervals.icu profile/settings URL and copy the athlete id from the URL or profile details. |
TELEGRAM_BOT_TOKEN |
Token for the Telegram bot users will message. | In Telegram, open @BotFather, run /newbot, choose a name and username, then copy the token. |
CYCLING_COACH_OPERATOR_ID |
Your numeric Telegram user id, for example 123456789. This is not the bot token and not the bot username. |
In Telegram, message a helper bot such as @userinfobot and copy your numeric id. Only this Telegram user is allowed to talk to your bot by default. |
Railway does not run cycling-coach setup; the variables above are the setup. After deploy, open Telegram and send /start to the bot you created with BotFather. If a value is wrong, edit the service variables in Railway and redeploy or restart the service.
- Fly.io — works with the same Dockerfile. In
fly.tomlsetauto_stop_machines = falseandmin_machines_running = 1, otherwise Fly will stop the machine on idle inbound HTTP and the bot stops polling. Mount a 1 GB volume at/data. - VPS (Hetzner, DigitalOcean, Lightsail, Oracle Free) —
docker runas above, or use systemd. Mount a host directory at/datafor state. - Avoid scale-to-zero serverless (Lambda, Cloud Run with
min=0, Cloudflare Workers, Vercel) and platforms with ephemeral filesystems (Heroku) — both break this app.
npm run check # tsc --noEmit + oxlint
npm test # vitest (112 tests)
npm run test:watch # vitest watch mode
npm run lint # oxlint
npm run fmt # oxfmt
npm run build # tsc → dist/Set CYCLING_COACH_HOME to isolate npm run dev from the globally-installed
cycling-coach CLI. Each dir has its own config.yaml, auth-profiles.json,
sessions/, and memory/, so dev and prod never collide:
# .env (loaded only by `npm run dev`)
CYCLING_COACH_HOME=~/.cycling-coach-devThe global install keeps using ~/.cycling-coach. For full isolation, run
npm run setup once against the dev home to register a separate Telegram bot
token and (recommended) a separate intervals.icu athlete.
src/
cycling/ # Pure domain logic (extracted from training-app)
schemas.ts # Zod schemas + types
zones.ts # calculateCyclingZones(ftp)
periodization.ts # Model selection, phases, volume tiers
plan-builder.ts # buildPlanSkeleton(profile)
feasibility.ts # FTP/W:kg goal assessment
templates.ts # Sample week builder
agent/
core.ts # CyclingCoachAgent (Vercel AI SDK v6)
tools.ts # 12 tool definitions
system-prompt.ts # SOUL + skills + memory → system prompt
memory.ts # File-based memory system
channels/
telegram.ts # grammY bot
config.ts # Config loader (env + yaml)
index.ts # Entry point
skills/ # Markdown domain knowledge (loaded into system prompt)
SOUL.md # Coaching persona
tests/ # 112 tests
| Dependency | Version | Purpose |
|---|---|---|
| Vercel AI SDK | 6.x | Model-agnostic LLM interface with tool calling |
| intervals-icu-api | local | TypeScript client for intervals.icu |
| grammY | 1.x | Telegram bot framework |
| Zod | 4.x | Schema validation |
| oxlint | 1.x | Linter |
| oxfmt | 0.x | Formatter |
| TypeScript | 6.x | Type system |
| Vitest | 4.x | Testing |
Cycling Coach's data substrate (the "Reference" submodule that grounds coaching in verified athlete numerics) is a port of section-11 (CrankAddict, MIT-licensed). See NOTICE.md for the full attribution and the list of modifications introduced during the port.
MIT