← Trope Table
Trope Table API
Tokens

Drive Trope Table from your own code

The one metered read is available over HTTP: give it a fan-fiction premise, its tags and a mood, and it returns a verdict, every trope in play with what readers expect, what it gives them, the axis a twist moves (form, knowledge, goal or role) and the twist itself, a catalyst from outside the tropes' world, and a new summary - or, for a bare premise, the questions to answer first.

The division of labour first: the page owns the table; the model reads the tropes; the page re-checks the reply. The 48-element table, the word-list matching, the word count, the formula and the page's own verdict all run in the browser (tropekit.js) and travel in the run input. The model may add a trope the word lists missed or drop one they matched by accident. The page then checks the reply against its own rules: a verdict that breaks its own rule, a cut outside a pile-up, a straight trope with no twist, a name or a gender your premise never gave.

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":"trope-table"}), 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 or premise 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: the read 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

Every run sends task: twist - there is one lane. The verdict is decided by four ordered, non-overlapping rules; the first that matches wins:

OrderVerdictWhen
1bareThe premise, tags aside, is under 25 words, or never says who wants what and what starts the story. Tropes come back straight with no twist; blurb and catalyst are empty; 2-4 questions.
2pileup6 or more distinct tropes in play. Two or three are kept and read in full; every other one is cut, with a one-line reason in twist.
3freshEvery trope already has a twist the premise states. All twisted; each twist names the premise's own and pushes it further on the same axis.
4twistEverything else: at least one trope is played straight and gets a new twist.

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

Input fields

FieldTypeNotes
taskstringtwist.
premisestringThe story's summary. Past 2,400 characters whole sentences are cut from the middle with a marker.
tagsstringComma-separated tags, up to 1,000 characters (Time Loop, Oblivious); may be empty.
moodstringeither, fluff or angst; anything else becomes either.
page_tropesstring (JSON)Array of the table tropes the word lists matched, with where: "Tl Time Loop (premise + tags)".
page_leanstringThe page's verdict by the rules above (it never says fresh - a word list cannot see a twist).
page_flagsstring (JSON)Array of the page's warnings (usually empty).
retry_notestringOnly on a reformat retry.

The reply

The job's output.output is a string holding one JSON object: task, verdict, title (a working title), summary, tropes ([{id, name, status, expected, function, axis, twist}] - id is the table's two-letter symbol or custom, status is straight, twisted or cut, axis is form, knowledge, goal, role or empty), catalyst, blurb (a 40-90 word summary using only the premise's names) and questions (strings, only for bare).

Worked examples

Replies produced against the app's prompt for the four samples (original characters with made-up premises; long fields and lists shortened; the full replies ship in example-result.js). Together they cover every verdict.

a coffee-shop closing shift - twist the execution

Input:

{
 "task": "twist",
 "premise": "Rowan has been the only barista at a tiny coffee shop for three years and likes it quiet. When the shop's new manager hires Tamsin, the loud ... (401 characters)",
 "tags": "Coffee Shop AU, Enemies to Lovers, Slow Burn, Grumpy/Sunshine",
 "mood": "fluff",
 "page_tropes": "[\"El Enemies to Lovers (tags)\",\"Sb Slow Burn (tags)\",\"Ca Coffee Shop AU (premise + tags)\",\"Gs Grumpy / Sunshine (tags)\"]",
 "page_lean": "twist",
 "page_flags": "[]"
}

Reply:

{
 "task": "twist",
 "verdict": "twist",
 "title": "Every Closing Shift Together",
 "summary": "This comes out as a twist: the farmers-market grudge, the grumpy/sunshine dynamic, the coffee shop setting, the forced closing shifts, and the slow burn are all still played straight, so every one of them gets a new angle rather than a cut. The single change that reshapes the rest is making the new manager's pairing deliberate instead of a scheduling accident, since once the pairing has a reason behind it, the grudge, the cheer, and the slow burn all have something real to push against. Five tropes are in play, which is well short of a pileup, and the premise already gives enough - who wants quiet, what starts the story, what neither will admit - that it clears the bare rule easily.",
 "tropes": [
  {
   "id": "El",
   "name": "Enemies to Lovers",
   "status": "straight",
   "expected": "Readers expect two people who already dislike each other to spend the story sniping until the dislike turns into attraction.",
   "function": "the charged friction that makes small kindnesses land hard",
   "axis": "knowledge",
   "twist": "What if Rowan already suspects the farmers market ban wasn't really about spite, but keeps the grudge going because without it there's nothing to say to Tamsin during a quiet closing shift?"
  },
  {
   "id": "Tu",
   "name": "Forced Teamwork",
   "status": "straight",
   "expected": "Readers expect two people who wouldn't choose each other to be thrown into a shared task and have to lean on each other to get through it.",
   "function": "reluctant reliance that slowly turns into trust",
   "axis": "knowledge",
   "twist": "Let the new manager know exactly what putting Rowan and Tamsin on every closing shift would stir up, while both of them go on thinking the schedule is only about the inspection."
  },
  {
   "id": "Gs",
   "name": "Grumpy / Sunshine",
   "status": "straight",
   "expected": "Readers expect the grumpy character to resist the sunshine character's cheer until the cheer slowly wins them over.",
   "function": "the contrast that makes a small warmth from the grump feel earned",
   "axis": "goal",
   "twist": "What if Tamsin's cheer during the closing shift isn't aimed at winning Rowan over at all - what if it's just how Tamsin gets through the end of a long day - so the one time it's clearly meant for Rowan stands out instead of blending into the usual noise?"
  },
  "... (5 in all)"
 ],
 "catalyst": "Let the inspector who grades the shop's winter pass care only about code compliance, not romance - a last-minute requirement forces Rowan and Tamsin to redo a night's closing prep from scratch with no time left to argue. The inspector has no stake in how the two of them get along, only in whether the shop passes.",
 "blurb": "Rowan runs the coffee shop's closing shift alone, quiet and familiar, until the new manager pairs the schedule with Tamsin, owner of the bakery across the street and the reason Rowan got banned from the farmers market. Every night until the winter inspection, the two of them close up together, trading sniping for something neither will name. Rowan still remembers how that ban really happened, and Tamsin's cheer was never a performance. The inspector grading the shop only cares whether it passes, not whose heart is on the line.",
 "questions": []
}

a royal fake engagement with eight tropes - pileup

Input:

{
 "task": "twist",
 "premise": "Prince Aldric must be engaged by midwinter or lose his claim, so he strikes a deal with Sefa, the bodyguard who has despised him since their ... (310 characters)",
 "tags": "Royalty AU, Fake Dating, Enemies to Lovers, Bodyguard, Only One Bed, Body Swap, Snowed In, Slow Burn",
 "mood": "either",
 "page_tropes": "[\"El Enemies to Lovers (tags)\",\"Sb Slow Burn (tags)\",\"Fd Fake Dating (premise + tags)\",\"Ob Only One Bed (premise + tags)\",\"Tt Trapped Together (premise + tags)\" ... (258 characters)",
 "page_lean": "pileup",
 "page_flags": "[]"
}

Reply:

{
 "task": "twist",
 "verdict": "pileup",
 "title": "The Midwinter Deal",
 "summary": "This is a pileup: between your tags and premise, eight distinct tropes are stacked on top of each other, more than one story can pay off, and that matches what the page already flagged. The deal, the grudge between Aldric and Sefa, and the curse are the three the central conflict can't lose, so those get full twists while the other five are cut down to one line each. The biggest change: let the curse run on its own rule - tied to whoever is failing their own promise, not to romance - so neither of them can hide behind borrowing the other's body.",
 "tropes": [
  {
   "id": "Fd",
   "name": "Fake Dating",
   "status": "straight",
   "expected": "Readers expect the fake engagement to be a performance for outsiders that quietly turns into real feelings neither will admit.",
   "function": "cover for growing closeness without either having to risk saying it first",
   "axis": "goal",
   "twist": "What if keeping his claim matters more to Aldric than the engagement itself, so he's already planning a second, non-romantic way out of it - leaving Sefa to wonder whether anything between them was ever real, or just leverage?"
  },
  {
   "id": "El",
   "name": "Enemies to Lovers",
   "status": "straight",
   "expected": "Readers expect the long-standing animosity to thaw through forced closeness until both admit they were wrong about each other.",
   "function": "friction that makes every small kindness land harder",
   "axis": "knowledge",
   "twist": "What if Sefa's grudge traces back to one specific thing Aldric did at the academy that he genuinely doesn't remember, so closing the gap depends on him finding out what it was, not just logging enough hours stuck together?"
  },
  {
   "id": "Bs",
   "name": "Body Swap",
   "status": "straight",
   "expected": "Readers expect slapstick mornings and a growing appreciation for what the other person's day costs them, resolving once they understand each other.",
   "function": "forces literal empathy by making each live a stretch of the other's life",
   "axis": "role",
   "twist": "What if the curse isn't keeping score on the romance at all - it keeps swapping them until whichever one is furthest from keeping their own promise fixes it themselves, so neither can solve it by leaning on the other?"
  },
  "... (8 in all)"
 ],
 "catalyst": "The inn's keeper won't call the engagement real until it's proven in front of the whole dining room, and has no stake in whether either of them actually falls for the other - only in whether the room gets paid for before the next storm closes the road.",
 "blurb": "Prince Aldric must be engaged by midwinter or lose his claim, so he strikes a deal with Sefa, the bodyguard who has despised him since their academy days. But the ruse isn't about romance for him. A blizzard traps them at a mountain inn with one bed, and a curse starts swapping their bodies every morning - tied not to fate, but to whichever of them is furthest from keeping their own promise. Sefa's grudge traces to a single academy memory that Aldric still can't recall.",
 "questions": []
}

two knights in thirteen words - bare

Input:

{
 "task": "twist",
 "premise": "Two rival knights get stuck in a tower. Hurt/comfort and only one bed.",
 "tags": "Rivals, Only One Bed, Hurt/Comfort, Fantasy AU",
 "mood": "either",
 "page_tropes": "[\"Ob Only One Bed (premise + tags)\",\"Fa Fantasy AU (tags)\",\"Rv Rivals (premise + tags)\",\"Hc Hurt / Comfort (premise + tags)\"]",
 "page_lean": "bare",
 "page_flags": "[]"
}

Reply:

{
 "task": "twist",
 "verdict": "bare",
 "title": "Two Rivals, One Tower",
 "summary": "This comes back bare: the premise is barely more than a label list - rival knights, a tower, hurt/comfort, one bed - and never says why they clash, what sends them up the tower, or what either one actually wants. That matches the page's own bare call. The detail worth pinning down first is what traps them there, since it will shape how every other trope can play out.",
 "tropes": [
  {
   "id": "Rv",
   "name": "Rivals",
   "status": "straight",
   "expected": "Readers expect the two knights to clash and compete at every turn while an unspoken spark simmers underneath.",
   "function": "friction and charged tension between the leads",
   "axis": "",
   "twist": ""
  },
  {
   "id": "Tt",
   "name": "Trapped Together",
   "status": "straight",
   "expected": "Readers expect the tower to cut off any way out, forcing the two to stay close whether they like it or not.",
   "function": "proximity neither of them chose",
   "axis": "",
   "twist": ""
  },
  {
   "id": "Hc",
   "name": "Hurt / Comfort",
   "status": "straight",
   "expected": "Readers expect one knight to be hurt or shaken and the other to drop their guard to help.",
   "function": "a reason for the rivalry's walls to come down",
   "axis": "",
   "twist": ""
  },
  "... (5 in all)"
 ],
 "catalyst": "",
 "blurb": "",
 "questions": [
  "What event traps the two knights in the tower together?",
  "What does each knight want, both in general and in this moment?",
  "Why are they rivals in the first place?",
  "Is either of them hiding an injury, a feeling, or a secret from the other?"
 ]
}

a time loop the sister started - fresh

Input:

{
 "task": "twist",
 "premise": "Every night of the harvest fair, Odile relives the same evening, and the time loop resets at midnight. She is not trying to escape it: she s ... (346 characters)",
 "tags": "Time Loop, Oblivious",
 "mood": "angst",
 "page_tropes": "[\"Tl Time Loop (premise + tags)\",\"Ov Oblivious (tags)\"]",
 "page_lean": "twist",
 "page_flags": "[]"
}

Reply:

{
 "task": "twist",
 "verdict": "fresh",
 "title": "Eleven Quiet Loops",
 "summary": "This reads as fresh, not twist: your premise already breaks both tropes before any suggestion is needed - Odile loops on purpose to keep Wren off the dawn train instead of trying to escape, and Wren, assumed oblivious, has secretly remembered every loop and played along for eleven of them. Your page called it twist because its word lists can't see a twist already written into the premise text, only trope labels. The one change that matters most: neither of them can hide behind not-knowing anymore, so the real question becomes what each does now that both are aware.",
 "tropes": [
  {
   "id": "Tl",
   "name": "Time Loop",
   "status": "twisted",
   "expected": "Readers expect someone trapped reliving the same day, desperate to escape the loop and break free before they run out of chances.",
   "function": "repeated chances to get one moment right, raising the stakes of that single choice with every reset.",
   "axis": "goal",
   "twist": "Your premise already twists this on the goal axis: Odile isn't trying to escape the loop - she started it on purpose, to keep Wren off the dawn train. Push it further: let the loop's true end condition be something only Wren can grant, so Odile's goal can only be won with Wren's cooperation, not through her own effort alone."
  },
  {
   "id": "Ov",
   "name": "Oblivious",
   "status": "twisted",
   "expected": "Readers expect one person to be the only one who doesn't know what's really going on, staying in the dark until the truth finally comes out.",
   "function": "the gap between what one person believes and what everyone else can see, paid off when that gap closes.",
   "axis": "knowledge",
   "twist": "Your premise already twists this on the knowledge axis: the one assumed oblivious, Wren, has actually remembered every loop and quietly let Odile keep running it. Push it further: let Wren start nudging small details of the evening without saying so, in ways Odile mistakes for coincidence, so the one assumed oblivious turns out to be shaping the loop too."
  }
 ],
 "catalyst": "Let the harvest fair's contest judge be the outside pressure: they have to announce a winner before midnight every single night and have no idea the evening ever repeats. Their deadline collides with the loop's reset whether or not either sister is ready for it, and their own stake in getting the judging right has nothing to do with the sisters at all.",
 "blurb": "Every night of the fair, Odile resets the loop at midnight, looping on purpose to keep Wren off the dawn train. She's sure Wren has no idea why the evening keeps repeating - until this year, when Odile learns Wren has remembered every loop too, and has quietly played along for eleven of them. Now the loop isn't only Odile's to run: Wren knows its rules as well as she does, and might be the one deciding how it ends.",
 "questions": []
}

Build the input yourself

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

const K = TropeKit;
const form = { premise: "Every night of the harvest fair, Odile relives the same evening...", tags: "Time Loop, Oblivious", mood: "angst" };
console.log(K.lean(form));                                // the page's verdict, the matched tropes and the formula
const wire = K.toWire(K.buildInput(form).input);          // POST this to /estimate and /run
// then: const reply = K.normalize(K.parseReply(job.output.output));
// const { notes } = K.check(reply, form);                // the page's re-check of the reply
// K.plainText(reply)                                     // the new summary with its tag line

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://trope-table.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":"trope-table"}' | 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": "twist", "premise": "Every night of the harvest fair, Odile relives the same evening...", "tags": "Time Loop, Oblivious", "mood": "angst", "page_tropes": "[\"Tl Time Loop (premise + tags)\", ...]", "page_lean": "twist", "page_flags": "[]"}
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 trope-table:<task>:<inputHash>:a<attempt> (there is one lane, twist), 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="trope-table:twist:$(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: trope-table:twist:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json