← Called It
API

Drive Called It from your own code

The one metered task, call, is available over HTTP. It takes what someone is about to sit through - the thing in their words, its kind (watching, going, a call or a trip), what they already know, the tone (gentle or cheeky), their own squares and a leave-out list - and returns the pool of bingo squares: 30 moments, each tagged with its odds (likely, maybe or longshot) and the elemental genre whose feeling it gives, plus a free centre square, a title, a one-line promise and a house rule.

The division of labour first: the page deals; the model writes. Which squares go on which card, where they sit, the balance of odds along every row, column and diagonal, the main genre of the blend and the line calls are all computed by CardKit - the model never places a square. After the reply, the page checks it against the input.

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":"called-it"}), 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, what, kind, 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: writing the squares is metered and needs 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: call (the one task is call); a missing or different one is still answered as call. All fields are strings.

FieldRequiredWhat it holds
taskyesAlways call.
whatyesWhat the person is about to sit through, in their words (up to 120 characters).
kindyeswatch, go, call or trip, followed by a short description, e.g. go - going to an event or a gathering in person.
knownyesWhat the person already knows about it (up to 240 characters), or none.
toneyesgentle or cheeky, followed by a short description.
ownyesThe person's own squares as numbered lines (1. Someone loses a shoe dancing), up to 12, or none. The page puts these on every card; the reply does not repeat them.
avoidyesTopics to leave out, or none.
retry_notenoOnly on a reformat retry: why the previous reply was unusable.

Each square is one moment a player can see or hear happen, at most 32 characters, naming no one (only "someone", "everyone" or a role the input names), with no numbers, days, places or names the input never gave. Squares are about moments, never about a person's looks, age, money, faith, health or love life.

The reply

Exactly one JSON object:

{"task": "call", "title": "...", "promise": "...", "centre": "...", "house_rule": "...", "squares": [{"text": "...", "odds": "likely", "genre": "humor"}]}
FieldWhat it holds
squares[]30 objects: 10 likely, then 12 maybe, then 8 longshot. Each has text, odds and genre - one of wonder, idea, adventure, horror, mystery, thriller, humor, relationship, drama, issue, ensemble.
centreThe free centre square: the near-certain moment.
titleA name for the game, at most 40 characters.
promiseOne sentence: the feeling this kind of thing promises.
house_ruleOne sentence: when a square counts as marked.

The page reads the reply with ReplyKit.normalize (unknown odds read as maybe, unknown genres as none), deals the cards with CardKit.dealAll and checks the reply with ReplyKit.check, which lists, under "The page's check", a square count or odds mix other than asked, unknown odds or genres, repeats (shown once on the cards), long squares, contact details (left off the cards), anything from the leave-out list, and numbers, days or capitalised names the input never gave.

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 four kinds, both tones, things already known, the person's own squares and a leave-out list.

Romance film - cheeky tone

Input:

{
 "task": "call",
 "what": "a feel-good romance film we have both seen before",
 "kind": "watch - watching a film, a show or a programme",
 "known": "we always cry at the ending",
 "tone": "cheeky - playful and teasing about the situation, never about a person",
 "own": "none",
 "avoid": "none"
}

Reply:

{
 "task": "call",
 "title": "Seen-It-Before Romance Bingo",
 "promise": "Every twist is familiar, and watching it land again is still the fun part.",
 "centre": "Everyone cries at the ending",
 "house_rule": "A square counts once everyone watching agrees the moment clearly happened.",
 "squares": [
  {
   "text": "The couple have a meet-cute",
   "odds": "likely",
   "genre": "relationship"
  },
  {
   "text": "A mix-up drives the couple apart",
   "odds": "likely",
   "genre": "drama"
  },
  {
   "text": "The score swells at a key moment",
   "odds": "likely",
   "genre": "wonder"
  },
  {
   "text": "A best friend gives blunt advice",
   "odds": "likely",
   "genre": "relationship"
  },
  {
   "text": "Someone recites a line early",
   "odds": "likely",
   "genre": "humor"
  },
  {
   "text": "The couple almost kiss then stop",
   "odds": "likely",
   "genre": "relationship"
  },
  {
   "text": "Someone attempts a grand gesture",
   "odds": "likely",
   "genre": "wonder"
  },
  {
   "text": "Everyone says the line in unison",
   "odds": "likely",
   "genre": "ensemble"
  },
  {
   "text": "Someone laughs ahead of a joke",
   "odds": "likely",
   "genre": "humor"
  },
  {
   "text": "The camera lingers on an embrace",
   "odds": "likely",
   "genre": "wonder"
  },
  {
   "text": "A rival causes some trouble",
   "odds": "maybe",
   "genre": "drama"
  },
  {
   "text": "Someone overhears a private talk",
   "odds": "maybe",
   "genre": "mystery"
  },
  {
   "text": "A note gets slipped to someone",
   "odds": "maybe",
   "genre": "mystery"
  },
  {
   "text": "A friend plays matchmaker",
   "odds": "maybe",
   "genre": "humor"
  },
  {
   "text": "The couple share a knowing look",
   "odds": "maybe",
   "genre": "relationship"
  },
  {
   "text": "Someone turns back to try again",
   "odds": "maybe",
   "genre": "drama"
  },
  {
   "text": "A door slams during an argument",
   "odds": "maybe",
   "genre": "drama"
  },
  {
   "text": "Someone apologises at the door",
   "odds": "maybe",
   "genre": "relationship"
  },
  {
   "text": "A montage covers time passing",
   "odds": "maybe",
   "genre": "relationship"
  },
  {
   "text": "The couple hold hands at last",
   "odds": "maybe",
   "genre": "relationship"
  },
  {
   "text": "Everyone cheers at the reunion",
   "odds": "maybe",
   "genre": "ensemble"
  },
  {
   "text": "A rival loses out in the end",
   "odds": "maybe",
   "genre": "drama"
  },
  {
   "text": "Someone chases a departing ride",
   "odds": "longshot",
   "genre": "thriller"
  },
  {
   "text": "A stranger gives perfect advice",
   "odds": "longshot",
   "genre": "wonder"
  },
  {
   "text": "The credits hide an extra scene",
   "odds": "longshot",
   "genre": "mystery"
  },
  {
   "text": "A side character steals a scene",
   "odds": "longshot",
   "genre": "humor"
  },
  {
   "text": "Someone forgives the plot hole",
   "odds": "longshot",
   "genre": "issue"
  },
  {
   "text": "Someone applauds the final scene",
   "odds": "longshot",
   "genre": "wonder"
  },
  {
   "text": "Someone re-explains a side plot",
   "odds": "longshot",
   "genre": "idea"
  },
  {
   "text": "A flashback reveals old history",
   "odds": "longshot",
   "genre": "mystery"
  }
 ]
}

Wedding reception - gentle tone

Input:

{
 "task": "call",
 "what": "my cousin's wedding reception",
 "kind": "go - going to an event or a gathering in person",
 "known": "there will be speeches and a ceilidh band",
 "tone": "gentle - fond and kind - nobody is the butt of the joke",
 "own": "1. Someone loses a shoe dancing",
 "avoid": "exes"
}

Reply:

{
 "task": "call",
 "title": "Our Cousin's Wedding Bingo",
 "promise": "A warm gathering of speeches, ceilidh dancing, and small moments worth catching awaits you.",
 "centre": "Someone sways to the music",
 "house_rule": "Mark a square the moment everyone present agrees it clearly happened, no earlier.",
 "squares": [
  {
   "text": "A speech gets a laugh",
   "odds": "likely",
   "genre": "humor"
  },
  {
   "text": "The band strikes up a tune",
   "odds": "likely",
   "genre": "wonder"
  },
  {
   "text": "Someone claps on the wrong beat",
   "odds": "likely",
   "genre": "humor"
  },
  {
   "text": "Everyone joins a group dance",
   "odds": "likely",
   "genre": "ensemble"
  },
  {
   "text": "Someone forgets a dance step",
   "odds": "likely",
   "genre": "humor"
  },
  {
   "text": "A phone comes out to film it",
   "odds": "likely",
   "genre": "adventure"
  },
  {
   "text": "Someone is pulled onto the floor",
   "odds": "likely",
   "genre": "humor"
  },
  {
   "text": "The dance floor fills quickly",
   "odds": "likely",
   "genre": "ensemble"
  },
  {
   "text": "Someone glances at a watch",
   "odds": "likely",
   "genre": "thriller"
  },
  {
   "text": "A speech runs long",
   "odds": "likely",
   "genre": "thriller"
  },
  {
   "text": "The music slows for a moment",
   "odds": "maybe",
   "genre": "relationship"
  },
  {
   "text": "Someone learns the reel steps",
   "odds": "maybe",
   "genre": "adventure"
  },
  {
   "text": "Confetti appears for an entrance",
   "odds": "maybe",
   "genre": "wonder"
  },
  {
   "text": "Someone calls for a photo",
   "odds": "maybe",
   "genre": "ensemble"
  },
  {
   "text": "A group photo takes a long time",
   "odds": "maybe",
   "genre": "humor"
  },
  {
   "text": "Someone wipes away a tear",
   "odds": "maybe",
   "genre": "drama"
  },
  {
   "text": "Someone laughs at an old joke",
   "odds": "maybe",
   "genre": "humor"
  },
  {
   "text": "A speech loses its place",
   "odds": "maybe",
   "genre": "drama"
  },
  {
   "text": "Everyone claps along together",
   "odds": "maybe",
   "genre": "ensemble"
  },
  {
   "text": "Someone steps out for air",
   "odds": "maybe",
   "genre": "drama"
  },
  {
   "text": "The band takes a short break",
   "odds": "maybe",
   "genre": "mystery"
  },
  {
   "text": "The microphone squeals loudly",
   "odds": "maybe",
   "genre": "horror"
  },
  {
   "text": "Someone does the splits dancing",
   "odds": "longshot",
   "genre": "wonder"
  },
  {
   "text": "The whole room falls silent",
   "odds": "longshot",
   "genre": "mystery"
  },
  {
   "text": "A singalong breaks out",
   "odds": "longshot",
   "genre": "ensemble"
  },
  {
   "text": "Someone attempts a dance lift",
   "odds": "longshot",
   "genre": "adventure"
  },
  {
   "text": "Someone does an impromptu solo",
   "odds": "longshot",
   "genre": "adventure"
  },
  {
   "text": "The ceilidh band gets an encore",
   "odds": "longshot",
   "genre": "wonder"
  },
  {
   "text": "A heel gets caught in a gap",
   "odds": "longshot",
   "genre": "humor"
  },
  {
   "text": "The last dance runs long",
   "odds": "longshot",
   "genre": "thriller"
  }
 ]
}

All-hands call - cheeky tone

Input:

{
 "task": "call",
 "what": "the monthly all-hands video call",
 "kind": "call - sitting through a call or a meeting",
 "known": "it always starts late and the boss shares the slides",
 "tone": "cheeky - playful and teasing about the situation, never about a person",
 "own": "none",
 "avoid": "layoffs"
}

Reply:

{
 "task": "call",
 "title": "Mute Yourself: All-Hands Bingo",
 "promise": "You're about to sit through a call that tends to start late and lean on the slides.",
 "centre": "Someone asks can everyone hear",
 "house_rule": "A square counts the moment players agree they all just saw or heard it happen.",
 "squares": [
  {
   "text": "Call starts later than planned",
   "odds": "likely",
   "genre": "humor"
  },
  {
   "text": "The boss shares the slides",
   "odds": "likely",
   "genre": "idea"
  },
  {
   "text": "Someone is told they are on mute",
   "odds": "likely",
   "genre": "humor"
  },
  {
   "text": "A long pause follows a question",
   "odds": "likely",
   "genre": "thriller"
  },
  {
   "text": "The chat fills with greetings",
   "odds": "likely",
   "genre": "ensemble"
  },
  {
   "text": "Someone asks can you see this",
   "odds": "likely",
   "genre": "humor"
  },
  {
   "text": "The agenda runs past its time",
   "odds": "likely",
   "genre": "issue"
  },
  {
   "text": "A slide takes a moment to load",
   "odds": "likely",
   "genre": "humor"
  },
  {
   "text": "An echo comes through the call",
   "odds": "likely",
   "genre": "humor"
  },
  {
   "text": "Everyone is thanked at the close",
   "odds": "likely",
   "genre": "relationship"
  },
  {
   "text": "Someone forgets to unmute first",
   "odds": "maybe",
   "genre": "humor"
  },
  {
   "text": "A video feed freezes mid word",
   "odds": "maybe",
   "genre": "humor"
  },
  {
   "text": "The call drops and reconnects",
   "odds": "maybe",
   "genre": "thriller"
  },
  {
   "text": "No hands go up for the question",
   "odds": "maybe",
   "genre": "mystery"
  },
  {
   "text": "A join alert sounds mid call",
   "odds": "maybe",
   "genre": "ensemble"
  },
  {
   "text": "A virtual background glitches",
   "odds": "maybe",
   "genre": "humor"
  },
  {
   "text": "Reaction emojis pop up in chat",
   "odds": "maybe",
   "genre": "ensemble"
  },
  {
   "text": "Someone arrives after the start",
   "odds": "maybe",
   "genre": "humor"
  },
  {
   "text": "Someone reads off a script",
   "odds": "maybe",
   "genre": "drama"
  },
  {
   "text": "A shoutout mentions a recent win",
   "odds": "maybe",
   "genre": "ensemble"
  },
  {
   "text": "A poll result appears onscreen",
   "odds": "maybe",
   "genre": "idea"
  },
  {
   "text": "A camera stays off throughout",
   "odds": "maybe",
   "genre": "mystery"
  },
  {
   "text": "The call starts exactly on time",
   "odds": "longshot",
   "genre": "wonder"
  },
  {
   "text": "Every camera stays on throughout",
   "odds": "longshot",
   "genre": "ensemble"
  },
  {
   "text": "A genuine laugh breaks the quiet",
   "odds": "longshot",
   "genre": "humor"
  },
  {
   "text": "The slides run without a hitch",
   "odds": "longshot",
   "genre": "wonder"
  },
  {
   "text": "The connection stays steady",
   "odds": "longshot",
   "genre": "wonder"
  },
  {
   "text": "Every voice comes through clear",
   "odds": "longshot",
   "genre": "wonder"
  },
  {
   "text": "A tricky question gets answered",
   "odds": "longshot",
   "genre": "idea"
  },
  {
   "text": "The chat stays quiet throughout",
   "odds": "longshot",
   "genre": "mystery"
  }
 ]
}

Train journey - gentle tone

Input:

{
 "task": "call",
 "what": "a long train journey to visit family",
 "kind": "trip - a journey or a day out",
 "known": "none",
 "tone": "gentle - fond and kind - nobody is the butt of the joke",
 "own": "1. A dog boards the train\n2. Someone eats an egg sandwich",
 "avoid": "none"
}

Reply:

{
 "task": "call",
 "title": "The Long Way To Family",
 "promise": "A long, gentle ride unfolds, full of small sights, quiet waits, and the slow pull toward family.",
 "centre": "Family comes into view at last",
 "house_rule": "A square only counts once it clearly happens and everyone playing agrees they saw it too.",
 "squares": [
  {
   "text": "A ticket gets checked again",
   "odds": "likely",
   "genre": "humor"
  },
  {
   "text": "A bag goes on the overhead rack",
   "odds": "likely",
   "genre": "adventure"
  },
  {
   "text": "A reserved seat is already taken",
   "odds": "likely",
   "genre": "thriller"
  },
  {
   "text": "The next stop gets announced",
   "odds": "likely",
   "genre": "adventure"
  },
  {
   "text": "A window seat is claimed first",
   "odds": "likely",
   "genre": "humor"
  },
  {
   "text": "Someone naps between stops",
   "odds": "likely",
   "genre": "humor"
  },
  {
   "text": "The trolley comes down the aisle",
   "odds": "likely",
   "genre": "humor"
  },
  {
   "text": "A view opens up past the window",
   "odds": "likely",
   "genre": "wonder"
  },
  {
   "text": "Someone checks how long is left",
   "odds": "likely",
   "genre": "thriller"
  },
  {
   "text": "A wave goes up on the platform",
   "odds": "likely",
   "genre": "relationship"
  },
  {
   "text": "A seat gets shared with someone",
   "odds": "maybe",
   "genre": "relationship"
  },
  {
   "text": "A queue forms outside the toilet",
   "odds": "maybe",
   "genre": "humor"
  },
  {
   "text": "Someone pulls out a book to read",
   "odds": "maybe",
   "genre": "idea"
  },
  {
   "text": "The train slows for no reason",
   "odds": "maybe",
   "genre": "mystery"
  },
  {
   "text": "A headphone leaks a faint sound",
   "odds": "maybe",
   "genre": "humor"
  },
  {
   "text": "A call drops and is redialed",
   "odds": "maybe",
   "genre": "thriller"
  },
  {
   "text": "A door sticks between carriages",
   "odds": "maybe",
   "genre": "humor"
  },
  {
   "text": "Someone rechecks which platform",
   "odds": "maybe",
   "genre": "thriller"
  },
  {
   "text": "A delay gets announced",
   "odds": "maybe",
   "genre": "thriller"
  },
  {
   "text": "Someone points out a landmark",
   "odds": "maybe",
   "genre": "wonder"
  },
  {
   "text": "A charger gets passed along",
   "odds": "maybe",
   "genre": "relationship"
  },
  {
   "text": "Someone starts packing up early",
   "odds": "maybe",
   "genre": "adventure"
  },
  {
   "text": "A lost item gets handed back",
   "odds": "longshot",
   "genre": "relationship"
  },
  {
   "text": "Someone strikes up a long chat",
   "odds": "longshot",
   "genre": "relationship"
  },
  {
   "text": "The whole carriage goes quiet",
   "odds": "longshot",
   "genre": "mystery"
  },
  {
   "text": "The train crosses a long bridge",
   "odds": "longshot",
   "genre": "wonder"
  },
  {
   "text": "Someone spots a familiar face",
   "odds": "longshot",
   "genre": "mystery"
  },
  {
   "text": "The train arrives ahead of time",
   "odds": "longshot",
   "genre": "adventure"
  },
  {
   "text": "A round of applause breaks out",
   "odds": "longshot",
   "genre": "ensemble"
  },
  {
   "text": "A long tunnel swallows the view",
   "odds": "longshot",
   "genre": "wonder"
  }
 ]
}

Build the input yourself

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

const K = CardKit, R = ReplyKit;
const form = K.form({ what: "the monthly all-hands video call", kind: "call", known: "it always starts late",
  tone: "cheeky", own: "", avoid: "", players: "4" });
const built = K.buildInput(form);
// built.ok is false, with built.note, until the thing is named and no square carries contact details
const wire = K.toWire(built.input);   // POST this to /estimate and /run
// then: const reply = R.normalize(R.parseReply(job.output.output));
// K.dealAll(form, reply.squares, reply.centre, 0).cards   // one balanced 5x5 card per player
// R.check(reply, { form: form, input: built.input }).notes   // the page's check of the reply

Form keys: what (up to 120 characters), kind - watch, go, call or trip - known (up to 240, optional), tone - gentle or cheeky - own (one per line, up to 12, optional), avoid (optional) and players from 1 to 8 (page only; never 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://called-it.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":"called-it"}' | 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": "call", "what": "the monthly all-hands video call", "kind": "call - sitting through a call or a meeting", "known": "it always starts late and the boss shares the slides", "tone": "cheeky - ...", "own": "none", "avoid": "layoffs"}
# - 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 called-it:<task>:<inputHash>:a<attempt> (the one task is call), 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="called-it:call:$(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: called-it:call:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json