Drive Seven Kilos from your own code
The one metered step - the gate check of one traveller's carry-on packing list - is available over HTTP. A natural use is a packing-list spreadsheet or a trip-planning tool: your code sends the list with the page's weigh-in, and gets back the moves (what to wear on the plane, what to cut, swap, move between bags, check or ship), a rewear plan for the days, next steps and a fits / trim / check-a-bag verdict.
The split first: the page weighs; the model judges; the page re-weighs the model's moves. Reading the list, the bag totals against each limit, the wear moves, the clothing counts and the liquid and battery flags come from packkit.js, which runs in node as well as in the browser. Its check function then applies the reply's moves to the bags itself: every move must name a packed line and quote it as written, wearing shoes or a coat puts the pair you planned to wear in the bag, a swap only counts when its estimated weight is lighter, and a "fits" or "trim" verdict that still leaves a bag over is flagged.
Get a token on the token page (step 1 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":"seven-kilos"}), 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 gate check 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, weigh. It returns title, verdict, summary; moves [{item, action, qty, to, says, why, swap_for, swap_g}] (0-12, most weight saved first); rewear (2-5 lines); next_steps (3-5); caveats (0-3).
| Verdict | Meaning |
|---|---|
fits | Every allowance already has 300 g or more to spare and no liquid gift is in a cabin bag. Moves are optional. |
trim | Over, within 300 g of a limit, or a liquid gift to handle - and wearing, cutting, swapping and moving bring every allowance under its limit without a checked bag. |
check | Even after sensible wearing and cutting (the page's wear moves leave more than 1 kg over) the trip needs a checked bag or shipping: the moves name what goes in the hold or gets shipped. |
| Action | What the page does with it |
|---|---|
wear | One piece leaves its bag. For shoes or a coat, the pair or coat already listed as worn goes into that bag instead. |
cut | qty of the line leave the bag (default: the whole line). |
swap | Each of qty pieces weighs swap_g instead - only when swap_g is lighter. swap_g is the model's estimate. |
move | Moves to the bag named in to (cabin or personal). Changes nothing under a combined allowance. |
check / ship | Leaves the cabin bags. A spare battery sent to check is flagged. |
The declared input schema carries scalars only, so object-valued fields travel JSON-encoded as strings (PackKit.toWire does this). Only task is required by the schema.
Input fields
| Field | Type | Notes |
|---|---|---|
task | string | Always weigh. |
today | string | YYYY-MM-DD. |
packing | string | The packing list as pasted. Past 6,000 characters the middle is cut on whole lines around a marker. |
facts | string (JSON) | {trip, nights, days, nights_assumed, laundry, mode, cabin_limit_kg, personal_limit_kg, limit_assumed, cabin_tare_kg, personal_tare_kg, cabin_tare_assumed, personal_tare_assumed, bags{cabin, personal, worn}, checks[{id, label, total_g, limit_g, over_g}], worst_over_g, items_n, units, packed_g, by_kind, heaviest, wear_moves[{item, from, save_g, swap_back}], wear_save_g, after_wear_over_g, clothing{tops, bottoms, dresses, underwear, socks, shoes_pairs}, liquids, gift_liquids, batteries}. over_g above 0 is over the limit; below 0 is grams to spare. |
items | string (JSON) | [{item, unit_g, qty, total_g, bag, kind, liquid, battery}]; bag is cabin, personal or worn. |
page_reads | string (JSON) | [{level, code, text}]; level is fix or info. |
page_verdict | string | The page's own reading: fits, trim or check. |
notes | string | Optional, the traveller's own comments; past 600 characters the middle is cut. |
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 PackKit.parseReply, then normalize, then check it with check(reply, state), which returns {rows, applied, notes, totals, after, saved_g}: one row per move with the grams it saves as the page counts them (or why it was not counted), the bag totals and allowance checks after the moves, and the notes (ok, info, warn, bad) the page shows under "Re-weighed by the page".
Worked examples
Replies produced against the app's prompt for the sample bags (made-up trips and weights; 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: weigh - City weekend - fits
Input:
{
"task": "weigh",
"today": "2026-10-20",
"packing": "Merino T-shirt | 140 g | 2 | cabin\nChinos | 450 g | 1 | cabin\nSweater | 380 g | 1 | cabin\nUnderwear | 60 g | 3 | cabin\nSocks | 50 g | 3 | cabin\nToiletry bag, travel sizes | 650 g | 1 | cabin\nJeans | 6 ... (410 characters)",
"facts": "{\"trip\":\"Long weekend at a friend's flat\",\"nights\":2,\"days\":3,\"nights_assumed\":false,\"laundry\":false,\"mode\":\"separate\",\"cabin_limit_kg\":7,\"personal_limit_kg\":null,\"limit_assumed\":false,\"cabin_tare_kg\":1.6,\"personal_tare_kg\":0.4,\"cabin_tare_assumed\":false,\"personal_tare_assumed\":false,\"bags\":{\"cabin\":{\"items_g\":2090,\"total_g\":3690},\"personal\":{\"items_g\":2130,\"total_g\":2530},\"worn\":{\"total_g\":1650}},\"checks\":[{\"id\":\"cabin\",\"label\":\"Cabin bag\",\"total_g\":3690,\"limit_g\":7000,\"over_g\":-3310}],\"worst_over_g\":-3310,\"items_n\":13,\"units\":18,\"packed_g\":6220,\"by_kind\":{\"clothes\":1440,\"outer\":0,\"shoes\":0,\"toiletries\":650,\"tech\":1850,\"gifts\":0,\"other\":280},\"heaviest\":[{\"item\":\"Laptop\",\"total_g\":1300,\"bag\":\"personal\"},{\"item\":\"Toiletry bag, travel sizes\",\"total_g\":650,\"bag\":\"cabin\"},{\"item\":\"Chinos\",\"total_g\":450,\"bag\":\"cabin\"},{\"item\":\"Sweater\",\"total_g\":380,\"bag\":\"cabin\"},{\"item\":\"Laptop charger\",\"total_g\":300,\"bag\":\"personal\"}],\"wear_moves\":[{\"item\":\"Sweater\",\"from\":\"cabin\",\"save_g\":380,\"swap_back\":null}],\"wear_save_g\":380,\"after_wear_over_g\":-3690,\"clothing\":{\"tops\":3,\"bottoms\":2,\"dresses\":0,\"underwear\":3,\"socks\":3,\"shoes_pairs\":1},\"liquids\":[],\"gift_liquids\":[],\"batteries\":[{\"item\":\"Power bank\",\"bag\":\"personal\"}]}",
"items": "[{\"item\":\"Merino T-shirt\",\"unit_g\":140,\"qty\":2,\"total_g\":280,\"bag\":\"cabin\",\"kind\":\"clothes\",\"liquid\":false,\"battery\":false},{\"item\":\"Chinos\",\"unit_g\":450,\"qty\":1,\"total_g\":450,\"bag\":\"cabin\",\"kind\":\"clothes\",\"liquid\":false,\"battery\":false},\"... 11 more\"]",
"page_reads": "[{\"level\":\"info\",\"code\":\"personal_unchecked\",\"text\":\"No personal-item limit entered - it is totalled, not checked.\"}]",
"page_verdict": "fits",
"notes": ""
}
Reply:
{
"task": "weigh",
"title": "A weekend carry-on with room to spare",
"verdict": "fits",
"summary": "This cabin bag already fits: 3.69 kg packed against the 7 kg limit, leaving 3.31 kg to spare, and there are no liquid gifts to route around security. The personal item adds 2.53 kg of its own (unchecked, since no personal-item limit was entered), with the spare battery already riding there instead of in the cabin bag. No changes are needed - three tops and two bottoms already suit a three-day trip with no laundry. The biggest reason it travels this light: the jeans, sneakers and rain jacket are all worn, not packed.",
"moves": [],
"rewear": [
"Day 1 (travel): jeans, sneakers and the rain jacket (all worn) with a merino T-shirt",
"Day 2: chinos with the second merino T-shirt, sweater layered on top",
"Day 3: back to the worn jeans with the sweater; fresh underwear and socks each day from the packed three"
],
"next_steps": [
"Weigh the packed cabin bag at home - it should read close to 3.69 kg, well inside the 7 kg limit.",
"Wear the jeans, sneakers and rain jacket to the airport, as planned.",
"Keep the power bank in the personal item, not the cabin bag, in case either is weighed separately at the gate.",
"..."
],
"caveats": [
"The personal item's 2.53 kg hasn't been checked against a limit - only the cabin bag was entered.",
"Gate scales and exact allowances can vary by airline and fare, even though this list already clears the 7 kg cabin limit with room to spare."
]
}
task: weigh - Thanksgiving, 4 nights - trim
Input:
{
"task": "weigh",
"today": "2026-10-20",
"packing": "Winter boots | 1150 g | 1 | cabin\nPuffer jacket | 800 g | 1 | cabin\nSweater | 420 g | 2 | cabin\nT-shirt | 160 g | 4 | cabin\nJeans | 650 g | 2 | cabin\nPajamas | 350 g | 1 | cabin\nUnderwear | 60 g | 5 | ... (526 characters)",
"facts": "{\"trip\":\"Thanksgiving at my parents'\",\"nights\":4,\"days\":5,\"nights_assumed\":false,\"laundry\":true,\"mode\":\"separate\",\"cabin_limit_kg\":7,\"personal_limit_kg\":4,\"limit_assumed\":false,\"cabin_tare_kg\":2.4,\"personal_tare_kg\":0.6,\"cabin_tare_assumed\":false,\"personal_tare_assumed\":false,\"bags\":{\"cabin\":{\"items_g\":6905,\"total_g\":9305},\"personal\":{\"items_g\":1780,\"total_g\":2380},\"worn\":{\"total_g\":1250}},\"checks\":[{\"id\":\"cabin\",\"label\":\"Cabin bag\",\"total_g\":9305,\"limit_g\":7000,\"over_g\":2305},{\"id\":\"personal\",\"label\":\"Personal item\",\"total_g\":2380,\"limit_g\":4000,\"over_g\":-1620}],\"worst_over_g\":2305,\"items_n\":17,\"units\":30,\"packed_g\":11685,\"by_kind\":{\"clothes\":3705,\"outer\":800,\"shoes\":1150,\"toiletries\":1250,\"tech\":1030,\"gifts\":0,\"other\":750},\"heaviest\":[{\"item\":\"Jeans\",\"total_g\":1300,\"bag\":\"cabin\"},{\"item\":\"Winter boots\",\"total_g\":1150,\"bag\":\"cabin\"},{\"item\":\"Sweater\",\"total_g\":840,\"bag\":\"cabin\"},{\"item\":\"Puffer jacket\",\"total_g\":800,\"bag\":\"cabin\"},{\"item\":\"Toiletries\",\"total_g\":700,\"bag\":\"cabin\"}],\"wear_moves\":[{\"item\":\"Winter boots\",\"from\":\"cabin\",\"save_g\":450,\"swap_back\":\"Sneakers\"},{\"item\":\"Puffer jacket\",\"from\":\"cabin\",\"save_g\":800,\"swap_back\":null},{\"item\":\"Jeans\",\"from\":\"cabin\",\"save_g\":650,\"swap_back\":null}],\"wear_save_g\":1900,\"after_wear_over_g\":405,\"clothing\":{\"tops\":7,\"bottoms\":2,\"dresses\":0,\"underwear\":5,\"socks\":5,\"shoes_pairs\":2},\"liquids\":[],\"gift_liquids\":[],\"batteries\":[{\"item\":\"Power bank\",\"bag\":\"personal\"}]}",
"items": "[{\"item\":\"Winter boots\",\"unit_g\":1150,\"qty\":1,\"total_g\":1150,\"bag\":\"cabin\",\"kind\":\"shoes\",\"liquid\":false,\"battery\":false},{\"item\":\"Puffer jacket\",\"unit_g\":800,\"qty\":1,\"total_g\":800,\"bag\":\"cabin\",\"kind\":\"outer\",\"liquid\":false,\"battery\":false},\"... 15 more\"]",
"page_reads": "[{\"level\":\"fix\",\"code\":\"over\",\"text\":\"Cabin bag weighs 9.31 kg against a 7.00 kg limit - 2.31 kg over.\"},{\"level\":\"info\",\"code\":\"wear\",\"text\":\"Wearing Winter boots (instead of Sneakers), Puffer jacket and Jeans on the plane takes 1.90 kg off the bags - still 0.41 kg over after that.\"},\"... 1 more\"]",
"page_verdict": "trim",
"notes": "They have a washer. It might snow, so I want the boots there."
}
Reply:
{
"task": "weigh",
"title": "Trimming the Snowy Thanksgiving Carry-On",
"verdict": "trim",
"summary": "Trim: the cabin bag is 9.31 kg against a 7.00 kg limit (2.31 kg over), while the personal item already has room to spare at 2.38 kg against a 4.00 kg limit. Wearing the puffer jacket (800 g), one pair of jeans (650 g) and the winter boots (1150 g, with the 700 g sneakers going into the bag instead for a net 450 g) on the plane saves 1.90 kg but still leaves the cabin bag 0.41 kg over. Cutting 2 of the 4 T-shirts (320 g, trimming 7 tops to the roughly 5 that 5 days with a washer need) and swapping the 700 g toiletries bag for a roughly 300 g travel-size kit (400 g) bring the cabin bag down to about 6.69 kg, 0.32 kg under the limit. The single most useful change is wearing the puffer jacket and boots instead of packing them.",
"moves": [
{
"item": "Puffer jacket",
"action": "wear",
"qty": 1,
"to": "",
"says": "Puffer jacket | 800 g | 1 | cabin",
"why": "The puffer jacket is 800 g; wearing it on the plane takes the full weight out of the cabin bag.",
"swap_for": "",
"swap_g": null
},
{
"item": "Jeans",
"action": "wear",
"qty": 1,
"to": "",
"says": "Jeans | 650 g | 2 | cabin",
"why": "One pair of jeans is 650 g; wearing it on the plane instead of packing both pairs saves that weight straight out of the cabin bag.",
"swap_for": "",
"swap_g": null
},
{
"item": "Winter boots",
"action": "wear",
"qty": 1,
"to": "",
"says": "Winter boots | 1150 g | 1 | cabin",
"why": "The 1150 g winter boots go on your feet instead of the bag, and the sneakers you'd planned to wear (700 g) go in instead - a net 450 g off the cabin bag, and the boots are with you for the snow.",
"swap_for": "",
"swap_g": null
},
"..."
],
"rewear": [
"Day 1 (flight day): hoodie, the jeans you're wearing, puffer jacket on top, boots on your feet.",
"Days 2-3: a sweater or T-shirt with the packed jeans, swapping the top each day.",
"Day 4: wash with the washer on hand and repeat day 1's hoodie and jeans.",
"..."
],
"next_steps": [
"Pick up or pack the travel-size toiletries kit in place of the full-size one, and leave 2 T-shirts at home.",
"Lay out the wear-on-plane outfit the night before: puffer jacket, one pair of jeans, winter boots, and the hoodie.",
"Keep the power bank in the personal item, not the cabin bag, in case the cabin bag gets gate-checked.",
"..."
],
"caveats": [
"The travel-size toiletries kit's weight (about 300 g) is an estimate - check it against what you actually pack.",
"Gate scales and cabin-bag rules vary by airline and fare, so treat this as a guide, not a guarantee."
]
}
task: weigh - Winter holidays with gifts - check
Input:
{
"task": "weigh",
"today": "2026-10-20",
"packing": "Wrapped board game for the kids | 1400 g | 1 | cabin\nGift bottle of maple syrup | 500 g | 1 | cabin\nGift sweater for Dad | 600 g | 1 | cabin\nSnow boots | 1300 g | 1 | cabin\nSweater | 420 g | 3 | cabin ... (555 characters)",
"facts": "{\"trip\":\"Winter holidays with family\",\"nights\":9,\"days\":10,\"nights_assumed\":false,\"laundry\":true,\"mode\":\"combined\",\"cabin_limit_kg\":7,\"personal_limit_kg\":null,\"limit_assumed\":false,\"cabin_tare_kg\":2.2,\"personal_tare_kg\":0.8,\"cabin_tare_assumed\":false,\"personal_tare_assumed\":false,\"bags\":{\"cabin\":{\"items_g\":9110,\"total_g\":11310},\"personal\":{\"items_g\":2850,\"total_g\":3650},\"worn\":{\"total_g\":2100}},\"checks\":[{\"id\":\"both\",\"label\":\"Cabin bag + personal item\",\"total_g\":14960,\"limit_g\":7000,\"over_g\":7960}],\"worst_over_g\":7960,\"items_n\":17,\"units\":36,\"packed_g\":14960,\"by_kind\":{\"clothes\":4510,\"outer\":0,\"shoes\":1300,\"toiletries\":800,\"tech\":2850,\"gifts\":2500,\"other\":0},\"heaviest\":[{\"item\":\"Laptop\",\"total_g\":1500,\"bag\":\"personal\"},{\"item\":\"Wrapped board game for the kids\",\"total_g\":1400,\"bag\":\"cabin\"},{\"item\":\"Snow boots\",\"total_g\":1300,\"bag\":\"cabin\"},{\"item\":\"Jeans\",\"total_g\":1300,\"bag\":\"cabin\"},{\"item\":\"Sweater\",\"total_g\":1260,\"bag\":\"cabin\"}],\"wear_moves\":[{\"item\":\"Snow boots\",\"from\":\"cabin\",\"save_g\":600,\"swap_back\":\"Sneakers\"},{\"item\":\"Jeans\",\"from\":\"cabin\",\"save_g\":650,\"swap_back\":null}],\"wear_save_g\":1250,\"after_wear_over_g\":6710,\"clothing\":{\"tops\":8,\"bottoms\":2,\"dresses\":0,\"underwear\":7,\"socks\":7,\"shoes_pairs\":2},\"liquids\":[\"Gift bottle of maple syrup\"],\"gift_liquids\":[\"Gift bottle of maple syrup\"],\"batteries\":[{\"item\":\"Power bank\",\"bag\":\"personal\"}]}",
"items": "[{\"item\":\"Wrapped board game for the kids\",\"unit_g\":1400,\"qty\":1,\"total_g\":1400,\"bag\":\"cabin\",\"kind\":\"gifts\",\"liquid\":false,\"battery\":false},{\"item\":\"Gift bottle of maple syrup\",\"unit_g\":500,\"qty\":1,\"total_g\":500,\"bag\":\"cabin\",\"kind\":\"gifts\",\"liquid\":true,\"battery\":false},\"... 15 more\"]",
"page_reads": "[{\"level\":\"fix\",\"code\":\"over\",\"text\":\"Cabin bag + personal item weighs 14.96 kg against a 7.00 kg limit - 7.96 kg over.\"},{\"level\":\"info\",\"code\":\"wear\",\"text\":\"Wearing Snow boots (instead of Sneakers) and Jeans on the plane takes 1.25 kg off the bags - still 6.71 kg over after that.\"},\"... 3 more\"]",
"page_verdict": "check",
"notes": "One strict 7 kg allowance for both bags on this ticket."
}
Reply:
{
"task": "weigh",
"title": "Winter Holiday Carry-On Needs A Checked Bag",
"verdict": "check",
"summary": "This is a sensible 10-day winter list with laundry, but it's being weighed against an unusually strict combined 7.00 kg allowance covering the cabin bag and personal item together, and as packed it comes to 14.96 kg - 7.96 kg over. Wearing the snow boots and one pair of jeans saves 1.25 kg (6.71 kg still over), and shipping the three gifts ahead while checking the rest of the clothes and the toiletries saves another 7.16 kg, landing the combo at 6.55 kg - 450 g to spare under the 7.00 kg limit. The single biggest driver is structural, not the packing itself: the two empty bags already weigh 3.00 kg and the laptop, charger, camera and power bank add 2.85 kg, leaving almost no room in a 7 kg combined limit for any packed clothes at all.",
"moves": [
{
"item": "Wrapped board game for the kids",
"action": "ship",
"qty": 1,
"to": "",
"says": "Wrapped board game for the kids | 1400 g | 1 | cabin",
"why": "At 1400 g this is the single heaviest line in the bag; mailing it ahead keeps the full weight out of the gate weigh-in.",
"swap_for": "",
"swap_g": null
},
{
"item": "Sweater",
"action": "check",
"qty": 3,
"to": "",
"says": "Sweater | 420 g | 3 | cabin",
"why": "Three sweaters at 420 g each (1260 g) are the next-heaviest block; checking all three clears it since a checked bag is coming anyway.",
"swap_for": "",
"swap_g": null
},
{
"item": "Toiletries",
"action": "check",
"qty": 1,
"to": "",
"says": "Toiletries | 800 g | 1 | cabin",
"why": "Toiletries weigh 800 g; nothing about them is needed in the air, so checking the whole line takes 800 g off the combined total.",
"swap_for": "",
"swap_g": null
},
"..."
],
"rewear": [
"Days 1-2: the jeans worn onto the plane plus a sweater, thermals underneath if it's cold; first wash around day 3.",
"Days 3-5: second sweater over a t-shirt, the same jeans back in rotation after the wash.",
"Days 6-8: third sweater or t-shirts alone indoors; wash underwear and socks as needed from the 7 of each.",
"..."
],
"next_steps": [
"Weigh the cabin bag and personal item together at home before you leave - you're targeting about 6.55 kg against the 7.00 kg combined limit.",
"Set the board game, the maple syrup and Dad's sweater aside to ship or mail ahead rather than packing them.",
"Move the sweaters, the spare jeans, the t-shirts, thermals, underwear, socks and toiletries into the checked bag.",
"..."
],
"caveats": [
"Liquid rules for the maple syrup vary by airport - shipping sidesteps the question, but checking it instead is also fine.",
"This assumes the checked bag's own allowance has room for the added weight - confirm that with the airline separately."
]
}
Build the input yourself
Load the script in node (global.window = {}; require("./packkit.js")), then:
const K = PackKit;
const st = K.state({
today: "2026-10-20", trip: "Thanksgiving", nights: "4", laundry: "yes", mode: "separate",
cabin: "7", personal: "", cabin_tare: "2.4", personal_tare: "0.6", notes: "",
items: "Winter boots | 1150 g | 1 | cabin\nT-shirt | 160 g | 4 | cabin\nSneakers | 700 g | 1 | worn"
});
console.log(st.checks, st.facts.wear_moves, st.verdict);
const { input } = K.buildInput(st, { today: "2026-10-20" });
const wire = K.toWire(input); // POST this to /estimate and /run
// later: const reply = K.normalize(K.parseReply(job.output.output));
// const { rows, after, 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://seven-kilos.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://seven-kilos.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"]
// seven-kilos.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://seven-kilos.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://seven-kilos.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
}
// SevenKilos.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 SevenKilos {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://seven-kilos.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://seven-kilos.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://seven-kilos.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://seven-kilos.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":"seven-kilos"}' | 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": "seven-kilos"})["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: "seven-kilos" })).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": "seven-kilos"}, 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", "seven-kilos"), 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: "seven-kilos" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "seven-kilos"])["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 = "seven-kilos" }))!["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": "weigh", "today": "2026-10-20", "packing": "Winter boots | 1150 g | 1 | cabin ...", "facts": "{...}", "items": "[...]", "page_reads": "[...]", "page_verdict": "trim"}
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": "weigh", "today": "2026-10-20", "packing": "Winter boots | 1150 g | 1 | cabin ...", "facts": "{...}", "items": "[...]", "page_reads": "[...]", "page_verdict": "trim"}
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: "weigh", today, packing, facts, items, 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 seven-kilos:weigh:<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="seven-kilos:weigh:$(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 = "seven-kilos:" + 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 = `seven-kilos:${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 := "seven-kilos:" + 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 = "seven-kilos:" + 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 = "seven-kilos:#{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 = "seven-kilos:" . $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 = $"seven-kilos:{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: seven-kilos:weigh:$(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": "weigh", "today": "2026-10-20", "packing": "Winter boots | 1150 g | 1 | cabin ...", "facts": "{...}", "items": "[...]", "page_reads": "[...]", "page_verdict": "trim"}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "seven-kilos:weigh:" + 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": `seven-kilos:weigh:${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", "seven-kilos:weigh:"+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", "seven-kilos:weigh:" + 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" => "seven-kilos:weigh:#{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: seven-kilos:weigh:" . 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", $"seven-kilos:weigh:{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);