← Peel and Stick
API

Drive Peel and Stick from your own code

The one metered task, stickers, is available over HTTP. It takes a short list of the things someone is into - one per numbered line - and a tone, and returns one answer per line: either two to five sticker words with a little picture, or a reason the line stays off the lid (it names a private person or holds a personal detail, or it puts someone down).

The division of labour first: the page draws the stickers; the model writes the words. The page reads the list (up to eight lines, 60 characters each), keeps back any line holding an email address, phone number, handle or web address before anything is sent, and draws every sticker - shape and colours from its words, the picture from the everyday words the line uses - on a laptop lid. After the reply it checks every sticker against its line. Your code can do the same with the engine files the page loads (see "Build the input yourself").

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": "..." } }

The token is minted for this app (the guest call takes {"slug":"peel-and-stick"}), 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, things, thing_count, tone) 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: the sticker words are metered and need a personal sign-in.
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, the input and the rules

Every run sends task: stickers (the one task is stickers); a missing or different one is still answered as stickers. All fields are strings.

FieldRequiredWhat it holds
taskyesAlways stickers.
thingsyesNumbered lines N. the thing, one per sticker, at most eight, in the person's own words. Build it with StickerKit.buildInput, which also keeps back lines holding contact details.
thing_countyesHow many lines things has, as a string.
toneyesdry (deadpan), sweet (warm) or bold (loud; the only tone that may use an exclamation mark).
retry_notenoOnly on a reformat retry: why the previous reply was unusable.

Each line gets one verdict, checked in this order: skip-personal (a private person's name, an address, contact details, a workplace or school), then skip-unkind (it puts a person or a group down, or is crude), else sticker.

The pictures (motif ids): heart, star, sun, moon, cloud, bolt, flame, leaf, flower, wave, mountain, cat, dog, bird, fish, cup, bread, book, note, camera, bike, pencil, game, ball, crown, spiral.

The reply

One JSON object:

{"task": "stickers", "stickers": [{"n": 1, "thing": "cold sea swims", "verdict": "sticker", "text": "...", "motif": "wave"}]}
FieldWhat it holds
stickers[]One entry per input line, in order: n, thing (the line, unchanged), verdict, text and motif.
textFor sticker: two to five words, at most 24 characters, keeping a word of the line; empty for the two skip verdicts.
motifFor sticker: one motif id; empty for the skips. The page uses its own pick whenever the line's words call one up.

The page reads the reply with ReplyKit.normalize and checks it with ReplyKit.check, which lists, under "The page's check", a missing or extra answer, a changed line, sticker words of the wrong length, words that keep nothing of the line, and any number, boast or symbol the line never had.

Worked examples - every sample

These are the free samples on the page: the exact input the page sends and the reply that replays for free. Together they cover all three tones and all three verdicts - plain stickers, a line kept off because it names a private person, a line kept off because it puts someone down - plus a brand name written around and a line with a number.

A quiet weekend person (dry)

Input:

{
 "task": "stickers",
 "things": "1. sourdough starter that refuses to die\n2. cold sea swims\n3. bad at chess, still playing\n4. houseplants I talk to\n5. crosswords in pen\n6. naps on the sofa",
 "thing_count": "6",
 "tone": "dry"
}

Reply:

{
 "task": "stickers",
 "stickers": [
  {
   "n": 1,
   "thing": "sourdough starter that refuses to die",
   "verdict": "sticker",
   "text": "Sourdough won't quit",
   "motif": "bread"
  },
  {
   "n": 2,
   "thing": "cold sea swims",
   "verdict": "sticker",
   "text": "Runs on cold sea swims",
   "motif": "wave"
  },
  {
   "n": 3,
   "thing": "bad at chess, still playing",
   "verdict": "sticker",
   "text": "Still bad at chess",
   "motif": "game"
  },
  {
   "n": 4,
   "thing": "houseplants I talk to",
   "verdict": "sticker",
   "text": "Talks to the houseplants",
   "motif": "leaf"
  },
  {
   "n": 5,
   "thing": "crosswords in pen",
   "verdict": "sticker",
   "text": "Crosswords done in pen",
   "motif": "game"
  },
  {
   "n": 6,
   "thing": "naps on the sofa",
   "verdict": "sticker",
   "text": "Sofa nap mode",
   "motif": "moon"
  }
 ]
}

Cosy and outdoorsy (sweet)

Input:

{
 "task": "stickers",
 "things": "1. my old bike\n2. baking cinnamon buns\n3. sunday walks with Maya\n4. reading in the bath\n5. the cat that sits on my keyboard\n6. jigsaws with the radio on",
 "thing_count": "6",
 "tone": "sweet"
}

Reply:

{
 "task": "stickers",
 "stickers": [
  {
   "n": 1,
   "thing": "my old bike",
   "verdict": "sticker",
   "text": "Old bike person",
   "motif": "bike"
  },
  {
   "n": 2,
   "thing": "baking cinnamon buns",
   "verdict": "sticker",
   "text": "Cinnamon buns fan",
   "motif": "bread"
  },
  {
   "n": 3,
   "thing": "sunday walks with Maya",
   "verdict": "skip-personal",
   "text": "",
   "motif": ""
  },
  {
   "n": 4,
   "thing": "reading in the bath",
   "verdict": "sticker",
   "text": "Bath reading person",
   "motif": "book"
  },
  {
   "n": 5,
   "thing": "the cat that sits on my keyboard",
   "verdict": "sticker",
   "text": "Ask me about my cat",
   "motif": "cat"
  },
  {
   "n": 6,
   "thing": "jigsaws with the radio on",
   "verdict": "sticker",
   "text": "Radio on jigsaw mode",
   "motif": "game"
  }
 ]
}

Loud and proud (bold)

Input:

{
 "task": "stickers",
 "things": "1. karaoke, badly\n2. my ex's terrible taste in music\n3. 5am gym club\n4. Lego on the living room floor\n5. hot sauce collection\n6. trail running in the mud",
 "thing_count": "6",
 "tone": "bold"
}

Reply:

{
 "task": "stickers",
 "stickers": [
  {
   "n": 1,
   "thing": "karaoke, badly",
   "verdict": "sticker",
   "text": "Proudly bad at karaoke!",
   "motif": "note"
  },
  {
   "n": 2,
   "thing": "my ex's terrible taste in music",
   "verdict": "skip-unkind",
   "text": "",
   "motif": ""
  },
  {
   "n": 3,
   "thing": "5am gym club",
   "verdict": "sticker",
   "text": "Gym club at 5am",
   "motif": "ball"
  },
  {
   "n": 4,
   "thing": "Lego on the living room floor",
   "verdict": "sticker",
   "text": "Brick floor hazard mode",
   "motif": "game"
  },
  {
   "n": 5,
   "thing": "hot sauce collection",
   "verdict": "sticker",
   "text": "Hot sauce fan",
   "motif": "flame"
  },
  {
   "n": 6,
   "thing": "trail running in the mud",
   "verdict": "sticker",
   "text": "Mud trail running!",
   "motif": "mountain"
  }
 ]
}

Build the input yourself

Load the engine in node (global.window = global; require("./stickerkit.js"); require("./replykit.js")), then:

const K = StickerKit, R = ReplyKit;
const built = K.buildInput(K.form({ things: "cold sea swims\ncrosswords in pen", tone: "dry", finish: "silver" }));  // built.ok is false, with built.note, until one line is usable
const wire = K.toWire(built.input);   // POST this to /estimate and /run
// then: const reply = R.normalize(R.parseReply(job.output.output));
// R.check(reply, built).notes        // the page's re-check of every sticker

Form keys: things (one per line, up to eight, 60 characters each), tone (dry, sweet or bold) and finish (silver, graphite, midnight, sage or blush - the lid is the page's business and is not sent).

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://peel-and-stick.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":"peel-and-stick"}' | 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": "stickers", "things": "1. cold sea swims\n2. crosswords in pen", "thing_count": "2", "tone": "dry"}
# - 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 peel-and-stick:<task>:<inputHash>:a<attempt> (the one task is stickers), 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="peel-and-stick:stickers:$(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: peel-and-stick:stickers:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json