Drive Pile of Shame from your own code
The one metered task, sort, is available over HTTP. It takes a game pile (title, hours left, progress and notes per game), a play budget, an optional mood and the page's own numbers. It returns a call for every game - play next, a two-hour try, the shelf or retire with thanks - a play order that fits the budget, a first-session tip for each queued game, a farewell for each retired one, a mood note and a first step.
The division of labour first: the page owns the parsing, the budget, the pile ratio, the verdict rule and the week-by-week slots; the model owns the calls, the order within the budget and the wording; the page re-checks the reply. Build pile and page_facts with the page's own script (see Build the input yourself) rather than by hand.
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": "...", "status": 402, "details": { ... } } }
The token is minted for this app (the guest call takes {"slug":"pile-of-shame"}), 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, pile, budget, mood, page_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: a sort is metered and needs a personal token. |
| 404 | not_found | Unknown job id. |
| 429 | rate_limited | Back off and retry. |
| 5xx | server_error | Transient. Retry with the same Idempotency-Key so a retry never double-bills. |
The task field and the verdicts
Every run sends task: sort (the one task is sort); a missing or different one is still answered as sort. The verdict is a rule on the pile ratio (known hours left over budget hours, the ratio line of page_facts): clearable at 1.00 or less, trim above 1.00 up to 3.00, avalanche above 3.00. The model copies it; it never decides it.
| Field | Required | What it holds |
|---|---|---|
task | yes | Always sort. |
pile | yes | A JSON-encoded array (a string) of up to 40 games: {"id":"g1","title":"...","hours":"12","progress":"40","notes":"..."}. hours is hours left to finish, or "" when unknown; progress is a percent, 0 = not started; notes at most 200 characters. |
budget | yes | Plain words: hours a week, weeks, total hours. |
mood | yes | What the player wants from play right now, or (not given). |
page_facts | yes | The page's numbers, one key | ... per line: budget_hours, known_hours, unknown_hours_games, ratio, verdict (with its rule) and started. |
clip_note | no | What the page cut, if anything. |
retry_note | no | Only on the automatic reformat retry. |
The reply
One JSON object, in output.output of the finished job:
{"task": "sort", "title": "...", "verdict": "clearable | trim | avalanche", "verdict_reason": "...",
"games": [{"id": "g1", "call": "play-next | sample | shelve | retire", "order": 1, "why": "...", "reentry": "...", "thanks": ""}],
"mood_note": "...", "first_step": "..."}
| Field | What it holds |
|---|---|
verdict / verdict_reason | The rule above, and one sentence (at most 30 words) quoting the known hours and the budget hours. |
games | One entry per game, in pile order. The call is the first rule that applies: retire only when the game's own note says the interest is gone; sample for an unstarted game with unknown hours or an unsure note (costs 2 hours); shelve for a started game with unknown hours; then play-next while the queue fits budget_hours, and shelve for the rest. order numbers the queue 1, 2, 3 with no gaps (null off the queue); why at most 25 words; reentry (queued games only) at most 25; thanks (retired games only) at most 20. |
mood_note / first_step / title | How the queue answers the mood (at most 50 words); one small thing to do before the first session (at most 30); a playful title (at most 8). |
The page then checks the reply (PileKit.check): the verdict against the rule, one call per game, retirements backed by the player's own note, tries only for unstarted games, no queued game without hours, the queue total against the budget, the order, the word limits, and no weekday, month, season, he or she, or internal field name in the text. It never looks up a game: every fact comes from the pile.
Worked examples, one per verdict
These are the three free samples on the page (made-up games): the exact input and a stand-in model's reply, unedited. Together they cover clearable, trim and avalanche, and all four calls.
A small cozy pile - clearable
Input:
{
"task": "sort",
"pile": "[{\"id\":\"g1\",\"title\":\"Moss & Mortar\",\"hours\":\"12\",\"progress\":\"70\",\"notes\":\"building my village, left off right after the harvest fair; love it\"},{\"id\":\"g2\",\"title\":\"Lantern Ferry\",\"hours\":\"6\",\"progress\":\"0\",\"notes\":\"short and relaxing, saving it for a calm week\"},{\"id\":\"g3\",\"title\":\"Gearheart Tactics\",\"hours\":\"15\",\"progress\":\"20\",\"notes\":\"hard fights, stuck on the second boss; still want to beat it\"},{\"id\":\"g4\",\"title\":\"Pocket Orchard\",\"hours\":\"\",\"progress\":\"0\",\"notes\":\"heard it's good, no idea how long\"}]",
"budget": "5 hours a week for 9 weeks, 45 hours in all",
"mood": "Cozy, low-stress evenings after work",
"page_facts": "budget_hours | 45\nknown_hours | 33\nunknown_hours_games | 1 (Pocket Orchard)\nratio | 0.73\nverdict | clearable (rule: clearable when the ratio is 1.00 or less, trim above 1.00 up to 3.00, avalanche above 3.00)\nstarted | Moss & Mortar (70%); Gearheart Tactics (20%)"
}
Reply:
{
"task": "sort",
"title": "A Pile You Can Actually Clear",
"verdict": "clearable",
"verdict_reason": "With 33 hours of known game time against a 45 hours budget, the whole pile fits comfortably in the weeks ahead.",
"games": [
{
"id": "g1",
"call": "play-next",
"order": 2,
"why": "Already 70% in and loved, so finishing it keeps real progress moving during these low-stress evenings.",
"reentry": "Pick up right after the harvest fair, where you left off, for an easy return to the village.",
"thanks": ""
},
{
"id": "g2",
"call": "play-next",
"order": 1,
"why": "Not started yet, but short and relaxing - a calm match for these low-stress evenings, so it leads the queue.",
"reentry": "Start with a short, easy session - it's built to be relaxing, so there's no pressure to rush.",
"thanks": ""
},
{
"id": "g3",
"call": "play-next",
"order": 3,
"why": "20% in and still determined to beat the tough fight you're stuck on - good momentum to keep going.",
"reentry": "Jump back in at the second boss - try a fresh strategy or two before diving into the fight again.",
"thanks": ""
},
{
"id": "g4",
"call": "sample",
"order": 4,
"why": "Not started, with no sense of its length but good word of mouth - a two-hour try is a safe first step.",
"reentry": "Give it a short first session and see how the opening hours feel before deciding what's next.",
"thanks": ""
}
],
"mood_note": "The queue favors your request for cozy, low-stress evenings: Lantern Ferry's calm, relaxing note puts it first, then Moss & Mortar continues its 70% progress, Gearheart Tactics keeps its 20% momentum going, and Pocket Orchard gets a two-hour try at the end since its length is still unknown.",
"first_step": "Before Lantern Ferry's first session, carve out one short, calm block of time - no prep needed beyond that."
}
A mixed bag - trim
Input:
{
"task": "sort",
"pile": "[{\"id\":\"g1\",\"title\":\"Saltglass Odyssey\",\"hours\":\"60\",\"progress\":\"15\",\"notes\":\"huge, I keep restarting it\"},{\"id\":\"g2\",\"title\":\"Wyrmwatch\",\"hours\":\"25\",\"progress\":\"80\",\"notes\":\"left off right before the final area, want to finish\"},{\"id\":\"g3\",\"title\":\"Neon Ferry Racing\",\"hours\":\"8\",\"progress\":\"0\",\"notes\":\"bought it on sale, honestly not interested anymore\"},{\"id\":\"g4\",\"title\":\"Hollow Bell\",\"hours\":\"10\",\"progress\":\"0\",\"notes\":\"might be too spooky for me, curious though\"},{\"id\":\"g5\",\"title\":\"Starling Drift\",\"hours\":\"\",\"progress\":\"40\",\"notes\":\"lost my place, no idea how much is left\"},{\"id\":\"g6\",\"title\":\"Tiny Lighthouse\",\"hours\":\"4\",\"progress\":\"0\",\"notes\":\"short and calm\"},{\"id\":\"g7\",\"title\":\"Ember Sonata\",\"hours\":\"30\",\"progress\":\"0\",\"notes\":\"been looking forward to this one for ages\"}]",
"budget": "6 hours a week for 10 weeks, 60 hours in all",
"mood": "Finishing things I already started, in short sessions",
"page_facts": "budget_hours | 60\nknown_hours | 137\nunknown_hours_games | 1 (Starling Drift)\nratio | 2.28\nverdict | trim (rule: clearable when the ratio is 1.00 or less, trim above 1.00 up to 3.00, avalanche above 3.00)\nstarted | Saltglass Odyssey (15%); Wyrmwatch (80%); Starling Drift (40%)"
}
Reply:
{
"task": "sort",
"title": "Trim Time: Finish What You Started",
"verdict": "trim",
"verdict_reason": "With 137 hours of games against 60 hours of play time, there's more here than fits, so it's time to trim the pile.",
"games": [
{
"id": "g1",
"call": "shelve",
"order": null,
"why": "You keep restarting it, and 60 hours left doesn't fit this budget alongside shorter, closer-to-done picks, so it waits.",
"reentry": "",
"thanks": ""
},
{
"id": "g2",
"call": "play-next",
"order": 1,
"why": "You're 80% in and already want to finish, right at the final area - perfect for finishing what's started.",
"reentry": "Jump back in right before the final area - you're close, so even a short session should get you near the end.",
"thanks": ""
},
{
"id": "g3",
"call": "retire",
"order": null,
"why": "Bought on sale and honestly not interested anymore, so it's time to let this one go.",
"reentry": "",
"thanks": "No hard feelings - it's fine to let this one go and make room on the shelf."
},
{
"id": "g4",
"call": "sample",
"order": 3,
"why": "You're curious but unsure about the spookiness, so a short two-hour try settles it without a big commitment.",
"reentry": "Give it a short first session to test the spooky mood; a quick look at the controls helps too.",
"thanks": ""
},
{
"id": "g5",
"call": "shelve",
"order": null,
"why": "You're partway in but hours left are unknown, so it can't be costed yet - add them and it joins the queue.",
"reentry": "",
"thanks": ""
},
{
"id": "g6",
"call": "play-next",
"order": 2,
"why": "Short and calm at 4 hours, an easy fit for short sessions without using much of the budget.",
"reentry": "Start with a quick session; at just 4 hours, you could finish it in one sitting without much planning.",
"thanks": ""
},
{
"id": "g7",
"call": "shelve",
"order": null,
"why": "You've been looking forward to this one, but its 30 hours don't fit alongside shorter, closer-to-finishing picks this time.",
"reentry": "",
"thanks": ""
}
],
"mood_note": "The top pick is the game at 80%, right before the end, so you can finish what you started. Then a short four-hour game keeps things light, and a two-hour try explores something new without much commitment.",
"first_step": "Before diving in, check your last save so you remember exactly where you left off before the final area."
}
No mood given - avalanche
Input:
{
"task": "sort",
"pile": "[{\"id\":\"g1\",\"title\":\"Kingdom of Ash and Clover\",\"hours\":\"90\",\"progress\":\"10\",\"notes\":\"feel like I should play it since everyone did\"},{\"id\":\"g2\",\"title\":\"Copper Tide\",\"hours\":\"40\",\"progress\":\"0\",\"notes\":\"\"},{\"id\":\"g3\",\"title\":\"Pocket Lantern Quest\",\"hours\":\"12\",\"progress\":\"55\",\"notes\":\"stopped at the swamp, want to go back\"},{\"id\":\"g4\",\"title\":\"Garden of Gears\",\"hours\":\"20\",\"progress\":\"0\",\"notes\":\"not sure it is for me\"},{\"id\":\"g5\",\"title\":\"Starfall Couriers\",\"hours\":\"35\",\"progress\":\"0\",\"notes\":\"can't wait\"},{\"id\":\"g6\",\"title\":\"Mosswick Mysteries\",\"hours\":\"6\",\"progress\":\"90\",\"notes\":\"one case left!\"},{\"id\":\"g7\",\"title\":\"Boulder Bros\",\"hours\":\"15\",\"progress\":\"30\",\"notes\":\"got bored of the grinding\"},{\"id\":\"g8\",\"title\":\"Velvet Circuit\",\"hours\":\"\",\"progress\":\"0\",\"notes\":\"\"}]",
"budget": "3 hours a week for 8 weeks, 24 hours in all",
"mood": "(not given)",
"page_facts": "budget_hours | 24\nknown_hours | 218\nunknown_hours_games | 1 (Velvet Circuit)\nratio | 9.08\nverdict | avalanche (rule: clearable when the ratio is 1.00 or less, trim above 1.00 up to 3.00, avalanche above 3.00)\nstarted | Kingdom of Ash and Clover (10%); Pocket Lantern Quest (55%); Mosswick Mysteries (90%); Boulder Bros (30%)"
}
Reply:
{
"task": "sort",
"title": "Your Pile Is Technically an Avalanche",
"verdict": "avalanche",
"verdict_reason": "Your pile holds 218 hours of games but your budget is only 24 hours, so this is an avalanche, not a dent.",
"games": [
{
"id": "g1",
"call": "retire",
"order": null,
"why": "You feel you should play this only because everyone else did - that's obligation, not real interest, so retire it.",
"reentry": "",
"thanks": "It's okay to let this go - obligation isn't a reason to keep playing."
},
{
"id": "g2",
"call": "shelve",
"order": null,
"why": "At forty hours and not yet started, this is too long to fit what's left in the queue right now.",
"reentry": "",
"thanks": ""
},
{
"id": "g3",
"call": "play-next",
"order": 2,
"why": "You're over halfway through and said you want back in, so this comes right after the top pick.",
"reentry": "Open your last save at the swamp and ease back in - you were already most of the way there.",
"thanks": ""
},
{
"id": "g4",
"call": "sample",
"order": 3,
"why": "You're not sure about this one yet, so a two-hour try is a fair way to decide.",
"reentry": "Start fresh, play a short first session, and see how the controls and pace feel.",
"thanks": ""
},
{
"id": "g5",
"call": "shelve",
"order": null,
"why": "You can't wait to start this one, but thirty-five hours doesn't fit the queue with what's already in it.",
"reentry": "",
"thanks": ""
},
{
"id": "g6",
"call": "play-next",
"order": 1,
"why": "You're ninety percent in with six hours left and one case to close, so this is the top pick.",
"reentry": "Load your last save and finish that final case - you are nearly at the end.",
"thanks": ""
},
{
"id": "g7",
"call": "retire",
"order": null,
"why": "You said the grinding bored you, and thirty percent in is proof you gave it a real shot.",
"reentry": "",
"thanks": "It's okay to let this go - burning out on the grind is a fine reason to stop."
},
{
"id": "g8",
"call": "sample",
"order": 4,
"why": "Its length isn't known yet, so a two-hour try will show whether it's worth more time.",
"reentry": "Start it up, play a short first session, and get a feel for the controls.",
"thanks": ""
}
],
"mood_note": "No mood was given, so the order follows progress and length: the ninety-percent game leads, then the one you already want back into, followed by two short tries to see what's worth more of your time.",
"first_step": "Before your first session, open your last save and reread your note about that final case so it's ready."
}
Build the input yourself
Load the script in node (global.window = global; require("./pilekit.js")), then:
const K = PileKit;
const built = K.buildInput({ weekly: "6", from: "2026-10-26", to: "2027-01-03", mood: "Short sessions",
pile: "Wyrmwatch | 25 | 80% | left off before the final area\nTiny Lighthouse | 4 | 0 | short and calm" });
built.verdict; // "clearable" - 29 known hours against a 60-hour budget
built.ratio; // 0.48
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 sort
// K.schedule(built, reply) // each queued game with the weeks it fills
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://pile-of-shame.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://pile-of-shame.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"]
// pile-of-shame.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://pile-of-shame.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://pile-of-shame.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
}
// PileOfShame.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 PileOfShame {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://pile-of-shame.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://pile-of-shame.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://pile-of-shame.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://pile-of-shame.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":"pile-of-shame"}' | 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": "pile-of-shame"})["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: "pile-of-shame" })).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": "pile-of-shame"}, 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", "pile-of-shame"), 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: "pile-of-shame" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "pile-of-shame"])["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 = "pile-of-shame" }))!["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": "sort", "pile": "[{\"id\":\"g1\",\"title\":\"Saltglass Odyssey\",\"hours\":\"60\",\"progress\":\"15\",\"notes\":\"huge, I keep restarting it\"},{\"id\":\"g2\",\"title\":\"Wyrmwatch\",\"hours\":\"25\",\"progress\":\"80\",\"notes\":\"left off right before the final area, want to finish\"},{\"id\":\"g3\",\"title\":\"Neon Ferry Racing\",\"hours\":\"8\",\"progress\":\"0\",\"notes\":\"bought it on sale, honestly not interested anymore\"},{\"id\":\"g4\",\"title\":\"Hollow Bell\",\"hours\":\"10\",\"progress\":\"0\",\"notes\":\"might be too spooky for me, curious though\"},{\"id\":\"g5\",\"title\":\"Starling Drift\",\"hours\":\"\",\"progress\":\"40\",\"notes\":\"lost my place, no idea how much is left\"},{\"id\":\"g6\",\"title\":\"Tiny Lighthouse\",\"hours\":\"4\",\"progress\":\"0\",\"notes\":\"short and calm\"},{\"id\":\"g7\",\"title\":\"Ember Sonata\",\"hours\":\"30\",\"progress\":\"0\",\"notes\":\"been looking forward to this one for ages\"}]", "budget": "6 hours a week for 10 weeks, 60 hours in all", "mood": "Finishing things I already started, in short sessions", "page_facts": "budget_hours | 60\nknown_hours | 137\nunknown_hours_games | 1 (Starling Drift)\nratio | 2.28\nverdict | trim (rule: clearable when the ratio is 1.00 or less, trim above 1.00 up to 3.00, avalanche above 3.00)\nstarted | Saltglass Odyssey (15%); Wyrmwatch (80%); Starling Drift (40%)"}
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": "sort", "pile": "[{\"id\":\"g1\",\"title\":\"Saltglass Odyssey\",\"hours\":\"60\",\"progress\":\"15\",\"notes\":\"huge, I keep restarting it\"},{\"id\":\"g2\",\"title\":\"Wyrmwatch\",\"hours\":\"25\",\"progress\":\"80\",\"notes\":\"left off right before the final area, want to finish\"},{\"id\":\"g3\",\"title\":\"Neon Ferry Racing\",\"hours\":\"8\",\"progress\":\"0\",\"notes\":\"bought it on sale, honestly not interested anymore\"},{\"id\":\"g4\",\"title\":\"Hollow Bell\",\"hours\":\"10\",\"progress\":\"0\",\"notes\":\"might be too spooky for me, curious though\"},{\"id\":\"g5\",\"title\":\"Starling Drift\",\"hours\":\"\",\"progress\":\"40\",\"notes\":\"lost my place, no idea how much is left\"},{\"id\":\"g6\",\"title\":\"Tiny Lighthouse\",\"hours\":\"4\",\"progress\":\"0\",\"notes\":\"short and calm\"},{\"id\":\"g7\",\"title\":\"Ember Sonata\",\"hours\":\"30\",\"progress\":\"0\",\"notes\":\"been looking forward to this one for ages\"}]", "budget": "6 hours a week for 10 weeks, 60 hours in all", "mood": "Finishing things I already started, in short sessions", "page_facts": "budget_hours | 60\nknown_hours | 137\nunknown_hours_games | 1 (Starling Drift)\nratio | 2.28\nverdict | trim (rule: clearable when the ratio is 1.00 or less, trim above 1.00 up to 3.00, avalanche above 3.00)\nstarted | Saltglass Odyssey (15%); Wyrmwatch (80%); Starling Drift (40%)"}
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: "sort", pile, budget, mood, page_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 pile-of-shame:<task>:<inputHash>:a<attempt> (the one task is sort), 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="pile-of-shame:sort:$(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 = "pile-of-shame:" + 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("title"))
import { createHash } from "node:crypto";
const key = `pile-of-shame:${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.title);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "pile-of-shame:" + 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 = "pile-of-shame:" + 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 = "pile-of-shame:#{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["title"]].inspect
$key = "pile-of-shame:" . $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["title"], "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"pile-of-shame:{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: pile-of-shame:sort:$(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": "sort", "pile": "[{\"id\":\"g1\",\"title\":\"Saltglass Odyssey\",\"hours\":\"60\",\"progress\":\"15\",\"notes\":\"huge, I keep restarting it\"},{\"id\":\"g2\",\"title\":\"Wyrmwatch\",\"hours\":\"25\",\"progress\":\"80\",\"notes\":\"left off right before the final area, want to finish\"},{\"id\":\"g3\",\"title\":\"Neon Ferry Racing\",\"hours\":\"8\",\"progress\":\"0\",\"notes\":\"bought it on sale, honestly not interested anymore\"},{\"id\":\"g4\",\"title\":\"Hollow Bell\",\"hours\":\"10\",\"progress\":\"0\",\"notes\":\"might be too spooky for me, curious though\"},{\"id\":\"g5\",\"title\":\"Starling Drift\",\"hours\":\"\",\"progress\":\"40\",\"notes\":\"lost my place, no idea how much is left\"},{\"id\":\"g6\",\"title\":\"Tiny Lighthouse\",\"hours\":\"4\",\"progress\":\"0\",\"notes\":\"short and calm\"},{\"id\":\"g7\",\"title\":\"Ember Sonata\",\"hours\":\"30\",\"progress\":\"0\",\"notes\":\"been looking forward to this one for ages\"}]", "budget": "6 hours a week for 10 weeks, 60 hours in all", "mood": "Finishing things I already started, in short sessions", "page_facts": "budget_hours | 60\nknown_hours | 137\nunknown_hours_games | 1 (Starling Drift)\nratio | 2.28\nverdict | trim (rule: clearable when the ratio is 1.00 or less, trim above 1.00 up to 3.00, avalanche above 3.00)\nstarted | Saltglass Odyssey (15%); Wyrmwatch (80%); Starling Drift (40%)"}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "pile-of-shame:sort:" + 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": `pile-of-shame:sort:${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", "pile-of-shame:sort:"+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", "pile-of-shame:sort:" + 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" => "pile-of-shame:sort:#{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: pile-of-shame:sort:" . 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", $"pile-of-shame:sort:{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);