Drive Plot Trellis from your own code
The structural critique the page runs is available over HTTP. A natural use is an outline you keep as a Markdown or OPML file in your writing folder: a script re-reads it after each writing session, asks for a critique, and later a re-critique that checks what you did with the last one.
Be clear about the split first: the model never sees your file, only the outline text you build.
The caller turns the tree into indented lines with stable card ids (n0 is the story), computes the
facts (scene balance per part, threads, prescan problems) and sends them along. The reply proposes operations on
those ids; refusing the ones that name cards you do not have is the caller's job. The page's own tree, clipping and
reconciler ship as plain scripts you can load in node - see the end of the examples.
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":"plot-trellis"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object: never wrap it as {"input": {…}} — that returns 200, warns unknown field 'input', and hides every field from the model.
Errors:
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The body is not a JSON object. |
| 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: critiques are metered and need 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 comes first
One system prompt serves two tasks. task picks the contract; anything else is read as critique and the reply's meta line says so.
| task | When | Findings carry |
|---|---|---|
critique | The first structural read of an outline. | No status. |
recritique | After you edited the outline - accepted or dismissed proposals, moved cards yourself. Needs previous_json. | status: resolved / open for every earlier fid, new for new ones. |
Every run body is a JSON object of string fields (the declared input schema; task, title, structure, outline and facts_json are required, and /estimate warns when one is missing):
| Field | What it holds |
|---|---|
task | "critique" or "recritique". |
title | The working title (the root card's text). |
structure | three-act, kishotenketsu, heros-journey, seven-point, mystery or free. |
genre, logline | Optional; what the story is trying to be. |
outline | The tree as indented text, two spaces per level: <id> [kind] <text> -- note: <note>. Kinds: turn, setup, payoff, note; #words are threads. The page sends at most 220 cards: it caps depth first, then count, leaving a marker line such as (... 12 deeper cards under n40 not sent). |
facts_json | JSON string: cards, depth, scenes, balance (score and per-part scenes, share and expected share), prescan (codes missing_turn, empty_branch, dangling_setup, missing_setup, payoff_first, duplicate, imbalance with ids) and threads. |
previous_json | recritique only. JSON string: the earlier findings and each proposed op with its state (accepted, dismissed, pending, stale, refused). |
clip_note | What was not sent, in words; empty when nothing was cut. |
The reply text is JSON Lines - one object per line, so a reply cut short still yields every whole line:
{"k":"meta","task":"critique","structure":"three-act","summary":"..."}
{"k":"finding","fid":"f1","kind":"missing_turn","branch":"n11","ids":["n11"],"text":"...","status":"open|resolved|new (recritique only)"}
{"k":"op","oid":"o1","op":"add","parent":"n21","index":1,"text":"...","kind":"turn","fixes":"f1","reason":"..."}
{"k":"op","oid":"o2","op":"move","id":"n18","parent":"n6","index":1,"reason":"..."}
{"k":"op","oid":"o3","op":"merge","ids":["n22","n13"],"text":"...","reason":"..."}
{"k":"op","oid":"o4","op":"rename","id":"n10","text":"...","reason":"..."}
{"k":"verdict","shape":"sound|workable|needs_work","strength":"...","priority":"..."}
{"k":"end"}
Finding kinds: missing_turn, dangling_setup, missing_setup, empty_branch, imbalance, duplicate, order, missing_beat, pacing, other. Zero findings and zero ops with shape: "sound" is a valid answer.
Worked examples
These are the bundled examples on the page: the exact run body the page builds and a reply produced against the shipped prompt (the page replays the same replies for free).
critique: a three-act novel with real gaps
Input:
{
"task": "critique",
"title": "Wren and the Winter Light",
"structure": "three-act",
"genre": "coming-of-age mystery novel",
"logline": "When the light on Sable Point is automated, sixteen-year-old Wren has one winter to prove her father's logbooks hide a wreck the coast guard buried.",
"outline": "(indented text - shown below)",
"facts_json": "(a JSON string - decoded below)",
"clip_note": ""
}
outline:
n0 Wren and the Winter Light
n1 Act I - Setup
n2 The last keeper
n3 Wren rows the winter supplies out to the light
n4 [setup] Her father locks the logbook drawer when she comes in #logbook
n5 The letter
n6 [turn] The coast guard writes: the light goes automatic in spring
n7 Wren finds a page torn out of the 1987 log #logbook
n8 Harbour town
n9 Wren meets Ada, the harbour archivist
n10 Ada refuses to open the 1987 files
n11 Act II - Confrontation
n12 The archive
n13 Wren breaks into the archive at night
n14 Ada catches her and, against her own rules, helps
n15 The storm
n16 A three-day storm cuts the island off
n17 Her father tells half the story of the wreck -- note: He stops before the part about the second boat.
n18 The diver
n19 Wren spends her savings on a diver from the mainland
n20 [setup] The diver mentions sonar showing a second hull #second-hull
n21 Back to the archive
n22 Wren breaks into the archive again at night
n23 Act III - Resolution
n24 The wreck
n25 [turn] The dive brings up the ship's bell
facts_json, decoded:
{
"cards": 26,
"depth": 3,
"scenes": 14,
"balance": {
"score": 82,
"basis": "Three-act plot",
"branches": [
{
"id": "n1",
"scenes": 6,
"words": 64,
"share": 43,
"expected": 25,
"flag": "heavy"
},
{
"id": "n11",
"scenes": 7,
"words": 81,
"share": 50,
"expected": 50,
"flag": ""
},
{
"id": "n23",
"scenes": 1,
"words": 12,
"share": 7,
"expected": 25,
"flag": "thin"
}
]
},
"prescan": [
{
"code": "missing_turn",
"ids": [
"n11"
],
"branch": "n11"
},
{
"code": "dangling_setup",
"ids": [
"n4"
],
"branch": "n1"
},
{
"code": "dangling_setup",
"ids": [
"n20"
],
"branch": "n11"
},
{
"code": "duplicate",
"ids": [
"n13",
"n22"
],
"branch": "n11"
},
{
"code": "imbalance",
"ids": [
"n1"
],
"branch": "n1"
},
{
"code": "imbalance",
"ids": [
"n23"
],
"branch": "n23"
}
],
"threads": [
{
"tag": "#logbook",
"setups": [
"n4"
],
"payoffs": []
},
{
"tag": "#second-hull",
"setups": [
"n20"
],
"payoffs": []
}
]
}
Reply (JSON Lines):
{"k": "meta", "task": "critique", "structure": "three-act", "summary": "A three-act shape fits this wreck mystery, but Act II only accumulates incidents and never turns, and Act III is one scene that cannot pay off the logbook or second-hull threads Act I and II plant. Trim Act I, give Act II a turn, let the ending breathe."}
{"k": "finding", "fid": "f1", "kind": "missing_turn", "branch": "n11", "ids": ["n11"], "text": "Act II (n11) has no card marked [turn]: the archive break-ins, the storm, and hiring the diver are incidents, not a point where Wren can no longer undo what she's done."}
{"k": "finding", "fid": "f2", "kind": "dangling_setup", "branch": "n1", "ids": ["n4"], "text": "The #logbook thread is only planted: n4's locked drawer gets no [payoff]. n7 finds a torn page but is untagged, so the drawer's secret is never shown being opened."}
{"k": "finding", "fid": "f3", "kind": "dangling_setup", "branch": "n11", "ids": ["n20"], "text": "The #second-hull thread set up at n20 (the diver's sonar finds a second hull) has no [payoff] card anywhere, including in Act III's single scene at n24."}
{"k": "finding", "fid": "f4", "kind": "duplicate", "branch": "n11", "ids": ["n13", "n22"], "text": "n13 and n22 are both 'Wren breaks into the archive at night' with nothing new at stake between them: same actor, same place, same risk, just repeated two chapters apart."}
{"k": "finding", "fid": "f5", "kind": "imbalance", "branch": "n1", "ids": ["n1"], "text": "Act I carries 43% of the outline's scenes against three-act's expected 25% (6 scenes vs. 1 for Act III) -- a long runway before the coast-guard letter forces the clock to start."}
{"k": "finding", "fid": "f6", "kind": "imbalance", "branch": "n23", "ids": ["n23"], "text": "Act III is one scene (7% vs. an expected 25%): the dive brings up the bell, but neither dangling thread (#logbook, #second-hull) nor the father's half-told story (n17) gets room to land."}
{"k": "op", "oid": "o1", "op": "add", "parent": "n18", "index": 2, "kind": "turn", "text": "Spending her last savings on the diver, Wren passes the point of no return", "fixes": "f1", "reason": "Marks the one choice in Act II that can't be undone, giving the act the turn three-act structure expects."}
{"k": "op", "oid": "o2", "op": "add", "parent": "n24", "index": 1, "kind": "payoff", "text": "Her father finishes the wreck story he stopped mid-storm, naming what the second hull is #second-hull", "fixes": "f3", "reason": "Pays off the sonar setup at n20 and finally finishes the story n17 left half-told, inside the resolution act."}
{"k": "op", "oid": "o3", "op": "add", "parent": "n24", "index": 2, "kind": "payoff", "text": "Wren finally unlocks the logbook drawer and reads the torn page for herself #logbook", "fixes": "f2", "reason": "Pays off the drawer setup at n4, which the outline otherwise never resolves."}
{"k": "op", "oid": "o4", "op": "add", "parent": "n24", "index": 3, "kind": "note", "text": "Wren decides what to tell the coast guard before the light goes automatic", "fixes": "f6", "reason": "Gives the resolution a closing beat of its own instead of ending on the bell alone, growing Act III's share."}
{"k": "op", "oid": "o5", "op": "merge", "ids": ["n9", "n10"], "text": "Wren meets Ada, the harbour archivist, who refuses to open the 1987 files", "fixes": "f5", "reason": "Combines one conversation's two beats into one scene, trimming Act I's scene count toward its expected share."}
{"k": "op", "oid": "o6", "op": "merge", "ids": ["n12", "n21"], "text": "The archive", "fixes": "f4", "reason": "Puts both archive break-ins under one chapter so n13 and n22 sit side by side as one beat instead of two separate chapter-level trips."}
{"k": "verdict", "shape": "needs_work", "strength": "The through-line is tight: a locked drawer escalates cleanly to a torn page, a defiant archivist, a hired diver and a second hull, all before the dive.", "priority": "First give Act II a turn (hiring the diver) and grow Act III enough to pay off the logbook and second-hull threads before the story ends."}
{"k": "end"}
recritique: the same novel after every proposal was accepted
Input (previous_json carries the first critique and what became of each proposal):
{
"task": "recritique",
"title": "Wren and the Winter Light",
"structure": "three-act",
"genre": "coming-of-age mystery novel",
"logline": "When the light on Sable Point is automated, sixteen-year-old Wren has one winter to prove her father's logbooks hide a wreck the coast guard buried.",
"outline": "(indented text - shown below)",
"facts_json": "(a JSON string - decoded below)",
"clip_note": "",
"previous_json": "(a JSON string - decoded below)"
}
outline:
n0 Wren and the Winter Light
n1 Act I - Setup
n2 The last keeper
n3 Wren rows the winter supplies out to the light
n4 [setup] Her father locks the logbook drawer when she comes in #logbook
n5 The letter
n6 [turn] The coast guard writes: the light goes automatic in spring
n7 Wren finds a page torn out of the 1987 log #logbook
n8 Harbour town
n9 Wren meets Ada, the harbour archivist, who refuses to open the 1987 files -- note: Merged from "Ada refuses to open the 1987 files"
n11 Act II - Confrontation
n12 The archive -- note: Merged from "Back to the archive"
n13 Wren breaks into the archive at night
n14 Ada catches her and, against her own rules, helps
n22 Wren breaks into the archive again at night
n15 The storm
n16 A three-day storm cuts the island off
n17 Her father tells half the story of the wreck -- note: He stops before the part about the second boat.
n18 The diver
n19 Wren spends her savings on a diver from the mainland
n20 [setup] The diver mentions sonar showing a second hull #second-hull
n26 [turn] Spending her last savings on the diver, Wren passes the point of no return
n23 Act III - Resolution
n24 The wreck
n25 [turn] The dive brings up the ship's bell
n27 [payoff] Her father finishes the wreck story he stopped mid-storm, naming what the second hull is #second-hull
n28 [payoff] Wren finally unlocks the logbook drawer and reads the torn page for herself #logbook
n29 [note] Wren decides what to tell the coast guard before the light goes automatic
facts_json, decoded:
{
"cards": 28,
"depth": 3,
"scenes": 17,
"balance": {
"score": 96,
"basis": "Three-act plot",
"branches": [
{
"id": "n1",
"scenes": 5,
"words": 73,
"share": 29,
"expected": 25,
"flag": ""
},
{
"id": "n11",
"scenes": 8,
"words": 97,
"share": 47,
"expected": 50,
"flag": ""
},
{
"id": "n23",
"scenes": 4,
"words": 55,
"share": 24,
"expected": 25,
"flag": ""
}
]
},
"prescan": [
{
"code": "duplicate",
"ids": [
"n13",
"n22"
],
"branch": "n11"
}
],
"threads": [
{
"tag": "#logbook",
"setups": [
"n4"
],
"payoffs": [
"n28"
]
},
{
"tag": "#second-hull",
"setups": [
"n20"
],
"payoffs": [
"n27"
]
}
]
}
previous_json, decoded:
{
"findings": [
{
"fid": "f1",
"kind": "missing_turn",
"branch": "n11",
"ids": [
"n11"
],
"text": "Act II (n11) has no card marked [turn]: the archive break-ins, the storm, and hiring the diver are incidents, not a point where Wren can no longer undo what she's done."
},
{
"fid": "f2",
"kind": "dangling_setup",
"branch": "n1",
"ids": [
"n4"
],
"text": "The #logbook thread is only planted: n4's locked drawer gets no [payoff]. n7 finds a torn page but is untagged, so the drawer's secret is never shown being opened."
},
{
"fid": "f3",
"kind": "dangling_setup",
"branch": "n11",
"ids": [
"n20"
],
"text": "The #second-hull thread set up at n20 (the diver's sonar finds a second hull) has no [payoff] card anywhere, including in Act III's single scene at n24."
},
{
"fid": "f4",
"kind": "duplicate",
"branch": "n11",
"ids": [
"n13",
"n22"
],
"text": "n13 and n22 are both 'Wren breaks into the archive at night' with nothing new at stake between them: same actor, same place, same risk, just repeated two chapters apart."
},
{
"fid": "f5",
"kind": "imbalance",
"branch": "n1",
"ids": [
"n1"
],
"text": "Act I carries 43% of the outline's scenes against three-act's expected 25% (6 scenes vs. 1 for Act III) -- a long runway before the coast-guard letter forces the clock to start."
},
{
"fid": "f6",
"kind": "imbalance",
"branch": "n23",
"ids": [
"n23"
],
"text": "Act III is one scene (7% vs. an expected 25%): the dive brings up the bell, but neither dangling thread (#logbook, #second-hull) nor the father's half-told story (n17) gets room to land."
}
],
"ops": [
{
"oid": "o1",
"op": "add",
"state": "accepted",
"parent": "n18",
"text": "Spending her last savings on the diver, Wren passes the point of no return"
},
{
"oid": "o2",
"op": "add",
"state": "accepted",
"parent": "n24",
"text": "Her father finishes the wreck story he stopped mid-storm, naming what the second hull is #second-hull"
},
{
"oid": "o3",
"op": "add",
"state": "accepted",
"parent": "n24",
"text": "Wren finally unlocks the logbook drawer and reads the torn page for herself #logbook"
},
{
"oid": "o4",
"op": "add",
"state": "accepted",
"parent": "n24",
"text": "Wren decides what to tell the coast guard before the light goes automatic"
},
{
"oid": "o5",
"op": "merge",
... (shortened here)
Reply (JSON Lines):
{"k": "meta", "task": "recritique", "structure": "three-act", "summary": "Each act now turns on its own card, and both open threads close in Act III, which also carries its fair share of scenes. One carryover remains: merging the two archive chapters left a duplicated break-in scene sitting inside the same chapter."}
{"k": "finding", "fid": "f1", "kind": "missing_turn", "branch": "n11", "ids": ["n11", "n26"], "text": "Resolved: n26 now marks Act II's own turn, \"Spending her last savings on the diver, Wren passes the point of no return,\" placed at the end of n18 where the act hands off to Act III.", "status": "resolved"}
{"k": "finding", "fid": "f2", "kind": "dangling_setup", "branch": "n1", "ids": ["n4", "n28"], "text": "Resolved: the #logbook thread set up at n4 now pays off at n28, \"Wren finally unlocks the logbook drawer and reads the torn page for herself,\" in Act III's n24.", "status": "resolved"}
{"k": "finding", "fid": "f3", "kind": "dangling_setup", "branch": "n11", "ids": ["n20", "n27"], "text": "Resolved: the #second-hull thread set up at n20 (the diver's sonar) now pays off at n27, where the father names what the second hull is, inside Act III's n24.", "status": "resolved"}
{"k": "finding", "fid": "f4", "kind": "duplicate", "branch": "n11", "ids": ["n13", "n22"], "text": "Still open: n13 and n22 are both \"Wren breaks into the archive at night.\" Merging the chapters put them side by side in n12, right across n14 where Ada already agrees to help - the second break-in now reads as leftover, not escalation.", "status": "open"}
{"k": "finding", "fid": "f5", "kind": "imbalance", "branch": "n1", "ids": ["n1"], "text": "Resolved: Act I now holds 29% of scenes against an expected 25% (facts_json balance, no flag), down from 43%, after the harbour-town merge trimmed a redundant scene.", "status": "resolved"}
{"k": "finding", "fid": "f6", "kind": "imbalance", "branch": "n23", "ids": ["n23"], "text": "Resolved: Act III now holds 24% of scenes against an expected 25% (no flag in facts_json), up from 7%, once the father's full wreck story, the logbook payoff, and Wren's decision were added under n24.", "status": "resolved"}
{"k": "op", "oid": "o1", "op": "merge", "ids": ["n13", "n22"], "text": "Wren breaks into the archive at night", "fixes": "f4", "reason": "Collapses the repeated break-in into one scene now that both sit in the merged n12; n14's help already covers why a second break-in isn't needed."}
{"k": "verdict", "shape": "workable", "strength": "All three acts now turn on a card of their own, both dangling threads close inside Act III, and scene balance across acts sits within a few points of the template's expected share.", "priority": "Merge n13 and n22: the archive-chapter merge left two near-identical break-in scenes sitting in the same chapter, right next to the scene where Ada already agrees to help."}
{"k": "end"}
critique: a mystery whose clue is paid off before it is planted
Input:
{
"task": "critique",
"title": "The Cider Press Will",
"structure": "mystery",
"genre": "village cozy mystery",
"logline": "A retired schoolteacher investigates the death of the cider-press owner whose will was changed the week he died.",
"outline": "(indented text - shown below)",
"facts_json": "(a JSON string - decoded below)",
"clip_note": ""
}
outline:
n0 The Cider Press Will
n1 The crime
n2 Harvest week
n3 [turn] The press owner is found dead in the cider barn
n4 Four heirs arrive for the reading of the will
n5 The investigation
n6 First questions
n7 Margaret interviews the eldest son in the orchard
n8 [payoff] The ledger shows the will was witnessed on a day the solicitor was abroad #ledger
n9 The heirs
n10 Margaret interviews the eldest son again at the pub
n11 The niece's alibi holds
n12 The housekeeper lies about the barn key
n13 The second will
n14 [turn] A second will turns up in the church
n15 The solicitor's clerk goes missing
n16 The reveal
n17 The tasting
n18 [setup] Margaret notices the press ledger has a page in different ink #ledger
n19 [turn] Margaret names the clerk at the harvest supper
facts_json, decoded:
{
"cards": 20,
"depth": 3,
"scenes": 11,
"balance": {
"score": 96,
"basis": "Mystery",
"branches": [
{
"id": "n1",
"scenes": 2,
"words": 23,
"share": 18,
"expected": 20,
"flag": ""
},
{
"id": "n5",
"scenes": 7,
"words": 65,
"share": 64,
"expected": 60,
"flag": ""
},
{
"id": "n16",
"scenes": 2,
"words": 24,
"share": 18,
"expected": 20,
"flag": ""
}
]
},
"prescan": [
{
"code": "payoff_first",
"ids": [
"n8",
"n18"
],
"branch": "n5"
}
],
"threads": [
{
"tag": "#ledger",
"setups": [
"n18"
],
"payoffs": [
"n8"
]
}
]
}
Reply (JSON Lines):
{"k":"meta","task":"critique","structure":"mystery","summary":"Each act turns on its own beat (the body, the second will, naming the clerk), which suits a mystery. The investigation act fairly carries the bulk of the scene count. The one snag: the ledger's setup clue lands after the payoff it explains."}
{"k":"finding","fid":"f1","kind":"order","branch":"n16","ids":["n8","n18"],"text":"n8, the reveal that the will was witnessed while the solicitor was abroad, lands in First Questions early in the investigation, but n18, Margaret noticing the ledger's mismatched ink that would uncover this, doesn't happen until The Tasting near the end - the payoff precedes its setup."}
{"k":"op","oid":"o1","op":"move","id":"n18","parent":"n6","index":1,"fixes":"f1","reason":"Moves the ink-mismatch discovery before the ledger reveal (n8) so Margaret finds the clue before it's explained, restoring cause before effect."}
{"k":"verdict","shape":"workable","strength":"Each act lands its own turn (n3's death, n14's second will, n19's naming of the clerk), and the second interview with the eldest son escalates once the backdated will surfaces.","priority":"Move n18 (the ledger's mismatched ink) ahead of n8 (what it reveals) so the clue is found before it's explained."}
{"k":"end"}
critique: a kishotenketsu story that is already sound
Input:
{
"task": "critique",
"title": "The Teahouse at Kinosaki",
"structure": "kishotenketsu",
"genre": "literary short story",
"logline": "An old tea master's last season, told through the guests who come for the onsen and stay for the tea.",
"outline": "(indented text - shown below)",
"facts_json": "(a JSON string - decoded below)",
"clip_note": ""
}
outline:
n0 The Teahouse at Kinosaki
n1 Ki - Introduction
n2 Mrs Ota opens the shutters before the first bathers
n3 She sets out the cracked cup she never sells #cup
n4 Sho - Development
n5 A student comes every evening and orders the cheapest tea
n6 Mrs Ota starts giving him the better leaves without a word
n7 Ten - Twist
n8 [turn] A crane lands in the river and the whole street stops to watch
n9 [setup] The student sketches the crane on a napkin and leaves it in the cracked cup #cup
n10 Ketsu - Reconciliation
n11 Winter: the student does not come back
n12 [payoff] Mrs Ota frames the napkin above the counter, the cup beneath it #cup
facts_json, decoded:
{
"cards": 13,
"depth": 2,
"scenes": 8,
"balance": {
"score": 100,
"basis": "Kishotenketsu",
"branches": [
{
"id": "n1",
"scenes": 2,
"words": 21,
"share": 25,
"expected": 25,
"flag": ""
},
{
"id": "n4",
"scenes": 2,
"words": 23,
"share": 25,
"expected": 25,
"flag": ""
},
{
"id": "n7",
"scenes": 2,
"words": 31,
"share": 25,
"expected": 25,
"flag": ""
},
{
"id": "n10",
"scenes": 2,
"words": 22,
"share": 25,
"expected": 25,
"flag": ""
}
]
},
"prescan": [],
"threads": [
{
"tag": "#cup",
"setups": [
"n9"
],
"payoffs": [
"n12"
]
}
]
}
Reply (JSON Lines):
{"k":"meta","task":"critique","structure":"kishotenketsu","summary":"Ki, Sho, Ten and Ketsu each carry two scenes with an even 25% share, and only Ten lands a marked turn (the crane stopping the street), which is all this form requires. The #cup thread set up in Ten resolves cleanly in Ketsu, giving the plotless structure a real spine."}
{"k":"verdict","shape":"sound","strength":"Each part is built from exactly two scenes, the single required turn sits in Ten, and the napkin-in-the-cup image threads from Ten setup to Ketsu payoff without forcing a conflict the form does not need.","priority":"The logline promises guests (plural) across a last season; only one guest's thread is drafted. Watch that Mrs Ota's season still reads as full, not just this one student's arc."}
{"k":"end"}
The page builds the outline text and checks the reply; the API does not
Over the API you get the critique as JSON Lines. Building outline and facts_json, and refusing proposals that name cards you do not have, is the caller's job. The page's own code is plain scripts you can load in node after global.window = global: tree.js (Trellis.buildInput(task, doc) builds this exact run body, clipping by depth and count with marker lines; Trellis.prescan(doc) and Trellis.balance(doc) are the facts), critique.js (TrellisCritique.parse(text), .reconcile(parsed, doc, input), .applyOp(doc, op)) and io.js (Markdown and OPML in and out).
Step 1 — a tiny client helper
One function that sends JSON and unwraps the envelope.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="paste-your-token-here" # from https://plot-trellis.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://plot-trellis.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"]
// plot-trellis.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://plot-trellis.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://plot-trellis.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
}
// PlotTrellis.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 PlotTrellis {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://plot-trellis.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://plot-trellis.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://plot-trellis.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://plot-trellis.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":"plot-trellis"}' | 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": "plot-trellis"})["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: "plot-trellis" })).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": "plot-trellis"}, 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", "plot-trellis"), 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: "plot-trellis" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "plot-trellis"])["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 = "plot-trellis" }))!["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 (not wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model reads gpt-5.6-terra and model_alias gpt-terra.
# input.json: the run input object - {"task": "critique", "title": "...", "structure": "three-act", "outline": "n0 ...", "facts_json": "{...}"}
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": "critique", "title": "...", "structure": "three-act", "outline": "n0 ...", "facts_json": "{...}"}
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, title, structure, genre, logline, outline, facts_json}
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 plot-trellis:critique:<inputHash>:a<attempt>. The job's output.output is the reply text; truncated: true means the balance cut it short.
KEY="plot-trellis:critique:$(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 = "plot-trellis:" + 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 lane's JSON object
print(j["charged_credits"], j.get("truncated"), reply.get("headline"))
import { createHash } from "node:crypto";
const key = `plot-trellis:${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.headline);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "plot-trellis:" + 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 = "plot-trellis:" + 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 = "plot-trellis:#{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["headline"]].inspect
$key = "plot-trellis:" . $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["headline"], "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"plot-trellis:{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: plot-trellis:critique:$(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": "critique", "title": "...", "structure": "three-act", "outline": "n0 ...", "facts_json": "{...}"}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "plot-trellis:critique:" + 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": `plot-trellis:critique:${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", "plot-trellis:critique:"+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", "plot-trellis:critique:" + 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" => "plot-trellis:critique:#{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: plot-trellis:critique:" . 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", $"plot-trellis:critique:{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);