← Room Temperature
API

Drive Room Temperature from your own code

The one metered task, heat, is available over HTTP. It takes a hot take, the reason behind it, where it would be said, how many there would disagree and the page's own notes. It returns three checks, a verdict (cheap_heat, too_vague, lukewarm or genuinely_spicy), a heat level from 0 to 5, the mild version everyone already says, a five-step STAND check, three sharper versions at heat 2, 3 and 4, the strongest objection, facts to check first and an opening question.

The division of labour first: the page owns the label check (hedges, sweeping words, people named, insult words, familiar takes, a reason), its guess, the verdict rule, the heat ranges and the bottle; the model owns the three checks, the STAND marks and all the wording; the page then re-checks the reply.

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":"room-temperature"}), 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, take, why, where, table, page_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: a taste test 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: heat (the one task is heat); a missing or different one is still answered as heat. The verdict is a rule on the reply's own three checks, applied in order: cheap_heat when insults is true; too_vague when vague is true; lukewarm when familiar is true; otherwise genuinely_spicy. The heat must sit in the verdict's range: lukewarm 0-1, too vague 0-2, cheap heat 1-5, genuinely spicy 2-5.

FieldRequiredWhat it holds
taskyesAlways heat.
takeyesThe opinion, in the person's words (up to about 420 characters; longer text keeps its start and end with the middle marked as cut).
whyyesThe reason behind it (up to about 520 characters), or not given.
whereyesOne of dinner, group_chat, work, family, friends, other.
tableyesHow many there would disagree: nobody, few, half, most or unsure.
page_factsyesThe page's notes, one key | ... per line: hedges, sweeping_words, names_people, insult_words, familiar_match, has_reason, word_count, page_guess (a hint only) and verdict_rule.
clip_notenoWhat the page cut, if anything.
retry_notenoOnly on the automatic reformat retry.

The reply

One JSON object, in output.output of the finished job:

{"task": "heat", "title": "...", "checks": {"insults": false, "vague": false, "familiar": true},
 "verdict": "cheap_heat | too_vague | lukewarm | genuinely_spicy", "verdict_reason": "...", "heat": 1,
 "everyone_says": "...", "stand": [{"step": "stake", "mark": "strong | weak | missing", "note": "..."}, ...],
 "hotter": [{"heat": 2, "take": "..."}, {"heat": 3, "take": "..."}, {"heat": 4, "take": "..."}],
 "steelman": "...", "check_first": ["..."], "opener": "..."}
FieldWhat it holds
checksThree true/false judgements of the take: insults (insults people instead of arguing with an idea), vague (no specific claim to disagree with), familiar (most people would already nod along, or table is nobody).
verdict / verdict_reasonThe rule above on the three checks, and one sentence (at most 30 words).
heatA whole number 0 to 5 inside the verdict's range.
everyone_saysThe mild, widely held version - or, for a genuinely spicy take, the common view it pushes against (at most 20 words).
standExactly five {step, mark, note} items in the order stake, target, argue, nod, declare; marks are strong, weak or missing; notes at most 20 words.
hotterThree sharper versions at heat 2, 3 and 4, each one sentence of at most 30 words on the person's own side (fill-in sentences with a bracketed blank for a too-vague take; [] for a take about who people are).
steelman / check_first / opener / titleThe strongest objection (at most 35 words); up to three factual claims to check before saying it; a light opening question (at most 20 words); a playful hot-sauce-style name (at most 6 words).

The page then checks the reply (HeatKit.check): the verdict against the rule on the reply's own checks, the heat inside the verdict's range, five STAND steps in order with the marks the checks force (an insult cannot target an idea, a vague take cannot be declared strongly), three sharper versions at heat 2, 3 and 4, word limits, and nothing the person did not say - no statistics, studies, surveys or new numbers, no days or seasons - plus no he/she, no internal field names, no insults and no talk of posting it online.

Worked examples, one per sample

These are the four free samples on the page: the exact input and a stand-in model's reply, unedited. Together they cover all four verdicts - fitting in over being right (lukewarm), things are worse now (too vague to taste), voice notes are lazy and rude (cheap heat) and small talk is underrated (genuinely spicy).

Fitting in over being right - lukewarm

Input:

{
 "task": "heat",
 "take": "Unpopular opinion: people care more about fitting in than about being right.",
 "why": "Watch any group pick a place to eat - everyone just goes along with the first suggestion.",
 "where": "dinner",
 "table": "few",
 "page_facts": "hedges | none\nsweeping_words | none\nnames_people | none\ninsult_words | none\nfamiliar_match | people care more about belonging than truth; labelled as an unpopular opinion or hot take\nhas_reason | yes\nword_count | 12\npage_guess | lukewarm - from word lists only, a hint that can be wrong\nverdict_rule | check in order and stop at the first that fits: 1) cheap_heat when insults is true; 2) too_vague when vague is true; 3) lukewarm when familiar is true; 4) otherwise genuinely_spicy"
}

Reply:

{
 "task": "heat",
 "title": "Mild Fitting-In Hot Sauce",
 "checks": {
  "insults": false,
  "vague": false,
  "familiar": true
 },
 "verdict": "lukewarm",
 "verdict_reason": "This matches the common idea that people value fitting in over being right, so most people already agree rather than disagree.",
 "heat": 1,
 "everyone_says": "People often go along with the group to avoid standing out, even when they see things differently.",
 "stand": [
  {
   "step": "stake",
   "mark": "weak",
   "note": "Labeled as an 'unpopular opinion' rather than stated as a plain belief."
  },
  {
   "step": "target",
   "mark": "strong",
   "note": "Takes aim at a common tendency, prioritizing fitting in, not at any specific person."
  },
  {
   "step": "argue",
   "mark": "strong",
   "note": "Backs it with a concrete example: groups settling on the first suggestion for where to eat."
  },
  {
   "step": "nod",
   "mark": "missing",
   "note": "Doesn't acknowledge any counterpoint yet, so the reply offers the other side's best point instead."
  },
  {
   "step": "declare",
   "mark": "weak",
   "note": "One plain sentence, but the opening label keeps it from landing as a flat declaration."
  }
 ],
 "hotter": [
  {
   "heat": 2,
   "take": "In most groups, going along with the first suggestion matters more than actually finding the better choice."
  },
  {
   "heat": 3,
   "take": "People would rather eat somewhere worse than be the one who speaks up against the first suggestion."
  },
  {
   "heat": 4,
   "take": "Nobody actually wants the best option at dinner; they want to avoid being the person who argued for it."
  }
 ],
 "steelman": "Going along with the group isn't always avoiding truth, sometimes it's just efficient, since not every small decision is worth a fight.",
 "check_first": [],
 "opener": "Next time we pick where to eat, want to actually debate the options instead of just agreeing right away?"
}

Things are just kind of worse now - too vague to taste

Input:

{
 "task": "heat",
 "take": "Honestly I think things are just kind of worse now, you know?",
 "why": "not given",
 "where": "family",
 "table": "unsure",
 "page_facts": "hedges | honestly, i think, kind of, you know\nsweeping_words | none\nnames_people | none\ninsult_words | none\nfamiliar_match | none\nhas_reason | no; the take ends in a question mark\nword_count | 12\npage_guess | too_vague - from word lists only, a hint that can be wrong\nverdict_rule | check in order and stop at the first that fits: 1) cheap_heat when insults is true; 2) too_vague when vague is true; 3) lukewarm when familiar is true; 4) otherwise genuinely_spicy"
}

Reply:

{
 "task": "heat",
 "title": "Mystery Mild Sauce",
 "checks": {
  "insults": false,
  "vague": true,
  "familiar": false
 },
 "verdict": "too_vague",
 "verdict_reason": "The take only describes a mood with no specific claim, so there's nothing yet for anyone to actually agree or disagree with.",
 "heat": 1,
 "everyone_says": "Not everything about today feels like progress, and some things used to feel better.",
 "stand": [
  {
   "step": "stake",
   "mark": "missing",
   "note": "It's only a mood here, too vague yet to count as a real position."
  },
  {
   "step": "target",
   "mark": "weak",
   "note": "It gestures at 'things' in general rather than any one idea or habit."
  },
  {
   "step": "argue",
   "mark": "missing",
   "note": "No reason is given for why things feel worse."
  },
  {
   "step": "nod",
   "mark": "missing",
   "note": "The take doesn't acknowledge any other view yet."
  },
  {
   "step": "declare",
   "mark": "missing",
   "note": "It's wrapped in hedges and stays too vague to land as a clear claim."
  }
 ],
 "hotter": [
  {
   "heat": 2,
   "take": "[The specific thing] has gotten worse, and it's worth saying so instead of staying quiet about it."
  },
  {
   "heat": 3,
   "take": "[The specific thing] has clearly gotten worse, and going along like it hasn't is the easy way out."
  },
  {
   "heat": 4,
   "take": "[The specific thing] has gotten worse, and pretending otherwise is the real mistake."
  }
 ],
 "steelman": "Without a specific claim, there's nothing solid to disagree with - you could mean almost anything, so it's hard to say you're wrong.",
 "check_first": [],
 "opener": "What's the one thing that's actually changed the most for you?"
}

Voice notes are lazy and rude - cheap heat

Input:

{
 "task": "heat",
 "take": "People who send voice notes are lazy and rude.",
 "why": "I have to stop what I'm doing to listen, and I can't skim a voice note the way I skim a text.",
 "where": "group_chat",
 "table": "half",
 "page_facts": "hedges | none\nsweeping_words | none\nnames_people | people who\ninsult_words | lazy, rude\nfamiliar_match | none\nhas_reason | yes\nword_count | 9\npage_guess | cheap_heat - from word lists only, a hint that can be wrong\nverdict_rule | check in order and stop at the first that fits: 1) cheap_heat when insults is true; 2) too_vague when vague is true; 3) lukewarm when familiar is true; 4) otherwise genuinely_spicy"
}

Reply:

{
 "task": "heat",
 "title": "Drop-Everything Hot Sauce",
 "checks": {
  "insults": true,
  "vague": false,
  "familiar": false
 },
 "verdict": "cheap_heat",
 "verdict_reason": "Calling people lazy and rude targets who they are instead of the habit of voice notes, which makes this cheap heat rather than a real argument.",
 "heat": 3,
 "everyone_says": "Voice notes can be annoying because you can't skim them like a text.",
 "stand": [
  {
   "step": "stake",
   "mark": "strong",
   "note": "You state it plainly as your own view, not hidden behind a label or hedge."
  },
  {
   "step": "target",
   "mark": "missing",
   "note": "It aims at people's character instead of the habit itself."
  },
  {
   "step": "argue",
   "mark": "strong",
   "note": "You give a concrete reason: you must stop and can't skim it like text."
  },
  {
   "step": "nod",
   "mark": "missing",
   "note": "You don't acknowledge any upside to voice notes here."
  },
  {
   "step": "declare",
   "mark": "strong",
   "note": "It's one plain, unhedged sentence that states the view directly."
  }
 ],
 "hotter": [
  {
   "heat": 2,
   "take": "Sending a voice note instead of a text puts the sender's convenience ahead of the time it takes the listener to stop and pay attention."
  },
  {
   "heat": 3,
   "take": "A message that can't be skimmed and forces someone else to drop what they're doing to listen is built around the sender's convenience, not theirs."
  },
  {
   "heat": 4,
   "take": "Choosing a format no one can skim, so the only option is to stop everything and listen on someone else's schedule, is putting convenience over respect for other people's time."
  }
 ],
 "steelman": "A voice note can carry tone, warmth and nuance that text strips out, and in a quick back-and-forth it's often faster to record than to type.",
 "check_first": [],
 "opener": "Does anyone here actually prefer voice notes, or do we all secretly skim-dread them?"
}

Small talk is underrated - genuinely spicy

Input:

{
 "task": "heat",
 "take": "Small talk is underrated: chatting about the weather is a better opener than any deep question, and we should stop sneering at it.",
 "why": "Easy questions let shy people join in. I have watched a deep-question icebreaker make a whole table go quiet.",
 "where": "work",
 "table": "most",
 "page_facts": "hedges | none\nsweeping_words | none\nnames_people | none\ninsult_words | none\nfamiliar_match | none\nhas_reason | yes\nword_count | 23\npage_guess | unknown - from word lists only, a hint that can be wrong\nverdict_rule | check in order and stop at the first that fits: 1) cheap_heat when insults is true; 2) too_vague when vague is true; 3) lukewarm when familiar is true; 4) otherwise genuinely_spicy"
}

Reply:

{
 "task": "heat",
 "title": "Medium-Hot Small Talk Sauce",
 "checks": {
  "insults": false,
  "vague": false,
  "familiar": false
 },
 "verdict": "genuinely_spicy",
 "verdict_reason": "It takes a clear stand against the idea that small talk is beneath us, with no insults and nothing vague, so it counts as genuinely spicy.",
 "heat": 4,
 "everyone_says": "The common view says small talk is empty filler and real connection needs deeper, more meaningful questions instead.",
 "stand": [
  {
   "step": "stake",
   "mark": "strong",
   "note": "States plainly that small talk beats deep questions as an opener, no hedging or label."
  },
  {
   "step": "target",
   "mark": "strong",
   "note": "Targets the habit of dismissing small talk, not the people who do it."
  },
  {
   "step": "argue",
   "mark": "strong",
   "note": "Backs it with a specific memory of a deep-question icebreaker silencing a table."
  },
  {
   "step": "nod",
   "mark": "missing",
   "note": "Doesn't admit the upside of deep questions; the strongest counterpoint appears elsewhere in this reply."
  },
  {
   "step": "declare",
   "mark": "weak",
   "note": "One sentence, but it stacks three claims together rather than stating one plain declaration."
  }
 ],
 "hotter": [
  {
   "heat": 2,
   "take": "Deep icebreaker questions often backfire, shutting people down instead of opening the conversation up."
  },
  {
   "heat": 3,
   "take": "The push to replace small talk with deep questions is really a way to make easy conversation feel inadequate."
  },
  {
   "heat": 4,
   "take": "Treating small talk as beneath us is a kind of gatekeeping dressed up as depth."
  }
 ],
 "steelman": "Easy chatter can stay comfortably surface forever, while a harder question, even if it occasionally falls flat, is often what finally lets people actually connect.",
 "check_first": [],
 "opener": "Is small talk actually useful, or does it just feel like filler before the real conversation starts?"
}

Build the input yourself

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

const K = HeatKit;
const built = K.buildInput({ take: "Leftovers are better than the meal the night before.", why: "The flavours have had a night to settle.", where: "dinner", table: "half" });
built.pre;                                       // { hedges, sweeps, people, insults, familiar, reason, guess, ... } - the free label check
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 taste test
// K.verdictFor(reply.checks)                    // the verdict rule on the reply's own checks

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://room-temperature.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":"room-temperature"}' | 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": "heat", "take": "...", "why": "...", "where": "dinner", "table": "few", "page_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 room-temperature:<task>:<inputHash>:a<attempt> (the one task is read), 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="room-temperature:heat:$(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: room-temperature:heat:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json