Contribute

Publish a workout pack.

Anyone can run a community repository: a coach with a structured plan, a training group sharing their winter intervals, or a single rider who's built a pack worth handing out. The process is two static JSON files and a pull request.

The shape of the deal

  1. You write a manifest.json describing your repository (name, author, and a homepage if you have one) plus one or more bundle files holding the workouts themselves.
  2. You host it over HTTPS, anywhere that serves a JSON file with permissive CORS. GitHub Pages, Codeberg Pages, your own web folder.
  3. You open a pull request against this app's Codeberg repository adding a single entry to website/registry.json that points at your manifest URL.
  4. On merge, your repository shows up in the community browser and riders can browse it from inside the app and pick individual workouts to add to their library.

1. Write your manifest (and bundles)

A repository is two kinds of file:

Manifest top-level fields

FieldTypeRequiredNotes
schemaVersionintegeryes Manifest format version. Currently 2. The app rejects manifests with versions higher than it understands and shows a "please upgrade" hint.
idstringyes Stable, unique identifier (kebab-case). Used as a folder name and as the dedup key, so don't change it after publishing.
namestringyes What's shown on the card. Up to about 40 characters reads well.
descriptionstringno One or two sentences. Plain text, no markdown.
authorstringno Your name, your collective, or your handle.
homepagestring (URL)no Adds a "Homepage" link to your repo page. Leave it out and no link is shown, which is the right answer if you don't run a site: a repository is just two JSON files and needs no page behind it. This is the value the link uses, so you can change or remove it any time without touching the registry.
updatedEpochinteger (ms)no Unix epoch in milliseconds, not seconds. Shown on your repo card in the community browser as "updated 3 days ago". The Android app parses it but doesn't display it, so a wrong value only ever shows on the website. Getting the unit wrong is the most common mistake contributors make: see Getting updatedEpoch right.
bundlesarrayyes Pointers to your bundle files. See the next table. Every published workout lives in some bundle; the manifest itself never contains segments.

Each bundle entry

FieldTypeRequiredNotes
idstringyes Stable bundle id (kebab-case). Used as the cache key on the app side, so don't rename after publication.
namestringno Maintainer-facing label. Bundles aren't shown to end users; this only helps you and other contributors.
schemaVersionintegeryes Format version of the bundle file. Currently 1 = bare JSON array of workout objects. Bumped if/when the bundle wrapper changes.
versionintegeryes Bump this every time you change any workout in the bundle, even by one second. The app re-downloads only when this number increases. Forgetting to bump it is the #1 cause of "my edit didn't show up".
urlstringyes Absolute (https://…) or relative to the manifest's URL. Relative is the portable form: survives a host change.

Each workout (inside a bundle file)

A bundle file is a JSON array; each element is a workout object:

FieldTypeRequiredNotes
schemaVersionintegerno Workout format version. Defaults to 1. Workouts with versions higher than the app understands are skipped, so future formats can land in an existing bundle without breaking older clients. Use 2 only if a segment carries a cadence target: a 1 workout loads everywhere, so don't claim 2 without needing it.
idstringyes Unique within your repo. Prefix with your repo id to avoid collisions with bundled or other-repo workouts (e.g. "alpine-tempo-3x10" rather than just "tempo-3x10").
namestringyes Display name. Keep it short; it shows on small cards.
descriptionstringno Why someone would ride this. One paragraph max.
tagsarray<string>no Free-form labels: "Threshold", "VO2 Max", "Recovery", etc.
segmentsarrayyes Sequential intervals. Each has lengthInSeconds (1–14400), powerPercentFTP (0–300), and intervalType: "CONSTANT".
cadenceMinRpm
cadenceMaxRpm
integerno Optional cadence target on a segment, 20–200 rpm. Both for a range (7080), cadenceMinRpm alone for a single target. The trainer holds the power either way; this tells the rider how to spin while it does, so use it for climbing-cadence or leg-speed work. Requires schemaVersion: 2.

Example: minimum viable repo

Two files. Copy, edit, host.

manifest.json

{
  "schemaVersion": 2,
  "id": "alpine-coach",
  "name": "Alpine Coach",
  "description": "Threshold and VO2 work tuned for stage racers.",
  "author": "Alpine Coach Collective",
  "homepage": "https://alpinecoach.example",
  "updatedEpoch": 1746547200000,
  "bundles": [
    {
      "id": "main",
      "schemaVersion": 1,
      "version": 1,
      "url": "main.json"
    }
  ]
}

main.json (sibling of the manifest):

[
  {
    "schemaVersion": 1,
    "id": "alpine-warmup-2min",
    "name": "Quick Warmup",
    "description": "Two-minute pre-effort opener.",
    "tags": ["Warm-up"],
    "segments": [
      { "lengthInSeconds": 60, "powerPercentFTP": 50, "intervalType": "CONSTANT" },
      { "lengthInSeconds": 60, "powerPercentFTP": 75, "intervalType": "CONSTANT" }
    ]
  }
]

For a longer reference, see the starter pack manifest and its main bundle: six workouts spanning warm-up through VO2.

Getting updatedEpoch right

Unix time comes in two flavours that look almost identical, and every contributor so far has picked the wrong one:

seconds:      1785297508      <- 10 digits, what most converters give you
milliseconds: 1785297508000   <- 13 digits, what this field wants

A seconds value isn't rejected, it's just read as a date in January 1970, so your repo card says "updated 56 years ago". If that's what you're seeing, this is why. Count the digits: you want 13.

Any of these gives you a correct value:

The field is optional. If you'd rather not think about it, leave it out entirely and your card simply won't carry an "updated" line, which looks tidier than a wrong date.

Splitting workouts across multiple bundles

For small repos, one bundle is fine. Split when you have logical groups that change at different rates: e.g. weekly-plan bumped every Monday and warmups updated rarely. Riders only re-download the bundle whose version moved.

2. Host it

The app fetches your manifest over plain HTTPS. Anywhere that serves static JSON works. Two things matter:

Checking CORS properly

Opening the URL in a browser tab does not test this. That's a same-origin request and it will happily show your JSON even when CORS is missing. The header only matters when indoorbike.app fetches your file, so the test has to run from there.

Pick whichever is easier:

Self-hosting on nginx? Add one line to the server or location block that serves your JSON, then reload:

add_header Access-Control-Allow-Origin "*";

On Apache, the equivalent in .htaccess:

Header set Access-Control-Allow-Origin "*"

If your host doesn't let you set headers at all, the simplest fix is to move the two JSON files to Codeberg Pages or GitHub Pages, which set the header for you, and point manifestUrl there. Your homepage link can still point at your own site.

3. Submit to the registry

Listing your repo in the community browser is a one-line addition to website/registry.json. Open a pull request on the app's Codeberg repository:

  1. Fork the repository.
  2. Edit website/registry.json and append a new entry to the repositories array. The fields:
    {
      "id": "alpine-coach",
      "name": "Alpine Coach",
      "description": "Threshold and VO2 work tuned for stage racers.",
      "author": "Alpine Coach Collective",
      "manifestUrl": "https://alpinecoach.example/manifest.json",
      "tags": ["Threshold", "VO2 Max"],
      "homepage": "https://alpinecoach.example"
    }
    The id here must match the id in your manifest. tags here are repo-level (shown on the card); they're independent from per-workout tags. homepage is optional here and optional in your manifest: the "Homepage" link on your repo page comes from the manifest, so omit it there if you don't want one.
  3. Open a pull request with a short note about who you are and what the pack covers.

Before you open the pull request

Five things worth a minute each. They cover almost every round of review feedback:

If you have Node installed, node scripts/validate_repo.mjs https://your-site.example/manifest.json from a checkout of the app repository checks all of the above and more in one command.

Review is light: a maintainer runs the validator against your manifest and skims the workouts. No backend, no account, no queue.

Merging isn't the same as publishing, though. The site is deployed by hand, so your entry goes live the next time that happens rather than within minutes of the merge. Allow a day or two, and don't worry if the community list looks unchanged in the meantime. The Android app reads the same published file, so it picks your repo up at the same moment the website does.

Updating after publication

Style guidelines (suggestions, not gates)

← Back to community Open on Codeberg ↗