Drive Another Coal from your own code
The one metered task, weigh-habits, is available over HTTP. It takes 2 to 8 money-saving habits, each with a name, how it works, who feels the pinch, how much it pinches, whether those people agreed, and what it saves. It returns a plain-words reason for each habit's call, one possible knock-on effect per habit, and a warmer swap for each habit that pinches.
The division of labour first: the page decides; the model explains. The page scores each habit's chill (how much of its cost lands on other people who did not agree, 0-5), picks its call and the first move before the run, and sends all of it in facts. The model copies each call verbatim and never changes one.
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":"another-coal"}), 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, habits, facts) is missing. |
| 401 | unauthorized | No token, or a stale one. Mint a guest token or sign in again. |
| 402 | insufficient_credits | The balance is under min_credits. Price with /estimate first. |
| 403 | forbidden | A guest token tried to run: the reasons are metered and need a personal sign-in. |
| 404 | not_found | Unknown job id. |
| 429 | rate_limited | Back off and retry. |
| 5xx | server_error | Transient. Retry with the same Idempotency-Key so a retry never double-bills. |
The task field, the input and the calls
Every run sends task: weigh-habits (the one task is weigh-habits); a missing or different one is still answered as weigh-habits. All fields are strings.
| Field | Required | What it holds |
|---|---|---|
task | yes | Always weigh-habits. |
habits | yes | One numbered line per habit (2 to 8): name | how it works: ... | who feels the pinch: ... | how much it pinches: ... | did they agree to it: ... | what it saves: .... Who: Nobody notices; Just me; People I live with; Guests or friends; People who work for me or serve me. Pinch: Not at all; A little; Noticeably; A lot. Agreed: Yes, they agreed; Never asked them; They have objected (or "does not apply" when the pinch is only on you or nobody). Saves: Pennies; A little; A real amount. |
facts | yes | The page's lines: per habit its chill (0-5) with its two parts and its verdict; then the count of each verdict; then the first move. |
retry_note | no | Only on the page's one reformat retry. |
Chill counts only when the pinch lands on other people who have not agreed: the pinch (A little 1, Noticeably 2, A lot 3) plus 1 when they were never asked or 2 when they objected; otherwise 0. The call, first match wins: others feel it, not agreed, pinch Noticeably or A lot is miserly; others feel it, not agreed, pinch A little is tight; pinch Noticeably or A lot for Pennies is false-economy; everything else is fair-thrift. The first move is the miserly habit with the most chill, else the tight one with the most chill (ties to the earlier one), else the first false-economy, else none.
The reply
{"task": "weigh-habits", "headline": "...", "habits": [{"name": "...", "verdict": "tight", "why": "...", "ripple": "...", "swap": "..."}], "closing": "..."}
| Field | What it holds |
|---|---|
headline | At most 22 words summing up the check; counts match facts. |
habits[] | One per input habit, same order and exact names: verdict copied from facts, why (at most 45 words, from that habit's own answers), ripple (at most 30 words: one possible knock-on effect, worded with "could" or "may") and swap (at most 22 words: a warmer version for miserly and tight, a way to drop or ease it for false-economy; empty for fair-thrift). |
closing | One sentence naming the first move; never claims anything is changed, asked or agreed. |
The page then checks the reply (CoalKit.check) - each call against its own, every habit present, a knock-on line for each, swaps present only where wanted, time or season words, money figures, purchases, contact-like details or posting somewhere public, "already done" claims, habits it did not list, story characters or holidays, internal ids in prose, a closing that misses the first move, and word limits - and lists any disagreement under "The page's check". The "what to do" line for each call is fixed text on the page, not model output.
Worked examples - all four calls
These are the 3 free samples on the page: the exact input the page sends and the reply that replays for free. Together they cover all four calls.
A shared flat on a tight budget - calls: pinches others, a bit tight, fair thrift
Input:
{
"task": "weigh-habits",
"habits": "1. Thermostat kept low all evening | how it works: my flatmates pay their share of the bill too | who feels the pinch: People I live with | how much it pinches: Noticeably | did they agree to it: Never asked them | what it saves: A real amount\n2. Buying the thinnest loo roll | how it works: it is the cheapest pack in the shop | who feels the pinch: People I live with | how much it pinches: A little | did they agree to it: Never asked them | what it saves: Pennies\n3. Cancelling the film streaming plan | how it works: I was the only one who watched it | who feels the pinch: Just me | how much it pinches: Not at all | did they agree to it: does not apply | what it saves: A little\n4. Making my own lunch | how it works: not given | who feels the pinch: Just me | how much it pinches: A little | did they agree to it: does not apply | what it saves: A real amount\n5. Washing up in cold water | how it works: so the boiler stays off | who feels the pinch: People I live with | how much it pinches: Noticeably | did they agree to it: They have objected | what it saves: Pennies",
"facts": "1. Thermostat kept low all evening: chill 3 of 5 (pinch on others 2, not agreed 1); verdict miserly\n2. Buying the thinnest loo roll: chill 2 of 5 (pinch on others 1, not agreed 1); verdict tight\n3. Cancelling the film streaming plan: chill 0 of 5 (pinch on others 0, not agreed 0); verdict fair-thrift\n4. Making my own lunch: chill 0 of 5 (pinch on others 0, not agreed 0); verdict fair-thrift\n5. Washing up in cold water: chill 4 of 5 (pinch on others 2, not agreed 2); verdict miserly\ncounts: miserly 2, tight 1, false-economy 0, fair-thrift 2 (of 5 habits)\nfirst move: Washing up in cold water"
}
Reply:
{
"task": "weigh-habits",
"headline": "Of your five habits, two pinch others who did not agree, one is a bit tight, and two are fair thrift.",
"habits": [
{
"name": "Thermostat kept low all evening",
"verdict": "miserly",
"why": "You keep the thermostat low all evening; your flatmates, who pay their share of the bill too, feel it noticeably and were never asked, for a real saving.",
"ripple": "Your flatmates could feel less comfortable with the low heat, and the goodwill between you could wear thin.",
"swap": "Ask your flatmates what thermostat setting they'd accept while still saving something on the bill."
},
{
"name": "Buying the thinnest loo roll",
"verdict": "tight",
"why": "You buy the thinnest loo roll because it is the cheapest pack in the shop, which pinches the people you live with a little, without asking them, to save pennies.",
"ripple": "The people you live with could find the thin loo roll a small irritation that nibbles away at their comfort and goodwill toward you.",
"swap": "Ask the people you live with which loo roll thickness they would accept."
},
{
"name": "Cancelling the film streaming plan",
"verdict": "fair-thrift",
"why": "You cancelled the film streaming plan since you were the only one who watched it, so there is no pinch on anyone, for a little saving.",
"ripple": "Since you were the only one watching, this change could be one you settle into without missing anything.",
"swap": ""
},
{
"name": "Making my own lunch",
"verdict": "fair-thrift",
"why": "You make your own lunch, which pinches just you a little, for a real saving that costs nobody else anything.",
"ripple": "A little pinch like this could stay easy for you to keep up, or could feel tiresome if it continues.",
"swap": ""
},
{
"name": "Washing up in cold water",
"verdict": "miserly",
"why": "You wash up in cold water so the boiler stays off, which pinches the people you live with noticeably, and they have objected, for a saving of pennies.",
"ripple": "Since the people you live with have already objected, keeping this up could put further strain on the goodwill between you and on their comfort with it.",
"swap": "Ask the people you live with what water temperature they would find acceptable instead."
}
],
"closing": "Look at Washing up in cold water first, before deciding on any of the other habits."
}
Hosting friends on the cheap - calls: pinches others, fair thrift
Input:
{
"task": "weigh-habits",
"habits": "1. Guests bring their own drinks | how it works: I say so on the invite | who feels the pinch: Guests or friends | how much it pinches: A little | did they agree to it: Yes, they agreed | what it saves: A real amount\n2. Splitting the dinner bill evenly | how it works: even when I ordered more than the others | who feels the pinch: Guests or friends | how much it pinches: Noticeably | did they agree to it: Never asked them | what it saves: A little\n3. Heating off until guests arrive | how it works: not given | who feels the pinch: Just me | how much it pinches: A little | did they agree to it: does not apply | what it saves: A little\n4. Party leftovers become my lunches | how it works: not given | who feels the pinch: Nobody notices | how much it pinches: Not at all | did they agree to it: does not apply | what it saves: A little\n5. Leaving no tip at group dinners | how it works: I leave the tip to the others | who feels the pinch: People who work for me or serve me | how much it pinches: Noticeably | did they agree to it: Never asked them | what it saves: Pennies",
"facts": "1. Guests bring their own drinks: chill 0 of 5 (pinch on others 0, not agreed 0); verdict fair-thrift\n2. Splitting the dinner bill evenly: chill 3 of 5 (pinch on others 2, not agreed 1); verdict miserly\n3. Heating off until guests arrive: chill 0 of 5 (pinch on others 0, not agreed 0); verdict fair-thrift\n4. Party leftovers become my lunches: chill 0 of 5 (pinch on others 0, not agreed 0); verdict fair-thrift\n5. Leaving no tip at group dinners: chill 3 of 5 (pinch on others 2, not agreed 1); verdict miserly\ncounts: miserly 2, tight 0, false-economy 0, fair-thrift 3 (of 5 habits)\nfirst move: Splitting the dinner bill evenly"
}
Reply:
{
"task": "weigh-habits",
"headline": "Two of five habits pass a cost to others who weren't asked; three cost nobody anything they haven't accepted.",
"habits": [
{
"name": "Guests bring their own drinks",
"verdict": "fair-thrift",
"why": "You ask guests on the invite to bring their own drinks, and they agreed to it. It pinches them only a little, and it saves you a real amount.",
"ripple": "Asking guests to bring their own drinks could make hosting feel a little less generous for them.",
"swap": ""
},
{
"name": "Splitting the dinner bill evenly",
"verdict": "miserly",
"why": "You split the dinner bill evenly even when you ordered more than the others, without asking them. It pinches your guests or friends noticeably, and it saves a little.",
"ripple": "Splitting the bill evenly when you've ordered more could wear on the goodwill between you and your guests.",
"swap": "Ask your guests what split they'd find fair, or cover the extra you ordered yourself."
},
{
"name": "Heating off until guests arrive",
"verdict": "fair-thrift",
"why": "Turning the heating off until guests arrive only pinches you, and just a little, while it saves a little too.",
"ripple": "Turning the heating off until guests arrive could mean a colder wait for you beforehand.",
"swap": ""
},
{
"name": "Party leftovers become my lunches",
"verdict": "fair-thrift",
"why": "Turning your party leftovers into lunches doesn't pinch anyone, since nobody even notices, and it saves a little.",
"ripple": "Turning party leftovers into your lunches could make those lunches feel a little less varied for you.",
"swap": ""
},
{
"name": "Leaving no tip at group dinners",
"verdict": "miserly",
"why": "At group dinners you leave the tip to the others without asking them, and it pinches the people who work for you or serve you noticeably, for a saving of pennies.",
"ripple": "Leaving the tip to others at group dinners could cost you goodwill with the people who work for you or serve you.",
"swap": "Ask the others what they'd like to tip, or cover the tip yourself instead of leaving it to them."
}
],
"closing": "Start with splitting the dinner bill evenly, before weighing the other habits."
}
Small savings, all mine - calls: not worth the pinch, fair thrift
Input:
{
"task": "weigh-habits",
"habits": "1. Walking instead of taking the bus | how it works: about twenty minutes each way | who feels the pinch: Just me | how much it pinches: A little | did they agree to it: does not apply | what it saves: A little\n2. Reheating yesterday's coffee | how it works: not given | who feels the pinch: Just me | how much it pinches: Noticeably | did they agree to it: does not apply | what it saves: Pennies\n3. Cutting my own hair | how it works: with clippers I already own | who feels the pinch: Just me | how much it pinches: Noticeably | did they agree to it: does not apply | what it saves: A real amount\n4. Washing and reusing cling film | how it works: not given | who feels the pinch: Just me | how much it pinches: Noticeably | did they agree to it: does not apply | what it saves: Pennies\n5. Library books instead of buying | how it works: not given | who feels the pinch: Nobody notices | how much it pinches: Not at all | did they agree to it: does not apply | what it saves: A little",
"facts": "1. Walking instead of taking the bus: chill 0 of 5 (pinch on others 0, not agreed 0); verdict fair-thrift\n2. Reheating yesterday's coffee: chill 0 of 5 (pinch on others 0, not agreed 0); verdict false-economy\n3. Cutting my own hair: chill 0 of 5 (pinch on others 0, not agreed 0); verdict fair-thrift\n4. Washing and reusing cling film: chill 0 of 5 (pinch on others 0, not agreed 0); verdict false-economy\n5. Library books instead of buying: chill 0 of 5 (pinch on others 0, not agreed 0); verdict fair-thrift\ncounts: miserly 0, tight 0, false-economy 2, fair-thrift 3 (of 5 habits)\nfirst move: Reheating yesterday's coffee"
}
Reply:
{
"task": "weigh-habits",
"headline": "Three of your five habits are fair thrift, two aren't worth the pinch, and none fall on someone who hasn't agreed.",
"habits": [
{
"name": "Walking instead of taking the bus",
"verdict": "fair-thrift",
"why": "Walking instead of the bus takes about twenty minutes each way, and the pinch lands only on you, just a little, for a saving that's also a little.",
"ripple": "The twenty minutes each way could start to feel long, which may affect whether you keep the habit up.",
"swap": ""
},
{
"name": "Reheating yesterday's coffee",
"verdict": "false-economy",
"why": "Reheating yesterday's coffee pinches you noticeably, for a saving that only comes to pennies.",
"ripple": "Noticeably pinching yourself each time could chip away at your comfort, and may make the habit harder to keep up.",
"swap": "Brew only what you will finish, to skip the reheating and its noticeable pinch."
},
{
"name": "Cutting my own hair",
"verdict": "fair-thrift",
"why": "Cutting your own hair with clippers you already own pinches you noticeably, but it saves a real amount.",
"ripple": "Doing it yourself each time could wear on your comfort with how it turns out, which may affect whether you keep cutting it yourself.",
"swap": ""
},
{
"name": "Washing and reusing cling film",
"verdict": "false-economy",
"why": "Washing and reusing cling film pinches you noticeably, for a saving that only comes to pennies.",
"ripple": "Washing cling film each time could chip away at your patience, and may affect how long you keep doing it.",
"swap": "Stop washing and reusing it, and use a fresh piece each time instead."
},
{
"name": "Library books instead of buying",
"verdict": "fair-thrift",
"why": "Choosing library books over buying pinches nobody at all, while still saving you a little.",
"ripple": "Sticking with library books instead of buying could affect how satisfied you feel with the habit, which may shape whether it continues.",
"swap": ""
}
],
"closing": "Start with Reheating yesterday's coffee before looking at the rest of your habits."
}
Build the input yourself
Load the engine in node (global.window = global; require("./coalkit.js")), then:
const K = CoalKit;
const form = K.form({ habits: [
{ name: "Splitting the dinner bill evenly", note: "even when I ordered more", who: "guests", pinch: "real", asked: "never", size: "some" },
{ name: "Making my own lunch", note: "", who: "me", pinch: "mild", asked: "never", size: "real" }] });
const built = K.buildInput(form); // built.facts holds each habit's chill, call and the first move
const wire = K.toWire(built.input); // POST this to /estimate and /run
// then: const reply = K.normalize(K.parseReply(job.output.output));
// K.check(reply, built).notes // the page's re-check of the reply
// K.md(built, reply, K.title(form)) // the check 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://another-coal.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://another-coal.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"]
// another-coal.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://another-coal.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://another-coal.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
}
// AnotherCoal.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 AnotherCoal {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://another-coal.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://another-coal.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://another-coal.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://another-coal.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":"another-coal"}' | 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": "another-coal"})["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: "another-coal" })).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": "another-coal"}, 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", "another-coal"), 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: "another-coal" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "another-coal"])["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 = "another-coal" }))!["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": "weigh-habits", "habits": "1. Splitting the dinner bill evenly | how it works: ... | ...", "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": "weigh-habits", "habits": "1. Splitting the dinner bill evenly | how it works: ... | ...", "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: "weigh-habits", habits, 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 another-coal:<task>:<inputHash>:a<attempt> (the one task is weigh-habits), 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="another-coal:weigh-habits:$(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 = "another-coal:" + 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("habits", [])))
import { createHash } from "node:crypto";
const key = `another-coal:${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.habits || []).length);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "another-coal:" + 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 = "another-coal:" + 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 = "another-coal:#{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["habits"] || []).length].inspect
$key = "another-coal:" . $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["habits"] ?? []), "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"another-coal:{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: another-coal:weigh-habits:$(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": "weigh-habits", "habits": "1. Splitting the dinner bill evenly | how it works: ... | ...", "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": "another-coal:weigh-habits:" + 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": `another-coal:weigh-habits:${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", "another-coal:weigh-habits:"+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", "another-coal:weigh-habits:" + 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" => "another-coal:weigh-habits:#{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: another-coal:weigh-habits:" . 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", $"another-coal:weigh-habits:{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);