Drive Turn the Crank from your own code
The one metered task, make-a-game, is available over HTTP. It takes one dull job, the parts it is made of (one per line, with an optional note such as "the worst bit" or "quick"), the person's small treats and, optionally, the minutes a usual go takes. It returns a score of 1 to 3 for every part with a cheer for each, four capsules (one prize per treat, short pauses for the rest), an opening line and a kind stopping line.
The division of labour first: the page plays; the model makes the game. The page numbers the parts and treats, reads each part's note for its own score, works out how many capsules of each kind to ask for, and sends those as facts. After the reply it checks the scores against the notes, the treats against the capsules and the words against the input, and runs the gumball machine - balls, rows, the crank and the clock - entirely in the browser.
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":"turn-the-crank"}), 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, job, parts, treats, minutes, 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 and the input
Every run sends task: make-a-game (the one task is make-a-game); a missing or different one is still answered as make-a-game. All fields are strings.
| Field | Required | What it holds |
|---|---|---|
task | yes | Always make-a-game. |
job | yes | The dull job, up to 80 characters. |
parts | yes | 2 to 8 parts, numbered, one per line, each up to 80 characters, exactly as typed (a note may follow a dash). |
treats | yes | Up to 4 small treats, numbered, one per line, or none. |
minutes | yes | Minutes a usual go takes (1 to 600), or not given. |
facts | yes | The page's lines: how many parts and treats were sent (and how many were left out), and the capsule plan - 4 capsules, treats first, pauses for the rest. |
retry_note | no | Only on a reformat retry: why the previous reply was unusable. |
Scores: a part whose own line calls it the worst, slowest, hardest, fiddly or one the person hates is worth 3; one called quick, easy, fine or liked is worth 1; every other part is worth 2. Each point drops one ball; every 10 points fill a row and earn one capsule.
The reply
One JSON object, no prose around it:
{"task": "make-a-game", "pitch": "...", "moves": [{"part": "...", "points": 2, "cheer": "..."}], "capsules": [{"kind": "treat", "text": "..."}], "stop": "..."}
| Field | What it holds |
|---|---|
moves[] | One per part, same order, part copied exactly; points 1, 2 or 3; cheer one sentence of 4 to 12 words shown when the part is tapped. |
capsules[] | Exactly 4: one treat per listed treat, in order, then pause capsules; each one sentence of at most 16 words. |
pitch | Two sentences, 20 to 45 words: how the game works for this job. |
stop | One sentence, at most 30 words: stop when the job is done (or the minutes are up); stopping early costs nothing. |
The page then checks the reply (CrankKit.check) - a score for every part that matches its note, a capsule for every treat, the right count of each capsule kind, and no time word, number, rushing, pronoun or contact detail you did not give - and lists what slipped under "The page's check". Any missing score or capsule is filled from the house game.
Worked examples - every score and both capsule kinds
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 scores of 1, 2 and 3, treat and pause capsules, a job with no treats and one with no minutes.
Packing boxes - scores 2, 3, 2, 1, 2 treat capsules
Input:
{
"task": "make-a-game",
"job": "Packing the spare room into boxes",
"parts": "1. Tape up a flat box\n2. Wrap a mug in newspaper - the worst bit, so fiddly\n3. Fill a box with books\n4. Write the room on the box - quick",
"treats": "1. a biscuit\n2. an episode of my podcast",
"minutes": "90",
"facts": "Parts sent: 4.\nTreats sent: 2.\nCapsules to write: 4 - 2 treat, then 2 pause."
}
Reply:
{
"task": "make-a-game",
"pitch": "Each time you finish a part while packing the spare room into boxes, you tap it and watch balls drop into the glass. Parts with more points send down more balls, and a full row lets you turn the crank for a capsule.",
"moves": [
{
"part": "Tape up a flat box",
"points": 2,
"cheer": "That flat box is taped up now!"
},
{
"part": "Wrap a mug in newspaper - the worst bit, so fiddly",
"points": 3,
"cheer": "You just wrapped that mug in newspaper!"
},
{
"part": "Fill a box with books",
"points": 2,
"cheer": "Another box is filled with books."
},
{
"part": "Write the room on the box - quick",
"points": 1,
"cheer": "The room is now written on the box."
}
],
"capsules": [
{
"kind": "treat",
"text": "A biscuit is waiting for you inside this capsule."
},
{
"kind": "treat",
"text": "Turn the crank, and this capsule hands you an episode of your podcast."
},
{
"kind": "pause",
"text": "This capsule offers you a short break before you carry on."
},
{
"kind": "pause",
"text": "Nothing is required now except a little time to rest."
}
],
"stop": "Stop when the spare room is packed into boxes or when 90 minutes have passed, whichever happens sooner, and stopping before then costs you nothing."
}
Scanning letters - scores 2, 3, 2, 2, 0 treat capsules
Input:
{
"task": "make-a-game",
"job": "Scanning a shoebox of old letters",
"parts": "1. Unfold a letter\n2. Scan both sides - the slowest part\n3. Name the file\n4. Put the letter on the done pile",
"treats": "none",
"minutes": "not given",
"facts": "Parts sent: 4.\nTreats sent: 0.\nCapsules to write: 4 - 0 treat, then 4 pause."
}
Reply:
{
"task": "make-a-game",
"pitch": "Once you finish a part of scanning a shoebox of old letters, you tap it, and parts carrying more points drop more balls. Every full row of balls earns a capsule for you to open with the crank.",
"moves": [
{
"part": "Unfold a letter",
"points": 2,
"cheer": "You've unfolded a letter!"
},
{
"part": "Scan both sides - the slowest part",
"points": 3,
"cheer": "Both sides are scanned!"
},
{
"part": "Name the file",
"points": 2,
"cheer": "You've named the file!"
},
{
"part": "Put the letter on the done pile",
"points": 2,
"cheer": "You've put the letter on the done pile!"
}
],
"capsules": [
{
"kind": "pause",
"text": "Take a short rest before you continue."
},
{
"kind": "pause",
"text": "Pause for a moment, if you would like to."
},
{
"kind": "pause",
"text": "Stop and rest for a little while."
},
{
"kind": "pause",
"text": "Give yourself a brief pause, with no need to rush."
}
],
"stop": "Stop once scanning a shoebox of old letters is done, or whenever you want to, since stopping early costs nothing."
}
Cleaning the car - scores 2, 3, 1, 2, 2, 4 treat capsules
Input:
{
"task": "make-a-game",
"job": "Cleaning out the car",
"parts": "1. Empty the door pockets\n2. Vacuum the footwells - I hate this\n3. Wipe the dashboard - easy\n4. Shake out the mats\n5. Clear the boot",
"treats": "1. a coffee from the van on the corner\n2. singing along to the radio\n3. a sit in the sun\n4. a chocolate bar",
"minutes": "40",
"facts": "Parts sent: 5.\nTreats sent: 4 (the person listed 5; the rest were left out).\nCapsules to write: 4 - 4 treat, then 0 pause."
}
Reply:
{
"task": "make-a-game",
"pitch": "Each time you finish a part of cleaning out the car, give it a tap and watch balls land in the glass, more of them for parts worth more points. Fill up a row and the crank pops a capsule open for you.",
"moves": [
{
"part": "Empty the door pockets",
"points": 2,
"cheer": "Wonderful, you emptied the door pockets!"
},
{
"part": "Vacuum the footwells - I hate this",
"points": 3,
"cheer": "Well done, the footwells are vacuumed!"
},
{
"part": "Wipe the dashboard - easy",
"points": 1,
"cheer": "Nice work, you wiped the dashboard!"
},
{
"part": "Shake out the mats",
"points": 2,
"cheer": "Lovely job, you shook out the mats!"
},
{
"part": "Clear the boot",
"points": 2,
"cheer": "Fantastic, the boot is cleared!"
}
],
"capsules": [
{
"kind": "treat",
"text": "This capsule holds a coffee from the van on the corner."
},
{
"kind": "treat",
"text": "Turning the crank here brings out singing along to the radio."
},
{
"kind": "treat",
"text": "Inside this capsule waits a sit in the sun."
},
{
"kind": "treat",
"text": "A chocolate bar is the prize inside this capsule."
}
],
"stop": "Stop when cleaning out the car is finished or when 40 minutes have passed, whichever happens sooner, and stopping before then costs nothing."
}
Build the input yourself
Load the engine in node (global.window = global; require("./crankkit.js")), then:
const K = CrankKit;
const form = K.form({ job: "Scanning a shoebox of old letters", parts: "Unfold a letter\nScan both sides - the slowest part", treats: "", minutes: "" });
const built = K.buildInput(form); // built.facts holds the parts, the treats and the page's own scores
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.gameOf(built.facts, reply) // the playable game (house values fill any gap)
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://turn-the-crank.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://turn-the-crank.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"]
// turn-the-crank.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://turn-the-crank.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://turn-the-crank.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
}
// TurnTheCrank.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 TurnTheCrank {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://turn-the-crank.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://turn-the-crank.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://turn-the-crank.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://turn-the-crank.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":"turn-the-crank"}' | 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": "turn-the-crank"})["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: "turn-the-crank" })).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": "turn-the-crank"}, 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", "turn-the-crank"), 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: "turn-the-crank" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "turn-the-crank"])["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 = "turn-the-crank" }))!["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": "make-a-game", "job": "Scanning a shoebox of old letters", "parts": "1. Unfold a letter\n2. Scan both sides - the slowest part\n...", "treats": "none", "minutes": "not given", "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": "make-a-game", "job": "Scanning a shoebox of old letters", "parts": "1. Unfold a letter\n2. Scan both sides - the slowest part\n...", "treats": "none", "minutes": "not given", "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: "make-a-game", job, parts, treats, minutes, 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 turn-the-crank:<task>:<inputHash>:a<attempt> (the one task is make-a-game), 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="turn-the-crank:make-a-game:$(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 = "turn-the-crank:" + 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("moves", [])))
import { createHash } from "node:crypto";
const key = `turn-the-crank:${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.moves || []).length);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "turn-the-crank:" + 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 = "turn-the-crank:" + 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 = "turn-the-crank:#{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["moves"] || []).length].inspect
$key = "turn-the-crank:" . $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["moves"] ?? []), "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"turn-the-crank:{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: turn-the-crank:make-a-game:$(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": "make-a-game", "job": "Scanning a shoebox of old letters", "parts": "1. Unfold a letter\n2. Scan both sides - the slowest part\n...", "treats": "none", "minutes": "not given", "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": "turn-the-crank:make-a-game:" + 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": `turn-the-crank:make-a-game:${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", "turn-the-crank:make-a-game:"+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", "turn-the-crank:make-a-game:" + 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" => "turn-the-crank:make-a-game:#{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: turn-the-crank:make-a-game:" . 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", $"turn-the-crank:make-a-game:{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);