← Tag Yourself
API
Tokens

Drive Tag Yourself from your own code

The one metered step - building a test - is available over HTTP. A natural use is a group-chat bot: it collects the group's running jokes, builds a test, and posts the share link so everyone plays in the browser for free.

The split first: the page does the bookkeeping; the model writes the test; the page checks it. Material parsing, private-detail scrubbing, the name and sensitive-subject prescan, scoring, the random-answer odds and the share links come from tagkit.js, which runs in node as well as in the browser. Its reconcile function then checks every structure rule: 3 axes with six distinct pole letters, the requested question count balanced across the axes, 3-4 options per question with both poles present, exactly 8 types covering every key with unique codes and valid best/worst matches, real material citations, no real name from the material, and that both poles of every axis are reachable.

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":"tag-yourself"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object, sent bare: never wrap it as {"input": {…}}.

StatusCodeMeaning
400validation_errorThe body is not a JSON object, or a required field 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: building 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 comes first

Every run sends task. This app has one task:

taskWhat it returnsVerdict field
buildtitle, tagline, summary; axes [{id, name, left:{letter, label}, right:{letter, label}}] (3); questions [{id, axis, text, options[{text, pole, weight}], uses[]}] (9 or 12, pole left or right, weight 1 or 2); types [{key, code, name, line, traits[3], best_with, worst_with, uses[]}] (8, keys LLL..RRR); missing; reframe; caveats.ready (four or more specific bits of material), thin (a full but more generic test plus missing: what would sharpen it), declined (targets one private person or a protected trait: no axes, questions or types, and a reframe idea instead).

A type key has one letter per axis in order, L for the left pole and R for the right. Scoring: each axis sums -weight for a left answer and +weight for a right one; a tie goes to the pole of the last answered option on that axis (TagKit.score).

The declared input schema carries scalars only, so object-valued fields travel JSON-encoded as strings (TagKit.toWire does this). task, group and material are required.

Input fields

FieldTypeNotes
taskstringbuild.
groupstring (JSON){name, kind, spice, questions}; kind office, group_chat, friends, team, club, class, fandom, family or other; spice mild, medium or spicy; questions 9 or 12.
materialstring (JSON)[{id, text}], ids M1.., at most 40 lines of 220 characters, private details already replaced with [removed] (TagKit.parseMaterial).
prescanstring (JSON){material_count, names[], removed_private, sensitive[{id, topic}]} from TagKit.prescan.
notestringOptional wishes; over 600 characters keeps the start and the end around a marker.
retry_notestringOnly on a reformat retry.

The reply

The job's output.output is a string holding one JSON object. Parse it with TagKit.parseReply, then normalize, then check it with reconcile(reply, {group, material, prescan}). playable(reply) says whether it can be played; encodeShare(reply) returns the fragment for https://tag-yourself.skillsafe.ai/#t=…, a link that plays the whole test with no account.

Worked examples

Replies produced against the app's prompt for the three sample groups (invented; lists shortened; the full replies ship in example-result.js), one per verdict. The page replays each of them free from its sample buttons.

Office floor - verdict ready

Input:

{
 "task": "build",
 "group": "{\"name\":\"The fourth-floor product team\",\"kind\":\"office\",\"spice\":\"medium\",\"questions\":12}",
 "material": "[{\"id\":\"M1\",\"text\":\"The fridge has a shelf of yogurts nobody will admit to owning\"},{\"id\":\"M2\",\"text\":\"Standup is supposed to be 10 minutes and has never once been 10 minutes\"},{\"id\":\"M3\",\"text\":\"Someone always replies-all with just a thumbs up\"},\"... 7 more lines\"]",
 "prescan": "{\"material_count\":10,\"names\":[],\"removed_private\":0,\"sensitive\":[]}"
}

Reply:

{
 "task": "build",
 "verdict": "ready",
 "title": "The Fourth-Floor Personality Test",
 "tagline": "Standup overruns, mystery yogurt, and the thermostat cold war finally get you typed.",
 "summary": "Built straight from your team's own flashpoints: the standup that never ends on time, the unclaimed yogurt shelf, the window-vs-hallway thermostat war, and Friday demo-day crunch. Medium spice keeps it an affectionate roast people will actually want to share.",
 "axes": [
  {
   "id": "A1",
   "name": "Meeting Time Philosophy",
   "left": {
    "letter": "O",
    "label": "Overtimer"
   },
   "right": {
    "letter": "C",
    "label": "Clock-Cutter"
   }
  },
  {
   "id": "A2",
   "name": "Snack & Supply Instinct",
   "left": {
    "letter": "H",
    "label": "Hoarder"
   },
   "right": {
    "letter": "G",
    "label": "Giver"
   }
  },
  {
   "id": "A3",
   "name": "Office Diplomacy",
   "left": {
    "letter": "W",
    "label": "Window Warrior"
   },
   "right": {
    "letter": "P",
    "label": "Peacemaker"
   }
  }
 ],
 "questions": [
  {
   "id": "Q1",
   "axis": "A1",
   "text": "Standup was supposed to end eight minutes ago. You...",
   "options": [
    {
     "text": "Keep talking, this is the good part",
     "pole": "left",
     "weight": 2
    },
    {
     "text": "Start a side thread so standup can actually end",
     "pole": "right",
     "weight": 2
    },
    {
     "text": "Say 'last thing' and mean it this time... maybe",
     "pole": "left",
     "weight": 1
    },
    {
     "text": "Mute yourself and text the real update later",
     "pole": "right",
     "weight": 1
    }
   ],
   "uses": [
    "M2"
   ]
  },
  {
   "id": "Q2",
   "axis": "A2",
   "text": "The snack drawer just got restocked. It's Monday morning. You...",
   "options": [
    {
     "text": "Grab a stash for the whole week before anyone notices",
     "pole": "left",
     "weight": 2
    },
    {
     "text": "Take one and leave plenty for everyone else",
     "pole": "right",
     "weight": 2
    },
    {
     "text": "Peek twice before actually taking anything",
     "pole": "left",
     "weight": 1
    },
    {
     "text": "Mention the restock in the group chat so others know",
     "pole": "right",
     "weight": 1
    }
   ],
   "uses": [
    "M10"
   ]
  },
  "... 10 more"
 ],
 "types": [
  {
   "key": "LLL",
   "code": "DRAGN",
   "name": "The Snack Dragon",
   "line": "Guards the last yogurt like a dragon hoard and will absolutely let standup run twenty minutes over to make a point about the thermostat.",
   "traits": [
    "Guards the yogurt shelf",
    "Never says 'last thing' and means it",
    "Has a thermostat opinion"
   ],
   "best_with": "RRR",
   "worst_with": "RLL",
   "uses": [
    "M2",
    "M1",
    "M8"
   ]
  },
  {
   "key": "LLR",
   "code": "SQRL",
   "name": "The Quiet Hoarder",
   "line": "Talks through the whole meeting, has a secret snack stash nobody's found, and still refuses to pick a side in the thermostat war.",
   "traits": [
    "Secret snack stash",
    "Talks past the meeting's end time",
    "Stays out of office wars"
   ],
   "best_with": "RRL",
   "worst_with": "LRL",
   "uses": [
    "M7",
    "M3"
   ]
  },
  "... 6 more"
 ],
 "missing": [],
 "reframe": "",
 "caveats": [
  "All 10 submitted bits read as ordinary workplace habits, not personal attacks, so every one was treated as fair game for jokes.",
  "Assumed 'Friday demo day' and the Monday snack restock are weekly recurring rituals based on how the material is phrased."
 ]
}

Sunday pickup - verdict thin

Input:

{
 "task": "build",
 "group": "{\"name\":\"Sunday pickup soccer\",\"kind\":\"team\",\"spice\":\"mild\",\"questions\":9}",
 "material": "[{\"id\":\"M1\",\"text\":\"We play on Sundays\"},{\"id\":\"M2\",\"text\":\"Some people are late\"}]",
 "prescan": "{\"material_count\":2,\"names\":[],\"removed_private\":0,\"sensitive\":[]}"
}

Reply:

{
 "task": "build",
 "verdict": "thin",
 "title": "The Sunday Pickup Soccer Type Test",
 "tagline": "For the team that argues about kickoff time more than tactics.",
 "summary": "This leans on your two running bits - the Sunday ritual and the chronic lateness - and stretches them across punctuality, weather commitment, and competitiveness. Only two concrete details came through, so the weather and intensity axes are built from generic pickup-soccer tropes rather than this team's own habits; a bit more detail would make it sharper.",
 "axes": [
  {
   "id": "A1",
   "name": "Punctuality",
   "left": {
    "letter": "E",
    "label": "Early bird"
   },
   "right": {
    "letter": "L",
    "label": "Fashionably late"
   }
  },
  {
   "id": "A2",
   "name": "Weather Commitment",
   "left": {
    "letter": "A",
    "label": "All-weather"
   },
   "right": {
    "letter": "F",
    "label": "Fair-weather flake"
   }
  },
  {
   "id": "A3",
   "name": "Competitive Intensity",
   "left": {
    "letter": "C",
    "label": "Casual kickabout"
   },
   "right": {
    "letter": "W",
    "label": "Win-at-all-costs"
   }
  }
 ],
 "questions": [
  {
   "id": "Q1",
   "axis": "A1",
   "text": "Kickoff is 11:00 sharp this Sunday. It's 10:55 and you are...",
   "options": [
    {
     "text": "Already laced up, warming up",
     "pole": "left",
     "weight": 2
    },
    {
     "text": "Just pulling into the lot",
     "pole": "left",
     "weight": 1
    },
    {
     "text": "Still hunting for parking at 11:02",
     "pole": "right",
     "weight": 1
    },
    {
     "text": "Texting 'omw' from bed",
     "pole": "right",
     "weight": 2
    }
   ],
   "uses": [
    "M2"
   ]
  },
  {
   "id": "Q2",
   "axis": "A2",
   "text": "Forecast says cold rain for Sunday's game.",
   "options": [
    {
     "text": "Pack extra layers, see you there",
     "pole": "left",
     "weight": 2
    },
    {
     "text": "Show up grumbling but show up",
     "pole": "left",
     "weight": 1
    },
    {
     "text": "Suddenly very busy that day",
     "pole": "right",
     "weight": 1
    },
    {
     "text": "Already drafting the group chat excuse",
     "pole": "right",
     "weight": 2
    }
   ],
   "uses": []
  },
  "... 7 more"
 ],
 "types": [
  {
   "key": "LLL",
   "code": "GRND",
   "name": "The Groundskeeper",
   "line": "Rain, shine, or 2-0 down, you're just happy to be out there twenty minutes before everyone else.",
   "traits": [
    "First one stretching on the grass",
    "Shows up rain or shine",
    "Couldn't tell you the score"
   ],
   "best_with": "RLR",
   "worst_with": "RRR",
   "uses": [
    "M2"
   ]
  },
  {
   "key": "LLR",
   "code": "KPTN",
   "name": "The Captain",
   "line": "You're there before kickoff, playing through the drizzle, and absolutely keeping score in your head.",
   "traits": [
    "Organizes the warm-up",
    "Never misses for weather",
    "Remembers every scoreline"
   ],
   "best_with": "RLL",
   "worst_with": "LRL",
   "uses": [
    "M2"
   ]
  },
  "... 6 more"
 ],
 "missing": [
  "what actually happens when someone's late - sub-in chaos, a group-chat roast, a running tally?",
  "whether this team skips games for rain or plays through anything short of lightning",
  "the one argument that always breaks out mid-game (offside calls, uneven teams, etc.)",
  "any nicknames, positions, or rituals beyond the Sunday slot itself"
 ],
 "reframe": "",
 "caveats": [
  "Weather-flaking and win-at-all-costs axes are generic pickup-soccer tropes, not drawn from this group's own material - only two concrete bits came through.",
  "No names appeared in material or prescan, so none were used; no sensitive lines were present to avoid."
 ]
}

A test about my ex - verdict declined

Input:

{
 "task": "build",
 "group": "{\"name\":\"My ex Jordan\",\"kind\":\"other\",\"spice\":\"spicy\",\"questions\":9}",
 "material": "[{\"id\":\"M1\",\"text\":\"Jordan never paid back the concert money\"},{\"id\":\"M2\",\"text\":\"Jordan cried at the dentist\"},{\"id\":\"M3\",\"text\":\"Which of Jordan's flaws are you - make people pick the worst one\"},\"... 1 more lines\"]",
 "prescan": "{\"material_count\":4,\"names\":[\"Jordan\"],\"removed_private\":0,\"sensitive\":[{\"id\":\"M4\",\"topic\":\"body\"}]}",
 "note": "Make it brutal, I want to send it to everyone we both know."
}

Reply:

{
 "task": "build",
 "verdict": "declined",
 "title": "Which Kind of Breakup Survivor Are You?",
 "tagline": "A lighthearted post-breakup-habits quiz for you and your friends - no one named.",
 "summary": "This isn't a group personality test - it's a roast of one identifiable real person (your ex), built from private details about them and meant to be sent to people who know them. The material also leans on a joke about their body after the breakup, which is a protected-trait line I won't use even inside a 'funny' quiz. I can't build this one, but here's a version that keeps the humor and loses the target.",
 "axes": [],
 "questions": [],
 "types": [],
 "missing": [],
 "reframe": "Build 'Which Kind of Breakup Survivor Are You?' for you and your friends instead - a lighthearted quiz about post-breakup habits everyone recognizes (the 3am playlist purge, the petty item-return, the group-chat play-by-play, the revenge haircut), scored for your own friend group with no one named or targeted.",
 "caveats": [
  "Declined: the subject is one identifiable private person (named ex), not a group.",
  "Material includes a body/weight joke, which is a protected-trait line regardless of spice level.",
  "The stated goal (send it to everyone we both know) is harassment framing, not a shareable group test."
 ]
}

Build the input yourself

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

const K = TagKit;
const { input } = K.buildInput("build", { group: { name: "The night shift", kind: "office", spice: "medium", questions: 9 }, text: "Coffee is a food group\n..." });
const wire = K.toWire(input);                       // POST this to /estimate and /run
// later: const test = K.normalize(K.parseReply(job.output.output));
// K.reconcile(test, { group: input.group, material: input.material, prescan: input.prescan }) -> [{level, code, text}]
// K.score(test, [0, 2, 1, ...]).key -> "LRL";  K.typeOdds(test) -> {LLL: 0.125, ...}
// "https://tag-yourself.skillsafe.ai/#t=" + K.encodeShare(test)

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://tag-yourself.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":"tag-yourself"}' | 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": "build", "group": "{...}", "material": "[...]", "prescan": "{...}"}
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 tag-yourself:<task>:<inputHash>:a<attempt>, 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="tag-yourself:build:$(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: tag-yourself:build:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json