TypeScript SDK for reading and writing Apple's .workout file format
Report Bug
·
Request Feature
Table of Contents
@bibixx/workoutkit reads and writes the binary .workout files produced by Apple's Workout app on iOS 17+ and watchOS 10+. Those are the same bytes you get when you tap Share Workout on your Apple Watch or iPhone, or when a WorkoutKit.WorkoutPlan is encoded through Transferable for the share sheet.
This is a pure file-format SDK. It doesn't talk to HealthKit, it doesn't read or write workout history, and it doesn't call any Apple APIs. You don't need an Apple Developer account to use it.
What you do get:
- A fluent TS class API (
WorkoutPlan,CustomWorkout,Goal, ...) for building workouts in memory. encode(plan)returning aUint8Arrayof the exact bytes Apple's runtime produces.decode(bytes)returning a structuredWorkoutPlan.- JSON round-trip with
toJSON()/fromJson()for storage and diffing. - Zero runtime dependencies. Runs in Node, Bun, Deno, and the browser.
This SDK is built by reverse engineering Apple's binary format. There's no public spec, so Apple can change the format whenever they want. See the Compatibility section for details.
| Name | Earliest tested version |
|---|---|
| Node | 18.0 |
npm install @bibixx/workoutkit
# or
pnpm add @bibixx/workoutkit
# or
yarn add @bibixx/workoutkit
# or
bun add @bibixx/workoutkitimport { WorkoutPlan, Step, Distance, Duration, DistanceGoal, TimeGoal } from "@bibixx/workoutkit";
const plan = new WorkoutPlan({ referenceId: crypto.randomUUID() });
const custom = plan.asCustom({
activity: "running",
location: "outdoor",
displayName: "Tempo intervals",
});
// 5-minute warmup
custom.warmup = new Step(new TimeGoal(new Duration(5, "minutes")));
// 4× (1 km work + 2 min recovery)
const block = custom.addBlock(4);
block.addStep("work", new DistanceGoal(new Distance(1, "kilometers")));
block.addStep("recovery", new TimeGoal(new Duration(2, "minutes")));
// 5-minute cooldown
custom.cooldown = new Step(new TimeGoal(new Duration(5, "minutes")));import { encode, toBlob, toBase64 } from "@bibixx/workoutkit/encode";
const bytes = encode(plan); // Uint8Array with the exact .workout bytes
const blob = toBlob(plan); // Blob, application/octet-stream
const b64 = toBase64(plan); // base64 stringimport { decode } from "@bibixx/workoutkit/decode";
const bytes = new Uint8Array(await (await fetch("/shared.workout")).arrayBuffer());
const plan = decode(bytes);
console.log(plan.custom?.displayName);A WorkoutPlan wraps exactly one of four variants. This mirrors Apple's discriminated union. Switching variants (for example, calling asGoal after asCustom) clears the others, and that invariant is enforced at runtime.
const plan = new WorkoutPlan({ referenceId: crypto.randomUUID() });
// 1. Custom: warmup + blocks of work/recovery intervals + cooldown.
plan.asCustom({ activity: "running", location: "outdoor" });
// 2. Single-goal: one activity, one goal (time / distance / energy / open).
plan.asGoal({
activity: "cycling",
location: "outdoor",
goal: new TimeGoal(new Duration(45, "minutes")),
});
// 3. Pacer: run or cycle a set distance in a set time.
plan.asPacer({
activity: "running",
location: "outdoor",
distance: new Distance(5, "kilometers"),
time: new Duration(25, "minutes"),
});
// 4. SwimBikeRun: triathlon and brick workouts, any sequence of legs.
// (Import SwimmingActivity / CyclingActivity / RunningActivity from the root.)
plan
.asSwimBikeRun({ displayName: "Sprint tri" })
.add(new SwimmingActivity({ swimmingLocation: "openWater" }))
.add(new CyclingActivity({ location: "outdoor" }))
.add(new RunningActivity({ location: "outdoor" }));Goals attach to steps (Step.goal) and to SingleGoalWorkout. The quantities (Distance, Duration, Energy) take the same units WorkoutKit does.
import {
OpenGoal,
TimeGoal,
DistanceGoal,
EnergyGoal,
PoolSwimDistanceWithTimeGoal,
Distance,
Duration,
Energy,
} from "@bibixx/workoutkit";
new OpenGoal(); // untimed
new TimeGoal(new Duration(30, "minutes"));
new DistanceGoal(new Distance(10, "kilometers"));
new EnergyGoal(new Energy(350, "kilocalories"));
new PoolSwimDistanceWithTimeGoal(new Distance(1500, "meters"), new Duration(30, "minutes"));Supported units:
LengthUnit:meters,kilometers,feet,yards,milesDurationUnit:seconds,minutes,hoursEnergyUnit:kilocalories,kilojoules
Steps inside a CustomWorkout can carry an optional Alert, mirroring
WorkoutKit's WorkoutAlert hierarchy. Nine concrete subclasses cover every
alert shape Apple ships — zone, range, and threshold variants across heart
rate, power, speed, and cadence.
import {
Cadence,
HeartRate,
Power,
Speed,
Distance,
Duration,
HeartRateZoneAlert,
HeartRateRangeAlert,
PowerZoneAlert,
PowerRangeAlert,
PowerThresholdAlert,
SpeedRangeAlert,
SpeedThresholdAlert,
CadenceThresholdAlert,
CadenceRangeAlert,
} from "@bibixx/workoutkit";
const block = custom.addBlock(4);
// Heart-rate zone alert on a work interval.
block.addStep(
"work",
new TimeGoal(new Duration(3, "minutes")),
/* displayName */ undefined,
new HeartRateZoneAlert(3),
);
// Power range with the average metric (vs. default "current").
block.addStep(
"work",
new TimeGoal(new Duration(20, "minutes")),
undefined,
new PowerRangeAlert(new Power(200, "watts"), new Power(250, "watts"), "average"),
);
// Pace — SpeedThresholdAlert with a (distance, time) pair other than
// "<X> per 1 second". 5:00/mile below; the SDK preserves the pair shape.
block.addStep(
"work",
new OpenGoal(),
undefined,
new SpeedThresholdAlert(new Speed(new Distance(1, "miles"), new Duration(5, "minutes"))),
);Heart-rate and cadence alerts always use the "current" metric on Apple's
API surface, so HeartRateZoneAlert, HeartRateRangeAlert,
CadenceThresholdAlert, and CadenceRangeAlert don't take a metric
parameter. Power and speed accept "current" (default) or "average".
Speed always rides on a {distance, time} pair; that shape covers both
speed (3.5 m per 1 s) and pace (1 mile per 5 min) without lossy
conversion.
Alert subtypes:
| Class | Target shape | Metric |
|---|---|---|
HeartRateZoneAlert |
zone: number |
current |
HeartRateRangeAlert |
HeartRate min + max |
current |
PowerZoneAlert |
zone: number |
current |
PowerRangeAlert |
Power min + max |
current / average |
PowerThresholdAlert |
Power threshold |
current / average |
SpeedRangeAlert |
Speed min + max |
current / average |
SpeedThresholdAlert |
Speed threshold |
current / average |
CadenceThresholdAlert |
Cadence threshold |
current |
CadenceRangeAlert |
Cadence min + max |
current |
Every class round-trips through a stable JSON shape. This is useful for storage, for diffing workouts, or for driving the SDK from a declarative spec.
import { WorkoutPlan } from "@bibixx/workoutkit";
const json = plan.toJSON(); // WorkoutPlanJson
const copy = WorkoutPlan.fromJson(json); // round-trips losslessly
// encode() / toBlob() / toBase64() also accept a WorkoutPlanJson directly,
// so you don't have to hydrate a class first.Small runnable snippets for each entry on each runtime it supports. All of them assume plan is a WorkoutPlan built as in Build a custom workout.
Let the user download a .workout file.
import { toBlob } from "@bibixx/workoutkit/encode";
const url = URL.createObjectURL(toBlob(plan));
const a = Object.assign(document.createElement("a"), {
href: url,
download: "workout.workout",
});
a.click();
URL.revokeObjectURL(url);Decode a file the user picked via <input type="file">.
import { decode } from "@bibixx/workoutkit/decode";
input.addEventListener("change", async () => {
const file = input.files?.[0];
if (!file) return;
const plan = decode(new Uint8Array(await file.arrayBuffer()));
console.log(plan.custom?.displayName);
});POST a workout to your backend.
import { encode } from "@bibixx/workoutkit/encode";
await fetch("/api/workouts", {
method: "POST",
headers: { "content-type": "application/octet-stream" },
body: encode(plan),
});Write a .workout file to disk with the /fs convenience wrappers.
import { saveWorkoutPlan, loadWorkoutPlan } from "@bibixx/workoutkit/fs";
await saveWorkoutPlan(plan, "./out.workout");
const roundTripped = await loadWorkoutPlan("./out.workout");Or go through /encode + /decode directly — useful when you're streaming over HTTP without touching disk.
import { encode } from "@bibixx/workoutkit/encode";
import { decode } from "@bibixx/workoutkit/decode";
import { writeFile, readFile } from "node:fs/promises";
await writeFile("./out.workout", encode(plan));
const plan2 = decode(new Uint8Array(await readFile("./out.workout")));/fs depends on node:fs/promises, so use /encode + /decode with Deno's own file APIs. Import via the npm: specifier.
import { encode } from "npm:@bibixx/workoutkit/encode";
import { decode } from "npm:@bibixx/workoutkit/decode";
await Deno.writeFile("./out.workout", encode(plan));
const plan2 = decode(await Deno.readFile("./out.workout"));There are four subpath entries. Pick the smallest one you need, each one is independently tree-shakable.
| Entry | Exports | Use case |
|---|---|---|
@bibixx/workoutkit |
All classes + types (WorkoutPlan, CustomWorkout, Goal, ...) |
Build workouts in memory |
@bibixx/workoutkit/encode |
encode, toBlob, toBase64 |
Serialize to bytes. Browser / Node / Bun / Deno |
@bibixx/workoutkit/decode |
decode |
Parse .workout bytes. Browser / Node / Bun / Deno |
@bibixx/workoutkit/fs |
saveWorkoutPlan, loadWorkoutPlan |
Convenience wrappers for local file IO. Node / Bun only (uses node:fs/promises) |
| Runtime | Supported | Notes |
|---|---|---|
| Browsers (evergreen) | ✅ | Use /encode and /decode. toBase64 uses btoa. |
| Node 18+ | ✅ | All four entries. Use /fs for local file IO. |
| Bun | ✅ | All four entries. |
| Deno | ✅ | Use /encode and /decode. /fs requires Node-compat. |
This SDK is built by reverse engineering Apple's binary format. There's no public spec, so Apple can change the format whenever they want. The majorVersion, minorVersion and privateVersion fields in the file are the version gate.
Current coverage: iOS 26 / watchOS 26 / macOS 26 (as of 2026-04). If Apple ships a breaking change, expect an SDK update. See DEVELOPMENT.md for how drift is detected and patched.
Upcoming features and known issues are tracked using GitHub issues.
Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are greatly appreciated.
If you have a suggestion that would make this better, please fork the repo and create a pull request. You can also simply open an issue with the tag "enhancement". Don't forget to give the project a star! Thanks again!
- Fork the Project
- Create your Feature Branch (
git checkout -b feature/AmazingFeature) - Commit your Changes (
git commit -m 'Add some AmazingFeature') - Push to the Branch (
git push origin feature/AmazingFeature) - Open a Pull Request
For development setup, byte-extraction tooling and the test suite layout, see DEVELOPMENT.md.
Distributed under the MIT License. See LICENSE for more information.
Bartek Legięć — @bibix1999 — legiec.io
Project Link: https://github.com/bibixx/workoutkit
- This project is unofficial and is not associated in any way with Apple Inc. Apple, iOS, watchOS, WorkoutKit, HealthKit, Apple Watch, iPhone and related marks are trademarks of Apple Inc., registered in the U.S. and other countries.
- This SDK implements Apple's
.workoutfile format through reverse engineering and clean-room analysis of publicly shipping binaries. It doesn't distribute any Apple code, assets or private APIs.