@trainheroic-unofficial/js
v4.0.0
Published
Unofficial TypeScript SDK for the TrainHeroic API.
Readme
@trainheroic-unofficial/js
An unofficial TypeScript SDK for the TrainHeroic API. The client covers auth and session renewal across both TrainHeroic hosts, a searchable exercise library, workout encoding, and messaging. It runs in any modern JavaScript runtime, including Cloudflare workerd.
Part of the trainheroic-unofficial workspace.
Unaffiliated with TrainHeroic. It drives the same undocumented endpoints the web app uses, so a server-side change can break it without warning. Use it against your own account.
Contents
- Install
- Quickstart
- Two entry points
- What it covers
- Working with exercises
- Building a workout
- Reading athlete training
- Analytics
- The workout encoder
- Develop
Install
npm install @trainheroic-unofficial/js
# or: pnpm add @trainheroic-unofficial/jsRequires a runtime with global fetch and Web Crypto (Node >= 18, workerd, modern browsers).
Quickstart
Construct a client with your TrainHeroic credentials and call request. The client logs in
lazily on the first call and renews the session for you.
import { TrainHeroicClient } from "@trainheroic-unofficial/js";
const client = new TrainHeroicClient(
process.env.TRAINHEROIC_EMAIL!,
process.env.TRAINHEROIC_PASSWORD!,
);
// request<T>(method, path, options) reaches any endpoint the web app uses.
// The type parameter T types res.data; omit it and data is `unknown`.
const res = await client.request<{ id: number }>("GET", "/user/simple");
if (res.ok) {
console.log(res.data.id);
}request returns { status: number; ok: boolean; data: T }. It does not throw on an HTTP
error status, so check res.ok. It throws TrainHeroicAuthError only when a login attempt
fails (bad credentials).
The third argument, options, is RequestOptions:
type RequestOptions = {
body?: unknown; // serialized as JSON for non-GET/DELETE requests
base?: "coach" | "apis"; // which host; defaults to "coach" (api.trainheroic.com)
};So a write is the same call with a body:
const created = await client.request("POST", "/2.0/coach/exercise/create", {
body: { title: "Sled Push", points_of_performance: "" },
});The paths are the ones the TrainHeroic web app calls.
Most are reachable through the typed helpers below, so you rarely call request directly. The
request and response shapes those helpers use live in
@trainheroic-unofficial/dto as zod schemas and types.
Reusing a session across restarts
TrainHeroic has no refresh token and a session expires after roughly one or two hours. By default the client logs in on its first request and keeps the session token in memory for the life of the process, so a fresh process logs in again. To skip that login, read the token off one client and hand it to the next via the third constructor argument.
After a request has run, client.sessionId holds the active token, typed string | null (it
is null only before the first login). Persist it with your own storage; saveToken and
loadToken below stand in for whatever you use (a file, a KV store, an env var):
await client.request("GET", "/user/simple");
const token = client.sessionId;
if (token) saveToken(token);A later process passes the saved token as the third constructor argument (also string | null,
so a missing token simply falls back to a normal login):
const client = new TrainHeroicClient(
process.env.TRAINHEROIC_EMAIL!,
process.env.TRAINHEROIC_PASSWORD!,
loadToken(),
);If the reused token has expired, the next request gets a 401/403; the client logs in once with
the credentials, retries, and updates client.sessionId to the new token.
The optional fourth constructor argument can persist renewed sessions and report final HTTP failures without coupling the SDK to a telemetry vendor:
const client = new TrainHeroicClient(email, password, savedSession, {
onSession: saveSession,
onHttpError: (error) => telemetry.captureException(error),
transport: (url, init) => fetch(url, init), // optional host-specific HTTP transport
});The client keeps at most four API operations in flight. A custom transport receives the same
URL and RequestInit that the default global fetch would receive; hosted runtimes can use this
seam to add coordination without changing SDK authentication or response parsing.
onHttpError receives a TrainHeroicHttpError containing the method, status, host, a bounded
request-body summary, and sanitized provider response diagnostics. The request summary records
field names, array lengths, a derived date-span count, and a small allowlist of non-sensitive enum
values; arbitrary request values are never included. Response diagnostics retain bounded
provider error strings, status fields, and boolean success flags. Credential values, email
addresses, IPv4 addresses, and SSNs are scrubbed from diagnostic strings, and unknown response
fields are omitted. Paths, query strings, session tokens, and login request and response data
remain excluded. A transient 401/403
that succeeds after automatic re-login does not call the hook, and synchronous or asynchronous
hook failures never change the request result.
Two entry points
// Runtime-agnostic. Safe in browsers and on workerd.
import { TrainHeroicClient, ExerciseLibrary, buildSession } from "@trainheroic-unofficial/js";
// Node-only filesystem helpers, kept out of the main entry.
import { JsonFileLibraryCache, defaultCachePath } from "@trainheroic-unofficial/js/node";The . entry imports no node:* modules. Anything that touches the filesystem lives behind
./node.
What it covers
- Client and auth.
TrainHeroicClientholds the coach credentials, acquires a session token lazily, and renews it transparently. TrainHeroic issues no refresh token, so on a 401/403 the client logs in again with the stored credentials and retries once. A cold client hit by concurrent requests performs a single shared login.RequestOptions.baseselects the host (coachforapi.trainheroic.com,apisforapis.trainheroic.com). - Exercises.
ExerciseIndexis the interface the rest of the system codes against;ExerciseLibraryis the in-memory implementation, handling name-to-id resolution, fuzzy search ranking, and persistence through aLibraryCache(in-memory by default, JSON file via./node). The hosted server supplies a D1-backed implementation of the same interface. - Workouts. A session builder that creates a session, saves blocks and exercises,
and optionally publishes, along with read-back, instruction editing, and removal.
Includes the encoder that turns a
WorkoutSpecinto the API's payload. - Athlete training. Functions for the logged-in account's own training, covering scheduled and completed workouts, per-exercise history, personal records, and working maxes.
- Analytics. Coach analytics reports via
queryAnalytics, withanalyticsMetricCatalogandANALYTICS_METRIC_KEYSfor discovery.teamVolumerolls up per-athlete training summary rows into a team total. Large training summaries are read as sequential five-athlete, 90-day batches and merged into one report. - Messaging. Tools for conversation streams: listing them, reading a stream, and building or sending or deleting a comment.
createProgram(client, { kind, name }) creates a standalone calendar or fixed program and
returns both containerId (the id in GET /1.0/coach/programs) and programId (the id used by
program detail and workout writes). TrainHeroic may replace the requested name; inspect the
returned title and nameApplied. Creation is not idempotent, so do not blindly retry an
uncertain result. deleteProgram(client, programId) removes it via DELETE /v5/programs/{id}
and accepts either id (a container id is resolved to group_program first).
Working with exercises
ExerciseLibrary loads the full library once, caches it, and answers name lookups and fuzzy
search against the in-memory copy. By default it caches in memory; pass a JsonFileLibraryCache
from ./node to persist between runs (it writes to defaultCachePath(),
~/.trainheroic/library.json, unless you pass a path). create / update / remove write through to
TrainHeroic (POST /2.0/coach/exercise/create, POST /2.0/coach/exercise/update/{id},
DELETE /v5/exercises/{id}); recordDelete
only drops the cached row.
import { ExerciseLibrary } from "@trainheroic-unofficial/js";
import { JsonFileLibraryCache } from "@trainheroic-unofficial/js/node";
// `client` is the TrainHeroicClient from the Quickstart above.
const library = new ExerciseLibrary(client, new JsonFileLibraryCache());
// Fuzzy search, ranked. Returns up to `limit` matches, each with id, title, and units.
const matches = await library.search("back squat", 5);
// resolve() returns { match, candidates }. A single confident hit fills `match`;
// an ambiguous name leaves `match` null and returns the candidates to choose from.
const { match, candidates } = await library.resolve("Barbell Back Squat");
if (!match) {
// Ask the user (or the model) to pick one of `candidates`, then use its id.
console.log(candidates.map((c) => `${c.id}: ${c.title}`));
}Building a workout
buildSession writes one session into a program on a given day: it creates the session, saves
the blocks and exercises, and optionally publishes. programId identifies one of your
TrainHeroic programs (find it in the program's URL in the web app, or from a programs read
via client.request("GET", ...)). Exercise ids come from the library.
import { buildSession, type BlockSpec } from "@trainheroic-unofficial/js";
const { match } = await library.resolve("Back Squat");
if (!match) throw new Error("Resolve to a single exercise before building.");
if (match.units[0] !== "reps" || match.units[1] !== "lb") {
throw new Error("Choose an exercise with reps and pounds as its fixed units.");
}
const blocks: BlockSpec[] = [
{
title: "Strength",
exercises: [
// sets/reps/weight scalars; rpe is routed into the instruction text (see the encoder).
{
id: match.id,
sets: 5,
reps: 5,
primaryUnit: "reps",
weight: 225,
secondaryUnit: "lb",
rpe: 8,
},
],
},
];
const { pwId, workoutId } = await buildSession(client, {
programId: 12345,
index: library,
date: [2026, 6, 22], // [year, month, day]; month is 1-based, so 6 = June
blocks,
instruction: "Warm up first.",
publish: false, // build as a draft; publish makes it visible to athletes
});Each exercise needs an id; sets, reps, weight, rpe, and a per-exercise instr are
optional. reps and weight take a scalar (broadcast across every set) or a per-set array
like reps: [5, 5, 3]. State primaryUnit for reps and secondaryUnit for weight.
The full field list is ExerciseSpec / BlockSpec in
@trainheroic-unofficial/dto. buildSession checks those units against the
exercise library and rejects a mismatch before writing.
buildSession returns { pwId, workoutId, advisories }: pwId is the program-workout id (the
placement of the session in the program, which is the handle subsequent calls take), and
workoutId is the underlying workout id. Use pwId with readSession(client, programId, date, pwId)
to read the session back. publishSession(client, pwId) publishes it later;
removeSession(client, programId, pwId) deletes it.
Reading athlete training
import { resolveAthleteUserId, fetchAthleteProfileSummary } from "@trainheroic-unofficial/js";
const userId = await resolveAthleteUserId(client);
const summary = await fetchAthleteProfileSummary(client, userId);These work from any session, coach or athlete, since a coach account also carries athlete scope.
Analytics
Coach analytics reports are read-only data pulls. TrainHeroic uses POST for these queries, but
they do not mutate anything. The metric keys in ANALYTICS_METRIC_KEYS are the SDK catalog
— they are not the raw category names from GET /v5/analytics. Use analyticsMetricCatalog()
to see each metric's scope and required inputs.
Team metrics (readiness-team, compliance-team, lift-progress-team) need teamId.
Athlete metrics (readiness-athlete, training-summary-athlete, lift-1rm-history,
working-max-history) need one or more userIds in a single call (the report returns a row
per athlete). readiness-team takes a single date; every other metric takes dateStart and
dateEnd. Lift metrics also need exerciseId. All dates are YYYY-MM-DD.
There is no team-wide training volume metric. Pass every athlete's id to
training-summary-athlete, or call teamVolume() to group those rows into a team rollup.
import { analyticsMetricCatalog, queryAnalytics } from "@trainheroic-unofficial/js";
// Read-only report (TrainHeroic uses POST for these queries).
const readiness = await queryAnalytics(client, {
metric: "readiness-team",
teamId: 42,
date: "2026-06-22",
});
console.log(readiness);
// Scope + required params for every metric key:
console.log(analyticsMetricCatalog());The workout encoder
buildSession calls the encoder for you; you only deal with it directly to preview values.
TrainHeroic's exercise payload expects every parameter slot present, so the encoder fills all
of them (empty slots included) to avoid an HTTP 500. A scalar prescription is broadcast across
the set count. RPE goes into the instruction text because the API coerces a numeric slot to
load. Each populated slot in a spec states its intended unit. collectAdvisories rejects a
unit that differs from the exercise's fixed parameter type before a workout is written.
buildSession calls collectAdvisories(blocks, index) before its first write and returns
its non-blocking notes and warnings in advisories. Call collectAdvisories directly to
preview validation without writing a workout. Validation fetches current units from
TrainHeroic; an unavailable library response stops the build.
Develop
Clone the workspace and run pnpm install once at the repo root (Node >= 24, pnpm 11), then
from this package directory:
pnpm build # tsdown -> dist (separate "." and "./node" outputs)
pnpm typecheck
pnpm test
pnpm exec vitest run test/workout-encode.test.ts # one file
pnpm exec vitest run -t "broadcasts a scalar over sets" # one testLicense
MIT
