Drive Two Tellings from your own code
The one metered task, compare-tellings, is available over HTTP. It takes the name of one Ramayana episode and two tellings of it, two to eight short lines each, and returns a note on every moment: told alike, told differently, or found in one telling only, plus a headline, the shared core and a closing line.
The division of labour first: the page decides; the model describes. The page matches the lines into moments (your own picks first, then shared words), counts the shared moments and picks the verdict, and sends those as facts. The model never changes a match or the verdict, and never ranks a telling.
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": "..." } }
The token is minted for this app (the guest call takes {"slug":"two-tellings"}), 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 (task, moments, facts) 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 reasons are metered and need a personal sign-in. |
| 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, the input and the verdicts
Every run sends task: compare-tellings (the one task is compare-tellings); a missing or different one is still answered as compare-tellings. All fields are strings.
| Field | Required | What it holds |
|---|---|---|
task | yes | Always compare-tellings. |
episode | yes | The episode both tellings tell (up to 60 characters). |
telling_a | yes | First line: the first telling's name (where it came from). Then one numbered line per moment: A1. ..., A2. ... (2 to 8 lines, up to 140 characters each). |
telling_b | yes | The same for the second telling, with lines B1. ..., B2. .... |
facts | yes | The page's moments in order (M1: A1 + B1, M2: A2 only, M3: B2 only ...), the shared count, the verdict id and the names each telling uses. |
retry_note | no | Only on a reformat retry: why the previous reply was unusable. |
The verdict, first match wins: no shared moment, or under a quarter of the moments shared, is distant-kin (distant kin); 60% or more shared is close-kin (close kin); anything else is cousins.
The reply
One JSON object, no prose around it:
{"task": "compare-tellings", "headline": "...", "moments": [{"id": "M1", "how": "alike", "note": "..."}], "core": "...", "closing": "..."}
| Field | What it holds |
|---|---|
headline | At most 24 words on how the two tellings sit side by side, agreeing with the verdict. |
moments[] | One per moment in facts, same order and ids: how is alike or differs for a shared moment, only-a or only-b otherwise; note is at most 40 words from the tellings' own words. |
core | One sentence on what the shared moments have in common. |
closing | One sentence inviting you to notice one difference the next time the episode is told. |
The page then checks the reply (TellKit.check) - every moment present and filed the way the page matched it, names neither telling uses, ranking words, outside sources, he/she for a figure, time or weather words, and contact details - and lists anything it finds under "The page's check".
Worked examples - all three verdicts
These are the 3 free samples on the page: the exact input the page sends and the reply that replays for free. Together they cover all three verdicts.
The golden deer - verdict: close kin
Input:
{
"task": "compare-tellings",
"episode": "The golden deer",
"telling_a": "How my grandmother told it\nA1. Sita sees a golden deer near the hut and asks Rama to catch it.\nA2. Rama chases the golden deer deep into the forest.\nA3. The deer cries out in Rama's voice, calling for Lakshmana.\nA4. Lakshmana draws a line around the hut and asks Sita not to cross it.\nA5. Ravana comes to the hut as a holy man begging for food, and Sita steps over the line.",
"telling_b": "A picture book we had\nB1. A deer with golden skin and silver spots plays near the hut, and Sita wants it.\nB2. Rama follows the deer into the forest and finally shoots an arrow.\nB3. The dying deer shouts for help in Rama's voice.\nB4. Lakshmana leaves the hut to find Rama after Sita insists.\nB5. Ravana arrives at the hut dressed as a wandering monk and carries Sita away.",
"facts": "M1: A1 + B1\nM2: A2 + B2\nM3: A3 + B3\nM4: A4 + B4\nM5: A5 + B5\nshared moments: 5 of 5 (only in the first telling 0, only in the second telling 0)\nverdict: close-kin\nnames in both tellings: Sita, Rama, Lakshmana, Ravana; only in the first telling: none; only in the second telling: none"
}
Reply:
{
"task": "compare-tellings",
"headline": "The two tellings share all five moments of the golden deer, making them close kin.",
"moments": [
{
"id": "M1",
"how": "differs",
"note": "In the first telling, Sita sees the golden deer and asks Rama to catch it; in a picture book we had, a deer with golden skin and silver spots plays near the hut and Sita wants it."
},
{
"id": "M2",
"how": "differs",
"note": "In the first telling, Rama chases the golden deer deep into the forest; in a picture book we had, Rama follows the deer into the forest and finally shoots an arrow."
},
{
"id": "M3",
"how": "differs",
"note": "In the first telling, the deer cries out in Rama's voice, calling for Lakshmana; in a picture book we had, the dying deer shouts for help in Rama's voice."
},
{
"id": "M4",
"how": "differs",
"note": "In the first telling, Lakshmana draws a line around the hut and asks Sita not to cross it; in a picture book we had, Lakshmana leaves the hut to find Rama after Sita insists."
},
{
"id": "M5",
"how": "differs",
"note": "In the first telling, Ravana comes to the hut as a holy man begging for food and Sita steps over the line; in a picture book we had, Ravana arrives dressed as a wandering monk and carries Sita away."
}
],
"core": "Sita, Rama, Lakshmana and Ravana are named in both tellings, along with the deer and the hut.",
"closing": "Notice, next time you hear it told, that Lakshmana draws a line in one telling, and leaves to find Rama in the other."
}
The bridge to Lanka - verdict: cousins
Input:
{
"task": "compare-tellings",
"episode": "The bridge to Lanka",
"telling_a": "Our school play\nA1. Rama's army reaches the sea and cannot cross to Lanka.\nA2. Rama prays to the ocean, and the ocean stays silent.\nA3. Nala and Nila lead the monkeys in building a bridge of stones.\nA4. Each stone floats because Rama's name is written on it.\nA5. The army crosses the bridge to Lanka.",
"telling_b": "A shadow-puppet show I saw\nB1. The army stops at the shore, and the monkeys stare at the waves.\nB2. A small squirrel rolls in the sand and shakes the sand onto the bridge to help.\nB3. Rama strokes the squirrel's back, leaving three stripes.\nB4. The monkeys carry rocks and trees to the water and build the bridge.\nB5. The whole army marches across the bridge to Lanka.",
"facts": "M1: A1 + B1\nM2: B2 only\nM3: B3 only\nM4: A2 only\nM5: A3 + B4\nM6: A4 only\nM7: A5 + B5\nshared moments: 3 of 7 (only in the first telling 2, only in the second telling 2)\nverdict: cousins\nnames in both tellings: Rama, Lanka; only in the first telling: Nala, Nila; only in the second telling: none"
}
Reply:
{
"task": "compare-tellings",
"headline": "These two tellings are cousins, sharing three of their seven moments while each adds its own details to the bridge to Lanka.",
"moments": [
{
"id": "M1",
"how": "alike",
"note": "In our school play, Rama's army reaches the sea and cannot cross to Lanka; in a shadow-puppet show I saw, the army stops at the shore while the monkeys stare at the waves."
},
{
"id": "M2",
"how": "only-b",
"note": "In a shadow-puppet show I saw, a small squirrel rolls in the sand and shakes the sand onto the bridge to help."
},
{
"id": "M3",
"how": "only-b",
"note": "In a shadow-puppet show I saw, Rama strokes the squirrel's back, leaving three stripes."
},
{
"id": "M4",
"how": "only-a",
"note": "In our school play, Rama prays to the ocean, and the ocean stays silent."
},
{
"id": "M5",
"how": "differs",
"note": "In our school play, Nala and Nila lead the monkeys in building a bridge of stones; in a shadow-puppet show I saw, the monkeys carry rocks and trees to the water and build the bridge."
},
{
"id": "M6",
"how": "only-a",
"note": "In our school play, each stone floats because Rama's name is written on it."
},
{
"id": "M7",
"how": "alike",
"note": "In our school play, the army crosses the bridge to Lanka; in a shadow-puppet show I saw, the whole army marches across the bridge to Lanka."
}
],
"core": "The shared moments bring together the army, the monkeys, and a bridge to Lanka.",
"closing": "Next time you hear the episode, notice the bridge of stones set against the rocks and trees the monkeys carry to build it."
}
The ring in Lanka - verdict: distant kin
Input:
{
"task": "compare-tellings",
"episode": "Hanuman and the ring in Lanka",
"telling_a": "My aunt's bedtime version\nA1. Hanuman leaps across the sea from the top of a mountain.\nA2. A sea demoness tries to swallow Hanuman in mid-air.\nA3. Hanuman shrinks to the size of a cat to slip into Lanka unseen.\nA4. Hanuman finds Sita in a grove of ashoka trees and gives Sita Rama's ring.",
"telling_b": "A dance performance\nB1. Hanuman bows before Sita and hands over Rama's ring.\nB2. Ravana's guards catch Hanuman in the garden.\nB3. The guards set Hanuman's tail on fire.\nB4. Hanuman leaps from roof to roof and sets Lanka ablaze.",
"facts": "M1: A1 only\nM2: A2 only\nM3: A3 only\nM4: A4 + B1\nM5: B2 only\nM6: B3 only\nM7: B4 only\nshared moments: 1 of 7 (only in the first telling 3, only in the second telling 3)\nverdict: distant-kin\nnames in both tellings: Hanuman, Lanka, Sita, Rama; only in the first telling: none; only in the second telling: Ravana"
}
Reply:
{
"task": "compare-tellings",
"headline": "My aunt's bedtime version and a dance performance share only one point, making the two distant kin overall.",
"moments": [
{
"id": "M1",
"how": "only-a",
"note": "In my aunt's bedtime version, Hanuman leaps across the sea from the top of a mountain."
},
{
"id": "M2",
"how": "only-a",
"note": "In my aunt's bedtime version, a sea demoness tries to swallow Hanuman in mid-air."
},
{
"id": "M3",
"how": "only-a",
"note": "In my aunt's bedtime version, Hanuman shrinks to the size of a cat to slip into Lanka unseen."
},
{
"id": "M4",
"how": "alike",
"note": "Both tellings have Hanuman giving Rama's ring to Sita, as my aunt's bedtime version has Hanuman give the ring and a dance performance has Hanuman bow before Sita and hand over the ring."
},
{
"id": "M5",
"how": "only-b",
"note": "In a dance performance, Ravana's guards catch Hanuman in the garden."
},
{
"id": "M6",
"how": "only-b",
"note": "In a dance performance, the guards set Hanuman's tail on fire."
},
{
"id": "M7",
"how": "only-b",
"note": "In a dance performance, Hanuman leaps from roof to roof and sets Lanka ablaze."
}
],
"core": "Hanuman, Sita and Rama's ring are named in the lines both tellings share.",
"closing": "Next time the episode is told, notice that a dance performance alone has Ravana's guards catch Hanuman in the garden."
}
Build the input yourself
Load the engine in node (global.window = global; require("./tellkit.js")), then:
const K = TellKit;
const form = K.form({ episode: "The golden deer",
a: { label: "How my grandmother told it", beats: [{ text: "Sita sees a golden deer near the hut." }, { text: "Rama chases the deer into the forest." }] },
b: { label: "A picture book we had", beats: [{ text: "A golden deer plays near the hut.", match: "auto" }, { text: "Rama follows the deer into the forest.", match: "2" }] } });
const built = K.buildInput(form); // built.facts holds the moments, the shared count and the verdict
const wire = K.toWire(built.input); // POST this to /estimate and /run
// then: const reply = K.normalize(K.parseReply(job.output.output));
// K.check(reply, built).notes // the page's re-check of the reply
// K.md(built, reply, K.title(form)) // the comparison as Markdown
A second-telling line's match is auto (shared words), none (only in this telling) or a first-telling line number as a string.
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://two-tellings.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://two-tellings.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"]
// two-tellings.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://two-tellings.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://two-tellings.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
}
// TwoTellings.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 TwoTellings {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://two-tellings.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://two-tellings.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://two-tellings.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://two-tellings.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":"two-tellings"}' | 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": "two-tellings"})["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: "two-tellings" })).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": "two-tellings"}, 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", "two-tellings"), 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: "two-tellings" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "two-tellings"])["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 = "two-tellings" }))!["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": "compare-tellings", "moments": "1. The golden deer | what happened: ... | ...", "facts": "...", ...}
# - see the field table
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": "compare-tellings", "moments": "1. The golden deer | what happened: ... | ...", "facts": "...", ...}
# - see the field table
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: "compare-tellings", episode, telling_a, telling_b, facts} (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 two-tellings:<task>:<inputHash>:a<attempt> (the one task is compare-tellings), 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="two-tellings:compare-tellings:$(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 = "two-tellings:" + 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 reply as one JSON object
print(j["charged_credits"], j.get("truncated"), len(reply.get("moments", [])))
import { createHash } from "node:crypto";
const key = `two-tellings:${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.moments || []).length);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "two-tellings:" + 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 = "two-tellings:" + 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 = "two-tellings:#{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["moments"] || []).length].inspect
$key = "two-tellings:" . $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"], " ", count($reply["moments"] ?? []), "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"two-tellings:{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: two-tellings:compare-tellings:$(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": "compare-tellings", "moments": "1. The golden deer | what happened: ... | ...", "facts": "...", ...}
# - see the field table
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "two-tellings:compare-tellings:" + 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": `two-tellings:compare-tellings:${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", "two-tellings:compare-tellings:"+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", "two-tellings:compare-tellings:" + 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" => "two-tellings:compare-tellings:#{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: two-tellings:compare-tellings:" . 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", $"two-tellings:compare-tellings:{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);