Drive Thaw and Tell from your own code
The one metered task, read-thaw, is available over HTTP. It takes a short log of cold-finger colour-change episodes (one line each, numbered by the page), the page's own tally of every line, its counts, when it began, the warning signs and one-hand episodes it found, and its verdict. It returns a plain reading of the pattern, the settings that set episodes off (grouped, with episode ids), what the log says helped, three gentle warm-up moves, a note to take to a doctor when the verdict calls for one, and a closing line.
The division of labour first: the page reads each line, finds the warning signs and picks the verdict by a fixed order; the model only writes the words. After a run the page checks that the reply copied the verdict, grouped the episodes the way the page does, quoted each setting and each helping step word for word from the cited line, gave three moves, added a doctor's note only when called for, named every warning-sign episode, and added no numbers, number words, pronouns, diagnoses, medicines or risky warming tips. Not medical advice.
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":"thaw-and-tell"}), 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, episodes, tally, verdict and the rest of the field table) 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 reading is metered and needs 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: read-thaw (the one task is read-thaw); a missing or different one is still answered as read-thaw. The page recognises six settings, most specific first: cold things held, wet hands, vibration, stress, warm-to-cold change, cold air. The verdict, first match wins: any warning sign (a sore, broken or darkened skin, or two hours or more) - see-a-doctor-soon; else one hand only, or it began at age 30 or later - mention-it; else warm-up-plan.
| Field | Required | What it holds |
|---|---|---|
task | yes | Always read-thaw. |
episodes | yes | 2 to 12 lines such as E1. Waiting at the bus stop in the wind, ... (each at most 220 characters). |
episode_count | yes | How many lines, as a string. |
tally | yes | One line per episode: E1 - colours: white, blue - minutes: 15 - fingers: index, middle - hand: not said - settings: cold air. |
summary | yes | The page's counts: pale, blue and red episodes, median and longest minutes. |
began | yes | before age 30, at age 30 or later or not sure. |
warning_signs, one_hand | yes | Episode ids with the words that triggered a warning sign, and the one-hand episodes; (none) when empty. |
verdict | yes | The page's verdict id. |
retry_note | no | Only on the page's single reformat retry. |
The reply is one JSON object:
{"verdict": "warm-up-plan", "reading": "...", "triggers": [{"setting": "...", "episodes": ["E2", "E3"], "note": "..."}], "helped": [{"what": "...", "episodes": ["E1"]}], "plan": ["...", "...", "..."], "doctor_note": "", "closing": "..."}
| Field | What it holds |
|---|---|
verdict | Copied from the input. |
reading | The pattern in plain words, with the episode count in digits (at most 35 words). |
triggers | 1 to 3 groups, each episode under the first setting in its tally line, most episodes first: setting (at most 6 words copied from a cited line), episodes, note (at most 20 words). |
helped | What the log's own lines say helped: what (at most 8 words copied from the line) and episodes; [] when no line says. |
plan | Exactly 3 gentle warm-up moves (at most 24 words each), aimed at the settings in order. |
doctor_note, closing | Empty for warm-up-plan; otherwise what to tell a doctor, naming the reasons (at most 45 words). Then a closing line (16). |
The page then checks the reply (ThawKit.check) and lists any disagreement under "The page's check".
Worked examples - every verdict
These are the three free samples on the page: the exact input the page sends and the reply that replays for free.
The bus-stop commute - five episodes, began before 30: warm-up-plan
Input:
{
"task": "read-thaw",
"episodes": "E1. Waiting at the bus stop in the wind, index and middle fingers went white, then blue, about 15 minutes. Gloves back on and hands in my armpits brought them round\nE2. Took a bag of peas out of the freezer, ring and little fingers white for 5 min\nE3. Holding an iced coffee on the walk to work, white then red, 10 minutes\nE4. Washing up in cold water, all fingers pale for 8 minutes. Running them under lukewarm water helped\nE5. Tense meeting in a cold office, index finger white then red, 12 minutes",
"episode_count": "5",
"tally": "E1 - colours: white, blue - minutes: 15 - fingers: index, middle - hand: not said - settings: cold air\nE2 - colours: white - minutes: 5 - fingers: ring, little - hand: not said - settings: cold things held\nE3 - colours: white, red - minutes: 10 - fingers: not said - hand: not said - settings: cold things held, cold air\nE4 - colours: white - minutes: 8 - fingers: all five - hand: not said - settings: cold things held, wet hands\nE5 - colours: white, red - minutes: 12 - fingers: index - hand: not said - settings: stress, cold air",
"summary": "pale in 5 of 5; blue in 1 of 5; red in 2 of 5; median 10 minutes, longest 15 minutes (5 of 5 timed)",
"began": "before age 30",
"warning_signs": "(none)",
"one_hand": "(none)",
"verdict": "warm-up-plan"
}
Reply:
{
"verdict": "warm-up-plan",
"reading": "Across 5 episodes, all started pale, 1 turned blue and 2 turned red, lasting a median of 10 minutes and a longest of 15 minutes.",
"triggers": [
{
"setting": "Holding an iced coffee",
"episodes": [
"E2",
"E3",
"E4"
],
"note": "While holding something cold - peas, iced coffee, washing water - fingers went white for 5, 8 or 10 minutes."
},
{
"setting": "the bus stop in the wind",
"episodes": [
"E1"
],
"note": "At the bus stop in the wind, index and middle fingers went white then blue for 15 minutes."
},
{
"setting": "Tense meeting in a cold office",
"episodes": [
"E5"
],
"note": "During a tense meeting in a cold office, the index finger went white then red for 12 minutes."
}
],
"helped": [
{
"what": "Gloves back on and hands in my armpits",
"episodes": [
"E1"
]
},
{
"what": "Running them under lukewarm water helped",
"episodes": [
"E4"
]
}
],
"plan": [
"When you need to hold something cold or wash up, wear gloves or use a cloth, and warm fingers in lukewarm water afterward.",
"Before going out into cold air or wind, put on gloves and warm hands first, tucking them under your arms if they turn pale.",
"In a tense moment in a cold room, try slow, steady breathing and keep a layer on your hands to stay warm."
],
"doctor_note": "",
"closing": "This plan may help your fingers feel warmer in these situations."
}
The frosty cycle and the drill - one hand only, began at 30 or later: mention-it
Input:
{
"task": "read-thaw",
"episodes": "E1. Cycling to work on a frosty morning, fingers white then blue on the handlebars, 25 minutes\nE2. Only my right hand went white after using the hammer drill, then throbbing red, 15 min\nE3. Supermarket chiller aisle, middle and ring fingers pale, 6 minutes\nE4. Rainy walk home, all fingers white, then blue, 20 minutes. Holding a warm mug of tea helped",
"episode_count": "4",
"tally": "E1 - colours: white, blue - minutes: 25 - fingers: not said - hand: not said - settings: cold things held, cold air\nE2 - colours: white, red - minutes: 15 - fingers: not said - hand: one hand only - settings: vibration\nE3 - colours: white - minutes: 6 - fingers: middle, ring - hand: not said - settings: warm-to-cold change\nE4 - colours: white, blue - minutes: 20 - fingers: all five - hand: not said - settings: wet hands, cold air",
"summary": "pale in 4 of 4; blue in 2 of 4; red in 1 of 4; median 17.5 minutes, longest 25 minutes (4 of 4 timed)",
"began": "at age 30 or later",
"warning_signs": "(none)",
"one_hand": "E2",
"verdict": "mention-it"
}
Reply:
{
"verdict": "mention-it",
"reading": "Across the 4 episodes, pale showed every time, blue in 2 of 4, and red in 1 of 4. Minutes had a median of 17.5 and a longest of 25.",
"triggers": [
{
"setting": "on the handlebars",
"episodes": [
"E1"
],
"note": "Cycling and holding the handlebars, fingers went white then blue for 25 minutes."
},
{
"setting": "using the hammer drill",
"episodes": [
"E2"
],
"note": "Using the hammer drill, your right hand went white then red for 15 minutes."
},
{
"setting": "Supermarket chiller aisle",
"episodes": [
"E3"
],
"note": "In the supermarket chiller aisle, your middle and ring fingers went pale for 6 minutes."
}
],
"helped": [
{
"what": "Holding a warm mug of tea",
"episodes": [
"E4"
]
}
],
"plan": [
"Warm your hands before you set off, and put a layer between your hands and the handlebars, such as gloves.",
"Before using vibrating tools, warm your hands first, and pause to move your arms if your fingers turn pale.",
"When moving into a cold aisle, warm your hands beforehand and keep a layer over your fingers."
],
"doctor_note": "Consider mentioning this at a routine appointment: E2 affected one hand only, and you say your colour changes began at age 30 or later.",
"closing": "It's worth mentioning these colour changes next time you see your doctor."
}
The outdoor market stall - a slow-healing sore and a two-hour episode: see-a-doctor-soon
Input:
{
"task": "read-thaw",
"episodes": "E1. Working the outdoor market stall, thumb and index finger white then blue, 40 minutes\nE2. Unloading frozen boxes at the stall, all fingers pale, 30 minutes. Warm water and swinging my arms helped\nE3. A small sore on my index fingertip that has been slow to heal\nE4. Windy evening packing up the stall, white, blue then red, 2 hours before the colour came back",
"episode_count": "4",
"tally": "E1 - colours: white, blue - minutes: 40 - fingers: thumb, index - hand: not said - settings: cold air\nE2 - colours: white - minutes: 30 - fingers: all five - hand: not said - settings: cold things held\nE3 - colours: not said - minutes: not said - fingers: index - hand: not said - settings: none recognised\nE4 - colours: white, blue, red - minutes: 120 - fingers: not said - hand: not said - settings: cold air",
"summary": "pale in 3 of 4; blue in 2 of 4; red in 1 of 4; median 40 minutes, longest 120 minutes (3 of 4 timed)",
"began": "before age 30",
"warning_signs": "E3: \"sore\"\nE4: \"2 hours\"",
"one_hand": "(none)",
"verdict": "see-a-doctor-soon"
}
Reply:
{
"verdict": "see-a-doctor-soon",
"reading": "Across 4 episodes, most turned pale, blue showed in 2, and red in 1; minutes were timed in 3, with a median of 40 and a longest of 120.",
"triggers": [
{
"setting": "Working the outdoor market stall",
"episodes": [
"E1",
"E4"
],
"note": "Both happened at the stall outdoors, colour turning white then blue."
},
{
"setting": "Unloading frozen boxes at the stall",
"episodes": [
"E2"
],
"note": "All fingers turned pale while unloading frozen boxes, lasting 30 minutes."
}
],
"helped": [
{
"what": "Warm water and swinging my arms",
"episodes": [
"E2"
]
}
],
"plan": [
"Before heading out to the stall, warm your body and hands first, and wear gloves or a windproof layer for the cold air.",
"When handling frozen boxes, wear gloves to keep a layer between your hands and the cold, and keep them dry.",
"After time in the wind at the stall, warm your hands again before continuing, rather than waiting until they feel numb."
],
"doctor_note": "Please book a doctor soon. Bring up E3, where the log notes \"sore,\" and E4, where it says \"2 hours.\"",
"closing": "This log is worth bringing to a doctor soon - please don't wait."
}
Build the input yourself
Load the engine in node (global.window = global; require("./thawkit.js")), then:
const K = ThawKit;
const built = K.buildInput(K.form({ log: "Walking the dog in the wind, fingers white then red, 10 minutes\nHolding a cold can, index finger pale, 4 min", began: "before-30" }));
built.facts.count, built.facts.verdict // the page's episode count and 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, "my log") // the reading as Markdown
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://thaw-and-tell.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://thaw-and-tell.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"]
// thaw-and-tell.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://thaw-and-tell.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://thaw-and-tell.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
}
// ThawAndTell.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 ThawAndTell {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://thaw-and-tell.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://thaw-and-tell.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://thaw-and-tell.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://thaw-and-tell.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":"thaw-and-tell"}' | 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": "thaw-and-tell"})["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: "thaw-and-tell" })).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": "thaw-and-tell"}, 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", "thaw-and-tell"), 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: "thaw-and-tell" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "thaw-and-tell"])["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 = "thaw-and-tell" }))!["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": "read-thaw", "episodes": "E1. Cycling to work on a frosty morning, ...\nE2. ...", "episode_count": "4", "tally": "E1 - colours: white, blue - minutes: 25 - ...", "summary": "pale in 4 of 4; ...", "began": "at age 30 or later", "warning_signs": "(none)", "one_hand": "E2", "verdict": "mention-it"}
# - 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": "read-thaw", "episodes": "E1. Cycling to work on a frosty morning, ...\nE2. ...", "episode_count": "4", "tally": "E1 - colours: white, blue - minutes: 25 - ...", "summary": "pale in 4 of 4; ...", "began": "at age 30 or later", "warning_signs": "(none)", "one_hand": "E2", "verdict": "mention-it"}
# - 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: "read-thaw", episodes, episode_count, tally, summary, began, warning_signs, one_hand, verdict} (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 thaw-and-tell:<task>:<inputHash>:a<attempt> (the one task is read-thaw), 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="thaw-and-tell:read-thaw:$(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 = "thaw-and-tell:" + 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"), reply.get("verdict"))
import { createHash } from "node:crypto";
const key = `thaw-and-tell:${input.task}:${createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16)}:a1`;
const job = await api("POST", "/run", input, { "Idempotency-Key": key });
let j;
do { await new Promise((r) => setTimeout(r, 2000)); j = await api("GET", "/jobs/" + job.job_id); }
while (!["succeeded", "failed", "cancelled"].includes(j.status));
const reply = JSON.parse(j.output.output);
console.log(j.charged_credits, j.truncated, reply.verdict);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "thaw-and-tell:" + 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 = "thaw-and-tell:" + 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 = "thaw-and-tell:#{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["verdict"]].inspect
$key = "thaw-and-tell:" . $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["verdict"], "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"thaw-and-tell:{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: thaw-and-tell:read-thaw:$(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": "read-thaw", "episodes": "E1. Cycling to work on a frosty morning, ...\nE2. ...", "episode_count": "4", "tally": "E1 - colours: white, blue - minutes: 25 - ...", "summary": "pale in 4 of 4; ...", "began": "at age 30 or later", "warning_signs": "(none)", "one_hand": "E2", "verdict": "mention-it"}
# - 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": "thaw-and-tell:read-thaw:" + 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": `thaw-and-tell:read-thaw:${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", "thaw-and-tell:read-thaw:"+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", "thaw-and-tell:read-thaw:" + 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" => "thaw-and-tell:read-thaw:#{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: thaw-and-tell:read-thaw:" . 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", $"thaw-and-tell:read-thaw:{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);