← A Hill To Die On
API
Tokens

Drive A Hill To Die On from your own code

Everything the page asks the writer is available over HTTP. A natural use is a content calendar: a script sends a week of takes, gets back timed scripts and hooks, and drops the captions straight into an editor as SRT.

Be clear about the split first: the model writes; the caller times and checks. The page computes the word budget from the clip length and conviction (2.2 + 0.12 × conviction words a second), times each beat, and checks every reply against the clip, the no-hedging rule, the 16-word line cap and the take's own words. That code ships as a plain script — takekit.js (TakeKit.prescan, timeBeats, reconcile, toSrt) — you can load in node.

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":"hill-to-die-on"}), 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": {…}} — the declared input schema warns unknown field 'input' and the model sees nothing.

StatusCodeMeaning
400validation_errorThe body is not a JSON object.
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: the writer 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

One system prompt serves two tasks. task picks the contract; send exactly one of:

taskWhat comes back
defendThe timed defense: beats (open, claim, evidence, escalation, callback, closer), one gesture per beat, 2–3 comment-section comebacks, the joke's anatomy, a caption and hashtags.
hooksFive opening hooks (contrarian, curiosity, callout, confession, stakes), each with on-screen text, the first frame, a 1–10 score and a critique, plus the best pick and what to avoid. Send the defense's lines as script_lines so the hooks lead into it.

Input fields

FieldTypeNotes
taskstring, requireddefend or hooks.
takestring, requiredThe opinion, at most 280 characters.
tonestringvilla, closing, documentary or coach.
convictionnumber1 (reasonable) to 5 (unhinged).
secondsnumber15, 30, 45 or 60 - the clip length.
word_budgetnumberround(seconds × (2.2 + 0.12 × conviction)).
prescanstringJSON-encoded {category, spice, hedges, absolutes, is_question} from TakeKit.prescan. The schema carries scalars only, so objects travel encoded.
script_linesstringhooks only, optional: JSON-encoded array of the defense's lines, in order.
retry_notestringOnly on a reformat retry.

The reply

The job's output.output is a string holding one JSON object. Gestures are one of point, count, chest, pinch, shrug, palms, clap, lean, pause. Parse it, then check it — the page never trusts the reply's own arithmetic.

Worked examples

Real replies, produced against the app's prompt. The page replays each of them free from its Try buttons.

defend: "Cereal is a soup" - closing argument, conviction 4, 30 seconds

Input (as sent):

{
  "task": "defend",
  "take": "Cereal is a soup",
  "tone": "closing",
  "conviction": 4,
  "seconds": 30,
  "word_budget": 80,
  "prescan": "{\"category\":\"food\",\"spice\":48,\"hedges\":[],\"absolutes\":[],\"is_question\":false}"
}

Reply:

{
  "lane": "defend",
  "title": "Closing Argument: Cereal Is Soup",
  "take_restated": "Cereal is soup: cold, grain-based, and legally defensible.",
  "beats": [
    {
      "kind": "open",
      "line": "Members of the jury, the defense never doubted this case.",
      "gesture": "point"
    },
    {
      "kind": "claim",
      "line": "Cereal is soup. Cold, grain-based, undeniable soup.",
      "gesture": "chest"
    },
    {
      "kind": "evidence",
      "line": "Exhibit A: a bowl. Exhibit B: a spoon. Case closed.",
      "gesture": "count"
    },
    {
      "kind": "evidence",
      "line": "Soup is solid food suspended in liquid. Milk is liquid.",
      "gesture": "count"
    },
    {
      "kind": "evidence",
      "line": "Gazpacho is cold soup, and nobody calls it cereal.",
      "gesture": "pinch"
    },
    {
      "kind": "escalation",
      "line": "If cereal is not soup, then soup is not real.",
      "gesture": "lean"
    },
    {
      "kind": "callback",
      "line": "Exhibit A, the bowl, never once objected to this truth.",
      "gesture": "point"
    },
    {
      "kind": "closer",
      "line": "The defense rests, and so should your spoon.",
      "gesture": "pause"
    }
  ],
  "closer": "The defense rests, and so should your spoon.",
  "counterpoints": [
    {
      "objection": "Soup is hot. Cereal is cold.",
      "comeback": "Gazpacho is served cold and still called soup."
    },
    {
      "objection": "You don't call cereal soup on a diner menu.",
      "comeback": "Diners also call hot dogs sandwiches. Menus lie constantly."
    },
    {
      "objection": "Milk isn't broth.",
      "comeback": "Broth is just milk that chose a savory life."
    }
  ],
  "joke": {
    "premise": "Soup is defined by its format, solid food in liquid in a bowl, not by temperature or flavor.",
    "mechanism": "category error argued by strict definition, applying soup's structural rules while ignoring its cultural assumptions.",
    "misdirection": "The audience expects the lawyer to concede cereal is just breakfast, and instead he cross-examines soup itself.",
    "callback": "Exhibit A, the bowl, returns in the second-to-last beat as a silent witness.",
    "why_it_lands": "It argues a silly breakfast question with real courtroom gravity, so the mismatch in stakes is the joke."
  },
  "caption": "Closing arguments: cereal was soup this whole time, and the bowl knew it.",
  "hashtags": [
    "twofishes",
    "hottake",
    "cerealisoup",
    "courtroomdrama",
    "breakfastcourt"
  ],
  "notes": ""
}

defend: a hedged take on a 15-second clip

The take arrives with "I think"; the prescan lists it, and the reply's take_restated drops it. At conviction 5 the 42-word budget times at a little over 15 seconds, so the page warns if the beats overrun by more than 15%.

{
  "task": "defend",
  "take": "I think voicemails should be illegal",
  "tone": "villa",
  "conviction": 5,
  "seconds": 15,
  "word_budget": 42,
  "prescan": "{\"category\":\"etiquette\",\"spice\":45,\"hedges\":[\"i think\"],\"absolutes\":[\"illegal\"],\"is_question\":false}"
}

Reply:

{
  "lane": "defend",
  "title": "The Case Against Voicemail",
  "take_restated": "Voicemails should be illegal.",
  "beats": [
    {
      "kind": "open",
      "line": "I am genuinely so hurt right now.",
      "gesture": "chest"
    },
    {
      "kind": "claim",
      "line": "Voicemails should be illegal. Full stop.",
      "gesture": "point"
    },
    {
      "kind": "evidence",
      "line": "You had my number. You had texting.",
      "gesture": "count"
    },
    {
      "kind": "evidence",
      "line": "Instead you made me do audio homework.",
      "gesture": "pinch"
    },
    {
      "kind": "escalation",
      "line": "I will go to actual prison over this.",
      "gesture": "lean"
    },
    {
      "kind": "closer",
      "line": "Delete your voicemail and keep our friendship.",
      "gesture": "palms"
    }
  ],
  "closer": "Delete your voicemail and keep our friendship.",
  "counterpoints": [
    {
      "objection": "Some people just hate texting.",
      "comeback": "Then they can send a pigeon like a normal person."
    },
    {
      "objection": "Voicemail-to-text makes it easy now.",
      "comeback": "That just proves the voicemail was never necessary."
    },
    {
      "objection": "What if it's an emergency?",
      "comeback": "Then call back. Twice. That's what phones are for."
    }
  ],
  "joke": {
    "premise": "That any message slower than a text is a personal betrayal.",
    "mechanism": "Category error - a minor inconvenience prosecuted with full legal seriousness.",
    "misdirection": "The audience expects the speaker to admit it's a small thing, and instead she asks for jail time.",
    "callback": "",
    "why_it_lands": "The gap between the tiny annoyance and the courtroom-grade outrage is the whole joke."
  },
  "caption": "Voicemails should be illegal. That's not my opinion, that's the law now.",
  "hashtags": [
    "twofishes",
    "hottake",
    "voicemail",
    "etiquette",
    "phoneproblems"
  ],
  "notes": ""
}

hooks: handed off from the cereal defense

{
  "task": "hooks",
  "take": "Cereal is a soup",
  "tone": "closing",
  "conviction": 4,
  "seconds": 30,
  "word_budget": 80,
  "prescan": "{\"category\":\"food\",\"spice\":48,\"hedges\":[],\"absolutes\":[],\"is_question\":false}",
  "script_lines": "[\"Members of the jury, the defense never doubted this case.\",\"Cereal is soup. Cold, grain-based, undeniable soup.\",\"Exhibit A: a bowl. Exhibit B: a spoon. Case closed.\",\"Soup is solid food suspended in liquid. Milk is liquid.\",\"Gazpacho is cold soup, and nobody calls it cereal.\",\"If cereal is not soup, then soup is not real.\",\"Exhibit A, the bowl, never once objected to this truth.\",\"The defense rests, and so should your spoon.\"]"
}

Reply:

{
  "lane": "hooks",
  "hooks": [
    {
      "type": "contrarian",
      "text": "Everyone's wrong: cereal was legally a soup the whole time.",
      "on_screen_text": "The verdict nobody wanted",
      "first_frame": "Speaker in a blazer bangs a toy gavel on the kitchen table.",
      "score": 7,
      "critique": "Leads with a meta-claim about being right instead of a visual image, so it lands a beat slower than it should."
    },
    {
      "type": "curiosity",
      "text": "There's a legal reason cereal has always been soup.",
      "on_screen_text": "Wait... cereal is WHAT?",
      "first_frame": "Speaker leans toward the camera, raising one finger before speaking.",
      "score": 8,
      "critique": "The vague tease only pays off if the reveal comes fast, or it reads as empty clickbait."
    },
    {
      "type": "callout",
      "text": "If you've ever grabbed a spoon for cereal, you're guilty too.",
      "on_screen_text": "You already proved my case",
      "first_frame": "Speaker points directly at the lens like a prosecutor accusing the jury.",
      "score": 7,
      "critique": "Only lands for viewers who already eat cereal with a spoon and milk, narrowing who feels called out."
    },
    {
      "type": "confession",
      "text": "I'll admit it: I've always known cereal was soup.",
      "on_screen_text": "My shameful cereal secret",
      "first_frame": "Speaker exhales, hand on heart, before confessing to the jury.",
      "score": 6,
      "critique": "The confession framing softens the courtroom's unshakable conviction before the gavel even drops."
    },
    {
      "type": "stakes",
      "text": "If cereal isn't soup, the entire food pyramid collapses tonight.",
      "on_screen_text": "Breakfast as we know it",
      "first_frame": "Speaker slams both palms on the table, eyes wide with alarm.",
      "score": 8,
      "critique": "The food-pyramid metaphor is a half-step removed from the bowl itself, costing a beat of clarity."
    }
  ],
  "best_index": 1,
  "why_best": "The curiosity hook opens a specific legal loop that only a courtroom verdict can close, pulling scrollers straight into the jury address that follows.",
  "avoid": [
    "A dictionary-definition hook ('cereal, noun...') reads like trivia, not an accusation, and won't survive the first second of a scroll.",
    "A hedge-softened opener that leaves room for doubt kills the courtroom's total conviction before the gavel falls.",
    "A generic 'you won't believe this' tease that never names cereal or soup fails the hook contract and wastes the first frame."
  ]
}

The page checks the reply; the API does not

Load takekit.js in node (global.window = {}; require("./takekit.js")) and call TakeKit.normalize(TakeKit.parseResult(text), {task}) then TakeKit.reconcile(res, {take, seconds, conviction}). It returns notes for an overrun clip, hedging in a line, lines past 16 words, a defense that never names the take, hooks past 12 words or on-screen text past 7, and missing hook types. TakeKit.toSrt(TakeKit.timeBeats(res.beats, conviction)) gives captions.

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://hill-to-die-on.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":"hill-to-die-on"}' | 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": "defend", "take": "Cereal is a soup", "tone": "closing", "conviction": 4, "seconds": 30, "word_budget": 80, "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 hill-to-die-on:<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="hill-to-die-on:defend:$(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: hill-to-die-on:defend:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json