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.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The body is not a JSON object, or a declared required field (task, about, facts) is missing. |
| 401 | unauthorized | No token, or a stale one. Mint a guest token or sign in again. |
| 402 | insufficient_credits | The balance is under min_credits. Price with /estimate first. |
| 403 | forbidden | A guest token tried to run: running is metered and needs a personal token. Sign in to run. |
| 404 | not_found | Unknown job id. |
| 429 | rate_limited | Back off and retry. |
| 5xx | server_error | Transient. 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"}'import json, urllib.request
req = urllib.request.Request("https://api.skillsafe.ai/v1/app-api/guest", data=json.dumps({"slug": "training-desk"}).encode(), headers={"Content-Type": "application/json"})
print(json.load(urllib.request.urlopen(req))["data"]["token"])const r = await fetch("https://api.skillsafe.ai/v1/app-api/guest", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ slug: "training-desk" }) });
const { data } = await r.json(); console.log(data.token);res, err := http.Post("https://api.skillsafe.ai/v1/app-api/guest", "application/json", strings.NewReader(`{"slug":"training-desk"}`))
if err != nil { log.Fatal(err) }
defer res.Body.Close()
io.Copy(os.Stdout, res.Body)HttpRequest req = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/guest")).header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"training-desk\"}")).build();
System.out.println(HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString()).body());require "net/http"; require "json"
uri = URI("https://api.skillsafe.ai/v1/app-api/guest")
res = Net::HTTP.post(uri, { slug: "training-desk" }.to_json, "Content-Type" => "application/json")
puts JSON.parse(res.body)["data"]["token"]$ch = curl_init("https://api.skillsafe.ai/v1/app-api/guest");
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ["Content-Type: application/json"], CURLOPT_POSTFIELDS => '{"slug":"training-desk"}']);
echo json_decode(curl_exec($ch), true)["data"]["token"];var res = await http.PostAsync("https://api.skillsafe.ai/v1/app-api/guest", new StringContent("{\"slug\":\"training-desk\"}", Encoding.UTF8, "application/json"));
Console.WriteLine(await res.Content.ReadAsStringAsync());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"import json, urllib.request
TOKEN = "YOUR_TOKEN"
def call(method, path, body=None):
req = urllib.request.Request("https://api.skillsafe.ai/v1/app-api" + path, method=method, data=json.dumps(body).encode() if body is not None else None,
headers={"Authorization": "Bearer " + TOKEN, "Content-Type": "application/json"})
with urllib.request.urlopen(req) as r: return json.load(r)
print(call("GET", "/me"))const TOKEN = "YOUR_TOKEN";
async function call(method, path, body) {
const r = await fetch("https://api.skillsafe.ai/v1/app-api" + path, { method, headers: { Authorization: "Bearer " + TOKEN, "Content-Type": "application/json" }, body: body ? JSON.stringify(body) : undefined });
return r.json();
}
console.log(await call("GET", "/me"));req, _ := http.NewRequest("GET", "https://api.skillsafe.ai/v1/app-api/me", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil { log.Fatal(err) }
defer res.Body.Close()
io.Copy(os.Stdout, res.Body)HttpRequest req = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/me"))
.header("Authorization", "Bearer " + token).header("Content-Type", "application/json")
.method("GET", HttpRequest.BodyPublishers.noBody()).build();
System.out.println(HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString()).body());require "net/http"; require "json"
uri = URI("https://api.skillsafe.ai/v1/app-api/me")
req = Net::HTTP::Get.new(uri, "Authorization" => "Bearer #{token}", "Content-Type" => "application/json")
puts Net::HTTP.start(uri.host, 443, use_ssl: true) { |h| h.request(req) }.body$ch = curl_init("https://api.skillsafe.ai/v1/app-api/me");
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => "GET",
CURLOPT_HTTPHEADER => ["Authorization: Bearer $token", "Content-Type: application/json"]]);
echo curl_exec($ch);var req = new HttpRequestMessage(HttpMethod.Get, "https://api.skillsafe.ai/v1/app-api/me");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
Console.WriteLine(await (await http.SendAsync(req)).Content.ReadAsStringAsync());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"import json, urllib.request
TOKEN = "YOUR_TOKEN"
def call(method, path, body=None):
req = urllib.request.Request("https://api.skillsafe.ai/v1/app-api" + path, method=method, data=json.dumps(body).encode() if body is not None else None,
headers={"Authorization": "Bearer " + TOKEN, "Content-Type": "application/json"})
with urllib.request.urlopen(req) as r: return json.load(r)
print(call("GET", "/me"))const TOKEN = "YOUR_TOKEN";
async function call(method, path, body) {
const r = await fetch("https://api.skillsafe.ai/v1/app-api" + path, { method, headers: { Authorization: "Bearer " + TOKEN, "Content-Type": "application/json" }, body: body ? JSON.stringify(body) : undefined });
return r.json();
}
console.log(await call("GET", "/me"));req, _ := http.NewRequest("GET", "https://api.skillsafe.ai/v1/app-api/me", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil { log.Fatal(err) }
defer res.Body.Close()
io.Copy(os.Stdout, res.Body)HttpRequest req = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/me"))
.header("Authorization", "Bearer " + token).header("Content-Type", "application/json")
.method("GET", HttpRequest.BodyPublishers.noBody()).build();
System.out.println(HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString()).body());require "net/http"; require "json"
uri = URI("https://api.skillsafe.ai/v1/app-api/me")
req = Net::HTTP::Get.new(uri, "Authorization" => "Bearer #{token}", "Content-Type" => "application/json")
puts Net::HTTP.start(uri.host, 443, use_ssl: true) { |h| h.request(req) }.body$ch = curl_init("https://api.skillsafe.ai/v1/app-api/me");
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => "GET",
CURLOPT_HTTPHEADER => ["Authorization: Bearer $token", "Content-Type: application/json"]]);
echo curl_exec($ch);var req = new HttpRequestMessage(HttpMethod.Get, "https://api.skillsafe.ai/v1/app-api/me");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
Console.WriteLine(await (await http.SendAsync(req)).Content.ReadAsStringAsync());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>"}'import json, urllib.request
TOKEN = "YOUR_TOKEN"
def call(method, path, body=None):
req = urllib.request.Request("https://api.skillsafe.ai/v1/app-api" + path, method=method, data=json.dumps(body).encode() if body is not None else None,
headers={"Authorization": "Bearer " + TOKEN, "Content-Type": "application/json"})
with urllib.request.urlopen(req) as r: return json.load(r)
print(call("POST", "/estimate", {"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>"}))const TOKEN = "YOUR_TOKEN";
async function call(method, path, body) {
const r = await fetch("https://api.skillsafe.ai/v1/app-api" + path, { method, headers: { Authorization: "Bearer " + TOKEN, "Content-Type": "application/json" }, body: body ? JSON.stringify(body) : undefined });
return r.json();
}
console.log(await call("POST", "/estimate", {"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>"}));req, _ := http.NewRequest("POST", "https://api.skillsafe.ai/v1/app-api/estimate", strings.NewReader(`{"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>"}`))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil { log.Fatal(err) }
defer res.Body.Close()
io.Copy(os.Stdout, res.Body)HttpRequest req = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/estimate"))
.header("Authorization", "Bearer " + token).header("Content-Type", "application/json")
.method("POST", HttpRequest.BodyPublishers.ofString("""
{"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>"}
""")).build();
System.out.println(HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString()).body());require "net/http"; require "json"
uri = URI("https://api.skillsafe.ai/v1/app-api/estimate")
req = Net::HTTP::Post.new(uri, "Authorization" => "Bearer #{token}", "Content-Type" => "application/json")
req.body = "{\"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>\"}"
puts Net::HTTP.start(uri.host, 443, use_ssl: true) { |h| h.request(req) }.body$ch = curl_init("https://api.skillsafe.ai/v1/app-api/estimate");
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_HTTPHEADER => ["Authorization: Bearer $token", "Content-Type: application/json"],
CURLOPT_POSTFIELDS => '{"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>"}']);
echo curl_exec($ch);var req = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/estimate");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
req.Content = new StringContent(@"{""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>""}", Encoding.UTF8, "application/json");
Console.WriteLine(await (await http.SendAsync(req)).Content.ReadAsStringAsync());The fields both lanes take
| Field | Type | Meaning |
|---|---|---|
task | string, required | The 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. |
about | string, required | The 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. |
log | string | The 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. |
facts | string, required | A 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_note | string | Optional, 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>"}'import json, urllib.request
TOKEN = "YOUR_TOKEN"
def call(method, path, body=None):
req = urllib.request.Request("https://api.skillsafe.ai/v1/app-api" + path, method=method, data=json.dumps(body).encode() if body is not None else None,
headers={"Authorization": "Bearer " + TOKEN, "Content-Type": "application/json"})
with urllib.request.urlopen(req) as r: return json.load(r)
print(call("POST", "/estimate", {"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>"}))const TOKEN = "YOUR_TOKEN";
async function call(method, path, body) {
const r = await fetch("https://api.skillsafe.ai/v1/app-api" + path, { method, headers: { Authorization: "Bearer " + TOKEN, "Content-Type": "application/json" }, body: body ? JSON.stringify(body) : undefined });
return r.json();
}
console.log(await call("POST", "/estimate", {"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>"}));req, _ := http.NewRequest("POST", "https://api.skillsafe.ai/v1/app-api/estimate", strings.NewReader(`{"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>"}`))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil { log.Fatal(err) }
defer res.Body.Close()
io.Copy(os.Stdout, res.Body)HttpRequest req = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/estimate"))
.header("Authorization", "Bearer " + token).header("Content-Type", "application/json")
.method("POST", HttpRequest.BodyPublishers.ofString("""
{"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>"}
""")).build();
System.out.println(HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString()).body());require "net/http"; require "json"
uri = URI("https://api.skillsafe.ai/v1/app-api/estimate")
req = Net::HTTP::Post.new(uri, "Authorization" => "Bearer #{token}", "Content-Type" => "application/json")
req.body = "{\"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>\"}"
puts Net::HTTP.start(uri.host, 443, use_ssl: true) { |h| h.request(req) }.body$ch = curl_init("https://api.skillsafe.ai/v1/app-api/estimate");
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_HTTPHEADER => ["Authorization: Bearer $token", "Content-Type: application/json"],
CURLOPT_POSTFIELDS => '{"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>"}']);
echo curl_exec($ch);var req = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/estimate");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
req.Content = new StringContent(@"{""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>""}", Encoding.UTF8, "application/json");
Console.WriteLine(await (await http.SendAsync(req)).Content.ReadAsStringAsync());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>"}'import json, urllib.request
TOKEN = "YOUR_TOKEN"
def call(method, path, body=None):
req = urllib.request.Request("https://api.skillsafe.ai/v1/app-api" + path, method=method, data=json.dumps(body).encode() if body is not None else None,
headers={"Authorization": "Bearer " + TOKEN, "Content-Type": "application/json"})
with urllib.request.urlopen(req) as r: return json.load(r)
print(call("POST", "/estimate", {"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>"}))const TOKEN = "YOUR_TOKEN";
async function call(method, path, body) {
const r = await fetch("https://api.skillsafe.ai/v1/app-api" + path, { method, headers: { Authorization: "Bearer " + TOKEN, "Content-Type": "application/json" }, body: body ? JSON.stringify(body) : undefined });
return r.json();
}
console.log(await call("POST", "/estimate", {"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>"}));req, _ := http.NewRequest("POST", "https://api.skillsafe.ai/v1/app-api/estimate", strings.NewReader(`{"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>"}`))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil { log.Fatal(err) }
defer res.Body.Close()
io.Copy(os.Stdout, res.Body)HttpRequest req = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/estimate"))
.header("Authorization", "Bearer " + token).header("Content-Type", "application/json")
.method("POST", HttpRequest.BodyPublishers.ofString("""
{"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>"}
""")).build();
System.out.println(HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString()).body());require "net/http"; require "json"
uri = URI("https://api.skillsafe.ai/v1/app-api/estimate")
req = Net::HTTP::Post.new(uri, "Authorization" => "Bearer #{token}", "Content-Type" => "application/json")
req.body = "{\"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>\"}"
puts Net::HTTP.start(uri.host, 443, use_ssl: true) { |h| h.request(req) }.body$ch = curl_init("https://api.skillsafe.ai/v1/app-api/estimate");
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_HTTPHEADER => ["Authorization: Bearer $token", "Content-Type: application/json"],
CURLOPT_POSTFIELDS => '{"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>"}']);
echo curl_exec($ch);var req = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/estimate");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
req.Content = new StringContent(@"{""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>""}", Encoding.UTF8, "application/json");
Console.WriteLine(await (await http.SendAsync(req)).Content.ReadAsStringAsync());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>"}'import json, urllib.request
TOKEN = "YOUR_TOKEN"
def call(method, path, body=None):
req = urllib.request.Request("https://api.skillsafe.ai/v1/app-api" + path, method=method, data=json.dumps(body).encode() if body is not None else None,
headers={"Authorization": "Bearer " + TOKEN, "Content-Type": "application/json"})
with urllib.request.urlopen(req) as r: return json.load(r)
print(call("POST", "/run", {"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>"}))const TOKEN = "YOUR_TOKEN";
async function call(method, path, body) {
const r = await fetch("https://api.skillsafe.ai/v1/app-api" + path, { method, headers: { Authorization: "Bearer " + TOKEN, "Content-Type": "application/json" }, body: body ? JSON.stringify(body) : undefined });
return r.json();
}
console.log(await call("POST", "/run", {"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>"}));req, _ := http.NewRequest("POST", "https://api.skillsafe.ai/v1/app-api/run", strings.NewReader(`{"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>"}`))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil { log.Fatal(err) }
defer res.Body.Close()
io.Copy(os.Stdout, res.Body)HttpRequest req = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/run"))
.header("Authorization", "Bearer " + token).header("Content-Type", "application/json")
.method("POST", HttpRequest.BodyPublishers.ofString("""
{"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>"}
""")).build();
System.out.println(HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString()).body());require "net/http"; require "json"
uri = URI("https://api.skillsafe.ai/v1/app-api/run")
req = Net::HTTP::Post.new(uri, "Authorization" => "Bearer #{token}", "Content-Type" => "application/json")
req.body = "{\"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>\"}"
puts Net::HTTP.start(uri.host, 443, use_ssl: true) { |h| h.request(req) }.body$ch = curl_init("https://api.skillsafe.ai/v1/app-api/run");
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_HTTPHEADER => ["Authorization: Bearer $token", "Content-Type: application/json"],
CURLOPT_POSTFIELDS => '{"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>"}']);
echo curl_exec($ch);var req = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
req.Content = new StringContent(@"{""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>""}", Encoding.UTF8, "application/json");
Console.WriteLine(await (await http.SendAsync(req)).Content.ReadAsStringAsync());curl -s -X GET https://api.skillsafe.ai/v1/app-api/jobs/JOB_ID \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H "Content-Type: application/json"import json, urllib.request
TOKEN = "YOUR_TOKEN"
def call(method, path, body=None):
req = urllib.request.Request("https://api.skillsafe.ai/v1/app-api" + path, method=method, data=json.dumps(body).encode() if body is not None else None,
headers={"Authorization": "Bearer " + TOKEN, "Content-Type": "application/json"})
with urllib.request.urlopen(req) as r: return json.load(r)
print(call("GET", "/jobs/JOB_ID"))const TOKEN = "YOUR_TOKEN";
async function call(method, path, body) {
const r = await fetch("https://api.skillsafe.ai/v1/app-api" + path, { method, headers: { Authorization: "Bearer " + TOKEN, "Content-Type": "application/json" }, body: body ? JSON.stringify(body) : undefined });
return r.json();
}
console.log(await call("GET", "/jobs/JOB_ID"));req, _ := http.NewRequest("GET", "https://api.skillsafe.ai/v1/app-api/jobs/JOB_ID", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil { log.Fatal(err) }
defer res.Body.Close()
io.Copy(os.Stdout, res.Body)HttpRequest req = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/jobs/JOB_ID"))
.header("Authorization", "Bearer " + token).header("Content-Type", "application/json")
.method("GET", HttpRequest.BodyPublishers.noBody()).build();
System.out.println(HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString()).body());require "net/http"; require "json"
uri = URI("https://api.skillsafe.ai/v1/app-api/jobs/JOB_ID")
req = Net::HTTP::Get.new(uri, "Authorization" => "Bearer #{token}", "Content-Type" => "application/json")
puts Net::HTTP.start(uri.host, 443, use_ssl: true) { |h| h.request(req) }.body$ch = curl_init("https://api.skillsafe.ai/v1/app-api/jobs/JOB_ID");
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => "GET",
CURLOPT_HTTPHEADER => ["Authorization: Bearer $token", "Content-Type: application/json"]]);
echo curl_exec($ch);var req = new HttpRequestMessage(HttpMethod.Get, "https://api.skillsafe.ai/v1/app-api/jobs/JOB_ID");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
Console.WriteLine(await (await http.SendAsync(req)).Content.ReadAsStringAsync());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.import json, urllib.request
TOKEN = "YOUR_TOKEN"
def call(method, path, body=None):
req = urllib.request.Request("https://api.skillsafe.ai/v1/app-api" + path, method=method, data=json.dumps(body).encode() if body is not None else None,
headers={"Authorization": "Bearer " + TOKEN, "Content-Type": "application/json"})
with urllib.request.urlopen(req) as r: return json.load(r)
print(call("POST", "/run-stream", {"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>"}))const TOKEN = "YOUR_TOKEN";
async function call(method, path, body) {
const r = await fetch("https://api.skillsafe.ai/v1/app-api" + path, { method, headers: { Authorization: "Bearer " + TOKEN, "Content-Type": "application/json" }, body: body ? JSON.stringify(body) : undefined });
return r.json();
}
console.log(await call("POST", "/run-stream", {"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>"}));req, _ := http.NewRequest("POST", "https://api.skillsafe.ai/v1/app-api/run-stream", strings.NewReader(`{"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>"}`))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil { log.Fatal(err) }
defer res.Body.Close()
io.Copy(os.Stdout, res.Body)HttpRequest req = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/run-stream"))
.header("Authorization", "Bearer " + token).header("Content-Type", "application/json")
.method("POST", HttpRequest.BodyPublishers.ofString("""
{"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>"}
""")).build();
System.out.println(HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString()).body());require "net/http"; require "json"
uri = URI("https://api.skillsafe.ai/v1/app-api/run-stream")
req = Net::HTTP::Post.new(uri, "Authorization" => "Bearer #{token}", "Content-Type" => "application/json")
req.body = "{\"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>\"}"
puts Net::HTTP.start(uri.host, 443, use_ssl: true) { |h| h.request(req) }.body$ch = curl_init("https://api.skillsafe.ai/v1/app-api/run-stream");
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_HTTPHEADER => ["Authorization: Bearer $token", "Content-Type: application/json"],
CURLOPT_POSTFIELDS => '{"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>"}']);
echo curl_exec($ch);var req = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run-stream");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
req.Content = new StringContent(@"{""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>""}", Encoding.UTF8, "application/json");
Console.WriteLine(await (await http.SendAsync(req)).Content.ReadAsStringAsync());The output contract
One JSON object. Common keys on every reply, whichever lane answered:
| Key | Type | Meaning |
|---|---|---|
lane | review | plan | The lane actually answered. |
title | string | Eighty characters or fewer. |
headline | string | One sentence: the finding, not a preamble. |
verdict | string | One of the lane's enum below. |
summary | string | Three 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. |
| Lane | Verdict | Body |
|---|---|---|
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.