← Chirp Sheet
API
Tokens

Drive Chirp Sheet from your own code

Both metered lanes are available over HTTP. card takes a rivalry dossier - both names, what they compete at, the head-to-head record, the heat (1 playful, 2 solid chirp, 3 spicy but clean), off-limits topics and the ammo - and returns a verdict, full-card, short-card or no-card, with three or two rounds of trash-talk lines (each tied to the ammo it stands on, with an angle and a heat rating) and a self-own. check takes the same dossier plus one line you wrote and returns send, tweak or bench, with corner notes and a rewrite. A natural use is a group-chat bot that keeps the league's dossier and checks a chirp before it posts.

The division of labour first: the page owns the corner, the record and the limits; the model writes the lines; the page re-checks the reply. The dossier reader, the corner and the clipping come from chirpkit.js, which runs in node as well as in the browser. Run ChirpKit.buildInput on anything you send: it drops every piece of ammo that touches an off-limits topic or someone's looks, family, money, health or identity, so the model never sees it. ChirpKit.check holds each reply to the dossier: the round count matches the verdict, every round cites ammo that was sent, no line is hotter than the heat you set, longer than 200 characters, or touches an off-limits word or a low blow, and no capitalised name, number or pronoun turns up that the dossier does not hold.

Get a token on the token page (step 2 below also shows how to mint a guest token in code).

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":"chirp-sheet"}), 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 such as task, you, rival or ammo 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: both lanes are metered and need 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 verdicts

Every run sends task: card or check; a missing or unknown task is treated as card. A piece of ammo is chirpable when it is about the rival (them) or a shared moment (us) and names something specific they did, said or always do; a bare insult ("trash", "so slow") is not, and me ammo never is. Each lane decides its verdict by three ordered, non-overlapping rules - the first that applies wins:

LaneOrderVerdictWhen
card1no-cardNo chirpable ammo. No rounds; asks holds two or three questions to dig up a specific moment.
card2short-cardOne or two chirpable pieces: exactly two rounds, opener and closer.
card3full-cardThree or more: exactly three rounds, opener, callback and closer, with heat that never drops.
check1benchThe line touches an off-limits topic or a low blow (looks, family, money or work, health, identity), or reads as a real insult. The rewrite is a different line on a different subject.
check2tweakOtherwise, if it is over 200 characters, states something the dossier contradicts or does not hold, runs hotter than the heat set, or buries the punchline. The rewrite keeps the joke.
check3sendNone of the above. No issues, no rewrite.

The declared input schema carries scalars only, so the array-valued off_limits and ammo fields travel JSON-encoded as strings (ChirpKit.toWire does this). Every value is a string.

Input fields

FieldTypeNotes
taskstringcard or check. Required.
you, rivalstringThe two names as typed, up to 24 characters each. Required.
arenastringWhat the two compete at, up to 80 characters, or empty.
you_wins, rival_winsstringWhole numbers, or both empty when nobody keeps score.
max_heatstring"1", "2" or "3".
off_limitsstring (JSON)Up to 10 topics as typed, e.g. ["Jordan's job","the divorce"]; may be [].
held_backstringHow many pieces of ammo the page kept in the corner; "0" when none.
ammostring (JSON)[{id, about, text}] - ids A1.., up to 16 pieces of up to 200 characters; about is them, me or us, from a "them:", "me:" or "us:" prefix. Required.
draft, draft_charsstringcheck only: the line to judge (up to 600 characters) and its length as counted by the page.
clip_note, retry_notestringWhat the page cut, when it cut something; and only on a reformat retry, what was wrong.

The reply

The job's output.output is a string holding one JSON object. Both lanes carry task, verdict, title and summary. card adds rounds ([{id, label, line, ammo, angle, heat}]; ids R1..; label is opener, callback or closer; angle is callback, record, prediction, absurd or deadpan; heat 1-3), self_own ({line, ammo}) and asks. check adds issues ([{rule, note}]; rule is off-limits, low-blow, ungrounded, too-long, too-hot or unclear), heat (of the draft), rewrite ({line, ammo}) and lands. Parse with ChirpKit.parseReply, then normalize(reply, task), then check(reply, form), which returns the page's notes.

Worked examples

Replies produced against the app's prompt for the six sample runs (made-up rivalries; long fields shortened; the full replies ship in example-result.js). Together they cover every verdict of both lanes.

card - Sam vs Jordan, pickup basketball, four specific moments: full-card

Input:

{
 "task": "card",
 "you": "Sam",
 "rival": "Jordan",
 "arena": "Sunday pickup basketball",
 "you_wins": "4",
 "rival_wins": "6",
 "max_heat": "2",
 "off_limits": "[\"Jordan's job\",\"the divorce\"]",
 "held_back": "1",
 "ammo": "[{\"id\":\"A1\",\"about\":\"them\",\"text\":\"called a timeout in a pickup game, where there are no timeouts\"},{\"id\":\"A2\",\"about\":\"them\",\"text\":\"blames every missed shot on the rim being \\\"a little loose\\\"\"},{\"id\":\"A3\",\"about\":\"them\",\"text\":\"shows up in brand-new shoes every week and still won't jump\"},{\"id\":\"A4\",\"about\":\"us\",\"text\":\"we both missed the same wide-open layup on the last play last week\"},{\"id\":\"A5\",\"about\":\"me\",\"t ... (518 characters)"
}

Reply:

{
 "task": "card",
 "verdict": "full-card",
 "title": "The Timeout Call Vs The Sneaker Show",
 "summary": "Sam and Jordan have more than enough ammo to spare, from the timeout call to the rim excuses and the brand-new shoes, so this one earns a full set of rounds. Those three moments carry the card from a light opener to a closer with some bite.",
 "rounds": [
  {
   "id": "R1",
   "label": "opener",
   "line": "Jordan, new shoes every week and still will not jump - the sneakers are getting a better workout than you are.",
   "ammo": [
    "A3"
   ],
   "angle": "absurd",
   "heat": 1
  },
  {
   "id": "R2",
   "label": "callback",
   "line": "Jordan, that timeout you called mid pickup game still gets brought up - pickup games do not have timeouts, but apparently you do.",
   "ammo": [
    "A1"
   ],
   "angle": "callback",
   "heat": 2
  },
  "... (3 in all)"
 ],
 "self_own": {
  "line": "I air-balled a free throw that nobody even asked me to take - genuinely impressive, if the goal was embarrassing myself.",
  "ammo": [
   "A5"
  ]
 },
 "asks": []
}

card - Riley vs Morgan, the office pool, two specific moments: short-card

Input:

{
 "task": "card",
 "you": "Riley",
 "rival": "Morgan",
 "arena": "the office baseball playoff pool",
 "you_wins": "1",
 "rival_wins": "1",
 "max_heat": "1",
 "off_limits": "[]",
 "held_back": "0",
 "ammo": "[{\"id\":\"A1\",\"about\":\"them\",\"text\":\"picked every series by which team has the nicer uniforms\"},{\"id\":\"A2\",\"about\":\"them\",\"text\":\"still drinks from last year's \\\"pool champion\\\" mug every morning\"},{\"id\":\"A3\",\"about\":\"them\",\"text\":\"total clown\"},{\"id\":\"A4\",\"about\":\"me\",\"text\":\"forgot to submit my picks for the first round\"}]"
}

Reply:

{
 "task": "card",
 "verdict": "short-card",
 "title": "The Uniform Pick vs the Champion Mug",
 "summary": "This card leans on Morgan's uniform-based picks and the pool champion mug ritual, the two stories with real receipts. The total clown line had no story behind it, so it is left out, and the head-to-head record sits level. Two rounds are enough to land clean chirps without padding the card.",
 "rounds": [
  {
   "id": "R1",
   "label": "opener",
   "line": "You picked every series by the nicer uniforms - a bold strategy for the office pool.",
   "ammo": [
    "A1"
   ],
   "angle": "absurd",
   "heat": 1
  },
  {
   "id": "R2",
   "label": "closer",
   "line": "The head-to-head is 1-1, and you're still drinking from last year's pool champion mug every morning.",
   "ammo": [
    "A2"
   ],
   "angle": "record",
   "heat": 1
  }
 ],
 "self_own": {
  "line": "I forgot to submit my picks for the first round, so maybe I'm the one who should skip the trash talk.",
  "ammo": [
   "A4"
  ]
 },
 "asks": []
}

card - Alex vs Casey, couch kart racing, only bare insults: no-card

Input:

{
 "task": "card",
 "you": "Alex",
 "rival": "Casey",
 "arena": "couch kart racing",
 "you_wins": "",
 "rival_wins": "",
 "max_heat": "3",
 "off_limits": "[]",
 "held_back": "1",
 "ammo": "[{\"id\":\"A1\",\"about\":\"them\",\"text\":\"bad\"},{\"id\":\"A2\",\"about\":\"them\",\"text\":\"so slow\"},{\"id\":\"A3\",\"about\":\"me\",\"text\":\"always picks the same character\"}]"
}

Reply:

{
 "task": "card",
 "verdict": "no-card",
 "title": "Alex Vs Casey Couch Kart Clash",
 "summary": "Alex and Casey only have bare jabs on hand, with no real story behind either one to build a line on. One more piece of ammo stayed in the corner, so what it says stays unknown for now. Casey needs a specific moment before this card can open.",
 "rounds": [],
 "self_own": {
  "line": "",
  "ammo": []
 },
 "asks": [
  "What is the most embarrassing crash Casey has had in couch kart racing?",
  "Has Casey ever used a shady shortcut to win a race?",
  "What is the funniest excuse Casey has given after a loss?"
 ]
}

check - the timeout line: send

Input:

{
 "task": "check",
 "you": "Sam",
 "rival": "Jordan",
 "arena": "Sunday pickup basketball",
 "you_wins": "4",
 "rival_wins": "6",
 "max_heat": "2",
 "off_limits": "[\"Jordan's job\",\"the divorce\"]",
 "held_back": "1",
 "ammo": "[{\"id\":\"A1\",\"about\":\"them\",\"text\":\"called a timeout in a pickup game, where there are no timeouts\"},{\"id\":\"A2\",\"about\":\"them\",\"text\":\"blames every missed shot on the rim being \\\"a little loose\\\"\"},{\"id\":\"A3\",\"about\":\"them\",\"text\":\"shows up in brand-new shoes every week and still won't jump\"},{\"id\":\"A4\",\"about\":\"us\",\"text\":\"we both missed the same wide-open layup on the last play last week\"},{\"id\":\"A5\",\"about\":\"me\",\"t ... (518 characters)",
 "draft": "Jordan, you called a timeout in pickup. Bold. Next week try calling one on the rim too, since it's apparently a little loose.",
 "draft_chars": "125"
}

Reply:

{
 "task": "check",
 "verdict": "send",
 "title": "The Loose Rim Timeout",
 "summary": "This line from Sam sends as written, stacking Jordan's pickup timeout call and the loose-rim excuse into one tidy jab. It stays clear of any low blow and keeps the heat within the limit for the group chat.",
 "issues": [],
 "heat": 2,
 "rewrite": {
  "line": "",
  "ammo": []
 },
 "lands": "The line keeps two real moments from Jordan - the pickup timeout call and the loose-rim excuse - and lands by turning the excuse into a second timeout call."
}

check - the long drive-home rant that gets the record wrong: tweak

Input:

{
 "task": "check",
 "you": "Sam",
 "rival": "Jordan",
 "arena": "Sunday pickup basketball",
 "you_wins": "4",
 "rival_wins": "6",
 "max_heat": "2",
 "off_limits": "[\"Jordan's job\",\"the divorce\"]",
 "held_back": "1",
 "ammo": "[{\"id\":\"A1\",\"about\":\"them\",\"text\":\"called a timeout in a pickup game, where there are no timeouts\"},{\"id\":\"A2\",\"about\":\"them\",\"text\":\"blames every missed shot on the rim being \\\"a little loose\\\"\"},{\"id\":\"A3\",\"about\":\"them\",\"text\":\"shows up in brand-new shoes every week and still won't jump\"},{\"id\":\"A4\",\"about\":\"us\",\"text\":\"we both missed the same wide-open layup on the last play last week\"},{\"id\":\"A5\",\"about\":\"me\",\"t ... (518 characters)",
 "draft": "So Jordan, I was thinking about it on the drive home and honestly after all these weeks of losing every single game to me you might want to consider that it isn't the rim and it isn't the shoes, it might just be that you can't shoot, also you called a timeout lol",
 "draft_chars": "263"
}

Reply:

{
 "task": "check",
 "verdict": "tweak",
 "title": "The Excuses and Timeout Line",
 "summary": "The line runs well past the message limit and claims a losing streak that the head-to-head record does not support, since Jordan actually leads Sam in wins. It also lands hotter than Sam wants and tucks the timeout callback behind too much wind-up. The rewrite keeps the rim-and-shoes setup and the timeout call but trims it down and drops the invented streak.",
 "issues": [
  {
   "rule": "too-long",
   "note": "The line runs to 263 characters, well past the 200-character limit for a group-chat message."
  },
  {
   "rule": "ungrounded",
   "note": "It claims Sam has been winning every game, but the record actually has Jordan ahead of Sam."
  },
  {
   "rule": "too-hot",
   "note": "Flatly declaring that Jordan can't shoot lands hotter than the solid-chirp heat Sam asked for."
  }
 ],
 "heat": 3,
 "rewrite": {
  "line": "Jordan, it's not the rim and it's not the shoes - and nobody's forgotten the timeout you called in a game with no timeouts.",
  "ammo": [
   "A1",
   "A2",
   "A3"
  ]
 },
 "lands": "The pivot from blaming the rim and the shoes to pointing out Jordan called a timeout is what actually lands."
}

check - a jab at an off-limits job: bench

Input:

{
 "task": "check",
 "you": "Sam",
 "rival": "Jordan",
 "arena": "Sunday pickup basketball",
 "you_wins": "4",
 "rival_wins": "6",
 "max_heat": "2",
 "off_limits": "[\"Jordan's job\",\"the divorce\"]",
 "held_back": "1",
 "ammo": "[{\"id\":\"A1\",\"about\":\"them\",\"text\":\"called a timeout in a pickup game, where there are no timeouts\"},{\"id\":\"A2\",\"about\":\"them\",\"text\":\"blames every missed shot on the rim being \\\"a little loose\\\"\"},{\"id\":\"A3\",\"about\":\"them\",\"text\":\"shows up in brand-new shoes every week and still won't jump\"},{\"id\":\"A4\",\"about\":\"us\",\"text\":\"we both missed the same wide-open layup on the last play last week\"},{\"id\":\"A5\",\"about\":\"me\",\"t ... (518 characters)",
 "draft": "Maybe spend less time at that job you hate and more time on your jump shot.",
 "draft_chars": "75"
}

Reply:

{
 "task": "check",
 "verdict": "bench",
 "title": "The Off-Limits Dig",
 "summary": "Sam's draft reaches into a subject the dossier marked off-limits, turning a tease into a low blow instead of fun ribbing. The rewrite drops that subject and leans on Jordan's new shoes and refusal to jump instead.",
 "issues": [
  {
   "rule": "off-limits",
   "note": "The draft brings up Jordan's job, which the dossier marked off-limits."
  },
  {
   "rule": "low-blow",
   "note": "Jabbing at someone's job crosses from ribbing into a real sting, not clean fun."
  }
 ],
 "heat": 2,
 "rewrite": {
  "line": "New shoes every week, Jordan, and you still won't jump.",
  "ammo": [
   "A3"
  ]
 },
 "lands": "Nothing in the draft lands safely, so the rewrite instead leans on Jordan's new shoes and refusal to jump for the laugh."
}

Build the input yourself

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

const K = ChirpKit;
const form = K.form({ you: "Sam", rival: "Jordan", arena: "pickup basketball", you_wins: "4", rival_wins: "6", heat: "2",
  off_limits: "Jordan's job", ammo: "them: called a timeout in pickup\nthem: is going bald\nme: air-balled a free throw",
  draft: "Jordan, you called a timeout in pickup. Bold." });
console.log(K.flags("card", form), K.leanCard(form), K.ammo(form).held);  // the flags, the page's lean, the ammo in the corner
const wire = K.toWire(K.buildInput("card", form).input);                 // POST this to /estimate and /run ("check" adds the draft)
// then: const card = K.normalize(K.parseReply(job.output.output), "card");
// const { notes } = K.check(card, form);                                 // the page's re-check of the reply

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://chirp-sheet.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":"chirp-sheet"}' | 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": "card", "you": "Sam", "rival": "Jordan", "arena": "Sunday pickup basketball", "you_wins": "4", "rival_wins": "6", "max_heat": "2", "off_limits": "[\"Jordan's job\",\"the divorce\"]", "held_back": "1", "ammo": "[{\"id\":\"A1\",\"about\":\"them\",\"text\":\"called a timeout in a pickup game, where there are no timeouts\"},{\"id\":\"A2\",\"about\":\"them\",\"text\":\"blames every missed shot on the rim being \\\"a little loose\\\"\"}, ...]"}
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 chirp-sheet:<task>:<inputHash>:a<attempt> (the lanes are card and check), 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="chirp-sheet:card:$(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: chirp-sheet:card:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json