Drive Sugar Slot from your own code
The one metered task, panel, is available over HTTP. It takes an invented sweet - the base, the shape and the finish, one to three flavours, the name and tagline, the wrapper and lettering colours, who it is for, the price - plus the page's own flags, and returns a verdict (stock it, tweak it or rethink it), five scores, one fix per flag, three name ideas that fit the coil label and a short shelf card.
The division of labour first: the page owns the flavour-pairing rule, the flags, the score bands and the verdict; the model owns the judgement and the words; the page re-checks the reply. All of it comes from sweetkit.js, which you can run yourself (see Build the input yourself). The pairing rule and the taste gauges are the page's own rules of thumb, not a taste test.
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":"sugar-slot"}), 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, sweet, flavours, name, wrapper, audience, price, 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: asking the panel 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, the flags and the verdict
Every run sends task: panel; a missing or different one is still answered as panel. The page first rates each flavour pair by family (SweetKit.pairs): classic (the same family, or a familiar match), clash (a known clash such as mint with salt) or bold (everything else). Then it lists numbered flags (SweetKit.flags), each red or amber and tied to one of the five score axes:
- red - a clashing flavour pair; a finish that fights the base (a sour dusting on chocolate, fudge or toffee; a soft centre in rock candy).
- amber - three flavours with no classic pair; a finish that adds nothing; a name over 18 characters or 3 words; a name of only everyday sweet words; lettering under 3:1 contrast on the wrapper; for kids, a grown-up flavour or a treat price.
The verdict follows a fixed order, first match wins (SweetKit.verdictFor): rethink if any flag is red; tweak if any flag is amber; stock otherwise. Each axis's allowed score range follows from its flags (SweetKit.axisBand): 1-2 with a red flag, 2-3 with an amber flag, 3-5 with none. The page sends all of it in page_facts; the model copies the verdict and keeps every score in its band, and the page flags a reply that does not.
The input
Every value is a string (objects are never sent). app.js builds it with SweetKit.buildInput(form).input.
| Field | Required | What it holds |
|---|---|---|
task | yes | Always panel. |
sweet | yes | The base, the shape and the finish, e.g. Gummy, heart shape, sugar-sanded. |
flavours | yes | One to three flavours, each with its family in brackets. |
name | yes | The sweet's name (at most 40 characters). |
tagline | no | The person's tagline (at most 60 characters), or none given. |
wrapper | yes | The wrapper colour and the lettering colour. |
audience | yes | Starts with kids, teens, grownups, everyone or nostalgia. |
price | yes | Starts with pocket, standard or treat, with its coins (1, 2 or 4). |
note | no | The person's words (at most 240 characters, the middle cut with [...]), or none given. |
page_facts | yes | The page's lines (SweetKit.pageFacts): verdict, flags, one flag N line per flag, pairings, taste meters, wrapper contrast and score bands. |
clip_note | no | What the page shortened, or absent. |
retry_note | no | Sent only on the page's one reformat retry. |
The reply
The reply is one JSON object in job.output.output. The page parses it with SweetKit.parseReply and SweetKit.normalize (a cut-off stream is closed by SweetKit.closeJson and shown as "N of 9 sections recovered").
{"task": "panel", "verdict": "stock | tweak | rethink", "verdict_reason": "...", "headline": "...",
"scores": [{"axis": "craving | standout | name | wrapper | fit", "score": 4, "why": "..."}],
"pull": "...", "hesitation": "...",
"fixes": [{"flag": 1, "change": "...", "why": "..."}],
"names": ["...", "...", "..."],
"shelf_card": {"tagline": "...", "blurb": "..."}}
| Field | Rule |
|---|---|
scores | Five, in the order craving, standout, name, wrapper, fit; each inside its band; why at most 20 words. |
fixes | One per flag in flag order (flag = its number), then polish ideas with flag 0: exactly 2 for stock, at most 1 otherwise. change at most 20 words, why at most 18. |
names | Exactly 3, each at most 18 characters and 3 words, no digits, not the current name, no real brand. |
shelf_card | tagline at most 8 words; blurb at most 30 words on taste and texture, using only the picked flavours, the finish, the shape and the base. |
pull, hesitation, headline | At most 25, 25 and 10 words. |
The page then checks the reply against its own facts (SweetKit.check): the verdict, every score's band, one fix per flag and their order, the number of polish ideas, the name ideas' length, a shelf card that names an unpicked flavour, any health, diet or allergy claim, and internal field names in the prose.
Worked examples, one per verdict
These are the four free samples on the page: the exact input and a stand-in model's reply, unedited.
A tropical chew with no flags - stock
Input:
{
"task": "panel",
"sweet": "Chewy taffy, drop shape, sugar-sanded",
"flavours": "mango (tropical family), passion fruit (tropical family), coconut (tropical family)",
"name": "Sunset Chews",
"tagline": "none given",
"wrapper": "tangerine wrapper with ink black lettering",
"audience": "everyone (anyone of any age)",
"price": "standard (an everyday sweet, 2 coins in the machine)",
"note": "For a stall at a school summer fair.",
"page_facts": "verdict: stock\nflags: none\npairings: mango + passion fruit classic; mango + coconut classic; passion fruit + coconut classic\ntaste meters (page estimates, 0-5): sweet 5, sour 2, kick 2\nwrapper contrast: 6.5:1\nscore bands: craving 3-5, standout 3-5, name 3-5, wrapper 3-5, fit 3-5"
}
Reply:
{
"task": "panel",
"verdict": "stock",
"verdict_reason": "No listed concerns hold back this tropical, sugar-sanded taffy, so it is ready to sell as is.",
"headline": "A juicy tropical taffy this panel would shelve now",
"scores": [
{
"axis": "craving",
"score": 4,
"why": "Tropical mango, passion fruit and coconut on sugar-sanded taffy feels juicy and craveable for a drop sweet."
},
{
"axis": "standout",
"score": 4,
"why": "Three tropical flavours together and a sugar-sanded drop shape set this apart from plainer chewy sweets."
},
{
"axis": "name",
"score": 4,
"why": "Sunset Chews reads warm and tropical, matching the mango and passion fruit without naming every flavour."
},
{
"axis": "wrapper",
"score": 5,
"why": "Ink black lettering reads clearly against the tangerine wrapper with strong, comfortable contrast."
},
{
"axis": "fit",
"score": 4,
"why": "A standard-priced tropical chew for everyone suits any age without leaning young or grown-up."
}
],
"pull": "Everyone goes for the tropical mango, passion fruit and coconut mix and the sugar-sanded crunch on this chewy drop.",
"hesitation": "Three tropical flavours together might taste a touch similar, so the mix could feel less varied than hoped.",
"fixes": [
{
"flag": 0,
"change": "Swap the sugar-sanded finish for fizzy popping bits to add more excitement to the chew.",
"why": "Popping texture would make the chew feel more exciting beyond simple sweetness."
},
{
"flag": 0,
"change": "Shape the taffy as a star instead of a drop to catch more eyes on the shelf.",
"why": "A star shape stands out more than a plain drop on a crowded shelf."
}
],
"names": [
"Tropic Taffy Trio",
"Mango Passion Chew",
"Mango Coconut Chew"
],
"shelf_card": {
"tagline": "Three tropical flavours in one chewy drop",
"blurb": "A sugar-sanded chewy taffy drop with juicy mango, passion fruit and coconut, each bite soft, sweet and lightly crunchy on the outside."
}
}
A fizzy lemon drop with a long name and pale lettering - tweak
Input:
{
"task": "panel",
"sweet": "Hard candy, drop shape, fizzy popping bits",
"flavours": "lemon (citrus family), elderflower (floral family)",
"name": "Grandma's Lemon Sherbet Fizzers",
"tagline": "Fizz like the old days",
"wrapper": "lemon yellow wrapper with cream lettering",
"audience": "nostalgia (people who miss old sweet-shop sweets)",
"price": "pocket (pocket change, 1 coin in the machine)",
"note": "none given",
"page_facts": "verdict: tweak\nflags: 2\nflag 1 (amber, name): the name \"Grandma's Lemon Sherbet Fizzers\" is 31 characters and 4 words; the coil label holds 18 characters and 3 words\nflag 2 (amber, wrapper): cream lettering on a lemon yellow wrapper has a contrast of 1.3:1; the page wants at least 3:1 so the name reads through the glass\npairings: lemon + elderflower classic\ntaste meters (page estimates, 0-5): sweet 3, sour 3, kick 4\nwrapper contrast: 1.3:1\nscore bands: craving 3-5, standout 3-5, name 2-3, wrapper 2-3, fit 3-5"
}
Reply:
{
"task": "panel",
"verdict": "tweak",
"verdict_reason": "The verdict is a tweak because the name runs long for the machine's window and the cream lettering barely shows on the yellow wrapper.",
"headline": "A nostalgic fizzy favorite that just needs tidying",
"scores": [
{
"axis": "craving",
"score": 4,
"why": "Fizzy lemon and elderflower in a hard candy drop feels bright and moreish, inviting another pick."
},
{
"axis": "standout",
"score": 4,
"why": "The floral elderflower twist with popping fizz sets this drop apart from ordinary lemon candies."
},
{
"axis": "name",
"score": 2,
"why": "The current name is far longer than the machine's window allows, even though it is charming."
},
{
"axis": "wrapper",
"score": 2,
"why": "Cream lettering almost disappears against the pale yellow wrapper, making the name hard to read."
},
{
"axis": "fit",
"score": 5,
"why": "A pocket-change nostalgic fizzy lemon and elderflower drop suits sweet-shop fans perfectly, true to its simple classic style."
}
],
"pull": "People who miss old sweet-shop sweets will reach for this drop: bright lemon and elderflower with a satisfying fizzy pop.",
"hesitation": "The name is long and may not fit cleanly on the machine's window, which could make it harder to notice fast.",
"fixes": [
{
"flag": 1,
"change": "Rename it to a short name like Lemon Fizz Drop so it reads clearly on the machine.",
"why": "The long name is hard to read; this keeps the citrus fizz feel in fewer words."
},
{
"flag": 2,
"change": "Change the lettering to ink black so it stands out on the lemon yellow wrapper.",
"why": "Cream lettering is too pale on yellow; dark letters will read through the glass clearly."
}
],
"names": [
"Fizzy Lemon Drop",
"Elderflower Fizz",
"Hard Lemon Drop"
],
"shelf_card": {
"tagline": "Zingy lemon and elderflower, popping with fizz",
"blurb": "A hard candy drop bursts with zingy lemon and delicate elderflower, finished with fizzy popping bits that crackle and pop on the tongue."
}
}
A mocha chocolate aimed at kids at a treat price - tweak (two flags on one axis)
Input:
{
"task": "panel",
"sweet": "Filled chocolate, coin shape, smooth, no finish",
"flavours": "coffee (roast family), milk chocolate (cocoa family)",
"name": "Mocha Moons",
"tagline": "none given",
"wrapper": "cocoa brown wrapper with cream lettering",
"audience": "kids (children, roughly 5 to 12)",
"price": "treat (a gift-shop treat, 4 coins in the machine)",
"note": "Party bags for a ten-year-old's birthday.",
"page_facts": "verdict: tweak\nflags: 2\nflag 1 (amber, fit): coffee leans to grown-up tastes for a sweet aimed at kids\nflag 2 (amber, fit): a gift-shop treat price is a lot of pocket money for a sweet aimed at kids\npairings: coffee + milk chocolate classic\ntaste meters (page estimates, 0-5): sweet 3, sour 0, kick 3\nwrapper contrast: 9.2:1\nscore bands: craving 3-5, standout 3-5, name 3-5, wrapper 3-5, fit 2-3"
}
Reply:
{
"task": "panel",
"verdict": "tweak",
"verdict_reason": "Coffee leans grown-up and the treat price is a lot of pocket money for kids.",
"headline": "A promising coin that needs a kid-friendlier flavour and price",
"scores": [
{
"axis": "craving",
"score": 4,
"why": "A smooth coin with a classic coffee and milk chocolate filled centre gives a tempting bite."
},
{
"axis": "standout",
"score": 4,
"why": "A coffee and milk chocolate filled coin is different from the usual sweets on the shelf."
},
{
"axis": "name",
"score": 5,
"why": "Mocha Moons pairs nicely with the coin shape and hints at the coffee and chocolate flavours."
},
{
"axis": "wrapper",
"score": 5,
"why": "Cream lettering reads clearly on the cocoa brown wrapper for strong shelf contrast."
},
{
"axis": "fit",
"score": 2,
"why": "Coffee leans to grown-up tastes and the treat price is steep pocket money for kids."
}
],
"pull": "Kids will reach for the smooth coin shape and its rich coffee and milk chocolate taste.",
"hesitation": "The coffee flavour might feel too grown-up for younger kids to enjoy.",
"fixes": [
{
"flag": 1,
"change": "Swap the coffee flavour for caramel to pair with the milk chocolate without the grown-up note.",
"why": "Caramel is a gentler flavour that still suits milk chocolate and feels right for kids."
},
{
"flag": 2,
"change": "Move the price down from treat to standard so it costs less pocket money for kids.",
"why": "A lower price fits a kids sweet better than a gift-shop treat price."
},
{
"flag": 0,
"change": "Swap the cream lettering for lemon yellow to lift the cocoa brown wrapper even more.",
"why": "Lemon yellow keeps strong contrast while adding a brighter pop on the shelf."
}
],
"names": [
"Choco Coin",
"Smooth Disc",
"Milk Choc Coin"
],
"shelf_card": {
"tagline": "Rich coffee meets milk chocolate in every coin",
"blurb": "A smooth filled chocolate coin combining coffee and milk chocolate in a classic match."
}
}
A sour-dusted mint and sea-salt fudge - rethink
Input:
{
"task": "panel",
"sweet": "Fudge, cube shape, sour-dusted",
"flavours": "mint (mint family), sea salt (salty family), caramel (toasted sweet family)",
"name": "Sweet Treat",
"tagline": "none given",
"wrapper": "deep teal wrapper with cream lettering",
"audience": "grownups (grown-ups)",
"price": "standard (an everyday sweet, 2 coins in the machine)",
"note": "I wanted something salty and fresh at once.",
"page_facts": "verdict: rethink\nflags: 3\nflag 1 (red, craving): mint with sea salt is a clash on the page's pairing rule (mint and salty)\nflag 2 (red, craving): a sour dusting on fudge fights the creamy base\nflag 3 (amber, name): the name \"Sweet Treat\" uses only everyday sweet-shop words, so it could be anything\npairings: mint + sea salt clash; mint + caramel bold; sea salt + caramel classic\ntaste meters (page estimates, 0-5): sweet 2, sour 2, kick 4\nwrapper contrast: 5.5:1\nscore bands: craving 1-2, standout 3-5, name 2-3, wrapper 3-5, fit 3-5"
}
Reply:
{
"task": "panel",
"verdict": "rethink",
"verdict_reason": "The mint and sea salt clash with each other, and the sour dusting fights the fudge base, which together call for bigger changes to this sweet.",
"headline": "A bold salty-sweet idea that needs its clash fixed",
"scores": [
{
"axis": "craving",
"score": 1,
"why": "Mint and sea salt clash while the sour dusting fights the fudge, both working against the pull here."
},
{
"axis": "standout",
"score": 4,
"why": "A sour-dusted fudge cube mixing mint, sea salt and caramel is unusual on any shelf."
},
{
"axis": "name",
"score": 2,
"why": "Sweet Treat says nothing about the mint, salt or caramel inside, just everyday words."
},
{
"axis": "wrapper",
"score": 4,
"why": "Cream lettering reads clearly on the deep teal wrapper, giving solid shelf contrast."
},
{
"axis": "fit",
"score": 4,
"why": "Sea salt and caramel suit a grown-up palate at an everyday price point."
}
],
"pull": "Grown-ups drawn to a sharp edge will reach for the sour-dusted cube, where mint and sea salt cut into the rich caramel.",
"hesitation": "Mint and sea salt are a known clash, so the first bite could taste muddled rather than refreshing or balanced.",
"fixes": [
{
"flag": 1,
"change": "Swap mint for coffee, which pairs well with the sea salt and caramel already picked.",
"why": "Mint and sea salt clash together; coffee suits the salty caramel instead."
},
{
"flag": 2,
"change": "Swap the sour dusting for a chocolate-coated finish, which suits the creamy fudge base.",
"why": "Sour dusting clashes with a creamy fudge base; chocolate-coated finishes work together instead."
},
{
"flag": 3,
"change": "Rename to \"Caramel Salt Cube,\" using the caramel, sea salt and cube shape already picked.",
"why": "Everyday sweet-shop words say nothing about this sweet; caramel and salt are distinct."
}
],
"names": [
"Salty Caramel Cube",
"Fudge Caramel Cube",
"Salted Fudge Cube"
],
"shelf_card": {
"tagline": "Sour, salty and sweet in one cube",
"blurb": "Chewy fudge cube dusted with a sharp sour coating, carrying cool mint, salty bite and rich caramel in every chew."
}
}
Build the input yourself
Load the script in node (global.window = global; require("./sweetkit.js")), then:
const K = SweetKit;
const form = K.form({ base: "gummy", flav1: "raspberry", flav2: "lime", flav3: "", finish: "sour", shape: "ring",
wrap: "grape", ink: "lemon", name: "Zing Rings", tagline: "", audience: "teens", price: "pocket", note: "" });
K.pairs(form); // each flavour pair: classic, bold or clash
K.flags(form); // numbered red and amber flags, each tied to a score axis
K.verdictFor(form); // {verdict: "stock", flags, why} - the page's rule
K.axisBand(K.flags(form), "name"); // {lo: 3, hi: 5} - the allowed score range
K.meters(form); // rough sweet, sour and kick, 0-5 (estimates)
const built = K.buildInput(form);
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, form).notes // the page's re-check of the reply
// K.md(form, reply) // the sweet and the panel as Markdown
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://sugar-slot.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://sugar-slot.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"]
// sugar-slot.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://sugar-slot.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://sugar-slot.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
}
// SugarSlot.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 SugarSlot {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://sugar-slot.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://sugar-slot.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://sugar-slot.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://sugar-slot.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":"sugar-slot"}' | 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": "sugar-slot"})["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: "sugar-slot" })).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": "sugar-slot"}, 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", "sugar-slot"), 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: "sugar-slot" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "sugar-slot"])["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 = "sugar-slot" }))!["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": "panel", "sweet": "Chewy taffy, drop shape, sugar-sanded", "flavours": "mango (tropical family), passion fruit (tropical family), coconut (tropical family)", "name": "Sunset Chews", "tagline": "none given", "wrapper": "tangerine wrapper with ink black lettering", "audience": "everyone (anyone of any age)", "price": "standard (an everyday sweet, 2 coins in the machine)", "note": "For a stall at a school summer fair.", "page_facts": "verdict: stock\nflags: none\npairings: mango + passion fruit classic; mango + coconut classic; passion fruit + coconut classic\ntaste meters (page estimates, 0-5): sweet 5, sour 2, kick 2\nwrapper contrast: 6.5:1\nscore bands: craving 3-5, standout 3-5, name 3-5, wrapper 3-5, fit 3-5"}
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": "panel", "sweet": "Chewy taffy, drop shape, sugar-sanded", "flavours": "mango (tropical family), passion fruit (tropical family), coconut (tropical family)", "name": "Sunset Chews", "tagline": "none given", "wrapper": "tangerine wrapper with ink black lettering", "audience": "everyone (anyone of any age)", "price": "standard (an everyday sweet, 2 coins in the machine)", "note": "For a stall at a school summer fair.", "page_facts": "verdict: stock\nflags: none\npairings: mango + passion fruit classic; mango + coconut classic; passion fruit + coconut classic\ntaste meters (page estimates, 0-5): sweet 5, sour 2, kick 2\nwrapper contrast: 6.5:1\nscore bands: craving 3-5, standout 3-5, name 3-5, wrapper 3-5, fit 3-5"}
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: "panel", sweet, flavours, name, tagline, wrapper, audience, price, note, page_facts, clip_note} (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 sugar-slot:<task>:<inputHash>:a<attempt> (the one task is panel), 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="sugar-slot:panel:$(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 = "sugar-slot:" + 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("headline"))
import { createHash } from "node:crypto";
const key = `sugar-slot:${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.headline);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "sugar-slot:" + 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 = "sugar-slot:" + 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 = "sugar-slot:#{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["headline"]].inspect
$key = "sugar-slot:" . $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["headline"], "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"sugar-slot:{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: sugar-slot:panel:$(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": "panel", "sweet": "Chewy taffy, drop shape, sugar-sanded", "flavours": "mango (tropical family), passion fruit (tropical family), coconut (tropical family)", "name": "Sunset Chews", "tagline": "none given", "wrapper": "tangerine wrapper with ink black lettering", "audience": "everyone (anyone of any age)", "price": "standard (an everyday sweet, 2 coins in the machine)", "note": "For a stall at a school summer fair.", "page_facts": "verdict: stock\nflags: none\npairings: mango + passion fruit classic; mango + coconut classic; passion fruit + coconut classic\ntaste meters (page estimates, 0-5): sweet 5, sour 2, kick 2\nwrapper contrast: 6.5:1\nscore bands: craving 3-5, standout 3-5, name 3-5, wrapper 3-5, fit 3-5"}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "sugar-slot:panel:" + 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": `sugar-slot:panel:${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", "sugar-slot:panel:"+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", "sugar-slot:panel:" + 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" => "sugar-slot:panel:#{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: sugar-slot:panel:" . 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", $"sugar-slot:panel:{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);