Drive Long Leg from your own code
The metered read is available over HTTP: send your flight legs, seat, cabin, age band, ticked clot-risk factors, medicines and notes, and get back a fly / adjust / clinician verdict with a plan for each leg (sleep, moves, water, meals), each medicine, the carry-on kit and the first day after landing. A natural use is a trip-planning or travel-journal app: your code sends the itinerary and the page's own reading of it, and gets back a structured plan.
The division of labour first: the page does the arithmetic; the model judges and writes; the page re-checks the reply against the trip. Reading the legs, the local clocks and layovers, the clock shift, the sleep windows on destination time, the walk and water estimates, the clot-risk tier and the medicine clock come from legkit.js; the run input and the reply check come from legreply.js. Both run in node as well as in the browser. check holds each reply to the trip: one plan per leg, every listed medicine answered and none invented, a clinician verdict when a major risk factor is ticked, a pharmacist pointer for a time-critical medicine across a big shift, and no internal field names in prose. UTC offsets are each airport's time zone on the travel date; sleep windows, walks and water are rules of thumb. Not medical advice.
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":"long-leg"}), 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 / legs 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 read 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
Every run sends task: "plan" - the one task. Verdicts: fly (low risk level, nothing worth changing), adjust (something should change first: a raised risk level, socks, seat, a tight connection, a time-critical medicine, a problem in the notes), clinician (a major clot-risk factor or a current symptom - see a clinician before flying; the plan still covers every leg and carries questions to ask). The declared input schema carries scalars only, so object-valued fields travel JSON-encoded as strings (LegKit.toWire does this). The schema requires task and legs.
Input fields
| Field | Type | Notes |
|---|---|---|
task, today | string | plan; YYYY-MM-DD. |
legs_text | string | The legs as typed, one a line: LHR 21:40 > DXB 6h55 2026-12-12. Up to 6 legs are planned. |
legs | string (JSON) | [{leg, from, from_utc, to, to_utc, date, depart_local, arrive_local, flight_time, layover_before, sleep_window_destination_time, walks_estimate, water_l_estimate}] - the sleep window is on the final destination's clock. |
trip | string (JSON) | {home, destination, clock_shift_h, air_time, door_to_door, longest_leg}; a positive shift is eastward. |
seat, cabin, age | string | aisle / window / middle; economy / premium / flat; under40 / 40to59 / 60plus. |
compression_socks | string | packed or not packed. |
risk_factors, risk_tier | string (JSON), string | The ticked clot-risk factors in plain words; low, raised or high (high = a major factor). |
meds | string (JSON) | [{name, as_written, home_times, destination_times, time_critical}]. |
page_flags, page_verdict | string (JSON), string | [{level, text}] with level fix / warn / info; fly, adjust or clinician. |
notes | string | The traveller's own words (may be empty; past 1,500 characters cut on whole lines with a marker). |
retry_note | string | Only on a reformat retry. |
The reply
The job's output.output is a string holding one JSON object: title, verdict, summary, risks [{factor, level, note}] (level low / raised / high), legs [{leg, sleep, moves, drink, eat}] (one per input leg), meds [{name, plan, note}] (one per input medicine), and kit, arrival and ask_clinician (strings). Parse it with LegKit.parseReply, then normalize(reply), then check(reply, state), which returns {notes}: the page's notes (info, warn) shown under "Checked by the page".
Worked examples
Replies produced against the app's prompt for the sample trips (made up; 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.
red-eye to London, nothing ticked - fly
Input:
{
"task": "plan",
"today": "2026-10-20",
"legs_text": "JFK 19:00 > LHR 7h00 2026-11-14",
"legs": "[{\"leg\":\"JFK-LHR\",\"from\":\"JFK\",\"from_utc\":\"UTC-5\",\"to\":\"LHR\",\"to_utc\":\"UTC+0\",\"date\":\"2026-11-14\",\"depart_local\":\"19:00\",\"arrive_local\":\"07:00 (+1 day)\",\"flight_time\":\"7h\",\"layover_before\":\"\",\"sleep_window_destination_time\":\"00:45-06:00\",\"walks_estimate\":1,\"water_l_estimate\":\"0.5\"}]",
"trip": "{\"home\":\"JFK\",\"destination\":\"LHR\",\"clock_shift_h\":5,\"air_time\":\"7h\",\"door_to_door\":\"7h\",\"longest_leg\":\"7h\"}",
"seat": "aisle",
"cabin": "economy",
"age": "under40",
"compression_socks": "not packed",
"risk_factors": "[]",
"risk_tier": "low",
"meds": "[]",
"page_flags": "[{\"level\":\"info\",\"text\":\"The clock moves 5 h east (later) - eastward shifts are usually the harder direction.\"}]",
"page_verdict": "fly",
"notes": "Landing for a work week. I'd like to sleep on the plane and be useful at a 2pm meeting the day I land."
}
Reply:
{
"task": "plan",
"title": "JFK to LHR overnight, 5-hour eastward shift",
"verdict": "fly",
"summary": "You're fine to fly as planned: your risk level is low, there's no page flag at a level that calls for a change, and the flight is a single 7-hour hop. The main thing to work with is the 5-hour eastward clock shift, which the page notes is usually the harder direction, so the plan below is built around sleeping through the destination night on board. The one practical gap to close before departure is that you haven't packed compression socks - worth picking up a pair for comfort and circulation on a flight this length, especially since you want to be sharp for your 2pm meeting the day you land.",
"risks": [
{
"factor": "Flight length (7h)",
"level": "low",
"note": "Even at a low risk level, a flight this length benefits from walking roughly every 90 minutes while awake and doing ankle circles and calf raises every half hour."
}
],
"legs": [
{
"leg": "JFK-LHR",
"sleep": "Try to sleep from 00:45 to 06:00 LHR time (estimate) - that's the destination's night on this leg, and since you want to be sharp for your 2pm meeting the day you land, use this window for your main sleep on board.",
"moves": "You're in an aisle seat, so getting up is easy. The page's estimate is about one walk down the aisle on this leg - take it before you settle in for the 00:45-06:00 sleep window, and do ankle circles and calf raises every 30 minutes while you're awake.",
"drink": "The page's estimate is about 0.5 litres of water for this leg - sip it steadily rather than all at once. Skip alcohol and any sleeping aids, since they make long, still sleep more likely than natural rest, and avoid caffeine after your evening meal so it doesn't work against your sleep window.",
"eat": "If a meal is served soon after take-off, treat it as your evening meal and wind down afterwards for the 00:45 sleep window. If anything is offered close to the 07:00 (+1 day) landing, have a light bite to mark the start of the day on LHR time; otherwise bring a light snack of your own for then."
}
],
"meds": [],
"kit": [
"Comfortable layers, since cabin temperature swings through the night",
"Eye mask and earplugs or noise-cancelling headphones to help you fall asleep during the 00:45-06:00 window",
"A neck or travel pillow for sleeping upright in economy",
"... 3 more"
],
"arrival": [
"Get outside into daylight as soon as you can after landing at 07:00 (+1 day) - this is the best way to help your body treat today as the start of a new day on LHR time",
"Eat breakfast and lunch on LHR time even if you're not hungry, to pull your body clock onto the new schedule before your 2pm meeting",
"... 3 more"
],
"ask_clinician": []
}
two legs to Sydney, age 60+, varicose veins - adjust
Input:
{
"task": "plan",
"today": "2026-10-20",
"legs_text": "LHR 21:40 > DXB 6h55 2026-12-12\nDXB 09:45 > SYD 13h50",
"legs": "[{\"leg\":\"LHR-DXB\",\"from\":\"LHR\",\"from_utc\":\"UTC+0\",\"to\":\"DXB\",\"to_utc\":\"UTC+4\",\"date\":\"2026-12-12\",\"depart_local\":\"21:40\",\"arrive_local\":\"08:35 (+1 day)\",\"flight_time\":\"6h 55m\",\"layover_before\":\"\",\"sleep_window_destination_time\":\"\",\"walks_estimate\":4,\"water_l_estimate\":\"1.8\"},{\"leg\":\"DXB-SYD\",\"from\":\"DXB\",\"from_utc\":\"UTC+4\",\"to\":\"SYD\",\"to_utc\":\"UTC+11\",\"date\":\"\",\"depart_local\":\"09:45\",\"arrive_local\":\"06:35 (+1 day)\",\"flight_time\":\"13h 50m\",\"layover_before\":\"1h 10m\",\"sleep_window_destination_time\":\"22:00-05:35\",\"walks_estimate\":4,\"water_l_estimate\":\"1.6\"}]",
"trip": "{\"home\":\"LHR\",\"destination\":\"SYD\",\"clock_shift_h\":11,\"air_time\":\"20h 45m\",\"door_to_door\":\"21h 55m\",\"longest_leg\":\"13h 50m\"}",
"seat": "window",
"cabin": "economy",
"age": "60plus",
"compression_socks": "not packed",
"risk_factors": "[\"varicose veins\",\"age 60 or over\"]",
"risk_tier": "raised",
"meds": "[{\"name\":\"Metformin 500 mg\",\"as_written\":\"Metformin 500 mg 08:00 and 20:00\",\"home_times\":[\"08:00\",\"20:00\"],\"destination_times\":[\"19:00\",\"07:00\"],\"time_critical\":false},{\"name\":\"Amlodipine 5 mg\",\"as_written\":\"Amlodipine 5 mg 08:00\",\"home_times\":[\"08:00\"],\"destination_times\":[\"19:00\"],\"time_critical\":false}]",
"page_flags": "[{\"level\":\"warn\",\"text\":\"Clot-risk factors on a long flight: varicose veins, age 60 or over.\"},{\"level\":\"warn\",\"text\":\"No compression socks packed - below-knee graduated socks are the usual advice for a long flight with clot-risk factors.\"},\"... 2 more\"]",
"page_verdict": "adjust",
"notes": "Visiting my daughter for Christmas. Last long flight my ankles swelled badly and I barely slept. I'd rather no ... (130 characters)"
}
Reply:
{
"task": "plan",
"title": "LHR to SYD via DXB, two long legs",
"verdict": "adjust",
"summary": "This trip can work, but a few things should change before you fly rather than staying as currently booked. Your clot-risk level is raised by your varicose veins and your age, made more relevant by the 13h 50m DXB-SYD leg, and you don't have compression socks packed yet; your notes also mention swollen ankles and poor sleep on your last long flight. The most important change is to get a pair of below-knee compression socks and plan around your window seat on the long leg - let your neighbours know early or see if an aisle seat opens up.",
"risks": [
{
"factor": "varicose veins",
"level": "raised",
"note": "Walk and do ankle circles regularly on both legs of this trip, and wear below-knee compression socks once you have a pair, since varicose veins raise the chance of leg swelling on a long flight."
},
{
"factor": "age 60 or over",
"level": "raised",
"note": "Stand, stretch and move every hour or so you're awake, and keep well hydrated - risk rises a little with age on long flights."
},
"... 1 more"
],
"legs": [
{
"leg": "LHR-DXB",
"sleep": "No part of this leg falls in SYD's night, so don't try to force sleep - rest with naps under 30 minutes if you need to, since by the SYD clock this whole leg is daytime.",
"moves": "Plan for about 4 short walks to the galley or toilet (the page's estimate, roughly every 90 minutes you're awake), plus ankle circles and calf raises in your seat every half hour. You're in a window seat, so give your neighbours a heads-up early that you'll be getting up.",
"drink": "About 1.8 litres of water (the page's estimate) over this leg; skip alcohol so you don't end up in a heavy seated sleep, and keep any caffeine to earlier in the flight.",
"eat": "This leg lands entirely in SYD's daytime, so if a meal is served, treat it as a lighter daytime meal rather than a big late dinner - that helps your body start shifting towards SYD time. If nothing is served, bring your own light snack instead."
},
{
"leg": "DXB-SYD",
"sleep": "Aim to actually sleep from 22:00 to 05:35 SYD time (estimate) - that's your best stretch for real sleep before landing. Settle in with an eye mask and earplugs around then.",
"moves": "About 4 walks (the page's estimate) spread across the time you're awake before and after that sleep window, plus ankle circles and calf raises every half hour. You're in a window seat on this 13h 50m leg, so ask your neighbours or the crew in advance so you're not climbing over anyone, or see if an aisle seat has opened up at check-in.",
"drink": "About 1.6 litres of water (the page's estimate) over this leg; skip alcohol so your sleep window is real sleep, and keep any caffeine to before early afternoon on the SYD clock, well clear of your sleep window.",
"eat": "If a meal comes before your sleep window, eat lightly so you settle faster. If breakfast is offered as you approach landing around 06:35 SYD time, eat it to line up with SYD's morning; otherwise bring your own light breakfast snack."
}
],
"meds": [
{
"name": "Metformin 500 mg",
"plan": "Keep taking it at your usual home times (08:00 and 20:00) while you're still effectively on home time during travel, then move to the SYD clock once you're settled there - about 19:00 and 07:00 SYD time. Check with your pharmacist if you're unsure how to handle the dose that falls during the flight itself.",
"note": "Don't double up if two doses end up close together across the time change - check with your pharmacist beforehand if unsure."
},
{
"name": "Amlodipine 5 mg",
"plan": "Take it at your usual home time (08:00) while travelling, then shift to the SYD time of about 19:00 once you're on SYD's clock. Check with your pharmacist if unsure how to manage the shift.",
"note": ""
}
],
"kit": [
"Below-knee graduated compression socks - get a pair before you go, since none are packed yet",
"A refillable water bottle to fill after security, to help hit the water estimates for each leg",
"Both medicines (Metformin and Amlodipine) plus a few spare days' supply, in original packaging, in your carry-on",
"... 3 more"
],
"arrival": [
"Get outside into morning light as soon as you can after landing around 06:35 - that helps your body read it as SYD morning.",
"Stay awake through the day; if you must nap, keep it under 30 minutes and before mid-afternoon.",
"... 3 more"
],
"ask_clinician": [
"Which class of below-knee compression sock should I get, given my varicose veins?",
"Would you suggest any change to how I split my Metformin and Amlodipine doses across the 11-hour shift to SYD?"
]
}
knee surgery three weeks ago, 16-hour leg - clinician
Input:
{
"task": "plan",
"today": "2026-10-20",
"legs_text": "SFO 23:15 > SIN 16h40 2026-11-20",
"legs": "[{\"leg\":\"SFO-SIN\",\"from\":\"SFO\",\"from_utc\":\"UTC-8\",\"to\":\"SIN\",\"to_utc\":\"UTC+8\",\"date\":\"2026-11-20\",\"depart_local\":\"23:15\",\"arrive_local\":\"07:55 (+2 day)\",\"flight_time\":\"16h 40m\",\"layover_before\":\"\",\"sleep_window_destination_time\":\"22:00-06:55\",\"walks_estimate\":5,\"water_l_estimate\":\"1.9\"}]",
"trip": "{\"home\":\"SFO\",\"destination\":\"SIN\",\"clock_shift_h\":-8,\"air_time\":\"16h 40m\",\"door_to_door\":\"16h 40m\",\"longest_leg\":\"16h 40m\"}",
"seat": "middle",
"cabin": "economy",
"age": "40to59",
"compression_socks": "not packed",
"risk_factors": "[\"surgery, a leg injury or a cast in the last 3 months\",\"an estrogen pill or HRT\"]",
"risk_tier": "high",
"meds": "[{\"name\":\"Combined contraceptive pill\",\"as_written\":\"Combined contraceptive pill 07:30\",\"home_times\":[\"07:30\"],\"destination_times\":[\"23:30\"],\"time_critical\":true}]",
"page_flags": "[{\"level\":\"fix\",\"text\":\"You ticked surgery, a leg injury or a cast in the last 3 months - with a flight over 4 hours that is a reason to ask a clinician before you fly.\"},{\"level\":\"warn\",\"text\":\"Clot-risk factors on a long flight: an estrogen pill or HRT.\"},\"... 4 more\"]",
"page_verdict": "clinician",
"notes": "Knee arthroscopy three weeks ago, walking fine now. Work trip, presenting the morning after I land."
}
Reply:
{
"task": "plan",
"title": "SFO to SIN, one long leg",
"verdict": "clinician",
"summary": "See a clinician before you fly: surgery within the last 3 months plus a 16h 40m flight needs clearing first, and your notes describe a knee arthroscopy three weeks ago. You're also on an estrogen pill, which adds to the clot risk, and the 8-hour clock change means your combined contraceptive pill needs a pharmacist or prescriber's shift plan. Once a clinician clears you to fly, ask for an aisle seat and pack compression socks - the plan below covers the leg, your medicine and your first day at SIN.",
"risks": [
{
"factor": "surgery, a leg injury or a cast in the last 3 months",
"level": "high",
"note": "Ask a clinician before flying to confirm a 16h 40m flight is safe this soon after your knee arthroscopy."
},
{
"factor": "an estrogen pill or HRT",
"level": "raised",
"note": "This adds to your clot risk on a long flight - keep moving, hydrate, and wear compression socks once you have them."
},
"... 1 more"
],
"legs": [
{
"leg": "SFO-SIN",
"sleep": "Try to sleep from about 22:00 to 06:55 on SIN's clock (estimate) - that's the destination's night on this leg, so use it to get ahead of the 8-hour shift.",
"moves": "With a middle seat, plan on roughly 5 walks to the aisle over the flight (estimate) - about one every couple of hours you're awake - plus ankle circles and calf raises every 30 minutes at your seat, which matters more given your recent knee surgery.",
"drink": "Aim for about 1.9 litres of water over the flight (estimate). Skip alcohol - it makes long still sleep more likely and adds to clot risk - and keep coffee to before SIN's early afternoon once you land.",
"eat": "If a meal service falls in your 22:00-06:55 sleep window (estimate), skip it or save it and sleep instead; have something light close to landing at 07:55 (+2 day) to start eating on SIN's clock, or use your own snacks if nothing's served."
}
],
"meds": [
{
"name": "Combined contraceptive pill",
"plan": "This is a time-critical medicine and the clock moves 8 hours on this trip, so ask your pharmacist or prescriber for a shift plan before you fly rather than changing the dose or timing yourself. Your home time is 07:30; the same slot on SIN's clock is 23:30, which your pharmacist can help you step onto.",
"note": "Don't change the dose or skip a pill without medical advice."
}
],
"kit": [
"Below-knee graduated compression socks - not yet packed, worth getting before you go given your risk factors.",
"Your contraceptive pills plus a few spare days' supply, in your carry-on rather than checked baggage.",
"A refillable water bottle to fill after security for the roughly 1.9 litres (estimate) on board.",
"... 3 more"
],
"arrival": [
"After landing at 07:55 (+2 day), get outside into daylight to help you stay awake and shift your body clock the 8 hours.",
"Keep caffeine to the morning only and avoid naps longer than about 30 minutes during the day.",
"... 3 more"
],
"ask_clinician": [
"Is it safe for me to take a 16h 40m flight three weeks after my knee arthroscopy?",
"Given my recent knee surgery and being on the combined contraceptive pill, do I need extra clot-prevention measures for this flight?",
"... 2 more"
]
}
Build the input yourself
Load both scripts in node (global.window = {}; require("./legkit.js"); require("./legreply.js")), then:
const K = LegKit;
const st = K.state({ legs: "LHR 21:40 > DXB 6h55 2026-12-12\nDXB 09:45 > SYD 13h50", seat: "window", cabin: "economy", age: "60plus",
risks: "veins", meds: "Metformin 500 mg 08:00 and 20:00", notes: "", socks: "no" });
console.log(st.legs.map(l => [l.from.code, l.to.code, K.hm(l.arrClock), l.plan]), st.shift, st.tier, st.flags, st.verdict);
const wire = K.toWire(K.buildInput(st, { today: "2026-10-20" }).input); // POST this to /estimate and /run
// later: const reply = K.normalize(K.parseReply(job.output.output));
// const { notes } = K.check(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://long-leg.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://long-leg.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"]
// long-leg.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://long-leg.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://long-leg.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
}
// LongLeg.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 LongLeg {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://long-leg.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://long-leg.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://long-leg.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://long-leg.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":"long-leg"}' | 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": "long-leg"})["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: "long-leg" })).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": "long-leg"}, 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", "long-leg"), 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: "long-leg" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "long-leg"])["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 = "long-leg" }))!["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": "plan", "today": "2026-10-20", "legs_text": "LHR 21:40 > DXB 6h55 2026-12-12\nDXB 09:45 > SYD 13h50", "legs": "[...]", "trip": "{...}", "seat": "window", "cabin": "economy", "age": "60plus", "compression_socks": "not packed", "risk_factors": "[...]", "risk_tier": "raised", "meds": "[...]", "page_flags": "[...]", "page_verdict": "adjust", "notes": "Visiting my daughter for Christmas ..."}
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": "plan", "today": "2026-10-20", "legs_text": "LHR 21:40 > DXB 6h55 2026-12-12\nDXB 09:45 > SYD 13h50", "legs": "[...]", "trip": "{...}", "seat": "window", "cabin": "economy", "age": "60plus", "compression_socks": "not packed", "risk_factors": "[...]", "risk_tier": "raised", "meds": "[...]", "page_flags": "[...]", "page_verdict": "adjust", "notes": "Visiting my daughter for Christmas ..."}
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: "plan", today, legs_text, legs, trip, seat, cabin, age, compression_socks, risk_factors, risk_tier, meds, page_flags, page_verdict, notes (see the field table)}
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 task and the input: a retry with the same key never bills twice. The page uses long-leg:plan:<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="long-leg:plan:$(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 = "long-leg:" + 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 reply's JSON object
print(j["charged_credits"], j.get("truncated"), reply.get("verdict"))
import { createHash } from "node:crypto";
const key = `long-leg:${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 := "long-leg:" + 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 = "long-leg:" + 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 = "long-leg:#{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 = "long-leg:" . $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 = $"long-leg:{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: long-leg:plan:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json
payload = json.load(open("input.json")) # the same run input as input.json: {"task": "plan", "today": "2026-10-20", "legs_text": "LHR 21:40 > DXB 6h55 2026-12-12\nDXB 09:45 > SYD 13h50", "legs": "[...]", "trip": "{...}", "seat": "window", "cabin": "economy", "age": "60plus", "compression_socks": "not packed", "risk_factors": "[...]", "risk_tier": "raised", "meds": "[...]", "page_flags": "[...]", "page_verdict": "adjust", "notes": "Visiting my daughter for Christmas ..."}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "long-leg:plan:" + hashlib.sha256(json.dumps(payload).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 payload = JSON.parse(readFileSync("input.json", "utf8"));
const r = await fetch(BASE + "/run-stream", { method: "POST", body: JSON.stringify(payload), headers: {
"Content-Type": "application/json", Authorization: "Bearer " + TOKEN,
"Idempotency-Key": `long-leg:plan:${createHash("sha256").update(JSON.stringify(payload)).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", "long-leg:plan:"+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 payload = java.nio.file.Files.readString(java.nio.file.Path.of("input.json"));
var rd = java.security.MessageDigest.getInstance("SHA-256").digest(payload.getBytes());
var sreq = HttpRequest.newBuilder(URI.create(BASE + "/run-stream")).header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token).header("Idempotency-Key", "long-leg:plan:" + HexFormat.of().formatHex(rd, 0, 8) + ":a1")
.POST(HttpRequest.BodyPublishers.ofString(payload)).build();
C.send(sreq, HttpResponse.BodyHandlers.ofLines()).body().filter(l -> l.startsWith("data:")).forEach(System.out::println);
payload = File.read("input.json")
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{TOKEN}",
"Idempotency-Key" => "long-leg:plan:#{Digest::SHA256.hexdigest(payload)[0, 16]}:a1")
req.body = payload
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
$payload = file_get_contents("input.json");
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $payload, CURLOPT_HTTPHEADER => [
"Content-Type: application/json", "Authorization: Bearer $TOKEN",
"Idempotency-Key: long-leg:plan:" . substr(hash("sha256", $payload), 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 payload = File.ReadAllText("input.json");
var rd = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(payload));
var sreq = new HttpRequestMessage(HttpMethod.Post, Api.Base + "/run-stream") { Content = new StringContent(payload, System.Text.Encoding.UTF8, "application/json") };
sreq.Headers.Add("Authorization", "Bearer " + Api.Token);
sreq.Headers.Add("Idempotency-Key", $"long-leg:plan:{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);