← Two MPH
Two MPH API
Tokens

Drive Two MPH from your own code

The metered lane is available over HTTP: the coach review. Send one workday as time blocks, a short walking profile (weeks already walking at the desk, treadmill top speed, exercise, discomfort, how often you get up) and optional notes, and get back a call for every block - walk, stroll, sit or off desk, with a speed and the minutes to walk - plus a verdict, up to three schedule moves, a four-week ramp, desk set-up checks and the signs that mean stop. A natural use is a calendar add-on that suggests "walk this one" on the meetings of a person with an under-desk treadmill.

The division of labour first: the page reads the calendar and holds the limits; the model makes the calls; the page re-checks every call. Parsing a pasted day or an .ics file, sorting each block into a kind, the ramp limits, the page's own draft program, the console numbers (walking minutes, distance, estimated steps, longest sitting stretch) and the 4-40 sitting-risk score all come from beltkit.js, which runs in node as well as in the browser. Its check function holds each reply to the day: one call per block, speeds under this week's top, the daily total and the longest unbroken stretch, the verdict rule, a four-week ramp that starts at the limits, and no internal names in prose.

Get a token on the token page (step 2 below also shows how to mint a guest token in code).

Base URL and the envelope

Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response uses one envelope:

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

The token is minted for this app (the guest call takes {"slug":"two-mph"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object, sent bare: never wrap it as {"input": {…}}.

StatusCodeMeaning
400validation_errorThe body is not a JSON object, or a required field such as task 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: the coach review is metered and needs a personal token.
404not_foundUnknown job id.
429rate_limitedBack off and retry.
5xxserver_errorTransient. Retry with the same Idempotency-Key so a retry never double-bills.

The task field comes first

Every run sends task: program, the only lane. The reply carries exactly one verdict, decided in this order:

VerdictWhenWhat changes
sit_firstThe notes describe a current health matter walking could worsen or that needs an all-clear (chest pain, dizziness, a recent injury, sprain or surgery, swelling, a clinician saying rest...), or symptoms is severe.Every block sits or is off desk; no moves; an empty ramp; 2-4 seated breaks.
ease_inNot the above, and weeks_walking is new or 1.Walking held at level 1 or 2 limits; a four-week ramp from there.
walk_readyNot the above, and weeks_walking is 2-3 or 4+.Walking at level 3 or 4 limits; a four-week ramp from there.

The limits by level (weeks already walking at the desk): level 1 (new) 60 walking minutes a day, 20 at a stretch, top 1.5 mph; level 2 (1) 90 / 30 / 2.0; level 3 (2-3) 120 / 45 / 2.5; level 4 (4+) 180 / 60 / 3.0. The top speed never exceeds the treadmill's own. These are the page's conservative planning defaults, not a published standard.

The declared input schema carries scalars only, so object-valued fields travel JSON-encoded as strings (BeltKit.toWire does this). Every value is a string.

Input fields: program

FieldTypeNotes
taskstringprogram.
blocksstring (JSON)[{id, start, end, minutes, title, kind, camera}] in time order, at most 24 in 14 hours, from BeltKit.parseDay. kind is type, talk, listen, break or other; camera is true when the title says the camera is on.
profilestring (JSON){unit, treadmill_top_mph, weeks_walking, exercise, symptoms, breaks, preset, limits: {daily_minutes, longest_bout, top_speed_mph}}.
notesstringOptional, at most 600 characters.
page_programstring (JSON)[{id, mode, speed, minutes}] - the page's draft (with the person's own per-block choices), for the model to improve on.
page_flagsstring (JSON)[{level, text}], level info / warn / stop. A stop flag is a worrying word the page found in the notes.
retry_notestringOnly on a reformat retry.

The reply

The job's output.output is a string holding one JSON object: {task, verdict, headline, summary, blocks: [{id, mode, speed, minutes, why}], moves: [{what, why}], ramp: [{week, daily_minutes, longest_bout, top_speed_mph}], setup, stop_if, seated_breaks}. Speeds are in mph whatever the person's unit; minutes counts from the start of the block. Parse it with BeltKit.parseReply, then normalize(reply), then check(reply, state), which returns {notes, total, longestBout}: the page's notes (info, warn) shown under "The page's check".

Worked examples

Replies produced against the app's prompt for the three sample days (invented workdays; long fields and lists shortened; the full replies ship in example-result.js). Together they cover every verdict. The page replays each of them free from its sample buttons.

task: program - first week, mixed day - ease in

Input:

{
 "task": "program",
 "blocks": "[{\"id\":\"b1\",\"start\":\"08:30\",\"end\":\"09:00\",\"minutes\":30,\"title\":\"Inbox and Slack\",\"kind\":\"type\",\"camera\":false},{\"id\":\"b2\",\"start\":\"09:00\",\"end\":\"09:15\",\"minutes\":15,\"title\":\"Team standup\",\"kind\":\"listen\",\"camera\":false},{\"id\":\"b3\",\"start\":\"09:15\",\"end\":\"10:15\",\"minutes\":60,\"title\":\"Write Q4 report d ... (1236 characters)",
 "profile": "{\"unit\":\"mph\",\"treadmill_top_mph\":4,\"weeks_walking\":\"new\",\"exercise\":\"under60\",\"symptoms\":\"mild\",\"breaks\":\"3h\",\"preset\":\"steady\",\"limits\":{\"daily_minutes\":60,\"longest_bout\":20,\"top_speed_mph\":1.5}}",
 "notes": "Got the treadmill on Monday. I get restless and stiff in long meetings by mid-afternoon.",
 "page_program": "[{\"id\":\"b1\",\"mode\":\"sit\",\"speed\":0,\"minutes\":0},{\"id\":\"b2\",\"mode\":\"walk\",\"speed\":1.5,\"minutes\":10},{\"id\":\"b3\",\"mode\":\"sit\",\"speed\":0,\"minutes\":0},{\"id\":\"b4\",\"mo ... (546 characters)",
 "page_flags": "[]"
}

Reply:

{
 "task": "program",
 "verdict": "ease_in",
 "headline": "Easing into your new treadmill desk",
 "summary": "Since this is your first week on the treadmill, we're keeping things gentle: about an hour of walking broken into short stretches across most of your listening blocks today, including the two afternoon meetings where you said you get restless and stiff. Keep your heavy writing and the client pitch seated for now, and notice how your legs and back feel by the end of the week before building up further.",
 "blocks": [
  {
   "id": "b1",
   "mode": "sit",
   "speed": 0,
   "minutes": 0,
   "why": "Heavy typing is easiest seated; save your steps for the easier blocks ahead."
  },
  {
   "id": "b2",
   "mode": "walk",
   "speed": 1.5,
   "minutes": 15,
   "why": "A short, listening-only standup is a gentle way to start your first week of walking."
  },
  {
   "id": "b3",
   "mode": "sit",
   "speed": 0,
   "minutes": 0,
   "why": "A long stretch of focused writing is better done with a still, steady desk."
  },
  "... (8 more blocks)"
 ],
 "moves": [],
 "ramp": [
  {
   "week": 1,
   "daily_minutes": 60,
   "longest_bout": 20,
   "top_speed_mph": 1.5
  },
  {
   "week": 2,
   "daily_minutes": 75,
   "longest_bout": 25,
   "top_speed_mph": 1.8
  },
  {
   "week": 3,
   "daily_minutes": 90,
   "longest_bout": 30,
   "top_speed_mph": 2.1
  },
  {
   "week": 4,
   "daily_minutes": 105,
   "longest_bout": 35,
   "top_speed_mph": 2.4
  }
 ],
 "setup": [
  "Set your desk height so your elbows stay near a right angle while you walk.",
  "Raise your screen to eye level so you're not looking down while moving.",
  "..."
 ],
 "stop_if": [
  "Chest pain or sudden shortness of breath.",
  "Dizziness, light-headedness, or feeling like you might faint.",
  "..."
 ],
 "seated_breaks": []
}

task: program - seasoned walker, call-heavy day in km/h - walk ready

Input:

{
 "task": "program",
 "blocks": "[{\"id\":\"b1\",\"start\":\"09:00\",\"end\":\"09:30\",\"minutes\":30,\"title\":\"Pipeline sync\",\"kind\":\"listen\",\"camera\":false},{\"id\":\"b2\",\"start\":\"09:30\",\"end\":\"10:00\",\"minutes\":30,\"title\":\"Customer call - Brindlecove Foods renewal (camera on)\",\"kind\":\"talk\",\"camera\":true},{\"id\":\"b3\",\"start\":\"10:00\",\"end\":\"10:45\",\" ... (1315 characters)",
 "profile": "{\"unit\":\"kmh\",\"treadmill_top_mph\":3.7,\"weeks_walking\":\"4+\",\"exercise\":\"60-150\",\"symptoms\":\"none\",\"breaks\":\"1h\",\"preset\":\"mileage\",\"limits\":{\"daily_minutes\":180,\"longest_bout\":60,\"top_speed_mph\":3}}",
 "notes": "Walking my calls for five weeks now. I like walking 1:1s. Typing faster than a slow stroll gives me typos.",
 "page_program": "[{\"id\":\"b1\",\"mode\":\"walk\",\"speed\":3,\"minutes\":30},{\"id\":\"b2\",\"mode\":\"stroll\",\"speed\":1.5,\"minutes\":30},{\"id\":\"b3\",\"mode\":\"sit\",\"speed\":0,\"minutes\":0},{\"id\":\"b4\" ... (554 characters)",
 "page_flags": "[]"
}

Reply:

{
 "task": "program",
 "verdict": "walk_ready",
 "headline": "You're ready to walk most of today.",
 "summary": "You're ready to keep walking through most of today, about three hours total spread across your calls and listening blocks, including the whole one-on-one with your manager. Keep typing blocks mostly seated with just a gentle easy start, and ease off your pace anywhere typing starts to slip into typos.",
 "blocks": [
  {
   "id": "b1",
   "mode": "walk",
   "speed": 3.0,
   "minutes": 30,
   "why": "A sync you mostly listen to is an easy place to start the day walking."
  },
  {
   "id": "b2",
   "mode": "walk",
   "speed": 2.0,
   "minutes": 20,
   "why": "Camera's on, so keep an easy, steady pace, then sit to finish the call."
  },
  {
   "id": "b3",
   "mode": "walk",
   "speed": 3.0,
   "minutes": 30,
   "why": "Listening in on onboarding calls is low-effort, so keep walking through most of it."
  },
  "... (8 more blocks)"
 ],
 "moves": [
  {
   "what": "Swap the follow-up emails and the partner check-in in your morning.",
   "why": "Puts the onboarding-call listening and the check-in back to back for one longer walk, with emails right before your gym break."
  },
  {
   "what": "Do the industry podcast before the CRM notes and admin.",
   "why": "Keeps the coaching calls and the podcast listening together for a longer walk, and leaves the typing for the very end of the day."
  }
 ],
 "ramp": [
  {
   "week": 1,
   "daily_minutes": 180,
   "longest_bout": 60,
   "top_speed_mph": 3.0
  },
  {
   "week": 2,
   "daily_minutes": 180,
   "longest_bout": 60,
   "top_speed_mph": 3.0
  },
  {
   "week": 3,
   "daily_minutes": 180,
   "longest_bout": 60,
   "top_speed_mph": 3.0
  },
  {
   "week": 4,
   "daily_minutes": 180,
   "longest_bout": 60,
   "top_speed_mph": 3.0
  }
 ],
 "setup": [
  "Set the desk so your elbows sit near a right angle while you walk.",
  "Raise your screen to eye level, especially for camera-on calls.",
  "..."
 ],
 "stop_if": [
  "Any chest pain or sudden shortness of breath.",
  "Dizziness, light-headedness or feeling faint.",
  "..."
 ],
 "seated_breaks": []
}

task: program - sprained ankle the physio said to rest - sit first

Input:

{
 "task": "program",
 "blocks": "[{\"id\":\"b1\",\"start\":\"09:00\",\"end\":\"09:30\",\"minutes\":30,\"title\":\"Standup and planning\",\"kind\":\"listen\",\"camera\":false},{\"id\":\"b2\",\"start\":\"09:30\",\"end\":\"11:00\",\"minutes\":90,\"title\":\"Heads-down coding\",\"kind\":\"type\",\"camera\":false},{\"id\":\"b3\",\"start\":\"11:00\",\"end\":\"11:30\",\"minutes\":30,\"title\":\"Archite ... (912 characters)",
 "profile": "{\"unit\":\"mph\",\"treadmill_top_mph\":3.5,\"weeks_walking\":\"2-3\",\"exercise\":\"60-150\",\"symptoms\":\"moderate\",\"breaks\":\"2h\",\"preset\":\"steady\",\"limits\":{\"daily_minutes\":120,\"longest_bout\":45,\"top_speed_mph\":2.5}}",
 "notes": "Sprained my left ankle on Saturday and it is still swollen. My physio told me to rest it for two weeks. My lower back gets sore after long sitting.",
 "page_program": "[{\"id\":\"b1\",\"mode\":\"sit\",\"speed\":0,\"minutes\":0},{\"id\":\"b2\",\"mode\":\"sit\",\"speed\":0,\"minutes\":0},{\"id\":\"b3\",\"mode\":\"sit\",\"speed\":0,\"minutes\":0},{\"id\":\"b4\",\"mode\": ... (377 characters)",
 "page_flags": "[{\"level\":\"stop\",\"text\":\"Your notes mention \\\"sprained\\\" - the page plans the day seated until someone who knows your health says walking is fine.\"},{\"level\":\"s ... (300 characters)"
}

Reply:

{
 "task": "program",
 "verdict": "sit_first",
 "headline": "Rest the ankle first, sit today",
 "summary": "Since you sprained your ankle and it's still swollen, and your physio told you to rest it for two weeks, today's whole day stays seated or away from the desk, with no walking at all. Keep weight off the ankle until your physio gives the all clear, and use short seated breaks to ease your sore lower back.",
 "blocks": [
  {
   "id": "b1",
   "mode": "sit",
   "speed": 0,
   "minutes": 0,
   "why": "Stay seated through the 09:00 standup and planning while your ankle rests."
  },
  {
   "id": "b2",
   "mode": "sit",
   "speed": 0,
   "minutes": 0,
   "why": "Keep the 09:30 heads-down coding seated to protect your healing ankle."
  },
  {
   "id": "b3",
   "mode": "sit",
   "speed": 0,
   "minutes": 0,
   "why": "Stay seated for the 11:00 architecture sync instead of walking it."
  },
  "... (5 more blocks)"
 ],
 "moves": [],
 "ramp": [],
 "setup": [
  "Keep your sore ankle elevated and off the floor rather than dangling or bearing weight.",
  "Make sure your chair gives solid lower-back support since long sitting bothers your back.",
  "..."
 ],
 "stop_if": [
  "Increasing swelling, redness, or warmth in the ankle",
  "Sharp or worsening pain in the ankle or foot",
  "..."
 ],
 "seated_breaks": [
  "Every hour, roll your shoulders and neck gently while seated to ease your sore lower back.",
  "Prop your sore ankle up and keep weight off it for a few minutes between blocks.",
  "..."
 ]
}

Build the input yourself

Load the script in node (global.window = global; require("./beltkit.js")), then:

const K = BeltKit;
const form = { day: "09:00-09:30 Standup\n09:30-11:00 Write the report\n11:00-12:00 All-hands\n12:00-13:00 Lunch",
  unit: "mph", top: "4", weeks: "new", exercise: "under60", symptoms: "none", breaks: "2h", preset: "steady", notes: "" };
const st = K.state(form);                          // blocks, flags, limits and the page's own program (st.auto)
const wire = K.toWire(K.buildInput(st).input);     // POST this to /estimate and /run
// then: const review = K.normalize(K.parseReply(job.output.output));
// const { notes } = K.check(review, st);
// const numbers = K.stats(st.blocks, K.coachProgram(st, review));   // walking minutes, miles, steps, longest sit
// const risk = K.sitScore(st, numbers);                              // 4-40 before and after

Step 1 — a tiny client helper

One function that sends JSON and unwraps the envelope. Paste a personal token from /tokens.html, or leave it empty and mint a guest token in step 2.

BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="paste-your-token-here"   # from https://two-mph.skillsafe.ai/tokens.html
api() { # api METHOD PATH [JSON-BODY]
  curl -s -X "$1" "$BASE$2" -H "Content-Type: application/json" \
    ${TOKEN:+-H "Authorization: Bearer $TOKEN"} ${3:+--data "$3"}
}

Step 2 — a token

A guest token is enough for /me and /estimate. A run is metered, so it needs a personal token: sign in on /tokens.html and copy it from there.

# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN=$(curl -s -X POST "$BASE/guest" -H "Content-Type: application/json" -d '{"slug":"two-mph"}' | jq -r '.data.token')

Step 3 — GET /me

Who the token is (subject_type: user or guest) and the credit balance.

api GET /me | jq '.data | {subject_type, credits}' 

Step 4 — POST /estimate

Free. The body is the run input itself, the bare object (never wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model_alias reads gpt-terra.

# input.json: the run input object - {"task": "program", "blocks": "[{\"id\":\"b1\",\"start\":\"08:30\", ...}]", "profile": "{\"unit\":\"mph\", ...}", "notes": "...", "page_program": "[...]", "page_flags": "[...]"}
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}' 

Step 5 — POST /run, then poll the job

Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses two-mph:<task>:<inputHash>:a<attempt>, and its one reformat retry (with retry_note added) is attempt 2, a new key and a second run. The job's output.output is the reply text; truncated: true means the balance cut it short.

KEY="two-mph:program:$(shasum -a 256 input.json | cut -c1-16):a1"
JOB=$(curl -s -X POST "$BASE/run" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: $KEY" --data @input.json | jq -r '.data.job_id')
while :; do
  J=$(api GET "/jobs/$JOB"); S=$(echo "$J" | jq -r '.data.status')
  case "$S" in succeeded|failed|cancelled) break;; esac; sleep 2
done
echo "$J" | jq -r '.data.output.output' > reply.json   # one JSON object, the reply contract
echo "$J" | jq '.data | {charged_credits, truncated}' 

Step 6 — POST /run-stream

The same run as Server-Sent Events, with the same input.json. Browsers receive progress ticks rather than text deltas; the final done event carries the reply.

# Server-Sent Events: tick/delta events while the job runs, then a final event with the job
curl -N -s -X POST "$BASE/run-stream" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: two-mph:program:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json