← Pocket Mascots
API

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.

StatusCodeMeaning
400validation_errorThe body is not a JSON object, or a required field (task, thing, kind, mood, quirks, keep_out, page_facts) is missing.
401unauthorizedNo token, or a stale one. Mint a guest token or sign in again.
402insufficient_creditsThe balance is under min_credits. Price with /estimate first.
403forbiddenA guest token tried to run: designing a series is metered and needs a personal token.
404not_foundUnknown job id.
429rate_limitedBack off and retry.
5xxserver_errorTransient. 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)

FieldRequiredWhat it holds
taskyesAlways lineup.
thingyesWhat the mascot is for, as typed, up to 60 characters (cut on a word past that).
kindyesobject, machine, plant or animal.
moodyescheerful, grumpy, smug, sleepy, nervous or proud.
quirksyes2 to 6 lines as n | quirk, numbered from 1; bullets stripped, repeats dropped, each up to 120 characters.
keep_outyesThings to leave out, up to 80 characters, or nothing named.
page_factsyesOne key | ... per line: quirk_count, quirk_words, person_words_in_thing, capitalised_words_in_thing and series_rule.
clip_notenoWhat the page left out, if anything.
retry_notenoOnly 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": "..."}
FieldWhat it holds
status, reasonThe status by the order above; for declined, at most 30 words saying why (empty for ok).
series_name2 to 4 words (empty when declined).
directionsExactly 3, ids A, B and C, each a 2 to 4 word name and an idea of at most 20 words ([] when declined).
figuresExactly 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_reasonThe number of the best single mascot and at most 25 words on why (0 and "" when declined).
closingA 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"}
}

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')

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}' 

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}' 

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}' 

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