Drive Snug Season from your own code
The one metered task, ready, is available over HTTP. It takes the warmers a person owns (each with its type, an age band and their own words on how it looks), the places they get cold, a short note, and the page's facts (its reading of each warmer, its fixed table of checks per type and the cold spots nothing covers). It returns one verdict (retire_one, check_first, fill_gap or snug), the warmers to retire, a phased checklist for the rest (before first use, every use, end of season, each check with a pass-if line), one idea per uncovered cold spot, the safety lines that apply and a short closing line.
The division of labour first: the page owns the table of checks, the age limits, the word hints, which cold spots are covered and the order applied with those hints; the model owns reading the person's words (is that damage, or a denial of it? has it been looked at?) and all the wording; the page then re-checks the reply against its own reading. Not professional advice.
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":"snug-season"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object, sent bare.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The body is not a JSON object, or a required field (task, items, spots, note, page_facts) 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: a tailored 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 and the verdicts
Every run sends task: ready (the one task is ready); a missing or different one is still answered as ready. The verdict follows a fixed check order, stopping at the first that fits: retire_one when a warmer is past the page's age limit (a hot water bottle 3 years old or more; an electric blanket over 10 years) or its words describe damage or wear (a crack, a leak, sticky or perished rubber, scorch marks, fraying, bare wires, soles worn smooth - the model's reading; "no cracks" is not damage); check_first when a warmer has nothing said about it, has not been looked at, or a hot water bottle or electric blanket has an unknown age; fill_gap when a ticked cold spot has nothing on the list that warms it; otherwise snug.
Input (every field is a string)
| Field | Required | What it holds |
|---|---|---|
task | yes | Always ready. |
items | yes | One warmer per line: <number> | <type> | age: <age> | condition: <the person's words>. Types: hot water bottle, electric blanket or heated throw, microwavable heat pack, rechargeable hand warmer, single-use hand warmers, slippers, throw or blanket, other warmer. Ages: under 2 years, 2 to 3 years, 3 to 10 years, over 10 years, not sure. Up to 8 warmers. |
spots | yes | Where they get cold, separated by ;: feet, hands, lap and legs, back and shoulders, neck. |
note | yes | The person's own words, at least 3; over 1,200 characters it keeps its start and end with the middle marked as cut. |
page_facts | yes | One key | ... per line: items, one item_<n> line per warmer (age against the limit, condition words, a damage hint), one table_<type> line per warmer type (the fixed checks by phase, each with its pass-if), covered_spots, uncovered_spots, a gap_idea per uncovered spot, page_pick and verdict_rule. Build it with SnugKit.buildInput (below) so the facts match the page's. |
clip_note | no | What the page shortened, if anything. |
retry_note | no | Only on the page's one reformat retry, after an unusable reply. |
Reply (one JSON object)
{"task": "ready", "title": "...", "verdict": "retire_one", "verdict_reason": "...", "retire": [{"n": 1, "why": "..."}],
"checks": [{"phase": "first_use", "n": 2, "check": "...", "pass_if": "..."}], "gaps": [{"spot": "hands", "idea": "..."}],
"safety": "...", "cosy_line": "..."}
| Field | What it holds |
|---|---|
verdict, verdict_reason | The verdict id by the check order above; at most 30 words on what decided it. |
retire | For retire_one, one entry per warmer to retire: its number n and a reason of at most 20 words. Empty for any other verdict. |
checks | 3 to 14 checks for the warmers not retired, by phase (first_use, each_use, season_end); every phase has one and every such warmer has a first_use check. check at most 16 words, pass_if at most 14, both based on the page's table for that type. |
gaps | Exactly one entry per uncovered cold spot: the spot words and an idea of at most 18 words, generic, never a brand. |
safety, cosy_line, title | Safety points that apply to the listed types (at most 40 words; never an electric blanket together with a hot water bottle when both are listed), a playful closing line (at most 20) and a title of at most 8 words. |
The page then checks the reply (SnugKit.check): the verdict against its order and hints, the retire list, every check's item number, phase and word limits, every band covered and every warmer given a first-use check, the gaps against the uncovered spots, the never-together and not-boiling lines, every number in the prose, names the input never uses, leaked field ids and the word limits.
Worked examples
These are the four free samples on the page: the exact input and a stand-in model's reply, unedited. Together they cover all four verdicts - an old hot water bottle with sticky, cracked rubber (retire one), an electric blanket not looked at since spring (check first), hands and lap covered but nothing for cold feet (fill a gap), and four checked warmers covering every cold spot (snug).
An old hot water bottle with sticky, cracked rubber - retire one
Input:
{
"task": "ready",
"items": "1 | hot water bottle | age: 3 to 10 years | condition: The rubber has gone sticky and there is a hairline crack by the neck\n2 | slippers | age: under 2 years | condition: Soles still grip and the backs hold my heels",
"spots": "feet; hands",
"note": "Working from home in a chilly flat, and my feet and hands go numb by mid-afternoon.",
"page_facts": "items | 2 items listed\nitem_1 | hot water bottle; age 3 to 10 years - past the page's limit (commonly replaced every 2 to 3 years); condition: \"The rubber has gone sticky and there is a hairline crack by the neck\"; damage_hint: the words may describe damage (sticky) - a hint only, read them\nitem_2 | slippers; age under 2 years; condition: \"Soles still grip and the backs hold my heels\"; damage_hint: none found - a hint only, read the words\ntable_bottle | before first use: Bend and stretch the rubber all over, especially around the neck (pass if no cracks, splits or sticky patches) / Fill with warm tap water, close the stopper and squeeze it over the sink (pass if not a single drip); every use: Fill about two-thirds with hot, not boiling, water and let the air out (pass if stopper closes firmly and a cover is on); end of season: Empty it and let it dry with the stopper out, stored flat (pass if dry inside, away from sunlight)\ntable_slippers | before first use: Check the soles and the backs (pass if soles still grip and the backs hold your heels); every use: Keep them fully on your feet on stairs and hard floors (pass if heels held, no slipping); end of season: Wash or air them as the label says, then store them dry (pass if dry inside)\ncovered_spots | feet (items 1, 2)\nuncovered_spots | hands\ngap_idea | hands: fingerless gloves, or a hand warmer kept in a glove or pocket\npage_pick | retire_one (the order applied with the page's word hints)\nverdict_rule | check in order and stop at the first that fits: retire_one when any item is past the page's age limit or its condition words describe damage or wear; check_first when any item's condition is blank, says it has not been checked or the person is not sure, or a hot water bottle or electric blanket has an unknown age; fill_gap when a ticked cold spot has no listed item that warms it; otherwise snug"
}
Reply:
{
"task": "ready",
"title": "Chilly Flat, Numb Feet and Hands",
"verdict": "retire_one",
"verdict_reason": "Retire one: the hot water bottle is past its 2 to 3 year limit and its rubber is sticky with a hairline crack by the neck.",
"retire": [
{
"n": 1,
"why": "Past the 2 to 3 year limit; rubber is sticky with a hairline crack by the neck."
}
],
"checks": [
{
"phase": "first_use",
"n": 2,
"check": "Check the soles and the backs",
"pass_if": "Soles still grip and backs hold your heels"
},
{
"phase": "each_use",
"n": 2,
"check": "Keep slippers fully on your feet on stairs and hard floors",
"pass_if": "Heels held, no slipping"
},
{
"phase": "season_end",
"n": 2,
"check": "Wash or air them as the label says, then store dry",
"pass_if": "Dry inside"
}
],
"gaps": [
{
"spot": "hands",
"idea": "Fingerless gloves, or a hand warmer kept in a glove or pocket"
}
],
"safety": "Never fill a hot water bottle with boiling water. Stop using anything that is damaged, too hot or smells of burning.",
"cosy_line": "One step at a time, let's ease those numb feet and hands through the mid-afternoon."
}
An electric blanket not looked at since spring - check first
Input:
{
"task": "ready",
"items": "1 | electric blanket or heated throw | age: 3 to 10 years | condition: Haven't checked it since it went in the cupboard in spring\n2 | throw or blanket | age: under 2 years | condition: Fine, washed it last week",
"spots": "lap and legs; back and shoulders",
"note": "Evenings on the sofa reading, and my lap and shoulders get cold first.",
"page_facts": "items | 2 items listed\nitem_1 | electric blanket or heated throw; age 3 to 10 years - within the page's limit (commonly replaced at 10 years); condition: \"Haven't checked it since it went in the cupboard in spring\"; damage_hint: none found - a hint only, read the words\nitem_2 | throw or blanket; age under 2 years; condition: \"Fine, washed it last week\"; damage_hint: none found - a hint only, read the words\ntable_eblanket | before first use: Unroll it and look over the fabric, the cord, the plug and the controller (pass if no scorch marks, fraying, bare wires or loose parts); every use: Lay it flat, use it on its own, and switch off when you are done (pass if no folds, no hot water bottle with it); end of season: Let it cool, then roll it loosely rather than folding it (pass if stored rolled, nothing heavy on top)\ntable_throw | before first use: Shake it out and look it over (pass if no holes or loose threads); every use: Keep it clear of heaters, fires and candles (pass if at least an arm's length from any heat); end of season: Wash it as its label says and store it dry (pass if fully dry before it is folded away)\ncovered_spots | lap and legs (items 1, 2); back and shoulders (items 1, 2)\nuncovered_spots | none\npage_pick | check_first (the order applied with the page's word hints)\nverdict_rule | check in order and stop at the first that fits: retire_one when any item is past the page's age limit or its condition words describe damage or wear; check_first when any item's condition is blank, says it has not been checked or the person is not sure, or a hot water bottle or electric blanket has an unknown age; fill_gap when a ticked cold spot has no listed item that warms it; otherwise snug"
}
Reply:
{
"task": "ready",
"title": "Cosy Sofa Evening Reading Kit",
"verdict": "check_first",
"verdict_reason": "Check first: the electric blanket hasn't been checked since it went in the cupboard in spring.",
"retire": [],
"checks": [
{
"phase": "first_use",
"n": 1,
"check": "Unroll it and look over the fabric, cord, plug and controller.",
"pass_if": "No scorch marks, fraying, bare wires or loose parts."
},
{
"phase": "first_use",
"n": 2,
"check": "Shake the throw out and look it over.",
"pass_if": "No holes or loose threads."
},
{
"phase": "each_use",
"n": 1,
"check": "Lay it flat, use it on its own, switch off when finished.",
"pass_if": "No folds, and no hot water bottle used with it."
},
{
"phase": "each_use",
"n": 2,
"check": "Keep it clear of heaters, fires and candles.",
"pass_if": "At least an arm's length from any heat."
},
{
"phase": "season_end",
"n": 1,
"check": "Let it cool, then roll it loosely rather than folding.",
"pass_if": "Stored rolled, with nothing heavy on top."
},
{
"phase": "season_end",
"n": 2,
"check": "Wash it as its label says and store it dry.",
"pass_if": "Fully dry before it is folded away."
}
],
"gaps": [],
"safety": "Keep throws and blankets clear of heaters, fires and candles. Stop using anything that feels damaged, gets too hot, or smells of burning.",
"cosy_line": "Let's give that blanket and throw a once-over, then settle in for cosy reading evenings on the sofa."
}
Hands and lap covered, nothing for cold feet - fill a gap
Input:
{
"task": "ready",
"items": "1 | rechargeable hand warmer | age: under 2 years | condition: Charges fine, the case is not cracked or swollen\n2 | throw or blanket | age: 2 to 3 years | condition: Big wool throw, no holes",
"spots": "feet; hands; lap and legs",
"note": "Long evenings at my desk, and my toes are always the first to freeze.",
"page_facts": "items | 2 items listed\nitem_1 | rechargeable hand warmer; age under 2 years; condition: \"Charges fine, the case is not cracked or swollen\"; damage_hint: none found - a hint only, read the words\nitem_2 | throw or blanket; age 2 to 3 years; condition: \"Big wool throw, no holes\"; damage_hint: none found - a hint only, read the words\ntable_handrech | before first use: Charge it with its own cable on a hard surface while you are nearby (pass if case not swollen, cracked or very hot); every use: Start on the lowest setting with a layer between it and your skin (pass if warm, never hot or painful); end of season: Charge it part way and store it somewhere cool and dry (pass if kept away from heaters and sunlight)\ntable_throw | before first use: Shake it out and look it over (pass if no holes or loose threads); every use: Keep it clear of heaters, fires and candles (pass if at least an arm's length from any heat); end of season: Wash it as its label says and store it dry (pass if fully dry before it is folded away)\ncovered_spots | hands (items 1); lap and legs (items 2)\nuncovered_spots | feet\ngap_idea | feet: thick socks or slippers that grip, or a covered hot water bottle\npage_pick | fill_gap (the order applied with the page's word hints)\nverdict_rule | check in order and stop at the first that fits: retire_one when any item is past the page's age limit or its condition words describe damage or wear; check_first when any item's condition is blank, says it has not been checked or the person is not sure, or a hot water bottle or electric blanket has an unknown age; fill_gap when a ticked cold spot has no listed item that warms it; otherwise snug"
}
Reply:
{
"task": "ready",
"title": "Long Desk Evenings, Cold Toes Kit",
"verdict": "fill_gap",
"verdict_reason": "Fill a gap: feet are a cold spot with no listed warmer covering them, while hands and lap and legs are already covered.",
"retire": [],
"checks": [
{
"phase": "first_use",
"n": 1,
"check": "Charge it with its cable on a hard surface while nearby",
"pass_if": "Case isn't swollen, cracked or very hot"
},
{
"phase": "first_use",
"n": 2,
"check": "Shake the throw out and look it over",
"pass_if": "No holes or loose threads"
},
{
"phase": "each_use",
"n": 1,
"check": "Start on the lowest setting with a layer between it and your skin",
"pass_if": "Feels warm, never hot or painful"
},
{
"phase": "each_use",
"n": 2,
"check": "Keep the throw clear of heaters, fires and candles",
"pass_if": "At least an arm's length from heat"
},
{
"phase": "season_end",
"n": 1,
"check": "Charge it part way, then store somewhere cool and dry",
"pass_if": "Kept away from heaters and sunlight"
},
{
"phase": "season_end",
"n": 2,
"check": "Wash it as the label says, then store it dry",
"pass_if": "Fully dry before it's folded away"
}
],
"gaps": [
{
"spot": "feet",
"idea": "Thick socks or slippers that grip, or a covered hot water bottle"
}
],
"safety": "Keep the throw clear of heaters, fires and candles, and stop using the hand warmer if it feels too hot or damaged.",
"cosy_line": "Time to pull the throw over your lap, charge up the hand warmer, and give those toes their turn next."
}
Four checked warmers, every cold spot covered - snug
Input:
{
"task": "ready",
"items": "1 | hot water bottle | age: under 2 years | condition: No cracks or leaks, the stopper seals tight, it has a knitted cover\n2 | electric blanket or heated throw | age: under 2 years | condition: Looked it over last week: no scorch marks or fraying, the controller works\n3 | slippers | age: under 2 years | condition: Soles grip well\n4 | microwavable heat pack | age: under 2 years | condition: No scorch marks, smells normal",
"spots": "feet; lap and legs; neck",
"note": "Draughty old house, and I start to feel the cold early in the evening.",
"page_facts": "items | 4 items listed\nitem_1 | hot water bottle; age under 2 years - within the page's limit (commonly replaced every 2 to 3 years); condition: \"No cracks or leaks, the stopper seals tight, it has a knitted cover\"; damage_hint: none found - a hint only, read the words\nitem_2 | electric blanket or heated throw; age under 2 years - within the page's limit (commonly replaced at 10 years); condition: \"Looked it over last week: no scorch marks or fraying, the controller works\"; damage_hint: none found - a hint only, read the words\nitem_3 | slippers; age under 2 years; condition: \"Soles grip well\"; damage_hint: none found - a hint only, read the words\nitem_4 | microwavable heat pack; age under 2 years; condition: \"No scorch marks, smells normal\"; damage_hint: none found - a hint only, read the words\ntable_bottle | before first use: Bend and stretch the rubber all over, especially around the neck (pass if no cracks, splits or sticky patches) / Fill with warm tap water, close the stopper and squeeze it over the sink (pass if not a single drip); every use: Fill about two-thirds with hot, not boiling, water and let the air out (pass if stopper closes firmly and a cover is on); end of season: Empty it and let it dry with the stopper out, stored flat (pass if dry inside, away from sunlight)\ntable_eblanket | before first use: Unroll it and look over the fabric, the cord, the plug and the controller (pass if no scorch marks, fraying, bare wires or loose parts); every use: Lay it flat, use it on its own, and switch off when you are done (pass if no folds, no hot water bottle with it); end of season: Let it cool, then roll it loosely rather than folding it (pass if stored rolled, nothing heavy on top)\ntable_slippers | before first use: Check the soles and the backs (pass if soles still grip and the backs hold your heels); every use: Keep them fully on your feet on stairs and hard floors (pass if heels held, no slipping); end of season: Wash or air them as the label says, then store them dry (pass if dry inside)\ntable_wheat | before first use: Look over the cover and smell it (pass if no scorch marks and no burnt smell); every use: Heat it only for the time on its label, and let it cool before reheating (pass if warm to hold, never too hot to touch); end of season: Store it dry in a sealed bag (pass if dry and smelling normal)\ncovered_spots | feet (items 1, 2, 3); lap and legs (items 1, 2, 4); neck (items 4)\nuncovered_spots | none\npage_pick | snug (the order applied with the page's word hints)\nverdict_rule | check in order and stop at the first that fits: retire_one when any item is past the page's age limit or its condition words describe damage or wear; check_first when any item's condition is blank, says it has not been checked or the person is not sure, or a hot water bottle or electric blanket has an unknown age; fill_gap when a ticked cold spot has no listed item that warms it; otherwise snug"
}
Reply:
{
"task": "ready",
"title": "Early Evening Chill in a Draughty House",
"verdict": "snug",
"verdict_reason": "No warmer is past its age limit or damaged, none need checking first, and feet, lap and legs, and neck are all covered, so this kit is snug.",
"retire": [],
"checks": [
{
"phase": "first_use",
"n": 1,
"check": "Bend and stretch the rubber all over, then fill and squeeze it over the sink.",
"pass_if": "No cracks, splits or sticky patches, and not a single drip."
},
{
"phase": "first_use",
"n": 2,
"check": "Unroll it and look over the fabric, cord, plug and controller.",
"pass_if": "No scorch marks, fraying, bare wires or loose parts."
},
{
"phase": "first_use",
"n": 3,
"check": "Check the soles and the backs before wearing them this season.",
"pass_if": "Soles still grip and the backs hold your heels."
},
{
"phase": "first_use",
"n": 4,
"check": "Look over the cover and give it a smell.",
"pass_if": "No scorch marks and no burnt smell."
},
{
"phase": "each_use",
"n": 1,
"check": "Fill it about two-thirds with hot, not boiling, water and let the air out.",
"pass_if": "Stopper closes firmly and a cover is on."
},
{
"phase": "each_use",
"n": 2,
"check": "Lay it flat, use it alone, and switch off when finished.",
"pass_if": "No folds, no hot water bottle with it."
},
{
"phase": "each_use",
"n": 3,
"check": "Keep them fully on your feet on stairs and hard floors.",
"pass_if": "Heels held, no slipping."
},
{
"phase": "each_use",
"n": 4,
"check": "Heat it only for the time on its label, letting it cool before reheating.",
"pass_if": "Warm to hold, never too hot to touch."
},
{
"phase": "season_end",
"n": 1,
"check": "Empty it and let it dry with the stopper out, stored flat.",
"pass_if": "Dry inside, away from sunlight."
},
{
"phase": "season_end",
"n": 2,
"check": "Let it cool, then roll it loosely rather than folding.",
"pass_if": "Stored rolled, nothing heavy on top."
},
{
"phase": "season_end",
"n": 3,
"check": "Wash or air them as the label says, then store dry.",
"pass_if": "Dry inside."
},
{
"phase": "season_end",
"n": 4,
"check": "Store it dry, in a sealed bag.",
"pass_if": "Dry and smelling normal."
}
],
"gaps": [],
"safety": "Never use an electric blanket together with a hot water bottle, and never fill a hot water bottle with boiling water.",
"cosy_line": "A draughty old house meets a hot water bottle, blanket, heat pack and slippers for those early evenings."
}
Build the input yourself
Load the script in node (global.window = global; require("./snugkit.js")), then:
const K = SnugKit;
const built = K.buildInput({ items: [{ type: "bottle", age: "y3_10", cond: "The rubber has gone sticky" }, { type: "slippers", age: "under_2", cond: "Soles still grip" }],
spots: { feet: true, hands: true }, note: "Working from home in a chilly flat." });
built.j[0]; // { label: "item 1 - hot water bottle", old: true, damage: "sticky", retire: true, ... } - the page's reading
built.cv.uncovered; // ["hands"] - cold spots nothing on the list warms
built.pick; // "retire_one" - the order applied with the page's word hints
built.base; // the page's own checklist, phase by phase
const wire = K.toWire(built.input); // POST this to /estimate and /run
// then: const reply = K.normalize(K.parseReply(job.output.output));
// K.check(reply, built).notes // the page's re-check of the reply
// K.csv(built, reply, {}) // the checklist as CSV
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://snug-season.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://snug-season.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"]
// snug-season.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://snug-season.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://snug-season.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
}
// SnugSeason.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 SnugSeason {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://snug-season.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://snug-season.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://snug-season.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://snug-season.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":"snug-season"}' | 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": "snug-season"})["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: "snug-season" })).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": "snug-season"}, 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", "snug-season"), 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: "snug-season" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "snug-season"])["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 = "snug-season" }))!["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": "ready", "items": "1 | hot water bottle | age: under 2 years | condition: ...", "spots": "feet; hands", "note": "...", "page_facts": "..."} - see the field table
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": "ready", "items": "1 | hot water bottle | age: under 2 years | condition: ...", "spots": "feet; hands", "note": "...", "page_facts": "..."} - see the field table
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: "ready", items, spots, note, page_facts} (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 lane and the input: a retry with the same key never bills twice. The page uses snug-season:<task>:<inputHash>:a<attempt> (the one task is ready), 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="snug-season:ready:$(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 = "snug-season:" + 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 as one JSON object
print(j["charged_credits"], j.get("truncated"), reply.get("title"))
import { createHash } from "node:crypto";
const key = `snug-season:${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.title);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "snug-season:" + 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 = "snug-season:" + 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 = "snug-season:#{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 = "snug-season:" . $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 = $"snug-season:{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: snug-season:ready:$(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": "ready", "items": "1 | hot water bottle | age: under 2 years | condition: ...", "spots": "feet; hands", "note": "...", "page_facts": "..."} - see the field table
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "snug-season:ready:" + 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": `snug-season:ready:${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", "snug-season:ready:"+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", "snug-season:ready:" + 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" => "snug-season:ready:#{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: snug-season:ready:" . 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", $"snug-season:ready:{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);