Drive Bone Dry from your own code
The one metered task, retell-dry, is available over HTTP. It takes one short true story, how it will be told (aloud or written), an optional line to keep word for word, and the page's own giveaway count and list. It returns a plain reading of the count, the turn (an exact quote from the story and why it is the turn), a fix for each giveaway, a deadpan retelling, its last line, two or three delivery notes and a closing line.
The division of labour first: the page finds the giveaways and picks the verdict from their count; the model only writes the words. After a run the page checks that the reply copied the verdict, gave one fix per giveaway in order, quoted the turn from the story, kept your line word for word, ended on its own last line, left no giveaways in the retelling and added no names, numbers or pronouns the story never had. A playful tool, not professional 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":"bone-dry"}), 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, story, giveaways, 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 retelling 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: retell-dry (the one task is retell-dry); a missing or different one is still answered as retell-dry. The page finds seven kinds of giveaway: exclaim (exclamation marks), laugh (laugh tags), emoji, boost (intensifiers), setup (set-up phrases), explain (joke explainers) and shout (words in capitals). The verdict, by count alone: 0 - bone-dry; 1 to 3 - damp; 4 or more - soaked.
| Field | Required | What it holds |
|---|---|---|
task | yes | Always retell-dry. |
story | yes | The story, at least 8 words and at most 900 characters. |
medium | yes | aloud or written. |
keep_line | yes | Words from the story to keep word for word (90 characters), or (not given). |
words, giveaway_count | yes | The story's word count and the page's giveaway count, as strings. |
giveaways | yes | Up to 12 numbered lines such as 1. boost: "honestly" in "which was honestly hilarious.", or (none). |
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": "damp", "reading": "...", "turn": {"quote": "...", "why": "..."}, "giveaways": [{"text": "honestly", "fix": "..."}, ...], "retelling": "...", "last_line": "...", "beats": ["...", "..."], "closing": "..."}
| Field | What it holds |
|---|---|
verdict | Copied from the input. |
reading | A plain reading of the story as it stands, with the count in digits (at most 30 words). |
turn | quote: an exact quote from the story (at most 12 words); why: why it is the turn (at most 20). |
giveaways | One {text, fix} per input line, same order; [] when there are none. |
retelling, last_line | The deadpan retelling and its final sentence, which delivers the turn. |
beats, closing | 2 or 3 delivery notes for the medium (at most 18 words each) and a closing line (16). |
The page then checks the reply (DryKit.check) and lists any disagreement under "The page's check".
Worked examples - every verdict
These are the four free samples on the page: the exact input the page sends and the reply that replays for free.
The out-of-service bus - five giveaways, said aloud: soaked
Input:
{
"task": "retell-dry",
"story": "OMG you won't believe this!! I literally ran for the bus for two whole streets, waving my arms like a lunatic. The driver waited for me, opened the doors, let me catch my breath and then told me the bus was out of service lol",
"medium": "aloud",
"keep_line": "(not given)",
"words": "45",
"giveaway_count": "5",
"giveaways": "1. laugh: \"OMG\" in \"OMG you won't believe\"\n2. setup: \"you won't believe\" in \"OMG you won't believe this!! I literally ran\"\n3. exclaim: \"!!\" in \"OMG you won't believe this!! I literally ran for\"\n4. boost: \"literally\" in \"you won't believe this!! I literally ran for the bus for\"\n5. laugh: \"lol\" in \"the bus was out of service lol\"",
"verdict": "soaked"
}
Reply:
{
"verdict": "soaked",
"reading": "Right now it reads like a hyped-up text: 5 giveaways, including laugh tags, a set-up phrase, exclamation marks and an intensifier.",
"turn": {
"quote": "then told me the bus was out of service",
"why": "You ran, waving your arms, and the driver waited and opened the doors - then the bus was out of service."
},
"giveaways": [
{
"text": "OMG",
"fix": "Cut it; no replacement."
},
{
"text": "you won't believe",
"fix": "Cut it; no replacement."
},
{
"text": "!!",
"fix": "Cut it; no replacement."
},
{
"text": "literally",
"fix": "Cut it; no replacement."
},
{
"text": "lol",
"fix": "Cut it; no replacement."
}
],
"retelling": "I ran for the bus for two whole streets. I waved my arms like a lunatic. The driver waited for me. The driver opened the doors and let me catch my breath. Then the driver told me the bus was out of service.",
"last_line": "Then the driver told me the bus was out of service.",
"beats": [
"Keep your voice flat and your face still while you describe running and waving your arms.",
"Say the driver waiting, opening the doors and letting you catch your breath in one even tone.",
"Pause briefly, then say the bus was out of service as plainly as the rest."
],
"closing": "Say the whole thing evenly and let the last fact speak for itself."
}
The repotted basil - two giveaways, written down: damp
Input:
{
"task": "retell-dry",
"story": "I finally repotted the basil I had been keeping alive on the kitchen windowsill since spring. New pot, fresh compost, a proper drink of water. The next morning the cat had eaten every leaf and was asleep in the empty pot, which was honestly hilarious.",
"medium": "written",
"keep_line": "(not given)",
"words": "45",
"giveaway_count": "2",
"giveaways": "1. boost: \"honestly\" in \"the empty pot, which was honestly hilarious.\"\n2. laugh: \"hilarious\" in \"pot, which was honestly hilarious.\"",
"verdict": "damp"
}
Reply:
{
"verdict": "damp",
"reading": "As written, 2 cues signal the joke early: an intensifier and a word that calls the ending funny before it lands.",
"turn": {
"quote": "cat had eaten every leaf and was asleep in the empty pot",
"why": "You gave the basil a new pot and fresh compost, but the cat had eaten every leaf and was asleep."
},
"giveaways": [
{
"text": "honestly",
"fix": "Cut, with nothing put in its place."
},
{
"text": "hilarious",
"fix": "Cut, with nothing put in its place."
}
],
"retelling": "I finally repotted the basil I had been keeping alive on the kitchen windowsill since spring. New pot, fresh compost, a proper drink of water. The next morning the cat had eaten every leaf and was asleep in the empty pot.",
"last_line": "The next morning the cat had eaten every leaf and was asleep in the empty pot.",
"beats": [
"Keep each sentence short and plain, the same even length as the rest.",
"Let the sentence about the cat and the empty pot be the final line, with nothing after it."
],
"closing": "Post it plainly and let the last line do the work on its own."
}
The office printer - no giveaways and a line to keep, said aloud: bone dry
Input:
{
"task": "retell-dry",
"story": "The office printer has jammed every morning this month. Someone from IT finally came to fix it. He spent an hour inside it and left a note on the tray saying it was working perfectly. The note had been printed on that printer. It jammed halfway.",
"medium": "aloud",
"keep_line": "It jammed halfway.",
"words": "46",
"giveaway_count": "0",
"giveaways": "(none)",
"verdict": "bone-dry"
}
Reply:
{
"verdict": "bone-dry",
"reading": "This already reads plainly: 0 exclamation marks, intensifiers, emoji, laugh words, set-up or explaining phrases, or capitals for emphasis.",
"turn": {
"quote": "It jammed halfway",
"why": "You expect the note saying it was working perfectly to be true, but it jammed halfway printing that note."
},
"giveaways": [],
"retelling": "The office printer has jammed every morning this month. Someone from IT finally came to fix it. He spent an hour inside it and left a note on the tray saying it was working perfectly. The note had been printed on that printer. It jammed halfway.",
"last_line": "It jammed halfway.",
"beats": [
"Keep your voice flat and steady, no rise in pitch, through the whole telling.",
"Pause briefly right before the final line, then say it flat, no smile.",
"Keep a straight face the whole time, especially when you mention the note."
],
"closing": "Say it just as it stands here, flat, and let the last line close it quietly."
}
The loyal gym member - four giveaways, written down: soaked
Input:
{
"task": "retell-dry",
"story": "Signed up for the gym in January. Went ONCE. Seriously. Today they emailed to thank me for being one of their most loyal members, because I never cancelled!!! 💀",
"medium": "written",
"keep_line": "(not given)",
"words": "28",
"giveaway_count": "4",
"giveaways": "1. shout: \"ONCE\" in \"the gym in January. Went ONCE. Seriously. Today they\"\n2. boost: \"Seriously\" in \"gym in January. Went ONCE. Seriously. Today they emailed to\"\n3. exclaim: \"!!!\" in \"because I never cancelled!!! 💀\"\n4. emoji: \"💀\" in \"I never cancelled!!! 💀\"",
"verdict": "soaked"
}
Reply:
{
"verdict": "soaked",
"reading": "Right now you signal the joke early: 4 giveaways — capital letters, an intensifier, exclamation marks and an emoji — give it away before the ending lands.",
"turn": {
"quote": "one of their most loyal members",
"why": "You went once, yet the gym calls that being one of their most loyal members, because you never cancelled."
},
"giveaways": [
{
"text": "ONCE",
"fix": "Written as a plain lowercase word, not capitalized for emphasis."
},
{
"text": "Seriously",
"fix": "Cut; adds no information once the emphasis is removed."
},
{
"text": "!!!",
"fix": "Replaced with a plain period to end the sentence."
},
{
"text": "💀",
"fix": "Cut, with nothing put in its place."
}
],
"retelling": "I signed up for the gym in January. I went once. Today they emailed because I never cancelled, and called me one of their most loyal members.",
"last_line": "Today they emailed because I never cancelled, and called me one of their most loyal members.",
"beats": [
"Keep each sentence short and matter-of-fact, the way you'd type a quick update.",
"Let the final line stand alone, with nothing added after it.",
"Leave the ending plain — no extra line, no comment, no punctuation to flag it."
],
"closing": "Send this version exactly as it reads, and let the ending do the work."
}
Build the input yourself
Load the engine in node (global.window = global; require("./drykit.js")), then:
const K = DryKit;
const built = K.buildInput(K.form({ story: "I waited an hour for the plumber, who rang to say he was outside the wrong house. It was mine. He had the wrong door.", medium: "aloud", keep: "" }));
built.facts.count, built.facts.verdict // the page's giveaway 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, "the plumber") // the retelling 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://bone-dry.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://bone-dry.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"]
// bone-dry.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://bone-dry.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://bone-dry.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
}
// WhatWasThat.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 WhatWasThat {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://bone-dry.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://bone-dry.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://bone-dry.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://bone-dry.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":"bone-dry"}' | 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": "bone-dry"})["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: "bone-dry" })).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": "bone-dry"}, 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", "bone-dry"), 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: "bone-dry" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "bone-dry"])["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 = "bone-dry" }))!["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": "retell-dry", "story": "I finally repotted the basil ...", "medium": "written", "keep_line": "(not given)", "words": "45", "giveaway_count": "2", "giveaways": "1. boost: \"honestly\" in ...\n2. laugh: \"hilarious\" in ...", "verdict": "damp"}
# - 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": "retell-dry", "story": "I finally repotted the basil ...", "medium": "written", "keep_line": "(not given)", "words": "45", "giveaway_count": "2", "giveaways": "1. boost: \"honestly\" in ...\n2. laugh: \"hilarious\" in ...", "verdict": "damp"}
# - 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: "retell-dry", story, medium, keep_line, words, giveaway_count, giveaways, 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 bone-dry:<task>:<inputHash>:a<attempt> (the one task is retell-dry), 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="bone-dry:retell-dry:$(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 = "bone-dry:" + 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 = `bone-dry:${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 := "bone-dry:" + 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 = "bone-dry:" + 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 = "bone-dry:#{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 = "bone-dry:" . $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 = $"bone-dry:{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: bone-dry:retell-dry:$(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": "retell-dry", "story": "I finally repotted the basil ...", "medium": "written", "keep_line": "(not given)", "words": "45", "giveaway_count": "2", "giveaways": "1. boost: \"honestly\" in ...\n2. laugh: \"hilarious\" in ...", "verdict": "damp"}
# - 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": "bone-dry:retell-dry:" + 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": `bone-dry:retell-dry:${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", "bone-dry:retell-dry:"+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", "bone-dry:retell-dry:" + 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" => "bone-dry:retell-dry:#{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: bone-dry:retell-dry:" . 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", $"bone-dry:retell-dry:{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);