← Jump Rings
API

Drive Jump Rings from your own code

The one metered task, pick-charms, is available over HTTP. It takes what the bracelet is for and three to ten small moments, one per line. It returns one charm per moment from a fixed list of 24, an engraving of one to three words copied from that moment, one line about what the charm marks, and a title taken from what the bracelet is for.

The division of labour first: the page draws and arranges the bracelet; the model picks the charms. The page reads the moments, makes its own pick for each from a word list (earliest matching word first, never one charm twice), draws the chain, the jump rings and the charms, and lets you drag, swap and re-engrave them. After the reply it checks every engraving is your own words, word for word, and every line against what you typed - no added time, feeling, judgement, pronoun, person word or number - and keeps its own pick wherever one strays.

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":"jump-rings"}), 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, occasion, moments, 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: the reasons are metered and need a personal sign-in.
404not_foundUnknown run 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 input

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

FieldRequiredWhat it holds
taskyesAlways pick-charms.
occasionyesWhat the bracelet is for, up to 70 characters.
momentsyes3 to 10 moments, numbered, one per line (1. ...), each up to 90 characters.
factsyesThe page's lines: how many moments were sent and anything it left out or cut.
retry_notenoOnly on a reformat retry: why the previous reply was unusable.

The charm ids: heart, star, moon, sun, key, house, shell, leaf, flower, cup, book, camera, bicycle, boat, plane, umbrella, music-note, fish, anchor, mountain, ticket, envelope, cake, snowflake.

The reply

One JSON object, no prose around it:

{"task": "pick-charms", "title": "...", "charms": [{"n": 1, "charm": "...", "engrave": "...", "why": "..."}]}
FieldWhat it holds
charms[]One per moment, same order; n is the moment's number.
charmOne id from the list, each at most once.
engrave1 to 3 words copied from that moment, at most 14 characters.
whyOne sentence: the charm, a pointing verb, then the moment copied whole.
title1 to 8 words taken from the occasion line.

The page then checks the reply (RingKit.resolve) and lists what slipped under "The page's check". A charm off the list, an engraving that is not word for word, or a line that adds something is replaced by the page's own pick or left off.

Worked examples

These are the 3 free samples on the page: the exact input the page sends and the reply that replays for free. Together they cover moments the page's word list misses, numbers and frequency words in the input, and a named person.

A long weekend away - 6 moments

Input:

{
 "task": "pick-charms",
 "occasion": "Our long weekend by the sea",
 "moments": "1. Custard tarts still warm from the bakery\n2. The yellow tram up the hill\n3. Getting lost in the tiled alleys\n4. Sardines grilled on the street\n5. Watching the sunset from the old fort\n6. A singer in a bar with six tables",
 "facts": "6 moments sent, numbered in the order they were typed."
}

Reply:

{
 "task": "pick-charms",
 "title": "Our long weekend by the sea",
 "charms": [
  {
   "n": 1,
   "charm": "cake",
   "engrave": "custard tarts",
   "why": "The cake marks the custard tarts still warm from the bakery."
  },
  {
   "n": 2,
   "charm": "ticket",
   "engrave": "yellow tram",
   "why": "A ticket marks the yellow tram up the hill."
  },
  {
   "n": 3,
   "charm": "heart",
   "engrave": "getting lost",
   "why": "This heart marks getting lost in the tiled alleys."
  },
  {
   "n": 4,
   "charm": "fish",
   "engrave": "sardines",
   "why": "The fish marks the sardines grilled on the street."
  },
  {
   "n": 5,
   "charm": "sun",
   "engrave": "sunset",
   "why": "This sun marks watching the sunset from the old fort."
  },
  {
   "n": 6,
   "charm": "music-note",
   "engrave": "singer",
   "why": "A music note marks a singer in a bar with six tables."
  }
 ]
}

Our first flat - 7 moments

Input:

{
 "task": "pick-charms",
 "occasion": "The first year in our flat",
 "moments": "1. Two keys on a ribbon from the landlord\n2. Painting the hallway green\n3. The radiator that clanks most nights\n4. Basil on the windowsill\n5. The neighbours' cat visiting every Sunday\n6. Eating dinner on the floor before the table came\n7. Snow on the skylight",
 "facts": "7 moments sent, numbered in the order they were typed."
}

Reply:

{
 "task": "pick-charms",
 "title": "The first year in our flat",
 "charms": [
  {
   "n": 1,
   "charm": "key",
   "engrave": "Two keys",
   "why": "The key marks two keys on a ribbon from the landlord."
  },
  {
   "n": 2,
   "charm": "house",
   "engrave": "hallway green",
   "why": "A house stands for painting the hallway green."
  },
  {
   "n": 3,
   "charm": "heart",
   "engrave": "radiator",
   "why": "This heart points to the radiator that clanks most nights."
  },
  {
   "n": 4,
   "charm": "leaf",
   "engrave": "Basil",
   "why": "One leaf marks the basil on the windowsill."
  },
  {
   "n": 5,
   "charm": "star",
   "engrave": "cat",
   "why": "The star stands for the neighbours' cat visiting every Sunday."
  },
  {
   "n": 6,
   "charm": "moon",
   "engrave": "dinner",
   "why": "A moon hangs for eating dinner on the floor before the table came."
  },
  {
   "n": 7,
   "charm": "snowflake",
   "engrave": "Snow",
   "why": "This snowflake points to the snow on the skylight."
  }
 ]
}

Me and my sister - 5 moments

Input:

{
 "task": "pick-charms",
 "occasion": "Me and my sister Ana",
 "moments": "1. Sharing a bunk bed until we were twelve\n2. Her terrible singing in the car\n3. The postcard she sent from her gap year\n4. Learning to ride bikes in the car park\n5. Matching umbrellas at her wedding",
 "facts": "5 moments sent, numbered in the order they were typed."
}

Reply:

{
 "task": "pick-charms",
 "title": "Me and my sister Ana",
 "charms": [
  {
   "n": 1,
   "charm": "heart",
   "engrave": "bunk bed",
   "why": "The heart marks sharing a bunk bed until we were twelve."
  },
  {
   "n": 2,
   "charm": "music-note",
   "engrave": "singing",
   "why": "A music note marks her terrible singing in the car."
  },
  {
   "n": 3,
   "charm": "envelope",
   "engrave": "postcard",
   "why": "This envelope marks the postcard she sent from her gap year."
  },
  {
   "n": 4,
   "charm": "bicycle",
   "engrave": "bikes",
   "why": "One bicycle marks learning to ride bikes in the car park."
  },
  {
   "n": 5,
   "charm": "umbrella",
   "engrave": "umbrellas",
   "why": "The umbrella marks matching umbrellas at her wedding."
  }
 ]
}

Build the input yourself

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

const K = RingKit;
const form = K.form({ occasion: "Our summer of Saturday swims", moments: "The kettle we bought at the car boot sale\nSwimming before work in the cold lido\nChips on the harbour wall" });
const built = K.buildInput(form);          // built.facts holds the moments as the page read them
const wire = K.toWire(built.input);        // POST this to /estimate and /run
const picks = K.pagePicks(built.facts.lines); // the page's own free picks
// then: const reply = K.normalize(K.parseReply(job.output.output));
// K.resolve(built.facts, reply)          // the bracelet, with the page's notes on anything that strayed

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://jump-rings.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":"jump-rings"}' | 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": "pick-charms", "occasion": "Me and my sister Ana", "moments": "1. Sharing a bunk bed until we were twelve
..."", "facts": "...", ...}
# - 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 jump-rings:<task>:<inputHash>:a<attempt> (the one task is pick-charms), 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="jump-rings:pick-charms:$(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: jump-rings:pick-charms:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json