Contribute
Publish a workout or route pack.
Anyone can run a community repository: a coach with a structured plan, a training group sharing their winter intervals, a rider who has mapped the roads they miss in winter. The process is two static JSON files and a pull request.
A repository can carry workouts, routes, or both. The route tool turns a GPX into the JSON for you, and if your repository is already listed you can add routes without opening another pull request at all - jump to routes.
The shape of the deal
-
You write a
manifest.jsondescribing your repository (name, author, and a homepage if you have one) plus one or more bundle files holding the workouts themselves. - You host it over HTTPS, anywhere that serves a JSON file with permissive CORS. GitHub Pages, Codeberg Pages, your own web folder.
-
You open a pull request against this app's
CodeFloe repository
adding a single entry to
website/registry.jsonthat points at your manifest URL. - 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:
-
One small
manifest.jsonwith repo metadata plus a list of pointers to your bundle files. The app downloads this on demand whenever a rider opens your repo in the browser, so keeping it small (just metadata, no segments) keeps everyone's bandwidth low. - One or more bundle files, each a JSON array of workout objects. The app downloads a bundle only when its content version changes, so unrelated workouts stay cached when you edit one.
Manifest top-level fields
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
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:
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".
cadenceMinRpmcadenceMaxRpmintegerno
Optional cadence target on a segment, 20–200 rpm. Both for a range (70–80), 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:
-
In a browser. Press F12, open the
Console tab, type
Date.now()and press Enter. Nothing to install. -
Linux or macOS terminal.
date +%s000 -
Python.
python3 -c "import time; print(int(time.time()*1000))" -
An online converter. Take the ordinary 10-digit
number it gives you and type
000on the end. That is genuinely all the conversion needed.
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:
-
HTTPS only. The app rejects
http://on import (defense against tampering on hostile networks). -
CORS. Your host must send
Access-Control-Allow-Origin: *(or includeindoorbike.app) so the community browser'sfetch()can read your manifest. Without this, your repo will work in the Android app but won't preview on the website. GitHub Pages, Codeberg Pages, and Cloudflare Pages all set this header by default for static files. A self-hosted nginx or Apache does not: you have to add it yourself (see below).
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:
-
In a browser. Open
indoorbike.app/community, press
F12 for developer tools, click the Console tab,
paste this (with your own URL) and press Enter:
You wantfetch('https://your-site.example/manifest.json') .then(r => r.json()) .then(j => console.log('CORS OK -', j.name)) .catch(e => console.log('CORS FAILED -', e.message))CORS OK. Anything else means the header is missing. -
On the command line.
curl -sI https://your-site.example/manifest.json | grep -i access-controlshould print anaccess-control-allow-originline. No output means no CORS.
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 CodeFloe repository:
- Fork the repository.
-
Edit
website/registry.jsonand append a new entry to therepositoriesarray. 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", "content": ["workouts", "routes"] }contentsays what you publish. It is a hint the browser uses to decide whether to show a Routes button on your card, because the only other way to know is to fetch every manifest in the registry to draw one list. Omit it and you are read as workouts-only, which is what every repository written before routes existed is. Theidhere must match theidin your manifest.tagshere are repo-level (shown on the card); they're independent from per-workout tags.homepageis 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. - 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:
- Your manifest URL is
https://and loads in a browser tab. - CORS passes the console test above, not just the browser-tab test.
updatedEpochis 13 digits (milliseconds), or absent.-
You've replaced the example workout from this page with your own.
The
alpine-warmup-2minid and its "Two-minute pre-effort opener." description are copy-paste starting points, not content to publish. -
The
id,name, andauthorin your registry entry match your manifest. The list card shows the registry values, the repo screen shows the manifest values, and a mismatch looks like a bug to riders.
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.
4. Publishing routes
A route is a real road the app simulates: it reads the gradient off a GPX and asks the trainer for that resistance, so riding one feels like the climb it came from. Publishing routes works like publishing workouts, with one structural difference worth understanding before you start.
Why routes are listed separately from their data
A workout bundle is the workout: a few dozen segments of JSON, and the app has everything. A GPX is three orders of magnitude bigger. If browsing your repository downloaded fifty of them to draw fifty cards, a rider would pay tens of megabytes for a browse they might abandon.
So route metadata and route geometry live in different files. Your
route bundle is a small JSON array describing each
route - distance, climbing, a gradient histogram, a tiny profile and
shape for the card - and each entry carries a gpxUrl
pointing at the actual file. The app downloads a GPX exactly once, for
the one route a rider adds.
The easy way: use the tool
The route publishing tool reads a
GPX in your browser - the file is never uploaded - and gives you the
entry to paste, plus your manifest with the pointer already added. Give
it the routes.json you already publish and it adds to that
instead, so you can put up a route at a time and download the whole
file with the earlier ones still in it; it will also show you the
listing as the app draws it before you publish anything. It
also warns you about the things that quietly ruin a route, most
importantly a GPX with no elevation, which the app would simulate as
dead flat. Two fields it can fill for you, each only when you ask:
elevation from the Copernicus DEM, and the place name,
which it gets by asking OpenStreetMap what is at the middle of your
route - a GPX has coordinates in it, but no idea where it is. Unless
you are automating this, start there and skip the rest of this section.
Manifest: add a routeBundles pointer
Note what does not change: schemaVersion stays at
2. Routes were added without bumping it on purpose. A
manifest declaring v3 would be rejected outright by every already
installed copy of the app, and those riders would lose your
workouts too. Instead routeBundles is an optional
field that older builds skip and never notice.
{
"schemaVersion": 2,
"id": "alpine-coach",
"name": "Alpine Coach",
"bundles": [
{ "id": "main", "schemaVersion": 1, "version": 3, "url": "workouts.json" }
],
"routeBundles": [
{ "id": "routes", "schemaVersion": 1, "version": 1, "url": "routes.json" }
]
}
A routeBundles entry has the same fields as a workout
bundle entry: id, optional name,
schemaVersion (the format of the file it points at, so the
app can skip one it is too old for without downloading it),
version, and a url that is absolute or
relative to the manifest.
The route bundle file
A JSON array of route entries. Every number below is measured on the smoothed elevation profile, not the raw GPX points, which is what the tool does for you and what the app recomputes after download.
schemaVersionintegeryes
Route entry format version. Currently 1. Entries newer than the app understands are skipped individually, so one future-format route costs a rider that route and nothing else.
idstringyes
Stable and unique in your repository. It is the filename on the rider device and the dedup key, so do not change it after publishing.
namestringyes
What the card says. The road, not the file: "Mont Ventoux from Bedoin", not "ride_2024_07_12".
descriptionstringno
One or two sentences. Plain text.
placestringno
Free text location, shown under the name. "Provence, France".
tagsstring[]no
Become the filter chips in the app. Keep them few and reusable.
versionintegerno
Bump when the GPX or the metadata changes.
gpxUrlstringyes
Absolute https://, or relative to the bundle file. Relative is portable and survives a host change. The app refuses plain HTTP and does not follow redirects, so point at the file itself.
gpxBytesintegerno
Size of the GPX, so the app can warn before a large download.
gpxSha256stringno
Lowercase hex digest. Checked after download; a mismatch is refused, because the rider asked for the route the listing described. Leave it out rather than letting it go stale.
distanceMetersnumberyes
An entry without a positive distance is dropped as unusable.
ascentMeters, descentMetersnumberno
Total climbing and descending on the smoothed profile.
minElevationMeters, maxElevationMeters, maxGradientPercentnumberno
Shown on the detail view.
loopbooleanno
True when the finish is back at the start, so the route can be lapped.
gradientMetersnumber[14]strongly recommended
Metres of road in each gradient bucket. This is what the estimated ride time is built from - see below. Any length other than 14 is ignored.
profilenumber[64]no
Elevation evenly spaced along the route, for the card sparkline.
shapestringno
The track simplified to about 160 points and encoded as a polyline (precision 5), for the card thumbnail.
boundsnumber[4]no
[minLat, minLon, maxLat, maxLon].
attributionobjectsee below
source, url, licence, modified.
Why the gradient histogram matters
The app puts an estimated ride time on every card, computed from the rider own FTP and weight, before any GPX has been downloaded. It cannot do that from distance and total climbing, because time on a road is not linear in gradient: five hundred metres of ascent taken as one steady 5 % drag and the same five hundred taken as twenty 12 % ramps are completely different rides, and the minutes lost climbing are never repaid on the descent.
So gradientMeters says how far the road runs at each
steepness, in these fourteen fixed buckets, in percent:
<-10 -10..-7 -7..-4 -4..-2 -2..0 0..2 2..4
4..6 6..8 8..10 10..12 12..15 15..20 >20
Fourteen numbers, about a hundred bytes, and the app can work out what the route costs this rider. The edges are part of the format, so do not invent your own - the tool generates them correctly.
Attribution is not optional in spirit
Route geometry usually comes from a licensed database. OpenStreetMap
and EuroVelo tracks are ODbL, which asks for credit and for adaptations
to stay open; national mapping agencies have their own terms. The app
shows attribution on the route card for as long as the
route is installed, which is how that credit actually reaches anybody.
"attribution": {
"source": "OpenStreetMap contributors",
"url": "https://www.openstreetmap.org/relation/12345",
"licence": "ODbL-1.0",
"modified": true
}
Set modified when you resampled, trimmed, or added
elevation to the published track, which is true of most routes that
started life as an OSM extract. If the ride is your own recording, say
so - that is useful information too.
Example: a one-route bundle
[
{
"schemaVersion": 1,
"id": "ventoux-bedoin",
"name": "Mont Ventoux from Bedoin",
"description": "Twenty-one kilometres and the last six above the treeline.",
"place": "Provence, France",
"tags": ["Climb", "France"],
"version": 1,
"gpxUrl": "gpx_files/ventoux-bedoin.gpx",
"gpxBytes": 184320,
"gpxSha256": "9f2c...",
"distanceMeters": 21400,
"ascentMeters": 1610,
"descentMeters": 20,
"minElevationMeters": 313,
"maxElevationMeters": 1909,
"maxGradientPercent": 12.1,
"loop": false,
"gradientMeters": [0, 0, 0, 0, 120, 300, 1500, 4200, 8100, 5900, 1200, 80, 0, 0],
"profile": [313, 340, 372, "… 64 values total"],
"shape": "_p~iF~ps|U_ulLnnqC…",
"bounds": [44.10, 5.20, 44.19, 5.31],
"attribution": {
"source": "OpenStreetMap contributors",
"licence": "ODbL-1.0",
"modified": true
}
}
]
Sizing your GPX
The app caps a single route download at 12 MB and resamples elevation onto a 10 m grid regardless, so points closer together than that are bytes every rider pays for and nobody can feel. A 100 km route at 10 to 25 m spacing is a few hundred kilobytes and loses nothing. If your file is several megabytes, it is almost certainly 1 Hz recording data that wants simplifying before you publish it.
Already in the registry? You are done
Upload the GPX, the route bundle and the updated manifest, and riders
see your routes the next time they open your repository. No pull
request. The only thing worth a one-line registry PR is adding
"content": ["workouts", "routes"] to your entry, which is
what makes the Routes button appear on your card in the browser.
Updating after publication
- Editing a workout: change the bundle file. Riders who open your repo after that point will see the new version. Workouts they had already added are static local copies and don't auto-update - that's by design (no surprise changes mid-training block).
-
Adding a workout: drop it into an existing bundle
file, or create a new bundle file and add a fresh entry to the
bundlesarray. - Removing a workout: remove it from the bundle file. Future browses of your repo won't see it. Riders who already added it keep their local copy, and past rides of it stay in their ride history.
-
Renaming or rebranding the repo: change the
manifest's
name,description, etc., but don't change theid. The id is the identity; changing it makes the app think it's a different repo. -
Bumping
versionon a bundle: optional today - the app refetches every bundle whenever a rider opens your repo. Bump it anyway whenever a bundle's contents change; the field is reserved for future caching support and contributors who consume your manifest programmatically may rely on it. -
Bumping
updatedEpoch: optional, but worth setting on each release so your repo card reads "updated 2 days ago" rather than carrying a stale timestamp. Milliseconds, 13 digits (see above). Only the website shows it.
Style guidelines (suggestions, not gates)
- Prefix workout ids with your repo id. Two workouts with the same id (one in your repo, another already in the rider's library) would clash inside the app, and the dedup rule (Imported > Bundled, first-wins) may not pick the one your user wanted.
-
Keep
powerPercentFTPrealistic. The app accepts 0–300, but anything above ~150 is sprint territory and most trainers will struggle to actually deliver it in ERG. - Open with a warm-up, close with a cool-down. Saves your riders from cold-start TSS spikes.
- Plain-text descriptions. Markdown isn't rendered anywhere; line breaks are.