Drive Start Cold from your own code
The one metered step - the layer plan for a cold-season hike - is available over HTTP. A natural use is a trip planner, a gear-list app or a chat assistant that already holds a forecast and a list of what someone owns: your code sends the day and the closet, and gets back what to wear at the trailhead, on the climb, at the top stop and on the way down, what to swap out, what is missing, a verdict and the sign that should turn them around.
The split first: the page does the weather arithmetic; the model plans; the page checks it. The four phases (air, wind, wind chill, warmth need), the reading of each closet line into a layer, the draft stacks and the flags come from layerkit.js, which runs in node as well as in the browser. Its plan function then checks the reply: every worn id must be in the closet, cotton still worn without a swap is called out, a wet forecast with the waterproof jacket left off the climb or the top is flagged, a top stop dressed colder than the closet allows is flagged, and a verdict that ignores a must-fix or a conditions flag is flagged.
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":"start-cold"}), 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 task 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, kit. It returns title, verdict, summary; phases [{phase, wear, why, vent}] for start, climb, top, down; swaps [{out, in, why}]; gaps [{slot, item, why, must}]; turn_back (one sentence); trail_tips (three to five); caveats.
| verdict | Means |
|---|---|
ready | The closet covers every phase of this forecast; anything missing is a nice-to-have. |
adjust | The hike works after the swaps and at most a couple of added items - a cotton layer must come off, or a must-have is missing. |
rethink | Clothing cannot make the day work as planned: freezing rain, a top wind chill near -25 C / -13 F, wind hard to stand in, or a top stop far short of its need. The reply says what would change it. |
Ids are the closet's: G1…G40, in the order the lines were given. A swap with an empty in means nothing in the closet replaces that item - look for the matching entry in gaps.
The declared input schema carries scalars only, so object-valued fields travel JSON-encoded as strings (LayerKit.toWire does this). Only task is required by the schema.
Input fields
| Field | Type | Notes |
|---|---|---|
task | string | Always kit. |
today | string | YYYY-MM-DD. |
units | string | us (F, mph, ft) or metric (C, km/h, m). The reply writes numbers in these units. |
hike | string (JSON) | {name, start, finish, hours, trailhead, top, climb, weather, effort, runs, estimated[]}. weather: dry, drizzle, rain, snow, rain and snow, freezing rain. estimated may hold top_temp, top_wind, hours, start. |
phases | string (JSON) | [{phase, clock, moving, air, wind, feels_like, need, page_wear[], page_warmth, estimated[]}], four of them. need and page_warmth are the page's warmth points (estimates). |
closet | string (JSON) | [{id, text, slot, warmth, grams, materials?, flags?, grams_estimated?}], up to 40 items. slot: base, mid, puffy, shell, legs, head, hands, neck, feet, traction, extra, other. |
page_reads | string (JSON) | [{level, code, text}], level conditions, must or nice. |
page_verdict | string | The page's own reading: ready, adjust or rethink. |
notes | string | Optional, the person's words; past 600 characters the middle is cut 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 LayerKit.parseReply, then normalizeKit, then check it with plan(reply, state), which returns {stacks, notes, kit, weights}: the stack per phase as the page would draw it, the notes (ok, info, warn, bad), the ids to bring and the pack weight per phase.
Worked examples
Replies produced against the app's prompt for the sample hikes (made-up forecasts and closets; 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 long fields folded.
task: kit - Fall ridge day - ready
Input:
{
"task": "kit",
"today": "2026-10-19",
"units": "us",
"hike": "{\"name\":\"Fall ridge day\",\"start\":\"08:30\",\"finish\":\"13:30\",\"hours\":5,\"trailhead\":\"1200 ft\",\"top\":\"3700 ft\",\"climb\":\"2500 ft\",\"weather\":\"dry\",\"effort\":\"steady\",\"runs\":\"average\",\"estimated\":[\"top_temp\",\"top_wind\"]}",
"phases": "[{\"phase\":\"start\",\"clock\":\"08:30\",\"moving\":true,\"air\":\"45 F\",\"wind\":\"8 mph\",\"feels_like\":\"41 F\",\"need\":0.6,\"page_wear\":[\"G1\",\"G7\",\"G5\",\"G8\"],\"page_warmth\":0.6,\"estimated\":[]},\"... 3 more\"]",
"closet": "[{\"id\":\"G1\",\"text\":\"Merino 150 long sleeve, 170g\",\"slot\":\"base\",\"warmth\":0.6,\"grams\":170,\"materials\":[\"wool\"]},{\"id\":\"G2\",\"text\":\"Grid fleece 100 weight, 280g\",\"slot\":\"mid\",\"warmth\":1,\"grams\":280,\"materials\":[\"synthetic\"]},\"... 8 more\"]",
"page_reads": "[]",
"page_verdict": "ready",
"notes": "Open ridge for the last mile. We usually stop 20 minutes at the top for lunch."
}
Reply:
{
"task": "kit",
"title": "Dressed Right for the Fall Ridge Climb",
"verdict": "ready",
"summary": "This closet comfortably covers the whole day, so the verdict agrees with the page: ready, with nothing to swap and nothing missing. The coldest moment is the 20-minute stop at the top, an estimated 36 F air and 28 F feels like on the open ridge, where the stack steps up from a base layer and fleece to adding the puffy and the waterproof shell. The biggest change of the day happens right there - putting both the puffy and the shell on together the moment you stop moving, then shedding the puffy again once you start down.",
"phases": [
{
"phase": "start",
"wear": [
"G1",
"G7",
"G5",
"G8"
],
"why": "At the trailhead it's 45 F with an 8 mph wind (feels like 41 F) - the merino base layer, liner gloves, pants and wool socks are enough once you're moving, without a mid layer that would have you sweating ten minutes in.",
"vent": "Expect to feel a little chilly for the first 10 to 15 minutes before the climb warms you up - that's expected, not a sign you're underdressed."
},
{
"phase": "climb",
"wear": [
"G1",
"G2",
"G6",
"G7",
"G5",
"G8"
],
"why": "Feels like 34 F while climbing (air 41 F, wind 10 mph, both estimated and averaged between the trailhead and the top) - add the fleece and wool beanie over the base layer to match the extra chill as you gain elevation.",
"vent": "If you're sweating on the steeper pitches, unzip the fleece or pull the beanie off rather than stopping to shed a layer."
},
"..."
],
"swaps": [],
"gaps": [],
"turn_back": "Turn back or cut the ridge short if the wind at the top is harder to stand in than this forecast, or if your fingers in the liner gloves go numb and won't rewarm at the stop.",
"trail_tips": [
"Leave the car in just the base layer, liner gloves, pants and socks - you'll warm up within the first 15 minutes of climbing.",
"Add the fleece and beanie as the trail gains elevation and the air cools.",
"Put the puffy on first, then the shell, as soon as you stop at the top - before you start to cool down.",
"..."
],
"caveats": [
"The top's temperature and wind, and the climb and descent readings in between, are estimated from the trailhead forecast, not a summit observation.",
"This is a clothing plan, not an avalanche, ice or trail-condition forecast - check trail reports for the ridge separately.",
"..."
]
}
task: kit - Wet November loop - adjust, with swaps
Input:
{
"task": "kit",
"today": "2026-10-19",
"units": "us",
"hike": "{\"name\":\"Wet November loop\",\"start\":\"10:00\",\"finish\":\"14:30\",\"hours\":4.5,\"trailhead\":\"600 ft\",\"top\":\"1500 ft\",\"climb\":\"900 ft\",\"weather\":\"rain\",\"effort\":\"steady\",\"runs\":\"cold\",\"estimated\":[\"top_temp\",\"top_wind\"]}",
"phases": "[{\"phase\":\"start\",\"clock\":\"10:00\",\"moving\":true,\"air\":\"41 F\",\"wind\":\"12 mph\",\"feels_like\":\"34 F\",\"need\":1.6,\"page_wear\":[\"G2\",\"G4\",\"G5\",\"G6\",\"G9\"],\"page_warmth\":2.5,\"estimated\":[]},\"... 3 more\"]",
"closet": "[{\"id\":\"G1\",\"text\":\"Cotton long-sleeve tee\",\"slot\":\"base\",\"warmth\":0.6,\"grams\":180,\"materials\":[\"cotton\"],\"grams_estimated\":true},{\"id\":\"G2\",\"text\":\"Polyester running shirt\",\"slot\":\"base\",\"warmth\":0.6,\"grams\":180,\"materials\":[\"synthetic\"],\"grams_estimated\":true},\"... 7 more\"]",
"page_reads": "[{\"level\":\"must\",\"code\":\"cotton\",\"text\":\"Jeans (G6) is cotton - it soaks up sweat and stays wet and cold. The draft only wears it because the closet has nothing else for that layer.\"},{\"level\":\"must\",\"code\":\"cotton\",\"text\":\"Cotton hoodie (G3) is cotton - it soaks up sweat and stays wet and cold. The draft only wears it because the closet has nothing else for that layer.\"},\"... 6 more\"]",
"page_verdict": "adjust",
"notes": "Rain most of the day, heaviest after noon. Forest trail, one open viewpoint at the top."
}
Reply:
{
"task": "kit",
"title": "Cold Rain Loop: Layer for the Climb",
"verdict": "adjust",
"summary": "This is an adjust: the climb and descent are covered, but the warmest stack this closet can build - hoodie, vest and shell - still comes up short of what the exposed top stop needs, and there is no warm hat or gloves anywhere in the kit. The coldest moment is the top stop at 12:29, feeling like 28 F (estimated) with an 18 mph wind. The biggest change from just hauling the climb gear up the mountain: keep the down vest dry through the climb and only add it at the stop, so the top gets the warmest combination this closet has rather than a vest already damp with sweat.",
"phases": [
{
"phase": "start",
"wear": [
"G2",
"G3",
"G5",
"G6",
"G9"
],
"why": "At 10:00 it's 41 F at the trailhead but feels like 34 F once you start moving, so wear the climb stack now - synthetic shirt, hoodie, and the rain shell, which stays on all day since it's raining - rather than bundling up for standing at the car.",
"vent": "Expect to feel a bit chilly for the first 10 to 15 minutes; that's right, since you're dressed for the climb, not the parking lot."
},
{
"phase": "climb",
"wear": [
"G2",
"G3",
"G5",
"G6",
"G9"
],
"why": "By 10:20 it's an estimated 39 F and feels like 31 F with a 15 mph wind, so keep the same shirt-hoodie-shell stack working uphill and leave the down vest zipped away dry for the stop.",
"vent": "If you warm up on the climb, loosen the shell's cuffs and collar rather than shedding the hoodie - you'll want that warmth again at the top."
},
"..."
],
"swaps": [
{
"out": "G1",
"in": "G2",
"why": "The cotton tee holds sweat and chills against the skin; the polyester shirt dries fast and keeps its warmth wet."
},
{
"out": "G8",
"in": "G9",
"why": "Cotton socks get cold and stay wet in rain; wool keeps insulating even damp."
},
"..."
],
"gaps": [
{
"slot": "mid",
"item": "a synthetic or fleece mid layer",
"why": "The only backup warmth besides the vest is a cotton hoodie, which holds sweat and chills; a dry-fast mid layer would stay warmer and close more of the gap at the top, where even the full stack only reaches about 3.7 of the 4.4 warmth points that stop calls for.",
"must": true
},
{
"slot": "legs",
"item": "synthetic or wool hiking pants",
"why": "Jeans are the only leg layer in the closet; they soak up rain and sweat and stay cold and heavy for the whole 4.5 hours.",
"must": true
},
"..."
],
"turn_back": "If your hands go numb and won't rewarm, or you're shivering hard at the viewpoint even with the vest on, cut the stop short and head back down into the trees.",
"trail_tips": [
"Before you leave, grab a warm hat and a pair of gloves if you have any lying around - this closet packs neither.",
"Start and climb in the synthetic shirt, hoodie, and rain shell; tuck the baseball cap under the shell's hood to keep rain off your face.",
"Keep the down vest zipped dry in your pack while climbing - don't put it on until you stop moving.",
"..."
],
"caveats": [
"The top's temperature and wind are estimates - standard lapse rate and 1.5x the trailhead wind - not a direct reading.",
"These warmth points are rough page estimates, not lab numbers; trust how you actually feel over the math.",
"..."
]
}
task: kit - Winter summit push - rethink
Input:
{
"task": "kit",
"today": "2026-10-19",
"units": "us",
"hike": "{\"name\":\"Winter summit push\",\"start\":\"07:00\",\"finish\":\"14:00\",\"hours\":7,\"trailhead\":\"2000 ft\",\"top\":\"5200 ft\",\"climb\":\"3200 ft\",\"weather\":\"snow\",\"effort\":\"hard\",\"runs\":\"average\",\"estimated\":[\"top_temp\"]}",
"phases": "[{\"phase\":\"start\",\"clock\":\"07:00\",\"moving\":true,\"air\":\"20 F\",\"wind\":\"15 mph\",\"feels_like\":\"6 F\",\"need\":3.1,\"page_wear\":[\"G1\",\"G2\",\"G3\",\"G6\",\"G7\",\"G4\",\"G5\"],\"page_warmth\":2.6,\"estimated\":[]},\"... 3 more\"]",
"closet": "[{\"id\":\"G1\",\"text\":\"Cotton thermal top\",\"slot\":\"base\",\"warmth\":0.6,\"grams\":180,\"materials\":[\"cotton\"],\"grams_estimated\":true},{\"id\":\"G2\",\"text\":\"Light fleece 100\",\"slot\":\"mid\",\"warmth\":1,\"grams\":330,\"materials\":[\"synthetic\"],\"grams_estimated\":true},\"... 5 more\"]",
"page_reads": "[{\"level\":\"conditions\",\"code\":\"extreme-cold\",\"text\":\"The top feels like -17 F with the wind - frostbite on exposed skin can come within minutes at that wind chill.\"},{\"level\":\"must\",\"code\":\"cotton\",\"text\":\"Cotton thermal top (G1) is cotton - it soaks up sweat and stays wet and cold. The draft only wears it because the closet has nothing else for that layer.\"},\"... 9 more\"]",
"page_verdict": "rethink",
"notes": "Above treeline for the last 1.5 miles. Forecast says gusts higher than the sustained wind."
}
Reply:
{
"task": "kit",
"title": "Winter Summit Push: Closet Comes Up Short",
"verdict": "rethink",
"summary": "This is a rethink: the closet has no insulated layer and nothing but cotton for the base layer, pants and socks, so even its warmest stack only reaches about 2.6 of the 8 warmth points the top stop calls for. The coldest moment of the day is the top stop at 10:51, feels like -17 F (estimated from the standard lapse rate) - cold enough that frostbite on exposed skin can start within minutes. The single biggest change: add a real insulated jacket and swap the cotton base layer, pants and socks for synthetic or wool versions, and treat treeline as the turnaround point if the forecast wind holds.",
"phases": [
{
"phase": "start",
"wear": [
"G1",
"G2",
"G3",
"G4",
"G5",
"G6",
"G7"
],
"why": "The trailhead already feels like 6 F and you're moving uphill within minutes, so the fleece and windproof softshell go on now over the base layer, with the hat and gloves too - not a heavier stack for standing at the car.",
"vent": "Expect to feel chilly for the first 10 to 15 minutes of the climb - that's this stack working as intended, not a reason to add more before you've warmed up."
},
{
"phase": "climb",
"wear": [
"G1",
"G2",
"G3",
"G4",
"G5",
"G6",
"G7"
],
"why": "The climb numbers are estimated by averaging the trailhead and top - 28 mph of wind dropping it to feels like -6 F - so the softshell stays zipped over the fleece, since the closet has no puffy to add.",
"vent": "The softshell has no vents to open, so if you're overheating on the steep sections ease the pace slightly rather than unzipping into a 28 mph wind."
},
"..."
],
"swaps": [
{
"out": "G1",
"in": "",
"why": "The cotton thermal top is the only base layer in the closet - it will hold sweat and chill the moment you warm up on the climb; wear it for this hike but replace it before a colder one."
},
{
"out": "G4",
"in": "",
"why": "The jeans are the only leg layer in the closet - cotton against the skin stays wet and cold all day, and there is nothing else here to put on instead."
},
"..."
],
"gaps": [
{
"slot": "puffy",
"item": "an insulated jacket or heavy midlayer",
"why": "the warmest stack this closet makes only reaches about 2.6 of the 8 warmth points the top stop needs, and 4.2-5.2 on the climb and descent - nothing in the closet closes that gap.",
"must": true
},
{
"slot": "base",
"item": "a synthetic or wool base layer",
"why": "the cotton thermal top is the only base layer in the closet and will stay wet and cold once you sweat.",
"must": true
},
"..."
],
"turn_back": "Turn back below treeline if the wind there is as hard to stand in as the forecast gusts, or if fingers, toes or face go numb and don't rewarm once you're moving again.",
"trail_tips": [
"Start moving in the full stack right away - don't stand at the trailhead trying to warm up first.",
"At the top, zip the shell fully and keep every task - food, water, layering - as quick as you can manage.",
"Turn around at treeline if the wind there matches the forecast gusts; nothing in this closet covers an extended exposed stretch at that wind chill.",
"..."
],
"caveats": [
"The top temperature is a page estimate from the standard lapse rate, not a direct forecast reading.",
"This is a clothing read only, not an avalanche, ice or trail-condition forecast - check recent trail reports for the snow and the above-treeline stretch.",
"..."
]
}
Build the input yourself
Load the script in node (global.window = {}; require("./layerkit.js")), then:
const K = LayerKit;
const st = K.state({
units: "us", name: "Saturday ridge", start: "08:30", hours: "5",
base_elev: "1200", top_elev: "3700", temp: "45", wind: "8",
top_temp: "", top_wind: "", // blank = estimated from the climb
precip: "none", effort: "steady", runs: "average",
closet: "Merino 150 long sleeve\nGrid fleece\nDown puffy jacket\nRain shell\nWool beanie\nLiner gloves"
});
const { input } = K.buildInput(st, { today: "2026-10-19" });
const wire = K.toWire(input); // POST this to /estimate and /run
// later: const reply = K.normalizeKit(K.parseReply(job.output.output));
// const { stacks, notes, kit, weights } = K.plan(reply, st);
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://start-cold.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://start-cold.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"]
// start-cold.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://start-cold.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://start-cold.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
}
// StartCold.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 StartCold {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://start-cold.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://start-cold.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://start-cold.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://start-cold.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":"start-cold"}' | 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": "start-cold"})["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: "start-cold" })).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": "start-cold"}, 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", "start-cold"), 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: "start-cold" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "start-cold"])["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 = "start-cold" }))!["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": "kit", "today": "2026-10-19", "units": "us", "hike": "{...}", "phases": "[...]", "closet": "[...]", "page_reads": "[...]", "page_verdict": "ready"}
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": "kit", "today": "2026-10-19", "units": "us", "hike": "{...}", "phases": "[...]", "closet": "[...]", "page_reads": "[...]", "page_verdict": "ready"}
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: "kit", today, units, hike, phases, closet, page_reads, page_verdict, notes?}
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 start-cold:kit:<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="start-cold:kit:$(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 = "start-cold:" + 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 = `start-cold:${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 := "start-cold:" + 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 = "start-cold:" + 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 = "start-cold:#{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["title"]].inspect
$key = "start-cold:" . $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["title"], "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"start-cold:{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: start-cold:kit:$(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": "kit", "today": "2026-10-19", "units": "us", "hike": "{...}", "phases": "[...]", "closet": "[...]", "page_reads": "[...]", "page_verdict": "ready"}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "start-cold:kit:" + 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": `start-cold:kit:${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", "start-cold:kit:"+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", "start-cold:kit:" + 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" => "start-cold:kit:#{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: start-cold:kit:" . 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", $"start-cold:kit:{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);