← Thought Extinct
Survey API
Tokens

Drive Thought Extinct from your own code

The one metered task, survey, is available over HTTP. It takes the hobbies you dropped (when last seen, gear left, how much you miss each, whether the reason you stopped is still there), one moment that pulled you in and what pushed you out of each, the page's sorting and its pick, and returns for every hobby what really pulled you in, what pushed you out, a plan and - for a hobby coming back - a first outing of 30 minutes at most, plus the pattern across hobbies, two outside-in questions, a title and a field note.

The division of labour first: the page owns the sorting rule, the pick, the dormancy and the camera traps; the model owns the reading of your moments; the page re-checks the reply. All of it comes from trapkit.js, which you can run yourself (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": "...", "status": 402, "details": { ... } } }

The token is minted for this app (the guest call takes {"slug":"thought-extinct"}), 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, hobbies, hobby_count, pulled_in, pushed_out, rule, pick) 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: a survey 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 and the verdicts

Every run sends task: survey; a missing or different one is still answered as survey. Each hobby's verdict is set by the page's rule, checked in order: rest (shown as "let it rest") if it is missed 1 or 2 out of 5; otherwise reshape if the reason is still there (yes or not sure); otherwise revive. The model copies the verdicts and the pick; the page warns if it does not.

The input

Every value is a string (objects are never sent). app.js builds it with TrapKit.buildInput(form).input.

FieldRequiredWhat it holds
taskyesAlways survey.
hobbiesyesOne line per hobby (at most 6): <n> | <hobby> | last seen <year or year-month> | gear: <all|some|none|not needed> | misses it: <1-5> of 5 | reason still there: <yes|no|not sure>.
hobby_countyesHow many lines hobbies has.
pulled_in / pushed_outyesOne line per hobby, <n> | <the moment> or <n> | (not given); each moment at most 240 characters, cut on a sentence or word boundary.
weekly_timenoRoughly how much time a week you could give.
ruleyesThe page's sorting, <n> | <revive|reshape|rest> | <why>.
pickyes<n> | <why>, or 0 | every hobby rests.
clip_notenoWhat the page shortened, if anything.
retry_notenoSent only on the page's single reformat retry.

The reply

The reply is one JSON object in job.output.output. The page parses it with TrapKit.parseReply and TrapKit.normalize (a cut-off stream is closed by TrapKit.closeJson and whatever parsed is shown).

{"task": "survey", "title": "...", "field_note": "...", "pick": 1, "pick_reason": "...",
 "hobbies": [{"n": 1, "hobby": "...", "verdict": "revive | reshape | rest", "driver": "...", "drain": "...", "plan": "...", "first_outing": "... or empty for rest"}],
 "pattern": {"driver": "...", "drain": "...", "note": "..."},
 "questions": ["...", "..."]}
FieldWhat it holds
hobbiesOne entry per hobby: what pulled you in (or No episode given.), what pushed you out (or No reason given.), the plan (at most 30 words) and a first outing of at most 30 minutes for a hobby coming back, empty for one at rest.
pick / pick_reasonThe number of the hobby to bring back first (0 when every hobby rests) and why.
patternThe pull and the push that repeat across hobbies (or From one hobby only: ...) and where that pattern helped and hurt.
questionsTwo questions to ask someone who knew you then.
title / field_noteA survey title of at most 8 words and a two-sentence field note.

The page then checks the reply against the form (TrapKit.check): one entry per hobby, each verdict and the pick against the rule, a first outing exactly where a hobby comes back, no outing past 30 minutes or set on a day or a time of day, no kept gear when none is left, no driver or reason where a moment was left blank, no "years ago", no he or she and no internal field name in the prose.

Worked examples, one per verdict

These are the three free samples on the page: the exact input and a stand-in model's reply, unedited. Together they cover revive, reshape, rest, a tie broken by gear and an empty pick.

The spare-room three - revive, reshape and rest

Input:

{
 "task": "survey",
 "hobbies": "1 | Watercolour painting | last seen 2022-03 | gear: all | misses it: 5 of 5 | reason still there: no\n2 | Bass guitar in a band | last seen 2019 | gear: some | misses it: 4 of 5 | reason still there: yes\n3 | Half-marathon training | last seen 2023-09 | gear: all | misses it: 2 of 5 | reason still there: not sure",
 "hobby_count": "3",
 "pulled_in": "1 | One rainy Saturday I painted the same pear six times and did not notice it had gone dark outside.\n2 | The first time the drummer and I locked in on a song, I forgot I was playing at all.\n3 | Finishing a long run by the river with my legs humming for an hour after.",
 "pushed_out": "1 | We moved to a flat with no table by a window, so the paints went into a box.\n2 | Rehearsals were two hours across town every Thursday and I could not keep up after I changed jobs.\n3 | It turned into chasing a time on my watch, and every slow week felt like failing.",
 "weekly_time": "about 1 to 3 hours a week",
 "rule": "1 | revive | misses it 5 of 5; the reason is gone\n2 | reshape | misses it 4 of 5; the reason is still there\n3 | rest | misses it 2 of 5",
 "pick": "1 | misses it 5 of 5, the most of those not resting"
}

Reply:

{
 "task": "survey",
 "title": "Three Specimens, Two Worth a Second Look",
 "field_note": "Three dropped hobbies were surveyed and sorted by how much each is missed and whether its old reason for stopping still stands. One is marked for revival, one for reshaping, one for rest, and painting is picked to come back first.",
 "pick": 1,
 "pick_reason": "Painting is picked first because it is missed 5 of 5, the most among the hobbies not resting.",
 "hobbies": [
  {
   "n": 1,
   "hobby": "Watercolour painting",
   "verdict": "revive",
   "driver": "Painting the same pear six times without noticing the day going dark outside.",
   "drain": "Moving to a flat with no table by a window, so the paints went into a box.",
   "plan": "Bring the paints back out as they were - the flat without a window table that pushed you out is no longer in the way.",
   "first_outing": "Get your gear out and spend fifteen minutes on one quick study, without trying to finish it."
  },
  {
   "n": 2,
   "hobby": "Bass guitar in a band",
   "verdict": "reshape",
   "driver": "Locking in with a drummer on a song, forgetting about playing entirely.",
   "drain": "Two-hour rehearsals across town every Thursday that got harder to manage after changing jobs.",
   "plan": "Reshape it around a single song session with the drummer, not a weekly cross-town rehearsal, so a tight slot still fits around the job.",
   "first_outing": "Play through one song alone with what you kept, then text the drummer to ask about a short session."
  },
  {
   "n": 3,
   "hobby": "Half-marathon training",
   "verdict": "rest",
   "driver": "Finishing a long run by the river with legs still humming an hour after.",
   "drain": "Chasing a time on the watch, with every slow week feeling like failing.",
   "plan": "Let the half-marathon training rest: it once gave long runs by the river and legs that hummed for an hour after - that's enough to have had.",
   "first_outing": ""
  }
 ],
 "pattern": {
  "driver": "Losing track of time completely while absorbed in what you were doing.",
  "drain": "A change in life circumstances - moving to a new place, changing jobs - leaving no room for the hobby.",
  "note": "In painting and bass, losing yourself in the task is what kept pulling you back; in both, a change outside the hobby - a new home, a new job - pushed you out."
 },
 "questions": [
  "When I was painting or playing bass, what made me lose track of time like that?",
  "Did you notice me pulling back from painting or bass before I actually quit?"
 ]
}

Two clubs - two reshapes, the pick decided by gear

Input:

{
 "task": "survey",
 "hobbies": "1 | Pottery | last seen 2021 | gear: none | misses it: 4 of 5 | reason still there: yes\n2 | Chess club | last seen 2024-02 | gear: all | misses it: 4 of 5 | reason still there: not sure",
 "hobby_count": "2",
 "pulled_in": "1 | Centring clay on the wheel took all my attention - my phone stayed in my bag for two hours.\n2 | A long endgame where the whole room went quiet around our board.",
 "pushed_out": "1 | The studio membership doubled in price.\n2 | Club nights ran late, and losing three games in a row on a work night left me too wired to sleep.",
 "weekly_time": "under an hour a week",
 "rule": "1 | reshape | misses it 4 of 5; the reason is still there\n2 | reshape | misses it 4 of 5; the reason may still be there",
 "pick": "2 | misses it 4 of 5, tied on missing it and ahead on gear (all of it)"
}

Reply:

{
 "task": "survey",
 "title": "Two Specimens, Neither Extinct Yet",
 "field_note": "Two dropped hobbies were surveyed and both were sorted as reshape, each still missed four of five. Chess club was picked to return first, tied on how much it is missed but ahead on gear.",
 "pick": 2,
 "pick_reason": "Chess club is picked: both are missed 4 of 5, but chess club keeps all its gear, breaking the tie.",
 "hobbies": [
  {
   "n": 1,
   "hobby": "Pottery",
   "verdict": "reshape",
   "driver": "Centring clay on the wheel took all your attention - your phone stayed in your bag for two hours.",
   "drain": "The studio membership doubled in price.",
   "plan": "Keep the stretch of total focus the wheel gives, but find wheel time without a membership, so the rising cost that pushed you out no longer applies.",
   "first_outing": "Ask about single-session wheel access this week and spend twenty minutes alone centring clay, no membership needed."
  },
  {
   "n": 2,
   "hobby": "Chess club",
   "verdict": "reshape",
   "driver": "A long endgame where the whole room went quiet around the board.",
   "drain": "Club nights ran late, and losing three games left you too wired to sleep on a work night.",
   "plan": "Keep the quiet, focused endgame, but ask for earlier or casual games instead of late club nights, so losing does not cost you sleep.",
   "first_outing": "Set up your gear this week and replay one quiet endgame alone, twenty minutes, before reaching out about casual games."
  }
 ],
 "pattern": {
  "driver": "A total concentration that pulled your attention in completely, time and surroundings fading away.",
  "drain": "From one hobby only: the studio membership doubled in price.",
  "note": "At the wheel, total focus gave you two hours that simply disappeared. At chess club, that same kind of investment was there the night three losses left you too wired to sleep."
 },
 "questions": [
  "When I was at the pottery wheel or the chessboard, what made me lose track of time?",
  "What did you notice push me away from chess club or pottery before I said I was done?"
 ]
}

Let them rest - nothing picked

Input:

{
 "task": "survey",
 "hobbies": "1 | Scrapbooking | last seen 2018 | gear: some | misses it: 2 of 5 | reason still there: no\n2 | Inline skating | last seen 2016-06 | gear: none | misses it: 1 of 5 | reason still there: yes",
 "hobby_count": "2",
 "pulled_in": "1 | Spending an evening laying out ticket stubs from one summer.\n2 | (not given)",
 "pushed_out": "1 | (not given)\n2 | A fall on a downhill stretch and a sprained wrist.",
 "rule": "1 | rest | misses it 2 of 5\n2 | rest | misses it 1 of 5",
 "pick": "0 | every hobby rests"
}

Reply:

{
 "task": "survey",
 "title": "Two Species, Both Already at Rest",
 "field_note": "Two dropped hobbies were surveyed and both were sorted to rest, going by how little each is missed. No specimen was picked for revival.",
 "pick": 0,
 "pick_reason": "Every hobby rests because each is missed 1 or 2 out of 5.",
 "hobbies": [
  {
   "n": 1,
   "hobby": "Scrapbooking",
   "verdict": "rest",
   "driver": "Losing an evening to laying out one summer's ticket stubs.",
   "drain": "No reason given.",
   "plan": "It gave you a full evening lost in one summer's ticket stubs. Let scrapbooking rest here, nothing more owed.",
   "first_outing": ""
  },
  {
   "n": 2,
   "hobby": "Inline skating",
   "verdict": "rest",
   "driver": "No episode given.",
   "drain": "A fall on a downhill stretch led to a sprained wrist.",
   "plan": "No episode says what inline skating gave you. The sprained wrist is reason enough to let it rest.",
   "first_outing": ""
  }
 ],
 "pattern": {
  "driver": "From one hobby only: spending an evening laying out one summer's ticket stubs.",
  "drain": "From one hobby only: a fall on a downhill stretch and a sprained wrist.",
  "note": "Too few moments yet to call this a pattern. The one pull worth keeping: losing an evening to laying out one summer's ticket stubs."
 },
 "questions": [
  "When I was going through those ticket stubs, what seemed to pull me into losing the evening?",
  "After I hurt my wrist skating, what seemed to keep me from going back?"
 ]
}

Build the input yourself

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

const K = TrapKit;
const form = K.form({ weekly: "1to3", hobbies: [
  { name: "Watercolour painting", year: "2022", month: "3", gear: "all", miss: "5", still: "no",
    pulled: "I painted the same pear six times and did not notice it had gone dark.", pushed: "No table by a window." }] });
K.ruleFor(form.hobbies[0]);                      // {verdict: "revive", why: "misses it 5 of 5; the reason is gone"}
const built = K.buildInput(form);
built.pick;                                      // {n: 1, why: "misses it 5 of 5, the only one not resting"}
const wire = K.toWire(built.input);              // POST this to /estimate and /run
// then: const reply = K.normalize(K.parseReply(job.output.output));
// K.check(reply, built).notes                   // the page's re-check of the survey
// K.md(built, reply)                            // the survey as Markdown

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://thought-extinct.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":"thought-extinct"}' | 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": "survey", "hobbies": "1 | Pottery | last seen 2021 | gear: none | misses it: 4 of 5 | reason still there: yes", "hobby_count": "1", "pulled_in": "1 | ...", "pushed_out": "1 | ...", "rule": "1 | reshape | ...", "pick": "1 | ..."}
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 thought-extinct:<task>:<inputHash>:a<attempt> (the one task is survey), 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="thought-extinct:survey:$(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: thought-extinct:survey:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json