Drive Pocket Mascots from your own code
The one metered task, lineup, is available over HTTP. It takes one thing a person lives with - a car, a kettle, a houseplant, a pet - with what kind of thing it is, the mood wanted, two to six quirks numbered from 1, what to leave out and the page's facts. It returns a status (ok or declined), and for ok a series name, three directions (A, B and C), exactly six figures - two per direction, each built from fixed parts (shape, body colour, second colour, background, eyes, mouth, extra, corner), drawn from one quirk with a quote, a bio and a catchphrase, one marked rare - a pick and a closing line.
The division of labour first: the page numbers the quirks; the model chooses parts and words; the page draws every figure and then re-checks the reply - six figures in direction order, exactly one rare, every part one it can draw, each body colour from the table of colours that stand out on its background, quotes word for word from their quirks, every quirk used, the mood's faces and the left-out words. Mascots are for things, plants and animals, not people. A toy for fun, not design or 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":"pocket-mascots"}), 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, thing, kind, mood, quirks, keep_out, 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: designing a series 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 status
Every run sends task: lineup (the one task is lineup); a missing or different one is still answered as lineup. The status field holds one of two ids, checked in this order: declined when the thing itself is a person (any human, named or not) or a character that belongs to a brand, film, show, book or game - decided by what the thing is, not who owns it, so "my mum's kettle" is a kettle; otherwise ok.
Input (every field is a string)
| Field | Required | What it holds |
|---|---|---|
task | yes | Always lineup. |
thing | yes | What the mascot is for, as typed, up to 60 characters (cut on a word past that). |
kind | yes | object, machine, plant or animal. |
mood | yes | cheerful, grumpy, smug, sleepy, nervous or proud. |
quirks | yes | 2 to 6 lines as n | quirk, numbered from 1; bullets stripped, repeats dropped, each up to 120 characters. |
keep_out | yes | Things to leave out, up to 80 characters, or nothing named. |
page_facts | yes | One key | ... per line: quirk_count, quirk_words, person_words_in_thing, capitalised_words_in_thing and series_rule. |
clip_note | no | What the page left out, if anything. |
retry_note | no | Only on the page's one reformat retry, after an unusable reply. |
Reply (one JSON object)
{"task": "lineup", "status": "ok", "reason": "", "series_name": "...",
"directions": [{"id": "A", "name": "...", "idea": "..."}, {"id": "B", ...}, {"id": "C", ...}],
"figures": [{"no": 1, "direction": "A", "name": "...", "form": "cube", "body": "tomato", "accent": "cream",
"background": "powder", "eyes": "stern", "mouth": "flat", "extra": "steam", "corner": "left",
"quirk": 1, "quirk_quote": "...", "bio": "...", "catchphrase": "...", "rarity": "common"}, ...],
"pick": 3, "pick_reason": "...", "closing": "..."}
| Field | What it holds |
|---|---|
status, reason | The status by the order above; for declined, at most 30 words saying why (empty for ok). |
series_name | 2 to 4 words (empty when declined). |
directions | Exactly 3, ids A, B and C, each a 2 to 4 word name and an idea of at most 20 words ([] when declined). |
figures | Exactly 6, no 1 to 6, directions A, A, B, B, C, C. Parts from fixed lists: form (blob, bean, cube, drop, cloud, capsule, ghost, potted), background (blush, lemon, sage, powder, lilac, sand, night), body (a colour that stands out on that background), accent (any other colour), eyes (dots, wide, sleepy, sparkle, wink, specs, stern), mouth (smile, grin, flat, open, smirk, wobbly, fang), extra (none, sprout, antenna, bow, beanie, crown, steam, bandage), corner (left, right). Then quirk (its number), quirk_quote (1 to 8 words copied from it), bio (at most 25 words), catchphrase (at most 8) and rarity (exactly one rare). |
pick, pick_reason | The number of the best single mascot and at most 25 words on why (0 and "" when declined). |
closing | A friendly line of at most 16 words. |
The page then checks the reply (MascotKit.check): six figures in direction order, three directions A to C, exactly one rare, every part one it can draw (an unknown part is drawn with a stand-in and flagged), each body colour from its background's table, an accent that differs from the body, quotes word for word from the quirk they point at, every quirk used, a face from the mood's list on every figure, variants that differ in at least two parts, unique names, the left-out words and parts, word limits, and a pick that names a real figure. A declined reply should carry no figures; a series for a thing that reads as a person is flagged.
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 both outcomes - an old hatchback (a grumpy machine), a monstera (a cheerful plant), a green armchair (a sleepy object with something to leave out), and a brother-in-law, which is a person, so the series is declined.
The old hatchback - a grumpy machine
Input:
{
"task": "lineup",
"thing": "my old hatchback",
"kind": "machine",
"mood": "grumpy",
"quirks": "1 | the passenger door only opens from the inside\n2 | the radio only gets one station\n3 | it smells faintly of oranges\n4 | the wipers squeak on every second swipe",
"keep_out": "nothing named",
"page_facts": "quirk_count | 4\nquirk_words | quirk 1: 8, quirk 2: 6, quirk 3: 5, quirk 4: 7\nperson_words_in_thing | none\ncapitalised_words_in_thing | none\nseries_rule | 3 directions (A, B, C) x 2 figures = 6; exactly one rare; every quirk used by at least one figure"
}
Reply:
{
"task": "lineup",
"status": "ok",
"reason": "",
"series_name": "Squeaky Citrus Grumble",
"directions": [
{
"id": "A",
"name": "Inside Rules",
"idea": "Plays up the door that only opens from the inside and the faint orange smell."
},
{
"id": "B",
"name": "Same Station",
"idea": "Plays up sticking to one radio station and the wipers' squeak every second swipe."
},
{
"id": "C",
"name": "Secretly Fresh",
"idea": "Plays up the orange scent and the inside-only door as a grump with a soft side."
}
],
"figures": [
{
"no": 1,
"direction": "A",
"name": "Inbolt",
"form": "capsule",
"body": "tomato",
"accent": "charcoal",
"background": "sage",
"eyes": "stern",
"mouth": "flat",
"extra": "none",
"corner": "left",
"quirk": 1,
"quirk_quote": "only opens from the inside",
"bio": "Inbolt only opens up on its own terms, from the inside and nowhere else, pure grump about every entrance.",
"catchphrase": "In from the inside, not before.",
"rarity": "common"
},
{
"no": 2,
"direction": "A",
"name": "Zestlatch",
"form": "cube",
"body": "tangerine",
"accent": "cocoa",
"background": "sage",
"eyes": "wide",
"mouth": "flat",
"extra": "none",
"corner": "right",
"quirk": 3,
"quirk_quote": "smells faintly of oranges",
"bio": "Zestlatch smells faintly of oranges and dares anyone to comment, radiating grump energy with a strange citrus calm underneath.",
"catchphrase": "Oranges, faintly, and completely unbothered.",
"rarity": "common"
},
{
"no": 3,
"direction": "B",
"name": "Stationstuck",
"form": "blob",
"body": "teal",
"accent": "bubblegum",
"background": "lemon",
"eyes": "stern",
"mouth": "open",
"extra": "antenna",
"corner": "left",
"quirk": 2,
"quirk_quote": "only gets one station",
"bio": "Stationstuck only gets one station and grumbles if anyone dares reach for the dial, loyal to that one sound.",
"catchphrase": "One station is plenty, thank you.",
"rarity": "common"
},
{
"no": 4,
"direction": "B",
"name": "Squeakbeat",
"form": "drop",
"body": "sky",
"accent": "grape",
"background": "sand",
"eyes": "wide",
"mouth": "flat",
"extra": "none",
"corner": "right",
"quirk": 4,
"quirk_quote": "squeak on every second swipe",
"bio": "Squeakbeat squeaks on every second swipe and seems to grumble right on beat, unbothered by the rhythm it keeps.",
"catchphrase": "Squeak on every second swipe, obviously.",
"rarity": "common"
},
{
"no": 5,
"direction": "C",
"name": "Zestgrump",
"form": "cloud",
"body": "teal",
"accent": "tangerine",
"background": "lilac",
"eyes": "stern",
"mouth": "smirk",
"extra": "none",
"corner": "left",
"quirk": 3,
"quirk_quote": "faintly of oranges",
"bio": "Zestgrump smells faintly of oranges despite the scowl, a grump with a soft, unexpected sweetness.",
"catchphrase": "Faintly orange, utterly unimpressed.",
"rarity": "rare"
},
{
"no": 6,
"direction": "C",
"name": "Doorgrump",
"form": "ghost",
"body": "mint",
"accent": "charcoal",
"background": "night",
"eyes": "wide",
"mouth": "flat",
"extra": "none",
"corner": "right",
"quirk": 1,
"quirk_quote": "the passenger door only opens",
"bio": "Doorgrump only opens from the inside, keeping the passenger door shut to the world until it decides otherwise.",
"catchphrase": "Inside only, no exceptions.",
"rarity": "common"
}
],
"pick": 1,
"pick_reason": "Inbolt's inside-only door is the hatchback's most stubborn, memorable quirk, making it the best mascot for the whole grumpy series.",
"closing": "A grumpy little lineup, each figure guarding its own favourite quirk with pride."
}
The monstera - a cheerful plant
Input:
{
"task": "lineup",
"thing": "the monstera by the window",
"kind": "plant",
"mood": "cheerful",
"quirks": "1 | it leans hard towards the light\n2 | one leaf has a split right down the middle\n3 | it grows a new leaf whenever I forget to water it",
"keep_out": "nothing named",
"page_facts": "quirk_count | 3\nquirk_words | quirk 1: 6, quirk 2: 9, quirk 3: 11\nperson_words_in_thing | none\ncapitalised_words_in_thing | none\nseries_rule | 3 directions (A, B, C) x 2 figures = 6; exactly one rare; every quirk used by at least one figure"
}
Reply:
{
"task": "lineup",
"status": "ok",
"reason": "",
"series_name": "Leans For Light",
"directions": [
{
"id": "A",
"name": "Light Leaners",
"idea": "Plays up leaning hard toward the light, angling for the brightest spot it can find."
},
{
"id": "B",
"name": "Split Leaf Crew",
"idea": "Plays up the leaf split right down the middle, each half still part of the whole leaf."
},
{
"id": "C",
"name": "Forgetful Growers",
"idea": "Plays up growing a new leaf whenever watering gets forgotten, turning neglect into growth."
}
],
"figures": [
{
"no": 1,
"direction": "A",
"name": "Lightward",
"form": "potted",
"body": "sky",
"accent": "butter",
"background": "sage",
"eyes": "sparkle",
"mouth": "smile",
"extra": "sprout",
"corner": "left",
"quirk": 1,
"quirk_quote": "leans hard towards the light",
"bio": "It leans hard toward the light, cheerful about chasing every brighter spot it can find.",
"catchphrase": "Leaning hard toward the light",
"rarity": "common"
},
{
"no": 2,
"direction": "A",
"name": "Brightbend",
"form": "bean",
"body": "teal",
"accent": "tangerine",
"background": "lemon",
"eyes": "dots",
"mouth": "grin",
"extra": "none",
"corner": "right",
"quirk": 1,
"quirk_quote": "it leans hard towards the light",
"bio": "It bends every which way to catch the light, cheerful no matter the angle.",
"catchphrase": "Bending toward the brightest light",
"rarity": "common"
},
{
"no": 3,
"direction": "B",
"name": "Halfline",
"form": "cube",
"body": "grape",
"accent": "cream",
"background": "powder",
"eyes": "dots",
"mouth": "smile",
"extra": "none",
"corner": "left",
"quirk": 2,
"quirk_quote": "a split right down the middle",
"bio": "A leaf on it splits right down the middle, and it wears both halves like a cheerful match.",
"catchphrase": "Split clean, still a happy leaf",
"rarity": "common"
},
{
"no": 4,
"direction": "B",
"name": "Splitshine",
"form": "drop",
"body": "mint",
"accent": "charcoal",
"background": "blush",
"eyes": "sparkle",
"mouth": "grin",
"extra": "none",
"corner": "right",
"quirk": 2,
"quirk_quote": "right down the middle",
"bio": "Splitshine wears its leaf split right down the middle and stays cheerful about it.",
"catchphrase": "Split down the middle, cheerful anyway",
"rarity": "common"
},
{
"no": 5,
"direction": "C",
"name": "Newleaf",
"form": "capsule",
"body": "cocoa",
"accent": "butter",
"background": "lilac",
"eyes": "dots",
"mouth": "smile",
"extra": "sprout",
"corner": "left",
"quirk": 3,
"quirk_quote": "grows a new leaf",
"bio": "Whenever watering gets forgotten, it grows a new leaf anyway, cheerful about turning a slip into something fresh.",
"catchphrase": "Forgot to water? New leaf anyway",
"rarity": "common"
},
{
"no": 6,
"direction": "C",
"name": "Growback",
"form": "ghost",
"body": "mint",
"accent": "tangerine",
"background": "night",
"eyes": "sparkle",
"mouth": "grin",
"extra": "none",
"corner": "right",
"quirk": 3,
"quirk_quote": "whenever I forget to water it",
"bio": "It turns forgetting to water it into a reason to grow a whole new leaf, cheerful through it.",
"catchphrase": "Forgot again? Watch it grow",
"rarity": "rare"
}
],
"pick": 1,
"pick_reason": "It captures how the monstera by the window leans hard toward the light, the most immediate thing about it.",
"closing": "Cheerful takes on the monstera by the window, all leaning into the light."
}
The green armchair - a sleepy object
Input:
{
"task": "lineup",
"thing": "the green armchair",
"kind": "object",
"mood": "sleepy",
"quirks": "1 | the left arm is worn shiny\n2 | it creaks when anyone sits down\n3 | coins vanish down the side of the cushion\n4 | the seat keeps the shape of the last person who sat in it\n5 | one button is missing from the back",
"keep_out": "no crowns",
"page_facts": "quirk_count | 5\nquirk_words | quirk 1: 6, quirk 2: 6, quirk 3: 8, quirk 4: 13, quirk 5: 7\nperson_words_in_thing | none\ncapitalised_words_in_thing | none\nseries_rule | 3 directions (A, B, C) x 2 figures = 6; exactly one rare; every quirk used by at least one figure"
}
Reply:
{
"task": "lineup",
"status": "ok",
"reason": "",
"series_name": "The Settle Set",
"directions": [
{
"id": "A",
"name": "Worn In Comfort",
"idea": "Plays up the worn left arm and the seat keeping the last sitter's shape."
},
{
"id": "B",
"name": "Coin Keeper",
"idea": "Plays up how coins vanish down the cushion, making it a quietly secretive type."
},
{
"id": "C",
"name": "Small Creaks And Gaps",
"idea": "Plays up the creak when sat on and the one missing button on its back."
}
],
"figures": [
{
"no": 1,
"direction": "A",
"name": "Shine Arm",
"form": "capsule",
"body": "cocoa",
"accent": "butter",
"background": "sage",
"eyes": "sleepy",
"mouth": "smile",
"extra": "bandage",
"corner": "left",
"quirk": 1,
"quirk_quote": "the left arm is worn shiny",
"bio": "Shine Arm leans on its worn, shiny arm, too sleepy to ever switch sides.",
"catchphrase": "Mind the worn shiny arm",
"rarity": "common"
},
{
"no": 2,
"direction": "A",
"name": "Soft Print",
"form": "cube",
"body": "charcoal",
"accent": "sky",
"background": "sage",
"eyes": "sleepy",
"mouth": "flat",
"extra": "none",
"corner": "right",
"quirk": 4,
"quirk_quote": "the seat keeps the shape of the last",
"bio": "Soft Print holds the shape of whoever sat there last, slow and sleepy about ever letting it go.",
"catchphrase": "I keep the last shape",
"rarity": "common"
},
{
"no": 3,
"direction": "B",
"name": "Coin Slip",
"form": "blob",
"body": "teal",
"accent": "cream",
"background": "lemon",
"eyes": "wide",
"mouth": "wobbly",
"extra": "none",
"corner": "left",
"quirk": 3,
"quirk_quote": "coins vanish down the side of the cushion",
"bio": "Coin Slip quietly lets coins vanish down its side, too drowsy to ever give them back.",
"catchphrase": "Watch your coins slip away",
"rarity": "common"
},
{
"no": 4,
"direction": "B",
"name": "Pocket Dip",
"form": "bean",
"body": "grape",
"accent": "charcoal",
"background": "blush",
"eyes": "sleepy",
"mouth": "smirk",
"extra": "bow",
"corner": "right",
"quirk": 3,
"quirk_quote": "vanish down the side",
"bio": "Pocket Dip drowses with coins slipping down its side, never quite awake enough to notice they are gone.",
"catchphrase": "Coins go in, never out",
"rarity": "common"
},
{
"no": 5,
"direction": "C",
"name": "Creak Note",
"form": "drop",
"body": "sky",
"accent": "tangerine",
"background": "sand",
"eyes": "stern",
"mouth": "wobbly",
"extra": "beanie",
"corner": "left",
"quirk": 2,
"quirk_quote": "it creaks when anyone sits down",
"bio": "Creak Note gives a slow creak whenever anyone sits down, half asleep before the sound even finishes.",
"catchphrase": "I creak before I wake",
"rarity": "common"
},
{
"no": 6,
"direction": "C",
"name": "Back Gap",
"form": "ghost",
"body": "mint",
"accent": "cocoa",
"background": "night",
"eyes": "sleepy",
"mouth": "flat",
"extra": "none",
"corner": "right",
"quirk": 5,
"quirk_quote": "one button is missing from the back",
"bio": "Back Gap is missing one button on its back, too sleepy to ever mind the small gap.",
"catchphrase": "One button missing, no rush",
"rarity": "rare"
}
],
"pick": 3,
"pick_reason": "Coin Slip captures quirk 3, coins quietly vanishing down the side of the cushion.",
"closing": "A sleepy little lineup, every quirk honored, nothing rushed."
}
A brother-in-law - declined
Input:
{
"task": "lineup",
"thing": "my brother-in-law",
"kind": "object",
"mood": "smug",
"quirks": "1 | always parks across two spaces\n2 | hums while cooking\n3 | has an opinion about every kettle",
"keep_out": "nothing named",
"page_facts": "quirk_count | 3\nquirk_words | quirk 1: 5, quirk 2: 3, quirk 3: 6\nperson_words_in_thing | brother-in-law\ncapitalised_words_in_thing | none\nseries_rule | 3 directions (A, B, C) x 2 figures = 6; exactly one rare; every quirk used by at least one figure"
}
Reply:
{
"task": "lineup",
"status": "declined",
"reason": "This app makes mascots for things, plants and animals, not people or brand characters. Please pick something that belongs to you instead.",
"series_name": "",
"directions": [],
"figures": [],
"pick": 0,
"pick_reason": "",
"closing": "Thanks for stopping by, we'd love to make something for you another time."
}
Build the input yourself
Load the engine in node (global.window = global; require("./mascotkit.js"); require("./figure.js")), then:
const K = MascotKit, F = MascotFigure;
const built = K.buildInput({ thing: "my old hatchback", kind: "machine", mood: "grumpy",
quirks: "the passenger door only opens from the inside
the radio only gets one station", keep_out: "" });
built.parsed.quirks; // ["the passenger door only opens from the inside", ...]
built.prescan.person; // [] - person words in the thing
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
// F.figureSvg(reply.figures[0]) // draw a figure as SVG
// K.md(built, reply) // the series 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://pocket-mascots.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://pocket-mascots.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"]
// pocket-mascots.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://pocket-mascots.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://pocket-mascots.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
}
// PocketMascots.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 PocketMascots {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://pocket-mascots.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://pocket-mascots.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://pocket-mascots.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://pocket-mascots.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":"pocket-mascots"}' | 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": "pocket-mascots"})["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: "pocket-mascots" })).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": "pocket-mascots"}, 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", "pocket-mascots"), 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: "pocket-mascots" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "pocket-mascots"])["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 = "pocket-mascots" }))!["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": "lineup", "thing": "...", "kind": "machine", "mood": "grumpy", "quirks": "1 | ...\n2 | ...", "keep_out": "nothing named", "page_facts": "quirk_count | 2\n..."} - 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": "lineup", "thing": "...", "kind": "machine", "mood": "grumpy", "quirks": "1 | ...\n2 | ...", "keep_out": "nothing named", "page_facts": "quirk_count | 2\n..."} - 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: "lineup", thing, kind, mood, quirks, keep_out, 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 pocket-mascots:<task>:<inputHash>:a<attempt> (the one task is lineup), 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="pocket-mascots:lineup:$(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 = "pocket-mascots:" + 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("status"))
import { createHash } from "node:crypto";
const key = `pocket-mascots:${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.status);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "pocket-mascots:" + 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 = "pocket-mascots:" + 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 = "pocket-mascots:#{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["status"]].inspect
$key = "pocket-mascots:" . $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["status"], "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"pocket-mascots:{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: pocket-mascots:lineup:$(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": "lineup", "thing": "...", "kind": "machine", "mood": "grumpy", "quirks": "1 | ...\n2 | ...", "keep_out": "nothing named", "page_facts": "quirk_count | 2\n..."} - 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": "pocket-mascots:lineup:" + 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": `pocket-mascots:lineup:${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", "pocket-mascots:lineup:"+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", "pocket-mascots:lineup:" + 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" => "pocket-mascots:lineup:#{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: pocket-mascots:lineup:" . 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", $"pocket-mascots:lineup:{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);