Drive Findings to Slides from your own code
The page does its free work in the browser: it reads a pasted analysis - prose, bullets, Markdown, CSV or TSV tables -
numbers every data point (D1..), recomputes each table's change from the first to the last period and
checks any stated change, re-totals weighted scoring matrices, and raises its findings (DS-01..). Its two
paid lanes are available over HTTP. You send one JSON object with task set to "story" or
"deck", and the model replies with one JSON object. The easiest way to build the facts string
is the page itself: StoryKit.buildInput(lane, StoryKit.analyze(text, {audience, goal, draft}), {handoff})
in storykit.js, which also runs under Node.
Base URL: https://api.skillsafe.ai/v1/app-api. Every call sends
Content-Type: application/json and, once you have a token,
Authorization: Bearer <token>. Both lanes run on gpt-terra with a 10%
publisher markup. A run needs a signed-in (user) token and is metered: the platform holds
the estimate's hold_credits while the run is in flight and then charges only
what it used (charged_credits). /estimate is free. A plain-text summary is at
/llms.txt.
The response envelope
Every response is {"data": ..., "error": null} on success or {"data": null, "error": {"code", "message"}} on failure.
Error codes
| HTTP | code | meaning |
|---|---|---|
| 400 | VALIDATION_ERROR | The body is not a JSON object, or a field is the wrong type. Every field of the run input is a string. |
| 401 | UNAUTHORIZED | No token, or an expired one: get a new one (step 2). |
| 402 | INSUFFICIENT_CREDITS | The balance is below the run's minimum. Top up at skillsafe.ai/account/billing/. |
| 403 | FORBIDDEN | A guest token tried a metered run: sign in for a personal token. |
| 404 | NOT_FOUND | Unknown job id. |
| 429 | RATE_LIMITED | Back off and retry with the same Idempotency-Key. |
The task field: two lanes
| task | what the model does |
|---|---|
"story" | The story the analysis supports: one answer per page finding (confirmed or disputed, the arithmetic ones confirmed); the framework (problem-solution, trend or comparison); the headline by the skill's formula and two more; exactly three points with their data and so-what; the six beats of the arc; the data to cut; up to three caveats; the ask. |
"deck" | The seven-slide storyboard: headline, context, discovery, deep dive, recommendation, impact, ask - each with an action title, one to three bullets, the data it shows, a visual and what it highlights, and a speaker note - plus an appendix. With story_handoff it follows that story. |
Input fields (all strings)
| field | when | content |
|---|---|---|
task | required | story or deck |
audience | required | executives, board or investors, cross-functional team, or non-technical audience. |
goal | optional | What you want decided or done - becomes the ask. |
draft_headline | optional | Your own headline, checked against the formula. |
analysis | required | The analysis text. The page sends up to 14,000 characters, cutting the middle on whole lines beyond that. |
facts | required | A JSON string: audience, goal, draft_headline, points[{id, text, source, table}] (up to 60), tables[{id, kind, head, from, to, changes[{point, label, from, to, abs, pct, pts, stated, stated_ok}], weight, weight_sum, options[{name, computed, stated, stated_ok, point}]}], findings[{id, kind, severity, text, data[], values[]}], counts, notes[]. |
story_handoff | deck, optional | A JSON string of the story the deck follows: framework, headline, points[{point, data[]}], ask, caveats[], cut[]. |
retry_note | optional | Only on a reformat retry: what was wrong with the previous reply. |
The reply contracts
Both lanes share an envelope: task, task_note, headline (with a number from the input), questions[], summary.
task: "story"
framework, framework_why, findings[{finding, response: confirmed|disputed, note}] (one per page finding, in order), headline_options[2], points[{point, data[], so_what}] (exactly 3), arc[{beat, text, data[]}] (hook, context, rising action, climax, resolution, call to action), cut[{data, why}], caveats[{caveat, data[]}] (0-3), ask.
task: "deck"
source (story handoff|from the input), slides[{n, stage, title, points[], data[], visual, visual_note, speaker_note}] (exactly 7, stages headline, context, discovery, deep dive, recommendation, impact, ask; visual one of big number, line, bar, before-after, table, scorecard, none), appendix[{data, why}].
Worked examples
These are the page's own bundled examples (invented companies and numbers), with the input as app.js builds it and the saved model reply the page replays for free: a story from a quarter-on-quarter table and its storyboard after the handoff, a churn story, and a board storyboard for an expansion choice.
Story: a quarter-on-quarter results table
Input (the analysis shown as lines; facts shortened here):
{
"task": "story",
"audience": "executives",
"goal": "Agree to move two account managers onto the hybrid onboarding team in Q1",
"analysis": [
"# Q4 performance",
"Relaybox closed Q4 at $1.46M MRR, up from $1.21M at the end of Q3 and 4% above the Q4 target.",
"In October we launched self-serve pricing; in November we cut the signup form from 11 fields to 4; in December we added a 30-minute success call for every new account.",
"",
"| Metric | Q3 | Q4 | Change |",
"| --- | --- | --- | --- |",
"... (15 lines in all)"
],
"facts": "(a JSON string; decoded: 9 data points, 1 tables; findings: DS-01 arithmetic/high)"
}
Reply:
{
"task": "story",
"task_note": "",
"framework": "trend",
"framework_why": "The analysis compares Q3 with Q4 across three onboarding changes and points to what should carry into next quarter, which is the trend shape: where we started, what changed, the key insight, going forward.",
"findings": [
{
"finding": "DS-01",
"response": "confirmed",
"note": "The table's expansion change is wrong; the story uses the page's recomputed figure, expansion rate from 3% to 9% (+200%), and never the stated one."
}
],
"headline_options": [
"Expansion Rate Tripled From 3% to 9% in Q4 - Hybrid Onboarding Is the Pattern to Scale",
"Accounts With Self-Serve Plus a Success Call Expand at 2.8x the Rate - Staff It Before the $1.75M Quarter"
],
"points": [
{
"point": "Accounts that self-served and took the 30-minute success call expanded at 2.8x the rate of accounts that did only one of the two.",
"data": [
"D8",
"D2"
],
"so_what": "The hybrid path is the onboarding pattern most linked to expansion, which is what two more account managers on that team would scale."
},
{
"point": "Expansion rate rose from 3% to 9% and net revenue retention from 101% to 108% in the quarter these changes landed.",
"data": [
"D6",
"D5"
],
"so_what": "Growth is coming from existing accounts as well as new ones, so onboarding quality now shows up in revenue."
},
{
"point": "Q4 closed at $1.46M MRR, 4% above target, with trial to paid up from 9% to 14%, and the Q1 target is $1.75M.",
"data": [
"D1",
"D3",
"D9"
],
"so_what": "The quarter proves the approach works at current staffing; the higher Q1 target is the reason to add capacity now."
}
],
"arc": [
{
"beat": "hook",
"text": "Accounts that self-served and also took a 30-minute success call expanded at 2.8x the rate of accounts that did only one of the two.",
"data": [
"D8"
]
},
{
"beat": "context",
"text": "At the end of Q3 Relaybox was at $1.21M MRR, with trial to paid at 9%, expansion at 3% and net revenue retention at 101%.",
"data": [
"D1",
"D3",
"D6",
"D5"
]
},
{
"beat": "rising action",
"text": "In Q4 we launched self-serve pricing, cut the signup form from 11 fields to 4 and added a success call for every new account. Trial to paid rose to 14%, time to first dispatch fell from 12 days
... (shortened here; the page shows it whole)
Storyboard: the same analysis, with the story handed over
Input (with the story handed over; shown as lines):
{
"task": "deck",
"audience": "executives",
"goal": "Agree to move two account managers onto the hybrid onboarding team in Q1",
"analysis": [
"# Q4 performance",
"Relaybox closed Q4 at $1.46M MRR, up from $1.21M at the end of Q3 and 4% above the Q4 target.",
"In October we launched self-serve pricing; in November we cut the signup form from 11 fields to 4; in December we added a 30-minute success call for every new account.",
"",
"| Metric | Q3 | Q4 | Change |",
"| --- | --- | --- | --- |",
"... (15 lines in all)"
],
"facts": "(a JSON string; decoded: 9 data points, 1 tables; findings: DS-01 arithmetic/high)",
"story_handoff": "{\"framework\":\"trend\",\"headline\":\"Q4 MRR Hit $1.46M, 4% Above Target - Accounts on Hybrid Onboarding Expand at 2.8x the Rate\",\"points\":[{\"point\":\"Accounts that self-served and took the 30-minute success call expanded at 2.8x the rate of accounts that did only one of the two.\",\"data\":[\"D8\",\"D2\"]},{\"point\":\"Expansion rate..."
}
Reply:
{
"task": "deck",
"task_note": "",
"source": "story handoff",
"slides": [
{
"n": 1,
"stage": "headline",
"title": "Q4 MRR Hit $1.46M, 4% Above Target - Accounts on Hybrid Onboarding Expand at 2.8x the Rate",
"points": [
"Q4 closed at $1.46M MRR, 4% above target.",
"Accounts that self-served and took the success call expanded at 2.8x the rate of accounts that did only one of the two.",
"Decision today: move two account managers onto the hybrid onboarding team in Q1."
],
"data": [
"D1",
"D8"
],
"visual": "big number",
"visual_note": "Two big numbers side by side: $1.46M MRR (annotated '4% above target') and 2.8x expansion rate (annotated 'self-serve plus success call').",
"speaker_note": "Open with the result and the one pattern behind it, then say up front that we are asking for two account managers."
},
{
"n": 2,
"stage": "context",
"title": "In Q4 we changed onboarding three times, one change a month",
"points": [
"October: self-serve pricing launched.",
"November: signup form cut from 11 fields to 4.",
"December: a 30-minute success call added for every new account."
],
"data": [
"D2",
"D1"
],
"visual": "line",
"visual_note": "MRR from $1.21M at the end of Q3 to $1.46M at the end of Q4, with the three launches marked as annotations on the months they landed.",
"speaker_note": "This is the baseline: where we ended Q3 and what changed during Q4. All three changes landed in the same quarter."
},
{
"n": 3,
"stage": "discovery",
"title": "Accounts that self-served and took the success call expanded at 2.8x the rate of those that did only one",
"points": [
"The combination of self-serve and a success call - hybrid onboarding - goes with much faster expansion.",
"Accounts that did only one of the two expanded far less."
],
"data": [
"D8",
"D2"
],
"visual": "bar",
"visual_note": "Two bars: accounts that did both versus accounts that did only one; highlight the 'both' bar and annotate it 2.8x.",
"speaker_note": "This is a correlation, not a tested cause, and no sample size was given; accounts that chose both may differ in other ways."
},
{
"n": 4,
"stage": "deep dive",
"title": "Expansion rose from 3% to 9% and net revenue retention from 101% to 108% in the quarter these changes landed",
"poi
... (shortened here; the page shows it whole)
Story: a churn analysis for the exec team
Input (shown as lines):
{
"task": "story",
"audience": "executives",
"goal": "Approve a $60K budget for a guided onboarding program in Q3",
"draft_headline": "Churn Analysis Q2",
"analysis": [
"We pulled 14 months of billing and product events from the warehouse with SQL and joined them to 38 customer exit interviews.",
"Tallywren lost 412 customers in Q2, 9.1% of the base, against 6.0% in Q1.",
"The average annual contract value of a churned customer was $5,850, so Q2 churn cost $2.41M in annual recurring revenue.",
"",
"- 71% of churned customers left within their first 90 days.",
"- Customers who never connected a bank feed churned at 3.4x the rate of those who did.",
"... (17 lines in all)"
],
"facts": "(a JSON string; decoded: 10 data points, 1 tables; findings: DS-01 method-first/medium, DS-02 causal/medium, DS-03 sample/medium, DS-04 jargon/medium, DS-05 headline/medium)"
}
Reply:
{
"task": "story",
"task_note": "",
"framework": "problem-solution",
"framework_why": "The analysis shows a costly problem (rising churn), points to where it concentrates (the first 90 days and unfinished setup), and the goal asks to fund a solution (guided onboarding).",
"findings": [
{
"finding": "DS-01",
"response": "confirmed",
"note": "The story opens with the Q2 cost of churn; how the data was gathered (D1) moves to the appendix."
},
{
"finding": "DS-02",
"response": "confirmed",
"note": "The analysis compares groups but did not test whether low checklist completion causes churn, so the story says finishing setup goes with lower early churn and carries that caveat."
},
{
"finding": "DS-03",
"response": "confirmed",
"note": "The interview sample (n = 38, D1 and D7) is small and the story states it; note that the 38 in D9 is the number of churned accounts out of 640, not a sample size, so the checklist table itself is not the small sample."
},
{
"finding": "DS-04",
"response": "confirmed",
"note": "The story says 'customers who finished setup' instead of 'cohort' and drops the SQL detail with the method."
},
{
"finding": "DS-05",
"response": "confirmed",
"note": "The headline replaces 'Churn Analysis Q2' with the $2.4M annual recurring revenue lost in Q2 and the onboarding decision."
}
],
"headline_options": [
"71% of Customers Who Leave Go in Their First 90 Days - A $60K Onboarding Program Targets That Window",
"Churn Rose to 9.1% in Q2 From 6.0% in Q1; Customers Who Finish Setup in Week 1 Churn at 5.9% Versus 11.2%"
],
"points": [
{
"point": "Churn is rising and expensive: 412 customers left in Q2, 9.1% of the base against 6.0% in Q1, costing $2.41M in annual recurring revenue.",
"data": [
"D2",
"D3"
],
"so_what": "The cost of churn is large next to the $60K being requested, so the question is where to act, not whether to."
},
{
"point": "Most churn happens early: 71% of churned customers left within their first 90 days, and only 22% of new customers finish the setup checklist in their first week.",
"data": [
"D4",
"D6"
],
"so_what": "The first weeks after sign-up are where an onboarding program would reach the customers most likely to leave."
},
{
"point": "Finishing setup goes with lower early churn: customers who finishe
... (shortened here; the page shows it whole)
Storyboard: a board decision between two regions
Input (shown as lines):
{
"task": "deck",
"audience": "board or investors",
"goal": "Approve Iberia as the second hub and release the EUR 4M capex envelope",
"analysis": [
"## Where to open the second warehouse hub",
"Kestravan's hub in Rotterdam runs at 91% of capacity and turned away 1,240 pallets of demand in Q3.",
"Iberia: a freight market of $3.1B, growing 9% a year, with 14 regional competitors and an average lane rate of $1.92 per km.",
"Nordics: a freight market of $2.4B, growing 6% a year, with 5 regional competitors and an average lane rate of $2.35 per km.",
"",
"- Lease quotes average $7.10 per square metre per month in Iberia and $9.40 in the Nordics.",
"... (22 lines in all)"
],
"facts": "(a JSON string; decoded: 16 data points, 1 tables; findings: DS-01 arithmetic/high, DS-02 dump/medium)"
}
Reply:
{
"task": "deck",
"task_note": "",
"source": "from the input",
"slides": [
{
"n": 1,
"stage": "headline",
"title": "Iberia Scores 3.85 Against the Nordics' 3.1 - Approve It as the Second Hub Within the EUR 4M Envelope",
"points": [
"Iberia leads the weighted comparison: 3.85 against 3.1 for the Nordics",
"The decision sought today: approve Iberia and release the EUR 4M capex envelope"
],
"data": [
"D15",
"D16",
"D9"
],
"visual": "big number",
"visual_note": "Two large scores side by side, Iberia 3.85 highlighted; the Nordics shown at the recomputed 3.1 with a small note that the source table said 3.2 (DS-01).",
"speaker_note": "Open with the answer: Iberia is the stronger second hub on the board's own weighted factors. The Nordics total has been corrected to 3.1 from the 3.2 in the source table."
},
{
"n": 2,
"stage": "context",
"title": "Rotterdam Is at 91% of Capacity and Turned Away 1,240 Pallets in Q3",
"points": [
"Rotterdam runs at 91% of capacity",
"1,240 pallets of demand were turned away in Q3",
"In September the board set a EUR 4M capex envelope for a second hub"
],
"data": [
"D1",
"D9"
],
"visual": "big number",
"visual_note": "Start with 91% capacity alone, then reveal 1,240 pallets turned away in Q3 as the second layer.",
"speaker_note": "This is the baseline: the current hub is close to full and demand is already being lost. The board has already set the envelope; the question is where to spend it."
},
{
"n": 3,
"stage": "discovery",
"title": "Iberia Is the Larger, Faster-Growing Market and More of Our Customers Already Ship There",
"points": [
"Freight market: $3.1B growing 9% a year in Iberia, $2.4B growing 6% in the Nordics",
"38% of current customers ship to Iberia at least monthly; 21% ship to the Nordics"
],
"data": [
"D2",
"D3",
"D6"
],
"visual": "bar",
"visual_note": "Paired bars for Iberia and the Nordics on market size, growth and share of customers shipping monthly; Iberia bars highlighted, revealed one pair at a time.",
"speaker_note": "The discovery is that Iberia leads on both market size and existing customer pull. This is where the case is won."
},
{
"n": 4,
"stage": "deep dive",
"title": "On the Weighted Scorecard Iberia Wins on Customer Demand and Loses
... (shortened here; the page shows it whole)
The page re-checks the reply; the API does not
The page holds every reply to account (recon.js). For a story: the framework one of three; one answer per page finding, in order, the arithmetic ones confirmed; two more headlines with a number; exactly three points, each citing one to three data points; the six beats in order, the hook and the climax citing data; cut data never used in a point (at least three after a data-dump finding); a caveat after causal wording or a small sample, and no causal wording in the headline or the points; an ask. For a storyboard: the source as sent; seven slides numbered 1-7 in the stage order; action titles of four words or more, slide 1 the headline; one to three bullets a slide; the discovery and deep dive slides citing data; with a story handed over, its headline kept and every point's data on a slide. In both, the headline carries a number, every data and finding id must be the page's, no figure the input or the page's reading does not contain may appear (rounding allowed), and no figure the page found mis-stated may be used in the story or on a slide. Over the API you get the model's reply as it is; run the same checks yourself with Recon.reconcile in recon.js, or paste the same input into the page.
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://findings-to-slides.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://findings-to-slides.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"]
// findings-to-slides.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://findings-to-slides.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://findings-to-slides.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
}
// FindingsToSlides.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 FindingsToSlides {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://findings-to-slides.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://findings-to-slides.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://findings-to-slides.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://findings-to-slides.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":"findings-to-slides"}' | 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": "findings-to-slides"})["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: "findings-to-slides" })).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": "findings-to-slides"}, 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", "findings-to-slides"), 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: "findings-to-slides" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "findings-to-slides"])["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 = "findings-to-slides" }))!["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": "story", "audience": "...", "goal": "...", "analysis": "...", "facts": "..."}
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": "story", "audience": "...", "analysis": "...", "facts": "...", ...}
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, audience, goal, draft_headline, analysis, facts, ...}
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 findings-to-slides:<lane>:<inputHash>:<press>:a<attempt>: one key per press of the button, so a retry reuses it and a deliberate re-run gets a fresh reply. The job's output.output is the reply text; truncated: true means the balance cut it short.
KEY="findings-to-slides:story:$(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 lane's contract
echo "$J" | jq '.data | {charged_credits, truncated}'
import hashlib, time
key = "findings-to-slides:" + 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 = `findings-to-slides:${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 := "findings-to-slides:" + 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 = "findings-to-slides:" + 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 = "findings-to-slides:#{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 = "findings-to-slides:" . $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 = $"findings-to-slides:{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 (deck)
The same run as Server-Sent Events. deck.json is a deck input, with story_handoff when it follows a story.
# 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: findings-to-slides:deck:$(shasum -a 256 deck.json | cut -c1-16):a1" --data @deck.json
rev = json.load(open("deck.json")) # {"task": "deck", "audience": "...", "analysis": "...", "facts": "...", "story_handoff": "..."}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "findings-to-slides:deck:" + 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("deck.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": `findings-to-slides:deck:${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("deck.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", "findings-to-slides:deck:"+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("deck.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", "findings-to-slides:deck:" + 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("deck.json")
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{TOKEN}",
"Idempotency-Key" => "findings-to-slides:deck:#{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("deck.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: findings-to-slides:deck:" . 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("deck.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", $"findings-to-slides:deck:{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);