← Training Desk / API
Tokens

Drive Training Desk from your own code

Everything the web page does is available over HTTP: send a description of the athlete with the load facts you computed from their training log, and get the same coach's reading back — or ask for the next block and get a week-by-week program. The natural use is a nightly job that re-reads an athlete's log and writes the reading, or a pipeline that regenerates a block whenever the acute:chronic ratio moves.

One thing to be clear about before the first call: the model never computes the numbers. Session load, weekly load, monotony, strain, the coupled and EWMA acute:chronic workload ratios, CTL, ATL, TSB, tonnage, estimated one-rep maxes, rest days and gaps are all computed by the caller and sent as facts. The model's job is judgement over those facts — what the trend means, whether recovery is adequate, which risk signals actually apply, what to change. See computing the facts yourself; the engine the web page uses ships as a plain script (/training.js) you can load in node.

Base URL and the envelope

Every endpoint lives under https://api.skillsafe.ai/v1/app-api and every response uses the same envelope, so one helper covers the whole API:

{ "ok": true,  "data":  { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "status": 402, "details": { ... } } }

The token is minted for this app (the guest endpoint takes {"slug":"training-desk"} in its body), so no slug header is needed afterwards — there is no X-App-Slug header anywhere. Send your token as Authorization: Bearer … on every call.

The run body is the input object: post {"task": "review", …} directly. Wrapping it as {"input": {…}} returns 200 and quietly hides every field from the model, so never do that.

StatusCodeMeaning
400validation_errorThe body is not a JSON object, or a declared required field (task, about, facts) is missing.
401unauthorizedNo token, or a stale one. Mint a guest token or sign in again.
402insufficient_creditsThe balance is under min_credits. Price with /estimate first.
403forbiddenA guest token tried to run: running is metered and needs a personal token. Sign in to run.
404not_foundUnknown job id.
429rate_limitedBack off and retry.
5xxserver_errorTransient. Retry with the same Idempotency-Key so a retry never double-bills.

1. Get a token

A guest token is free and enough for /me and /estimate. Running a lane is metered, so it needs a personal token — sign in on the token page and copy it from there. A guest token that calls /run gets 403.

curl -s -X POST https://api.skillsafe.ai/v1/app-api/guest -H "Content-Type: application/json" -d '{"slug":"training-desk"}'

2. A tiny client

One helper, one envelope. The samples below reuse it.

curl -s -X GET https://api.skillsafe.ai/v1/app-api/me \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json"

3. Check the session and the balance

GET /me returns subject_type (user or guest), subject_id and credits — and nothing else, so a signed-in caller is exactly subject_type === "user". A real user's first run should not 402: compare credits with the estimate's hold_credits before running.

curl -s -X GET https://api.skillsafe.ai/v1/app-api/me \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json"

4. Price the run — free

POST /estimate with the exact run body returns model (gpt-5.6-terra), model_alias (gpt-terra), markup_bps (1000), hold_credits and min_credits. hold_credits is a reservation placed against the balance while the job runs, not the price: the unused part is refunded and the real cost comes back as charged_credits on the finished job. No job is created and nothing is charged by estimating. The body must be a JSON object with scalar string fields only — no nested objects.

curl -s -X POST https://api.skillsafe.ai/v1/app-api/estimate \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"task": "review", "about": "38-year-old recreational runner, 6 weeks into a 10K build, right Achilles niggle two years ago, can train 5 days a week", "log": "2026-08-03, easy run, 45 min, RPE 4\n2026-08-05, intervals, 50 min, RPE 8\n2026-08-06, squat, 5x5 @ 100 kg\n2026-08-08, long run, 80 min, RPE 6", "facts": "<JSON string from Training.analyze(log) - see below>"}'

The fields both lanes take

FieldTypeMeaning
taskstring, requiredThe lane. review reads the log that already happened: trend, recovery, plateaus, risk signals, adjustments. plan designs the next block: a week-by-week program, a progression rule, a deload and checkpoints. Any other value is rejected as unknown; the reply always names the lane it answered in lane.
aboutstring, requiredThe athlete in their own words: sport, goal, history, injuries, constraints. Twenty characters or more. This is what makes the judgement specific — an Achilles niggle changes which adjustment is safe.
logstringThe pasted training log, one session per line: a date, an optional name, a duration and a session RPE (2026-08-03, easy run, 45 min, RPE 4), or a lift as 2026-08-04, squat, 5x5 @ 100 kg. Long logs may be clipped in the middle with a marker line; the facts are always computed from the whole log, never from the clipped copy.
factsstring, requiredA JSON string (not an object), produced by Training.analyze(logText, opts). For the plan lane it also carries facts.plan_request = {weeks, goal, sessions_per_week}. See computing the facts yourself.
retry_notestringOptional, and normally absent. The page sends it only on the automatic reformat retry, when the first reply did not parse as one JSON object.

Every field is a scalar string. Anything not in this table is rejected as unknown, and task, about, log and facts are declared required: /estimate names any that are missing in its warnings, so check them there before you run.

Computing the facts yourself

Load /training.js in node with a stub window and call the same function the page calls. Everything the model is allowed to quote comes out of this one call:

global.window = {}; require("vm").runInThisContext(require("fs").readFileSync("training.js", "utf8"));
const Training = window.Training;

const log = [
  "2026-08-03, easy run, 45 min, RPE 4",
  "2026-08-05, intervals, 50 min, RPE 8",
  "2026-08-06, squat, 5x5 @ 100 kg",
  "2026-08-08, long run, 80 min, RPE 6"
].join("\n");

const facts = Training.analyze(log, { about: "38, recreational runner, 10K in six weeks" });
// the page then trims the daily series before sending (the model gets the last day only)
facts.daily_last = facts.daily[facts.daily.length - 1]; delete facts.daily;
// PLAN LANE ONLY - omit it for the review lane
facts.plan_request = { weeks: 4, goal: "10K in under 45 minutes", sessions_per_week: 5 };

// facts.sessions[]        line, date, name, duration_min, rpe, load, distance_km, sets[], tonnage_kg, notes, folded
//                         load = duration x RPE (Foster session-RPE), in AU
// facts.weeks[]           week, week_start, week_end, load, mean_daily, sd_daily, monotony, strain, sessions,
//                         rest_days, hard_days, duration_min, acwr_ra, pct_change
//                         monotony = mean daily load / sample SD of the 7 daily loads (Foster 1998); strain = load x monotony
//                         acwr_ra  = this week / mean of this week and the three before it (Hulin 2016, Gabbett 2016)
// facts.summary           last_week_load, max_acwr_ra, last_acwr_ewma (lambda = 2/(N+1), N = 7 and 28, Williams 2017),
//                         max_monotony, max_strain, ctl, atl, tsb (Coggan; TSB from yesterday's values), rest days, gaps
// facts.lifts{}           name, sessions, tonnage_kg, best_e1rm_epley_kg, best_e1rm_brzycki_kg, weeks_without_new_best, plateau
// facts.flags             [{id, text, ...}]: acwr_spike, acwr_low, acwr_ewma_spike, monotony, ramp_rate,
//                         back_to_back_hard, no_rest_day, missing_rpe, plateau, monotony_uniform,
//                         extreme_volume (> 30 h in a week), illness, red_flag_symptom (keyword matches)
// facts.warnings, facts.errors   plain strings; an empty or unreadable log leaves errors non-empty

JSON.stringify(facts)   // send this string as the facts field

Send JSON.stringify(facts) as facts — a string, not an object. Session load is duration in minutes multiplied by session RPE, in arbitrary units (AU); the weekly figures, the two acute:chronic ratios and the CTL/ATL/TSB triple are all in the same AU. Estimated one-rep maxes are reported twice, by Epley and by Brzycki, so a disagreement between the two is visible rather than hidden behind an average.

The review request body, in full

Every field app.js sends, with facts abbreviated:

{
  "task": "review",
  "about": "38-year-old recreational runner, 6 weeks into a 10K build, right Achilles niggle two years ago, can train 5 days a week",
  "log": "2026-08-03, easy run, 45 min, RPE 4\n2026-08-05, intervals, 50 min, RPE 8\n2026-08-06, squat, 5x5 @ 100 kg\n2026-08-08, long run, 80 min, RPE 6",
  "facts": "<JSON string from Training.analyze(log) - see below>"
}

Worked example: the review lane

Four sessions across one week, an athlete with an old Achilles problem, and the question “is this going anywhere?”. Price it first; the same body goes to /run.

curl -s -X POST https://api.skillsafe.ai/v1/app-api/estimate \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"task": "review", "about": "38-year-old recreational runner, 6 weeks into a 10K build, right Achilles niggle two years ago, can train 5 days a week", "log": "2026-08-03, easy run, 45 min, RPE 4\n2026-08-05, intervals, 50 min, RPE 8\n2026-08-06, squat, 5x5 @ 100 kg\n2026-08-08, long run, 80 min, RPE 6", "facts": "<JSON string from Training.analyze(log) - see below>"}'

The plan request body, in full

The same athlete and the same log, but facts now carries plan_request:

{
  "task": "plan",
  "about": "38-year-old recreational runner, 6 weeks into a 10K build, right Achilles niggle two years ago, can train 5 days a week",
  "log": "2026-08-03, easy run, 45 min, RPE 4\n2026-08-05, intervals, 50 min, RPE 8\n2026-08-06, squat, 5x5 @ 100 kg\n2026-08-08, long run, 80 min, RPE 6",
  "facts": "<JSON string from Training.analyze(log, opts) with facts.plan_request = {weeks: 4, goal: 10K under 45 min, sessions_per_week: 5} - see below>"
}

Worked example: the plan lane

Ask for four weeks at five sessions a week toward a sub-45-minute 10K. The reply is a list of sessions, week and day; the browser then projects that program forward from the log and flags any week whose load, monotony, strain or acute:chronic ratio spikes.

curl -s -X POST https://api.skillsafe.ai/v1/app-api/estimate \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"task": "plan", "about": "38-year-old recreational runner, 6 weeks into a 10K build, right Achilles niggle two years ago, can train 5 days a week", "log": "2026-08-03, easy run, 45 min, RPE 4\n2026-08-05, intervals, 50 min, RPE 8\n2026-08-06, squat, 5x5 @ 100 kg\n2026-08-08, long run, 80 min, RPE 6", "facts": "<JSON string from Training.analyze(log, opts) with facts.plan_request = {weeks: 4, goal: 10K under 45 min, sessions_per_week: 5} - see below>"}'

5. Run it, then poll

POST /run returns {job_id}; GET /jobs/{job_id} until status is succeeded or failed. Send an Idempotency-Key header derived from the log, the lane and the facts so a retry never double-bills. The reply's output.output is the JSON text described in the contract below; charged_credits is the actual cost, and the unused part of the hold is released.

curl -s -X POST https://api.skillsafe.ai/v1/app-api/run \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"task": "review", "about": "38-year-old recreational runner, 6 weeks into a 10K build, right Achilles niggle two years ago, can train 5 days a week", "log": "2026-08-03, easy run, 45 min, RPE 4\n2026-08-05, intervals, 50 min, RPE 8\n2026-08-06, squat, 5x5 @ 100 kg\n2026-08-08, long run, 80 min, RPE 6", "facts": "<JSON string from Training.analyze(log) - see below>"}'
curl -s -X GET https://api.skillsafe.ai/v1/app-api/jobs/JOB_ID \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json"

6. Or stream it

POST /run-stream is server-sent events: job, then tick heartbeats, then done with the full output. Browsers receive ticks rather than text deltas, so build progress on elapsed time and parse the output from done.

curl -N -s -X POST https://api.skillsafe.ai/v1/app-api/run-stream \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Accept: text/event-stream" \
  -H "Content-Type: application/json" -d '{"task": "plan", "about": "38-year-old recreational runner, 6 weeks into a 10K build, right Achilles niggle two years ago, can train 5 days a week", "log": "2026-08-03, easy run, 45 min, RPE 4\n2026-08-05, intervals, 50 min, RPE 8\n2026-08-06, squat, 5x5 @ 100 kg\n2026-08-08, long run, 80 min, RPE 6", "facts": "<JSON string from Training.analyze(log, opts) with facts.plan_request = {weeks: 4, goal: 10K under 45 min, sessions_per_week: 5} - see below>"}'
# events: job (the job id), tick (heartbeat), done (the full output). Browsers receive ticks, not deltas.

The output contract

One JSON object. Common keys on every reply, whichever lane answered:

KeyTypeMeaning
lanereview | planThe lane actually answered.
titlestringEighty characters or fewer.
headlinestringOne sentence: the finding, not a preamble.
verdictstringOne of the lane's enum below.
summarystringThree to five sentences.
notes_on_input[]string[]What was missing, ambiguous or unparsed in the log — sessions without an RPE, an undated line, a gap that may be a holiday rather than a rest.
risks[]string[]What could go wrong if the reading or the block is followed as written.
next_steps[]string[]What to do next, in order.
LaneVerdictBody
review progressing · plateaued · overreaching · undertraining · insufficient_data reading (three to six short paragraphs, every number quoted from facts) · trend {direction: up|flat|down|unclear, why} · recovery {status: adequate|marginal|insufficient|unclear, why} · plateaus[] {what, since, note} · warning_signs[] {sign: acwr_spike|monotony|strain|ramp_rate|back_to_back_hard|no_rest_day|missing_rpe|plateau|other, applies: yes|no|unclear, note} · adjustments[] {change, why, when}
plan build · hold · deload · rebuild rationale (prose) · program[] {week: 1..N, day: 1..7, name, duration_min, rpe, exercise?, sets?, reps?, load_pct_e1rm?} · progression_rule (prose) · deload {week: number|null, why} · checkpoints[] {week, check}

In program, day is 1 for Monday through 7 for Sunday, rpe is the planned session RPE on the same 0–10 scale as the log, and duration_min is minutes — the two that multiply into the projected session load. Rest days are simply omitted. The lifting fields (exercise, sets, reps, load_pct_e1rm) appear only on lifting sessions, and load_pct_e1rm is a percentage of the estimated one-rep max already in facts.

Every entry in warning_signs is judged rather than merely listed: an item with applies: "no" is the model saying that signal is present in the facts and does not apply here, which is as useful as a hit. insufficient_data is a real verdict — a log with four sessions and no chronic window cannot support an acute:chronic reading, and saying so beats inventing one.

The web page projects the returned program forward — weekly load, monotony, strain and the acute:chronic ratio continuing from the log — and flags the weeks that spike. Do the same in a pipeline before handing a block to an athlete: the model wrote the sessions, but the arithmetic on what they add up to is still yours.

Derived from two agent skills by onewave-ai (MIT): @onewave-ai/training-log-analyzer for the review lane and @onewave-ai/workout-program-designer for the plan lane. Session-RPE load, monotony and strain follow Foster (1998); the coupled acute:chronic ratio follows Hulin (2016) and Gabbett (2016); the exponentially weighted form follows Williams (2017). Not medical advice.