Drive Extra Hour from your own code
The one metered step - planning the switch - is available over HTTP. A natural use is a family calendar, a nursery or daycare newsletter tool, or a baby-tracking app that already knows each person's bedtime and wake time: your code sends the household and its ladder, and gets back the nights to change and the change day hour by hour.
The split first: the page does the arithmetic; the model gives the judgement; the page checks it. Reading the times, the nights around the change, each strategy's minutes absorbed per night, every wall-clock bedtime, wake time and nap, and the flags with their ids come from ladderkit.js, which runs in node as well as in the browser. Its reconcile function then re-scores the ladder after the suggested edits (they must lower the bump points and never add a high flag), checks the verdict against the flags, that every high and medium flag has a fix and every person a plan, that every cited id is on the page, and that the reply never doses a medicine. Recommended sleep ranges are from the American Academy of Sleep Medicine; the per-night step limits are the page's own rules of thumb.
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":"extra-hour"}), 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": {…}}.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The body is not a JSON object, or a required field 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: the plan is metered and needs a personal token. |
| 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. |
The task field comes first
Every run sends task. There is one task, ladder:
| task | What it returns | Verdict field |
|---|---|---|
ladder | A title, a summary; edits [{member, night, offset, why, cite}] (set the minutes absorbed, 0-60, for one person on one night); hot_spots [{id, fix, cite}] keyed by the page's H#; people [{member, plan, cues[], cite}]; day_of [{time, text, who[], cite}] for the change date, 24-hour times on the new clock; fridge_note; caveats. | verdict: smooth, bumpy, rework |
rework means a high flag (a step more than double the age's limit, or an alarm cutting 40+ minutes) - edits are then required; bumpy means medium flags only; smooth means no high or medium flags. Cites are the page's ids: people M1…, nights D-4…D0…D+3 (D0 is the evening before the clocks move), flags H1…, plus NOTE.
The declared input schema carries scalars only, so object-valued fields travel JSON-encoded as strings (LadderKit.toWire does this). task, change, members, ladder and checks are required.
Input fields
| Field | Type | Notes |
|---|---|---|
task | string | Always ladder. |
change | string (JSON) | {direction, direction_name, date, weekday, clock, region, lead_nights, after_nights, step_minutes, nights[{id, evening, morning, school_morning, change_night}]}; direction is fall or spring. |
members | string (JSON) | [{id, name, kind, kind_name, bed, wake, naps[], up_by, strategy, step_limit, recommended, usual_sleep}]; kinds: baby, toddler, preschooler, kid, teen, adult; strategies: gradual, split, switch. |
ladder | string (JSON) | [{member, cells[{night, offset, bed, wake, naps[], lost}]}] - minutes absorbed and the wall-clock times per night. |
checks | string (JSON) | {bumps, counts{high, medium, low}, flags[{id, level, kind, member, nights[], text}]} from LadderKit.score; kinds: jump, squeeze, early, nap, unfinished, range. |
note | string | Optional, the household's own words; over 700 characters keeps the start and the end around a marker. |
retry_note | string | Only on a reformat retry. |
The reply
The job's output.output is a string holding one JSON object. Parse it with LadderKit.parseReply, then normalize, then check it with reconcile(reply, {st, overrides}). LadderKit.applyEdits(st, overrides, reply.edits) puts the edits on the ladder; score(st, offsets(st, overrides)) re-scores.
Worked examples
Replies produced against the app's prompt for the sample households (made-up names; lists shortened; the full replies ship in example-result.js). The page replays each of them free from its sample buttons. Inputs are shown with the ladder folded.
task: ladder - Toddler and a first-grader (bumpy)
Input:
{
"task": "ladder",
"change": "{\"direction\":\"fall\",\"direction_name\":\"Fall back\",\"date\":\"2026-11-01\",\"weekday\":\"Sunday\",\"clock\":\"2:00 am becomes 1:00 am\",\"region\":\"US & Canada\",\"lead_nights\":4,\"after_nights\":3,\"step_minutes\":15,\"nights\":[{\"id\":\"D-4\",\"evening\":\"Tue Oct 27\",\"morning\":\"Wed Oct 28\",\"school_morning\":true,\"change_night\":false},{\"id\":\"D-3\",\"evening\":\"Wed Oct 28\",\"morning\":\"Thu Oct 29\",\"school_morning\":true,\"change_night\":false},\"... 6 more\"]}",
"members": "[{\"id\":\"M1\",\"name\":\"Mila\",\"kind\":\"toddler\",\"kind_name\":\"toddler (1-2)\",\"bed\":\"19:00\",\"wake\":\"06:30\",\"naps\":[\"12:30-14:30\"],\"up_by\":\"\",\"strategy\":\"gradual\",\"step_limit\":15,\"recommended\":\"11-14 h\",\"usual_sleep\":\"13 h 30 min\"},{\"id\":\"M2\",\"name\":\"Theo\",\"kind\":\"kid\",\"kind_name\":\"school-age (6-12)\",\"bed\":\"19:45\",\"wake\":\"06:45\",\"naps\":[],\"up_by\":\"07:00\",\"strategy\":\"gradual\",\"step_limit\":30,\"recommended\":\"9-12 h\",\"usual_sleep\":\"11 h\"},{\"id\":\"M3\",\"name\":\"Sam\",\"kind\":\"adult\",\"kind_name\":\"adult\",\"bed\":\"22:30\",\"wake\":\"06:15\",\"naps\":[],\"up_by\":\"06:30\",\"strategy\":\"switch\",\"step_limit\":60,\"recommended\":\"7 h or more\",\"usual_sleep\":\"7 h 45 min\"},{\"id\":\"M4\",\"name\":\"Jo\",\"kind\":\"adult\",\"kind_name\":\"adult\",\"bed\":\"23:00\",\"wake\":\"07:00\",\"naps\":[],\"up_by\":\"\",\"strategy\":\"switch\",\"step_limit\":60,\"recommended\":\"7 h or more\",\"usual_sleep\":\"8 h\"}]",
"ladder": "[{\"member\":\"M1\",\"cells\":[{\"night\":\"D-4\",\"offset\":0,\"bed\":\"19:00\",\"wake\":\"06:30\",\"naps\":[\"12:30-14:30\"],\"lost\":0},{\"night\":\"D-3\",\"offset\":15,\"bed\":\"19:15\",\"wake\":\"06:45\",\"naps\":[\"12:45-14:45\"],\"lost\":0},\"... 6 more nights\"]},\"... 3 more people\"]",
"checks": "{\"bumps\":3,\"counts\":{\"high\":0,\"medium\":1,\"low\":0},\"flags\":[{\"id\":\"H1\",\"level\":\"medium\",\"kind\":\"squeeze\",\"member\":\"M2\",\"nights\":[\"D-2\"],\"text\":\"Theo's body would sleep to 07:15 on Fri Oct 30, but must be up by 07:00 - 15 min of sleep lost\"}]}",
"note": "Theo has school until Friday and the bus comes at 7:30, so he has to be up by 7. Halloween is the Saturday - the kids will be out trick-or-treating until about 7:30."
}
Reply:
{
"task": "ladder",
"verdict": "bumpy",
"title": "A gentle fall-back week for everyone",
"summary": "This household's fall-back ladder is mostly smooth: Mila and Theo step down gradually and are fully on the new clock by Saturday night, while Sam and Jo ride the old clock all week and switch straight over on Sunday night. The one snag is Theo's Thursday-night step, which would have pushed Friday's wake to 07:15 - 15 minutes past his 07:00 bus alarm - so we've held that night steady instead. With that one change nobody loses sleep to the alarm, and the only rough morning left is Sam's early 05:15 wake on change day itself.",
"edits": [
{
"member": "M2",
"night": "D-2",
"offset": 15,
"why": "Keeping Thursday night at the same 15-minute offset as Wednesday, instead of stepping to 30, lands Friday's wake at 07:00 - right on the bus alarm - instead of 07:15 with 15 minutes lost.",
"cite": [
"H1",
"D-2",
"D-3"
]
}
],
"hot_spots": [
{
"id": "H1",
"fix": "Hold Theo's Thursday-night step at 15 minutes instead of 30; that keeps his Friday wake at 07:00, right on the bus alarm, instead of the 07:15 it would have been, so no sleep is lost.",
"cite": [
"M2",
"D-2"
]
}
],
"people": [
{
"member": "M1",
"plan": "Mila steps 15 minutes later each night from Wednesday to Saturday, so she's fully on the new clock by Saturday night and wakes at her usual 06:30 come change morning.",
"cues": [
"Her nap drifts later through the week too - from the usual 12:30 start up to 13:00 Friday and 13:15 Saturday - then it's back to 12:30 once the clocks change Sunday.",
"A little more bright light after dinner on the step nights - bedtime easing from 19:15 Wednesday to 20:00 Saturday - will help her settle at the later hour.",
"Trick-or-treating runs until about 7:30 that Saturday, but her 20:00 bedtime still leaves a calm half hour to wind down afterward."
],
"cite": [
"M1",
"D-3",
"D-2",
"D-1",
"D0",
"NOTE"
]
},
{
"member": "M2",
"plan": "Theo steps later too, but Thursday night holds steady instead of jumping, so his 07:00 bus alarm never cuts into his sleep, and by Monday he's waking on his own at 06:45, ahead of the alarm.",
"cues": [
"Keep Thursday's bedtime at 20:00, not 20:15 - that pause is what keeps Friday's wake right at 07:00.",
"Open his curtains the moment he wakes on school mornings this week so his body locks onto the new time faster.",
"Slide dinner about 15-30 minutes later alongside each bedtime step so he's not hungry before the new bedtime."
],
"cite": [
"M2",
"D-2",
"D+1"
]
},
"..."
],
"day_of": [
{
"time": "05:15",
"text": "Sam's body clock wakes him first today, around 05:15, with Jo following by 06:00 - get both of you up and into bright light rather than lingering in bed.",
"who": [
"M3",
"M4"
],
"cite": [
"D0",
"M3",
"M4"
]
},
{
"time": "06:30",
"text": "Mila wakes at her usual time; keep the house bright and start breakfast at this new-clock hour, not the old one.",
"who": [
"M1"
],
"cite": [
"D0",
"M1"
]
},
"..."
],
"fridge_note": "Clocks fall back early Sunday morning, November 1 - set them back before bed Saturday night. Saturday night (Halloween) bedtimes stay as usual: Mila at 20:00, Theo at 20:45, Sam at 22:30, and Jo at 23:00. By Sunday night everyone's on the new clock - Mila at 19:00 and Theo at 19:45, with Sam and Jo still at their usual 22:30 and 23:00. Keep curtains open at breakfast this week to help everyone's body clock catch up.",
"caveats": [
"These are general sleep-timing tips, not medical advice - check with your doctor or health visitor for any ongoing sleep concerns."
]
}
task: ladder - A teen and two adults (smooth)
Input:
{
"task": "ladder",
"change": "{\"direction\":\"fall\",\"direction_name\":\"Fall back\",\"date\":\"2026-11-01\",\"weekday\":\"Sunday\",\"clock\":\"2:00 am becomes 1:00 am\",\"region\":\"US & Canada\",\"lead_nights\":3,\"after_nights\":3,\"step_minutes\":15,\"nights\":[{\"id\":\"D-3\",\"evening\":\"Wed Oct 28\",\"morning\":\"Thu Oct 29\",\"school_morning\":true,\"change_night\":false},{\"id\":\"D-2\",\"evening\":\"Thu Oct 29\",\"morning\":\"Fri Oct 30\",\"school_morning\":true,\"change_night\":false},\"... 5 more\"]}",
"members": "[{\"id\":\"M1\",\"name\":\"Ines\",\"kind\":\"teen\",\"kind_name\":\"teen (13-17)\",\"bed\":\"23:00\",\"wake\":\"07:00\",\"naps\":[],\"up_by\":\"07:15\",\"strategy\":\"switch\",\"step_limit\":60,\"recommended\":\"8-10 h\",\"usual_sleep\":\"8 h\"},{\"id\":\"M2\",\"name\":\"Rafa\",\"kind\":\"adult\",\"kind_name\":\"adult\",\"bed\":\"23:30\",\"wake\":\"06:45\",\"naps\":[],\"up_by\":\"07:00\",\"strategy\":\"switch\",\"step_limit\":60,\"recommended\":\"7 h or more\",\"usual_sleep\":\"7 h 15 min\"},{\"id\":\"M3\",\"name\":\"Dee\",\"kind\":\"adult\",\"kind_name\":\"adult\",\"bed\":\"22:15\",\"wake\":\"06:00\",\"naps\":[],\"up_by\":\"\",\"strategy\":\"gradual\",\"step_limit\":60,\"recommended\":\"7 h or more\",\"usual_sleep\":\"7 h 45 min\"}]",
"ladder": "[{\"member\":\"M1\",\"cells\":[{\"night\":\"D-3\",\"offset\":0,\"bed\":\"23:00\",\"wake\":\"07:00\",\"naps\":[],\"lost\":0},{\"night\":\"D-2\",\"offset\":0,\"bed\":\"23:00\",\"wake\":\"07:00\",\"naps\":[],\"lost\":0},\"... 5 more nights\"]},\"... 2 more people\"]",
"checks": "{\"bumps\":0,\"counts\":{\"high\":0,\"medium\":0,\"low\":0},\"flags\":[]}",
"note": "Dee runs at 6:30 most mornings and likes to keep that. Ines has a test on Monday."
}
Reply:
{
"task": "ladder",
"verdict": "smooth",
"title": "A Smooth Fall-Back Week for Everyone",
"summary": "This household's ladder comes back with no flags at all, so the fall-back weekend should pass without drama. Dee is already easing her clock 15 minutes later each night from Wednesday so she's fully shifted by the change night, while Ines and Rafa simply ride out the change and switch onto the new clock straight away. The one thing worth planning around is Monday's test, so Sunday night's bedtime for Ines is worth protecting rather than letting it slide.",
"edits": [],
"hot_spots": [],
"people": [
{
"member": "M1",
"plan": "Ines keeps her usual bedtime through the weekend and switches onto the new clock in one go, so by Monday she's waking at her usual 7:00 on the new time for school.",
"cues": [
"Sunday evening, keep the lights bright and the evening going until her usual 23:00 bedtime on the new clock - that helps her body let go of the old time.",
"If she's up early Sunday morning because her body still thinks it's an hour later, get her into daylight rather than letting her doze back off.",
"With a test Monday, hold her wind-down to its usual length Sunday night so she starts the week rested."
],
"cite": [
"M1",
"D0",
"D+1",
"NOTE"
]
},
{
"member": "M2",
"plan": "Rafa also switches straight onto the new clock, waking early on the change morning itself before settling into his usual bed and wake times from Sunday night.",
"cues": [
"Morning daylight on the change day is the fastest way to pull his body clock onto the new time.",
"Keep coffee to the morning on the switch days, so a day that starts early doesn't turn into a late one that undoes the shift."
],
"cite": [
"M2",
"D0",
"D+1"
]
},
"..."
],
"day_of": [
{
"time": "05:45",
"text": "Rafa is up first - his body is still on the old time. Let him ease into the morning quietly rather than waking the house early.",
"who": [
"M2"
],
"cite": [
"D0"
]
},
{
"time": "06:00",
"text": "Ines and Dee are both up around now. Open the curtains and get bright light going straight away to anchor the new clock.",
"who": [
"M1",
"M3"
],
"cite": [
"D0"
]
},
"..."
],
"fridge_note": "Clocks go back Saturday night into Sunday (2am becomes 1am). Tonight everyone keeps their usual bedtime - Ines at 11, Rafa at 11:30, and Dee a little later at 11:15 to finish her gradual shift. Tomorrow night Dee is back to her usual 10:15, while Ines and Rafa stay the same. One shared habit for the week: curtains open at breakfast to get everyone outside light early.",
"caveats": [
"This is sleep-scheduling guidance, not medical advice - if sleep stays hard for anyone despite the shift, it's worth mentioning to a doctor."
]
}
task: ladder - Baby and a preschooler, switch on the day (rework)
Input:
{
"task": "ladder",
"change": "{\"direction\":\"fall\",\"direction_name\":\"Fall back\",\"date\":\"2026-11-01\",\"weekday\":\"Sunday\",\"clock\":\"2:00 am becomes 1:00 am\",\"region\":\"US & Canada\",\"lead_nights\":4,\"after_nights\":3,\"step_minutes\":15,\"nights\":[{\"id\":\"D-4\",\"evening\":\"Tue Oct 27\",\"morning\":\"Wed Oct 28\",\"school_morning\":true,\"change_night\":false},{\"id\":\"D-3\",\"evening\":\"Wed Oct 28\",\"morning\":\"Thu Oct 29\",\"school_morning\":true,\"change_night\":false},\"... 6 more\"]}",
"members": "[{\"id\":\"M1\",\"name\":\"Noor\",\"kind\":\"baby\",\"kind_name\":\"baby (4-12 months)\",\"bed\":\"18:30\",\"wake\":\"06:00\",\"naps\":[\"09:30-10:30\",\"13:30-15:30\"],\"up_by\":\"\",\"strategy\":\"switch\",\"step_limit\":15,\"recommended\":\"12-16 h\",\"usual_sleep\":\"14 h 30 min\"},{\"id\":\"M2\",\"name\":\"Ava\",\"kind\":\"preschooler\",\"kind_name\":\"preschooler (3-5)\",\"bed\":\"19:00\",\"wake\":\"06:30\",\"naps\":[\"13:00-14:30\"],\"up_by\":\"07:30\",\"strategy\":\"switch\",\"step_limit\":15,\"recommended\":\"10-13 h\",\"usual_sleep\":\"13 h\"},{\"id\":\"M3\",\"name\":\"Kai\",\"kind\":\"adult\",\"kind_name\":\"adult\",\"bed\":\"22:00\",\"wake\":\"05:45\",\"naps\":[],\"up_by\":\"06:00\",\"strategy\":\"switch\",\"step_limit\":60,\"recommended\":\"7 h or more\",\"usual_sleep\":\"7 h 45 min\"}]",
"ladder": "[{\"member\":\"M1\",\"cells\":[{\"night\":\"D-4\",\"offset\":0,\"bed\":\"18:30\",\"wake\":\"06:00\",\"naps\":[\"09:30-10:30\",\"13:30-15:30\"],\"lost\":0},{\"night\":\"D-3\",\"offset\":0,\"bed\":\"18:30\",\"wake\":\"06:00\",\"naps\":[\"09:30-10:30\",\"13:30-15:30\"],\"lost\":0},\"... 6 more nights\"]},\"... 2 more people\"]",
"checks": "{\"bumps\":19,\"counts\":{\"high\":2,\"medium\":2,\"low\":1},\"flags\":[{\"id\":\"H1\",\"level\":\"high\",\"kind\":\"jump\",\"member\":\"M1\",\"nights\":[\"D+1\"],\"text\":\"Noor has to absorb 60 min in one night (D+1) - more than the 15-minute step that suits a baby (4-12 months)\"},{\"id\":\"H2\",\"level\":\"high\",\"kind\":\"jump\",\"member\":\"M2\",\"nights\":[\"D+1\"],\"text\":\"Ava has to absorb 60 min in one night (D+1) - more than the 15-minute step that suits a preschooler (3-5)\"},{\"id\":\"H3\",\"level\":\"medium\",\"kind\":\"early\",\"member\":\"M1\",\"nights\":[\"D0\"],\"text\":\"Noor is likely up at 05:00 on Sun Nov 1, 1 h before the usual 06:00\"},{\"id\":\"H4\",\"level\":\"medium\",\"kind\":\"early\",\"member\":\"M2\",\"nights\":[\"D0\"],\"text\":\"Ava is likely up at 05:30 on Sun Nov 1, 1 h before the usual 06:30\"},{\"id\":\"H5\",\"level\":\"low\",\"kind\":\"nap\",\"member\":\"M2\",\"nights\":[],\"text\":\"Ava's last nap usually ends 4 h 30 min before bed - already under the 5 h a preschooler (3-5) usually needs\"}]}",
"note": "We were just going to put everyone on the new clock on Sunday and hope for the best. Noor already wakes at 6 and I am dreading 5 am."
}
Reply:
{
"task": "ladder",
"verdict": "rework",
"title": "A Gentle Fall-Back Ladder for Noor and Ava",
"summary": "Noor and Ava were both set to jump the full hour in one night under their switch strategy - far more than the 15-minute step that suits a baby or preschooler, and the reason the note dreaded a 5 am wake-up Sunday. We've spread that hour into four 15-minute steps running from Friday night through Monday for both of them, so no single night moves more than 15 minutes. That also softens Sunday's early wake from a full hour early to about half an hour for each of them. Kai, as an adult, can still go straight onto the new clock Sunday with no changes needed.",
"edits": [
{
"member": "M1",
"night": "D-1",
"offset": 15,
"why": "Start Noor's shift on a non-school night, moving just 15 minutes to bedtime 6:45 pm - within her 15-minute step limit.",
"cite": [
"H1",
"D-1"
]
},
{
"member": "M1",
"night": "D0",
"offset": 30,
"why": "Carry Noor to 30 minutes absorbed by the change night (bedtime 7:00 pm), another 15-minute step.",
"cite": [
"H1",
"D0"
]
},
"..."
],
"hot_spots": [
{
"id": "H1",
"fix": "Spread Noor's hour over four nights instead of one 60-minute jump: 15 minutes Friday, 30 by the change night, 45 the night after, landing on 60 the following night - no single step bigger than 15 minutes.",
"cite": [
"M1",
"D-1",
"D0",
"D+1",
"D+2"
]
},
{
"id": "H2",
"fix": "Give Ava the same gentle climb as Noor: 15 minutes Friday, 30 on the change night, 45 the night after, 60 the night after that.",
"cite": [
"M2",
"D-1",
"D0",
"D+1",
"D+2"
]
},
"..."
],
"people": [
{
"member": "M1",
"plan": "Noor climbs the hour in four 15-minute steps from Friday night to the following Monday, so she's never shifted more than 15 minutes in a night.",
"cues": [
"Keep her evening light brighter until each night's new bedtime - evening light helps a baby's body accept staying up a little later.",
"Nudge her two naps along with the ladder each adjusted day rather than leaving them on the old schedule.",
"On Sunday morning, go to her calmly in a dim, quiet room rather than starting the day - treat it like still-night until closer to 6."
],
"cite": [
"M1"
]
},
{
"member": "M2",
"plan": "Ava follows the same four-night, 15-minute-a-night climb as Noor, landing fully on the new clock the Monday after.",
"cues": [
"Start wind-down and screens-off at her new bedtime each night so she learns the later cue.",
"Trim her one nap by about 10-15 minutes as it shifts earlier this week, so it doesn't crowd the evening before bed.",
"Her 7:30 alarm is just a backstop - she should be naturally up well before it all week."
],
"cite": [
"M2"
]
},
"..."
],
"day_of": [
{
"time": "05:30",
"text": "Noor is likely to wake now on the new clock - go in calmly and keep the room dim, like it's still the middle of the night.",
"who": [
"M1"
],
"cite": [
"D0",
"H3"
]
},
{
"time": "06:00",
"text": "Ava may wake around now too - keep lights low and voices quiet a while longer before starting the day.",
"who": [
"M2"
],
"cite": [
"D0",
"H4"
]
},
"..."
],
"fridge_note": "Clocks fall back overnight Saturday into Sunday, Nov 1. Saturday night Noor goes down at 7:00 pm and Ava at 7:30 pm - a bit later than usual to start the shift. Sunday night they're earlier than usual, Noor at 6:15 and Ava at 6:45, landing both on the new clock. Keep the curtains open at breakfast and screens off at wind-down all week to help everyone settle in.",
"caveats": [
"This eases the jump but won't erase it - some early waking Sunday and Monday is normal while bodies catch up to the new clock."
]
}
Build the input yourself
Load the script in node (global.window = {}; require("./ladderkit.js")), then:
const K = LadderKit;
const form = { dir: "fall", date: "2026-11-01", lead: 4, after: 3, step: 15, note: "",
members: [{ key: "a", name: "Mila", kind: "toddler", bed: "19:00", wake: "06:30", naps: "12:30-14:30", up_by: "", strategy: "switch" }] };
const st = K.state(form);
const offs = K.offsets(st, {}); // or pass hand edits: { a: { "D-1": 30 } }
const { input, score } = K.buildInput(st, offs, { note: "" });
const wire = K.toWire(input); // POST this to /estimate and /run
// later: const reply = K.normalize(K.parseReply(job.output.output));
// K.reconcile(reply, { st, overrides: {} }) -> [{level, code, text}]
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://extra-hour.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"}
}
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "paste-your-token-here" # from https://extra-hour.skillsafe.ai/tokens.html
def api(method, path, body=None, headers=None):
req = urllib.request.Request(BASE + path, method=method,
data=None if body is None else json.dumps(body).encode(),
headers={"Content-Type": "application/json", **({"Authorization": "Bearer " + TOKEN} if TOKEN else {}), **(headers or {})})
with urllib.request.urlopen(req) as r:
env = json.load(r)
if env.get("error"):
raise RuntimeError(env["error"])
return env["data"]
// extra-hour.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://extra-hour.skillsafe.ai/tokens.html
async function api(method, path, body, headers = {}) {
const r = await fetch(BASE + path, { method, headers: { "Content-Type": "application/json",
...(TOKEN ? { Authorization: "Bearer " + TOKEN } : {}), ...headers },
body: body === undefined ? undefined : JSON.stringify(body) });
const env = await r.json();
if (env.error) throw new Error(env.error.code + ": " + env.error.message);
return env.data;
}
package main
import ("bytes"; "encoding/json"; "fmt"; "net/http")
const base = "https://api.skillsafe.ai/v1/app-api"
var token = "paste-your-token-here" // from https://extra-hour.skillsafe.ai/tokens.html
func api(method, path string, body any, headers map[string]string) (map[string]any, error) {
var buf bytes.Buffer
if body != nil { json.NewEncoder(&buf).Encode(body) }
req, _ := http.NewRequest(method, base+path, &buf)
req.Header.Set("Content-Type", "application/json")
if token != "" { req.Header.Set("Authorization", "Bearer "+token) }
for k, v := range headers { req.Header.Set(k, v) }
res, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
defer res.Body.Close()
var env struct { Data map[string]any; Error map[string]any }
json.NewDecoder(res.Body).Decode(&env)
if env.Error != nil { return nil, fmt.Errorf("%v", env.Error) }
return env.Data, nil
}
// ExtraHour.java -- Java 17+, with com.fasterxml.jackson.core:jackson-databind
import com.fasterxml.jackson.databind.*;
import java.net.URI; import java.net.http.*; import java.util.*;
public class ExtraHour {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://extra-hour.skillsafe.ai/tokens.html
static final ObjectMapper M = new ObjectMapper();
static final HttpClient C = HttpClient.newHttpClient();
static JsonNode api(String method, String path, Object body, Map<String, String> headers) throws Exception {
var b = HttpRequest.newBuilder(URI.create(BASE + path)).header("Content-Type", "application/json");
if (!token.isEmpty()) b.header("Authorization", "Bearer " + token);
if (headers != null) headers.forEach(b::header);
b.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(M.writeValueAsString(body)));
JsonNode env = M.readTree(C.send(b.build(), HttpResponse.BodyHandlers.ofString()).body());
if (env.hasNonNull("error")) throw new RuntimeException(env.get("error").toString());
return env.get("data");
}
}
require "json"
require "net/http"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "paste-your-token-here" # from https://extra-hour.skillsafe.ai/tokens.html
def api(method, path, body = nil, headers = {})
uri = URI(BASE + path)
req = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri,
{ "Content-Type" => "application/json" }.merge(TOKEN.empty? ? {} : { "Authorization" => "Bearer #{TOKEN}" }).merge(headers))
req.body = JSON.generate(body) unless body.nil?
env = JSON.parse(Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }.body)
raise env["error"].to_s if env["error"]
env["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = "paste-your-token-here"; // from https://extra-hour.skillsafe.ai/tokens.html
function api(string $method, string $path, $body = null, array $headers = []) {
global $TOKEN;
$h = ["Content-Type: application/json"];
if ($TOKEN) $h[] = "Authorization: Bearer $TOKEN";
foreach ($headers as $k => $v) $h[] = "$k: $v";
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $h, CURLOPT_RETURNTRANSFER => true]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
$env = json_decode(curl_exec($ch), true);
if (!empty($env["error"])) throw new Exception(json_encode($env["error"]));
return $env["data"];
}
// Program.cs -- .NET 6+ console app
using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Nodes;
static class Api {
public const string Base = "https://api.skillsafe.ai/v1/app-api";
public static string Token = "paste-your-token-here"; // from https://extra-hour.skillsafe.ai/tokens.html
static readonly HttpClient C = new();
public static async Task<JsonNode?> Call(string method, string path, object? body = null, Dictionary<string, string>? headers = null) {
var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
if (Token != "") req.Headers.Add("Authorization", "Bearer " + Token);
if (headers != null) foreach (var h in headers) req.Headers.Add(h.Key, h.Value);
if (body != null) req.Content = JsonContent.Create(body);
var env = JsonNode.Parse(await (await C.SendAsync(req)).Content.ReadAsStringAsync())!;
if (env["error"] is JsonNode e && e.GetValueKind() != JsonValueKind.Null) throw new Exception(e.ToJsonString());
return env["data"];
}
}
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":"extra-hour"}' | jq -r '.data.token')
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = ""
TOKEN = api("POST", "/guest", {"slug": "extra-hour"})["token"]
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = "";
TOKEN = (await api("POST", "/guest", { slug: "extra-hour" })).token;
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = ""
g, err := api("POST", "/guest", map[string]string{"slug": "extra-hour"}, nil)
if err != nil { panic(err) }
token = g["token"].(string)
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = "";
token = api("POST", "/guest", Map.of("slug", "extra-hour"), null).get("token").asText();
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN.replace("")
TOKEN.replace(api("POST", "/guest", { slug: "extra-hour" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "extra-hour"])["token"];
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
Api.Token = "";
Api.Token = (await Api.Call("POST", "/guest", new { slug = "extra-hour" }))!["token"]!.GetValue<string>();
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}'
me = api("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
me, _ := api("GET", "/me", nil, nil)
fmt.Println(me["subject_type"], me["credits"])
JsonNode me = api("GET", "/me", null, null);
System.out.println(me.get("subject_type") + " " + me.get("credits"));
me = api("GET", "/me")
puts [me["subject_type"], me["credits"]].inspect
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"] ?? "", "\n";
var me = await Api.Call("GET", "/me");
Console.WriteLine($"{me!["subject_type"]} {me["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": "ladder", "change": "{...}", "members": "[...]", "ladder": "[...]", "checks": "{...}"}
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}'
inp = json.load(open("input.json")) # {"task": "ladder", "change": "{...}", "members": "[...]", "ladder": "[...]", "checks": "{...}"}
est = api("POST", "/estimate", inp)
print(est["hold_credits"], est["min_credits"], est["model"], est.get("warnings"))
import { readFileSync } from "node:fs";
const input = JSON.parse(readFileSync("input.json", "utf8")); // {task, change, members, ladder, checks, note?}
const est = await api("POST", "/estimate", input);
console.log(est.hold_credits, est.min_credits, est.model, est.warnings);
raw, _ := os.ReadFile("input.json") // add "os" to the imports
var input map[string]any
json.Unmarshal(raw, &input)
est, _ := api("POST", "/estimate", input, nil)
fmt.Println(est["hold_credits"], est["min_credits"], est["model"])
Map<String, Object> input = M.readValue(new java.io.File("input.json"), Map.class);
JsonNode est = api("POST", "/estimate", input, null);
System.out.println(est.get("hold_credits") + " " + est.get("min_credits") + " " + est.get("model"));
input = JSON.parse(File.read("input.json"))
est = api("POST", "/estimate", input)
puts [est["hold_credits"], est["min_credits"], est["model"]].inspect
$input = json_decode(file_get_contents("input.json"), true);
$est = api("POST", "/estimate", $input);
echo $est["hold_credits"], " ", $est["min_credits"], " ", $est["model"], "\n";
var input = JsonNode.Parse(File.ReadAllText("input.json"))!;
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"{est!["hold_credits"]} {est["min_credits"]} {est["model"]}");
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 extra-hour:<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="extra-hour:ladder:$(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}'
import hashlib, time
key = "extra-hour:" + inp["task"] + ":" + hashlib.sha256(json.dumps(inp).encode()).hexdigest()[:16] + ":a1"
job = api("POST", "/run", inp, {"Idempotency-Key": key})
while True:
j = api("GET", "/jobs/" + job["job_id"])
if j["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(2)
reply = json.loads(j["output"]["output"]) # the lane's JSON object
print(j["charged_credits"], j.get("truncated"), reply.get("verdict"))
import { createHash } from "node:crypto";
const key = `extra-hour:${input.task}:${createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16)}:a1`;
const job = await api("POST", "/run", input, { "Idempotency-Key": key });
let j;
do { await new Promise((r) => setTimeout(r, 2000)); j = await api("GET", "/jobs/" + job.job_id); }
while (!["succeeded", "failed", "cancelled"].includes(j.status));
const reply = JSON.parse(j.output.output);
console.log(j.charged_credits, j.truncated, reply.verdict);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "extra-hour:" + input["task"].(string) + ":" + hex.EncodeToString(sum[:8]) + ":a1"
job, _ := api("POST", "/run", input, map[string]string{"Idempotency-Key": key})
var j map[string]any
for {
time.Sleep(2 * time.Second)
j, _ = api("GET", "/jobs/"+job["job_id"].(string), nil, nil)
if s := j["status"]; s == "succeeded" || s == "failed" || s == "cancelled" { break }
}
fmt.Println(j["charged_credits"], j["truncated"], j["output"].(map[string]any)["output"])
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "extra-hour:" + input.get("task") + ":" + HexFormat.of().formatHex(digest, 0, 8) + ":a1";
JsonNode job = api("POST", "/run", input, Map.of("Idempotency-Key", key)), j;
do { Thread.sleep(2000); j = api("GET", "/jobs/" + job.get("job_id").asText(), null, null); }
while (!Set.of("succeeded", "failed", "cancelled").contains(j.get("status").asText()));
JsonNode reply = M.readTree(j.get("output").get("output").asText());
System.out.println(j.get("charged_credits") + " " + reply);
require "digest"
key = "extra-hour:#{input["task"]}:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1"
job = api("POST", "/run", input, { "Idempotency-Key" => key })
j = nil
loop do
sleep 2
j = api("GET", "/jobs/#{job["job_id"]}")
break if %w[succeeded failed cancelled].include?(j["status"])
end
reply = JSON.parse(j["output"]["output"])
puts [j["charged_credits"], j["truncated"], reply["verdict"]].inspect
$key = "extra-hour:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$job = api("POST", "/run", $input, ["Idempotency-Key" => $key]);
do { sleep(2); $j = api("GET", "/jobs/" . $job["job_id"]); }
while (!in_array($j["status"], ["succeeded", "failed", "cancelled"]));
$reply = json_decode($j["output"]["output"], true);
echo $j["charged_credits"], " ", $reply["verdict"], "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"extra-hour:{input["task"]}:{Convert.ToHexString(digest, 0, 8).ToLowerInvariant()}:a1";
var job = await Api.Call("POST", "/run", input, new() { ["Idempotency-Key"] = key });
JsonNode j;
do { await Task.Delay(2000); j = (await Api.Call("GET", "/jobs/" + job!["job_id"]))!; }
while (!new[] { "succeeded", "failed", "cancelled" }.Contains(j["status"]!.GetValue<string>()));
var reply = JsonNode.Parse(j["output"]!["output"]!.GetValue<string>());
Console.WriteLine($"{j["charged_credits"]} {reply}");
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: extra-hour:ladder:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json
rev = json.load(open("input.json")) # the same run input as input.json: {"task": "ladder", "change": "{...}", "members": "[...]", "ladder": "[...]", "checks": "{...}"}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "extra-hour:ladder:" + hashlib.sha256(json.dumps(rev).encode()).hexdigest()[:16] + ":a1"})
with urllib.request.urlopen(req) as r:
for line in r:
line = line.decode().strip()
if line.startswith("data:"):
print(line[5:].strip()[:200])
const rev = JSON.parse(readFileSync("input.json", "utf8"));
const r = await fetch(BASE + "/run-stream", { method: "POST", body: JSON.stringify(rev), headers: {
"Content-Type": "application/json", Authorization: "Bearer " + TOKEN,
"Idempotency-Key": `extra-hour:ladder:${createHash("sha256").update(JSON.stringify(rev)).digest("hex").slice(0, 16)}:a1` } });
const dec = new TextDecoder();
for await (const chunk of r.body) for (const line of dec.decode(chunk).split("\n"))
if (line.startsWith("data:")) console.log(line.slice(5).trim().slice(0, 200));
rraw, _ := os.ReadFile("input.json") // add "bufio", "strings" to the imports
rs := sha256.Sum256(rraw)
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(rraw))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", "extra-hour:ladder:"+hex.EncodeToString(rs[:8])+":a1")
res, _ := http.DefaultClient.Do(req)
sc := bufio.NewScanner(res.Body)
for sc.Scan() { if l := sc.Text(); strings.HasPrefix(l, "data:") { fmt.Println(l[5:]) } }
String rev = java.nio.file.Files.readString(java.nio.file.Path.of("input.json"));
var rd = java.security.MessageDigest.getInstance("SHA-256").digest(rev.getBytes());
var sreq = HttpRequest.newBuilder(URI.create(BASE + "/run-stream")).header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token).header("Idempotency-Key", "extra-hour:ladder:" + HexFormat.of().formatHex(rd, 0, 8) + ":a1")
.POST(HttpRequest.BodyPublishers.ofString(rev)).build();
C.send(sreq, HttpResponse.BodyHandlers.ofLines()).body().filter(l -> l.startsWith("data:")).forEach(System.out::println);
rev = File.read("input.json")
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{TOKEN}",
"Idempotency-Key" => "extra-hour:ladder:#{Digest::SHA256.hexdigest(rev)[0, 16]}:a1")
req.body = rev
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |h|
h.request(req) { |res| res.read_body { |chunk| chunk.each_line { |l| puts l if l.start_with?("data:") } } }
end
$rev = file_get_contents("input.json");
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $rev, CURLOPT_HTTPHEADER => [
"Content-Type: application/json", "Authorization: Bearer $TOKEN",
"Idempotency-Key: extra-hour:ladder:" . substr(hash("sha256", $rev), 0, 16) . ":a1"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) { foreach (explode("\n", $chunk) as $l) if (str_starts_with($l, "data:")) echo $l, "\n"; return strlen($chunk); }]);
curl_exec($ch);
var rev = File.ReadAllText("input.json");
var rd = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(rev));
var sreq = new HttpRequestMessage(HttpMethod.Post, Api.Base + "/run-stream") { Content = new StringContent(rev, System.Text.Encoding.UTF8, "application/json") };
sreq.Headers.Add("Authorization", "Bearer " + Api.Token);
sreq.Headers.Add("Idempotency-Key", $"extra-hour:ladder:{Convert.ToHexString(rd, 0, 8).ToLowerInvariant()}:a1");
using var http = new HttpClient();
using var res = await http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var rdr = new StreamReader(await res.Content.ReadAsStreamAsync());
for (string? l; (l = await rdr.ReadLineAsync()) != null;) if (l.StartsWith("data:")) Console.WriteLine(l);