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.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The body is not a JSON object, or a required field such as task or premise is missing. |
| 401 | unauthorized | No token, or a stale one. Mint a guest token or sign in again. |
| 402 | insufficient_credits | The balance is under min_credits. Price with /estimate first. |
| 403 | forbidden | A guest token tried to run: the read is metered and needs a personal token. |
| 404 | not_found | Unknown job id. |
| 429 | rate_limited | Back off and retry. |
| 5xx | server_error | Transient. 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:
| Order | Verdict | When |
|---|---|---|
| 1 | bare | The 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. |
| 2 | pileup | 6 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. |
| 3 | fresh | Every 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. |
| 4 | twist | Everything 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
| Field | Type | Notes |
|---|---|---|
task | string | twist. |
premise | string | The story's summary. Past 2,400 characters whole sentences are cut from the middle with a marker. |
tags | string | Comma-separated tags, up to 1,000 characters (Time Loop, Oblivious); may be empty. |
mood | string | either, fluff or angst; anything else becomes either. |
page_tropes | string (JSON) | Array of the table tropes the word lists matched, with where: "Tl Time Loop (premise + tags)". |
page_lean | string | The page's verdict by the rules above (it never says fresh - a word list cannot see a twist). |
page_flags | string (JSON) | Array of the page's warnings (usually empty). |
retry_note | string | Only 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"}
}
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "paste-your-token-here" # from https://trope-table.skillsafe.ai/tokens.html
def api(method, path, body=None, headers=None):
req = urllib.request.Request(BASE + path, method=method,
data=None if body is None else json.dumps(body).encode(),
headers={"Content-Type": "application/json", **({"Authorization": "Bearer " + TOKEN} if TOKEN else {}), **(headers or {})})
with urllib.request.urlopen(req) as r:
env = json.load(r)
if env.get("error"):
raise RuntimeError(env["error"])
return env["data"]
// trope-table.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://trope-table.skillsafe.ai/tokens.html
async function api(method, path, body, headers = {}) {
const r = await fetch(BASE + path, { method, headers: { "Content-Type": "application/json",
...(TOKEN ? { Authorization: "Bearer " + TOKEN } : {}), ...headers },
body: body === undefined ? undefined : JSON.stringify(body) });
const env = await r.json();
if (env.error) throw new Error(env.error.code + ": " + env.error.message);
return env.data;
}
package main
import ("bytes"; "encoding/json"; "fmt"; "net/http")
const base = "https://api.skillsafe.ai/v1/app-api"
var token = "paste-your-token-here" // from https://trope-table.skillsafe.ai/tokens.html
func api(method, path string, body any, headers map[string]string) (map[string]any, error) {
var buf bytes.Buffer
if body != nil { json.NewEncoder(&buf).Encode(body) }
req, _ := http.NewRequest(method, base+path, &buf)
req.Header.Set("Content-Type", "application/json")
if token != "" { req.Header.Set("Authorization", "Bearer "+token) }
for k, v := range headers { req.Header.Set(k, v) }
res, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
defer res.Body.Close()
var env struct { Data map[string]any; Error map[string]any }
json.NewDecoder(res.Body).Decode(&env)
if env.Error != nil { return nil, fmt.Errorf("%v", env.Error) }
return env.Data, nil
}
// TropeTable.java -- Java 17+, with com.fasterxml.jackson.core:jackson-databind
import com.fasterxml.jackson.databind.*;
import java.net.URI; import java.net.http.*; import java.util.*;
public class TropeTable {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://trope-table.skillsafe.ai/tokens.html
static final ObjectMapper M = new ObjectMapper();
static final HttpClient C = HttpClient.newHttpClient();
static JsonNode api(String method, String path, Object body, Map<String, String> headers) throws Exception {
var b = HttpRequest.newBuilder(URI.create(BASE + path)).header("Content-Type", "application/json");
if (!token.isEmpty()) b.header("Authorization", "Bearer " + token);
if (headers != null) headers.forEach(b::header);
b.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(M.writeValueAsString(body)));
JsonNode env = M.readTree(C.send(b.build(), HttpResponse.BodyHandlers.ofString()).body());
if (env.hasNonNull("error")) throw new RuntimeException(env.get("error").toString());
return env.get("data");
}
}
require "json"
require "net/http"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "paste-your-token-here" # from https://trope-table.skillsafe.ai/tokens.html
def api(method, path, body = nil, headers = {})
uri = URI(BASE + path)
req = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri,
{ "Content-Type" => "application/json" }.merge(TOKEN.empty? ? {} : { "Authorization" => "Bearer #{TOKEN}" }).merge(headers))
req.body = JSON.generate(body) unless body.nil?
env = JSON.parse(Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }.body)
raise env["error"].to_s if env["error"]
env["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = "paste-your-token-here"; // from https://trope-table.skillsafe.ai/tokens.html
function api(string $method, string $path, $body = null, array $headers = []) {
global $TOKEN;
$h = ["Content-Type: application/json"];
if ($TOKEN) $h[] = "Authorization: Bearer $TOKEN";
foreach ($headers as $k => $v) $h[] = "$k: $v";
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $h, CURLOPT_RETURNTRANSFER => true]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
$env = json_decode(curl_exec($ch), true);
if (!empty($env["error"])) throw new Exception(json_encode($env["error"]));
return $env["data"];
}
// Program.cs -- .NET 6+ console app
using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Nodes;
static class Api {
public const string Base = "https://api.skillsafe.ai/v1/app-api";
public static string Token = "paste-your-token-here"; // from https://trope-table.skillsafe.ai/tokens.html
static readonly HttpClient C = new();
public static async Task<JsonNode?> Call(string method, string path, object? body = null, Dictionary<string, string>? headers = null) {
var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
if (Token != "") req.Headers.Add("Authorization", "Bearer " + Token);
if (headers != null) foreach (var h in headers) req.Headers.Add(h.Key, h.Value);
if (body != null) req.Content = JsonContent.Create(body);
var env = JsonNode.Parse(await (await C.SendAsync(req)).Content.ReadAsStringAsync())!;
if (env["error"] is JsonNode e && e.GetValueKind() != JsonValueKind.Null) throw new Exception(e.ToJsonString());
return env["data"];
}
}
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')
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = ""
TOKEN = api("POST", "/guest", {"slug": "trope-table"})["token"]
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = "";
TOKEN = (await api("POST", "/guest", { slug: "trope-table" })).token;
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = ""
g, err := api("POST", "/guest", map[string]string{"slug": "trope-table"}, nil)
if err != nil { panic(err) }
token = g["token"].(string)
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = "";
token = api("POST", "/guest", Map.of("slug", "trope-table"), null).get("token").asText();
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN.replace("")
TOKEN.replace(api("POST", "/guest", { slug: "trope-table" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "trope-table"])["token"];
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
Api.Token = "";
Api.Token = (await Api.Call("POST", "/guest", new { slug = "trope-table" }))!["token"]!.GetValue<string>();
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}'
me = api("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
me, _ := api("GET", "/me", nil, nil)
fmt.Println(me["subject_type"], me["credits"])
JsonNode me = api("GET", "/me", null, null);
System.out.println(me.get("subject_type") + " " + me.get("credits"));
me = api("GET", "/me")
puts [me["subject_type"], me["credits"]].inspect
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"] ?? "", "\n";
var me = await Api.Call("GET", "/me");
Console.WriteLine($"{me!["subject_type"]} {me["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}'
inp = json.load(open("input.json")) # {"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": "[]"}
est = api("POST", "/estimate", inp)
print(est["hold_credits"], est["min_credits"], est["model"], est.get("warnings"))
import { readFileSync } from "node:fs";
const input = JSON.parse(readFileSync("input.json", "utf8")); // {task: "twist", premise, tags, mood, page_tropes, ... (see the field table)}
const est = await api("POST", "/estimate", input);
console.log(est.hold_credits, est.min_credits, est.model, est.warnings);
raw, _ := os.ReadFile("input.json") // add "os" to the imports
var input map[string]any
json.Unmarshal(raw, &input)
est, _ := api("POST", "/estimate", input, nil)
fmt.Println(est["hold_credits"], est["min_credits"], est["model"])
Map<String, Object> input = M.readValue(new java.io.File("input.json"), Map.class);
JsonNode est = api("POST", "/estimate", input, null);
System.out.println(est.get("hold_credits") + " " + est.get("min_credits") + " " + est.get("model"));
input = JSON.parse(File.read("input.json"))
est = api("POST", "/estimate", input)
puts [est["hold_credits"], est["min_credits"], est["model"]].inspect
$input = json_decode(file_get_contents("input.json"), true);
$est = api("POST", "/estimate", $input);
echo $est["hold_credits"], " ", $est["min_credits"], " ", $est["model"], "\n";
var input = JsonNode.Parse(File.ReadAllText("input.json"))!;
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"{est!["hold_credits"]} {est["min_credits"]} {est["model"]}");
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}'
import hashlib, time
key = "trope-table:" + inp["task"] + ":" + hashlib.sha256(json.dumps(inp).encode()).hexdigest()[:16] + ":a1"
job = api("POST", "/run", inp, {"Idempotency-Key": key})
while True:
j = api("GET", "/jobs/" + job["job_id"])
if j["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(2)
reply = json.loads(j["output"]["output"]) # the read as one JSON object
print(j["charged_credits"], j.get("truncated"), reply.get("verdict"))
import { createHash } from "node:crypto";
const key = `trope-table:${input.task}:${createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16)}:a1`;
const job = await api("POST", "/run", input, { "Idempotency-Key": key });
let j;
do { await new Promise((r) => setTimeout(r, 2000)); j = await api("GET", "/jobs/" + job.job_id); }
while (!["succeeded", "failed", "cancelled"].includes(j.status));
const reply = JSON.parse(j.output.output);
console.log(j.charged_credits, j.truncated, reply.verdict);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "trope-table:" + input["task"].(string) + ":" + hex.EncodeToString(sum[:8]) + ":a1"
job, _ := api("POST", "/run", input, map[string]string{"Idempotency-Key": key})
var j map[string]any
for {
time.Sleep(2 * time.Second)
j, _ = api("GET", "/jobs/"+job["job_id"].(string), nil, nil)
if s := j["status"]; s == "succeeded" || s == "failed" || s == "cancelled" { break }
}
fmt.Println(j["charged_credits"], j["truncated"], j["output"].(map[string]any)["output"])
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "trope-table:" + input.get("task") + ":" + HexFormat.of().formatHex(digest, 0, 8) + ":a1";
JsonNode job = api("POST", "/run", input, Map.of("Idempotency-Key", key)), j;
do { Thread.sleep(2000); j = api("GET", "/jobs/" + job.get("job_id").asText(), null, null); }
while (!Set.of("succeeded", "failed", "cancelled").contains(j.get("status").asText()));
JsonNode reply = M.readTree(j.get("output").get("output").asText());
System.out.println(j.get("charged_credits") + " " + reply);
require "digest"
key = "trope-table:#{input["task"]}:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1"
job = api("POST", "/run", input, { "Idempotency-Key" => key })
j = nil
loop do
sleep 2
j = api("GET", "/jobs/#{job["job_id"]}")
break if %w[succeeded failed cancelled].include?(j["status"])
end
reply = JSON.parse(j["output"]["output"])
puts [j["charged_credits"], j["truncated"], reply["title"]].inspect
$key = "trope-table:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$job = api("POST", "/run", $input, ["Idempotency-Key" => $key]);
do { sleep(2); $j = api("GET", "/jobs/" . $job["job_id"]); }
while (!in_array($j["status"], ["succeeded", "failed", "cancelled"]));
$reply = json_decode($j["output"]["output"], true);
echo $j["charged_credits"], " ", $reply["title"], "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"trope-table:{input["task"]}:{Convert.ToHexString(digest, 0, 8).ToLowerInvariant()}:a1";
var job = await Api.Call("POST", "/run", input, new() { ["Idempotency-Key"] = key });
JsonNode j;
do { await Task.Delay(2000); j = (await Api.Call("GET", "/jobs/" + job!["job_id"]))!; }
while (!new[] { "succeeded", "failed", "cancelled" }.Contains(j["status"]!.GetValue<string>()));
var reply = JsonNode.Parse(j["output"]!["output"]!.GetValue<string>());
Console.WriteLine($"{j["charged_credits"]} {reply}");
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
rev = json.load(open("input.json")) # the same run input as input.json: {"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": "[]"}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "trope-table:twist:" + hashlib.sha256(json.dumps(rev).encode()).hexdigest()[:16] + ":a1"})
with urllib.request.urlopen(req) as r:
for line in r:
line = line.decode().strip()
if line.startswith("data:"):
print(line[5:].strip()[:200])
const rev = JSON.parse(readFileSync("input.json", "utf8"));
const r = await fetch(BASE + "/run-stream", { method: "POST", body: JSON.stringify(rev), headers: {
"Content-Type": "application/json", Authorization: "Bearer " + TOKEN,
"Idempotency-Key": `trope-table:twist:${createHash("sha256").update(JSON.stringify(rev)).digest("hex").slice(0, 16)}:a1` } });
const dec = new TextDecoder();
for await (const chunk of r.body) for (const line of dec.decode(chunk).split("\n"))
if (line.startsWith("data:")) console.log(line.slice(5).trim().slice(0, 200));
rraw, _ := os.ReadFile("input.json") // add "bufio", "strings" to the imports
rs := sha256.Sum256(rraw)
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(rraw))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", "trope-table:twist:"+hex.EncodeToString(rs[:8])+":a1")
res, _ := http.DefaultClient.Do(req)
sc := bufio.NewScanner(res.Body)
for sc.Scan() { if l := sc.Text(); strings.HasPrefix(l, "data:") { fmt.Println(l[5:]) } }
String rev = java.nio.file.Files.readString(java.nio.file.Path.of("input.json"));
var rd = java.security.MessageDigest.getInstance("SHA-256").digest(rev.getBytes());
var sreq = HttpRequest.newBuilder(URI.create(BASE + "/run-stream")).header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token).header("Idempotency-Key", "trope-table:twist:" + HexFormat.of().formatHex(rd, 0, 8) + ":a1")
.POST(HttpRequest.BodyPublishers.ofString(rev)).build();
C.send(sreq, HttpResponse.BodyHandlers.ofLines()).body().filter(l -> l.startsWith("data:")).forEach(System.out::println);
rev = File.read("input.json")
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{TOKEN}",
"Idempotency-Key" => "trope-table:twist:#{Digest::SHA256.hexdigest(rev)[0, 16]}:a1")
req.body = rev
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |h|
h.request(req) { |res| res.read_body { |chunk| chunk.each_line { |l| puts l if l.start_with?("data:") } } }
end
$rev = file_get_contents("input.json");
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $rev, CURLOPT_HTTPHEADER => [
"Content-Type: application/json", "Authorization: Bearer $TOKEN",
"Idempotency-Key: trope-table:twist:" . substr(hash("sha256", $rev), 0, 16) . ":a1"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) { foreach (explode("\n", $chunk) as $l) if (str_starts_with($l, "data:")) echo $l, "\n"; return strlen($chunk); }]);
curl_exec($ch);
var rev = File.ReadAllText("input.json");
var rd = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(rev));
var sreq = new HttpRequestMessage(HttpMethod.Post, Api.Base + "/run-stream") { Content = new StringContent(rev, System.Text.Encoding.UTF8, "application/json") };
sreq.Headers.Add("Authorization", "Bearer " + Api.Token);
sreq.Headers.Add("Idempotency-Key", $"trope-table:twist:{Convert.ToHexString(rd, 0, 8).ToLowerInvariant()}:a1");
using var http = new HttpClient();
using var res = await http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var rdr = new StreamReader(await res.Content.ReadAsStreamAsync());
for (string? l; (l = await rdr.ReadLineAsync()) != null;) if (l.StartsWith("data:")) Console.WriteLine(l);