Drive Jar Shelf from your own code
The one metered task, plan, is available over HTTP. It takes your savings goals (each with a target, what is saved, the month it is wanted by and whether that date is fixed), the one amount you can put aside each month and your growth and price assumptions, together with the page's own plan, and returns a headline, a short read per goal in funding order with where to keep its money, the one choice the plan leaves you, the split for the first month and when to look again.
The division of labour first: the page decides; the model words it. The page works out the monthly each goal needs (the sinking-fund formula), shares your monthly amount down the list in order, gives each goal a call (keep, stretch, top_up, pause) and the plan a verdict. It sends all of that as facts. The model never changes a number, a call, the order or the verdict.
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":"jar-shelf"}), 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, currency, monthly, goals, 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 verdicts
Every run sends task: plan (the one task is plan); a missing or different one is still answered as plan. All fields are strings.
| Field | Required | What it holds |
|---|---|---|
task | yes | Always plan. |
currency | yes | The currency symbol every amount uses: $, £, €, ₹ or ¥. |
monthly | yes | The amount to share each month, with its symbol, for example $900. |
goals | yes | The goal names in funding order, separated by ; . |
facts | yes | The page's lines: the verdict and why, the monthly to share against what all goals need (short by, or spare), the growth and price assumptions, the start and look-again months, the funding order, then one line per goal (g1 to g6: name and kind, target, month, fixed or movable, saved so far, needs a month, gets a month, call, where it stands, where to keep it). |
retry_note | no | Only on a reformat retry: why the previous reply was unusable. |
Calls: keep when a goal gets all it needs; pause when it gets nothing; stretch when it gets part and its date can move (it fills later); top_up when it gets part and its date is fixed (or would slip by more than ten years). The verdict, first match wins: any top_up or pause is short; any stretch is move; otherwise fits.
The reply
One JSON object, no prose around it:
{"task": "plan", "verdict": "move", "headline": "...", "goals": [{"id": "g1", "call": "keep", "say": "...", "keep_in": "..."}, ...], "tradeoff": "...", "first_month": "...", "checkpoint": "..."}
| Field | What it holds |
|---|---|
verdict | The verdict from facts, copied exactly. |
headline | At most 20 words: the verdict in plain words with the monthly amount and what all goals need. |
goals[] | One per goal in funding order, with id and call copied from facts, say (what the goal gets and what that means, from its own numbers) and keep_in (where its money could wait, with a short reason). |
tradeoff | The one choice the plan leaves you: what to do with a spare amount, accept later months, or find the shortfall. |
first_month | The split for the month saving starts, goal by goal. |
checkpoint | When to look again. |
The page then checks the reply (GoalKit.check) - the verdict and every call against its own, one read for every goal and none for goals not on the list, every number one the page gave, only a keep goal called on track, no account types, products or firms, no promises about returns, no days or events you did not give, no contact details or internal ids - and lists anything it finds under "The page's check".
Worked examples - every verdict and every call
These are the 4 free samples on the page: the exact input the page sends and the reply that replays for free. Together they cover every verdict and every call.
Steady saver, a year out - it all fits (on track, on track, on track)
Input:
{
"task": "plan",
"currency": "$",
"monthly": "$900",
"goals": "Emergency fund; New laptop; Summer trip",
"facts": "verdict: fits (rule 3 - every goal gets the monthly it needs)\nmonthly to share: $900; needed by all goals together: $881; spare: $19 a month\ngrowth assumed: 3% a year; targets grow with prices: no\nsaving starts: November 2026; look again in: May 2027\nfunding order: g1, g2, g3 (money goes down this list; each goal gets what it needs while money is left)\ng1: Emergency fund (emergency fund) - target $9,000 by October 2027 (12 months of saving) - the date is fixed - saved so far $4,000 - needs $401 a month - gets $401 a month - call keep - on track for $9,000 by October 2027 - keep it in: easy-access savings\ng2: New laptop (car or big purchase) - target $1,500 by April 2027 (6 months of saving) - the date can move - saved so far $0 - needs $249 a month - gets $249 a month - call keep - on track for $1,500 by April 2027 - keep it in: easy-access savings\ng3: Summer trip (trip or event) - target $2,400 by July 2027 (9 months of saving) - the date can move - saved so far $300 - needs $231 a month - gets $231 a month - call keep - on track for $2,400 by July 2027 - keep it in: easy-access savings"
}
Reply:
{
"task": "plan",
"verdict": "fits",
"headline": "$900 a month covers the $881 all your goals need, with some left over.",
"goals": [
{
"id": "g1",
"call": "keep",
"say": "Emergency fund gets the full $401 a month it needs, keeping it headed toward $9,000 by October 2027.",
"keep_in": "Easy-access savings keeps it ready for when the fixed date arrives."
},
{
"id": "g2",
"call": "keep",
"say": "New laptop gets the full $249 a month it needs, keeping it headed toward $1,500 by April 2027.",
"keep_in": "Easy-access savings suits it, since the laptop is needed soon."
},
{
"id": "g3",
"call": "keep",
"say": "Summer trip gets the full $231 a month it needs, keeping it headed toward $2,400 by July 2027.",
"keep_in": "Easy-access savings works well, since the trip is coming up soon."
}
],
"tradeoff": "With $19 a month spare, you could put more into any one goal or leave it as a buffer for now.",
"first_month": "In November 2026, put $401 into Emergency fund, $249 into New laptop, and $231 into Summer trip.",
"checkpoint": "Look again in May 2027, or sooner if the monthly amount or a target changes, and go through the plan again."
}
First flat in two years - fits if dates move (on track, on track, stretch the date)
Input:
{
"task": "plan",
"currency": "£",
"monthly": "£1,380",
"goals": "Rainy-day pot; Flat deposit; Lisbon trip",
"facts": "verdict: move (rule 2 - every fixed date is covered, and Lisbon trip reaches the target later)\nmonthly to share: £1,380; needed by all goals together: £1,430; short by £50 a month\ngrowth assumed: 3.5% a year; targets grow with prices: no\nsaving starts: November 2026; look again in: May 2027\nfunding order: g1, g2, g3 (money goes down this list; each goal gets what it needs while money is left)\ng1: Rainy-day pot (emergency fund) - target £6,000 by August 2027 (10 months of saving) - the date is fixed - saved so far £2,000 - needs £389 a month - gets £389 a month - call keep - on track for £6,000 by August 2027 - keep it in: easy-access savings\ng2: Flat deposit (home deposit) - target £30,000 by December 2028 (26 months of saving) - the date can move - saved so far £8,000 - needs £793 a month - gets £793 a month - call keep - on track for £30,000 by December 2028 - keep it in: easy-access savings\ng3: Lisbon trip (trip or event) - target £2,000 by June 2027 (8 months of saving) - the date can move - saved so far £0 - needs £248 a month - gets £198 a month - call stretch - reaches £1,600 by June 2027 - at £198 a month it reaches the target in August 2027, 2 months later - keep it in: easy-access savings"
}
Reply:
{
"task": "plan",
"verdict": "move",
"headline": "£1,380 a month falls short of the £1,430 needed together, so the Lisbon trip will take longer.",
"goals": [
{
"id": "g1",
"call": "keep",
"say": "Your Rainy-day pot gets the £389 a month it needs and is on track to reach £6,000 by August 2027.",
"keep_in": "Easy-access savings, so it is there when needed."
},
{
"id": "g2",
"call": "keep",
"say": "Your Flat deposit gets the £793 a month it needs and is on track to reach £30,000 by December 2028.",
"keep_in": "Easy-access savings, keeping it ready for when you need it."
},
{
"id": "g3",
"call": "stretch",
"say": "The Lisbon trip gets £198 a month instead of the £248 it needs, reaching its £2,000 target in August 2027, 2 months later than planned.",
"keep_in": "Easy-access savings, so the money is there when you need it."
}
],
"tradeoff": "You can accept the Lisbon trip reaching its target 2 months late, or find another £50 a month to keep it on time.",
"first_month": "In November 2026, put £389 into the Rainy-day pot, £793 into the Flat deposit and £198 into the Lisbon trip.",
"checkpoint": "Look again in May 2027, or sooner if the monthly amount or a target changes, and run the plan again."
}
Wedding year, money tight - short each month (on track, top up, paused)
Input:
{
"task": "plan",
"currency": "$",
"monthly": "$1,300",
"goals": "Wedding; Emergency fund; Second car",
"facts": "verdict: short (rule 1 - Emergency fund cannot reach its target by its date with what is left; Second car gets nothing after the goals above)\nmonthly to share: $1,300; needed by all goals together: $1,839; short by $539 a month\ngrowth assumed: 2% a year; targets grow with prices: no\nsaving starts: November 2026; look again in: May 2027\nfunding order: g1, g2, g3 (money goes down this list; each goal gets what it needs while money is left)\ng1: Wedding (trip or event) - target $18,000 by September 2027 (11 months of saving) - the date is fixed - saved so far $5,000 - needs $1,164 a month - gets $1,164 a month - call keep - on track for $18,000 by September 2027 - keep it in: easy-access savings\ng2: Emergency fund (emergency fund) - target $5,000 by December 2027 (14 months of saving) - the date is fixed - saved so far $1,000 - needs $281 a month - gets $136 a month - call top_up - short by $145 a month - at $136 a month it reaches $2,948 of $5,000 by December 2027 - keep it in: easy-access savings\ng3: Second car (car or big purchase) - target $8,000 by June 2028 (20 months of saving) - the date can move - saved so far $0 - needs $394 a month - gets $0 a month - call pause - nothing is left for it after the goals above - nothing is saved for it yet - keep it in: easy-access savings"
}
Reply:
{
"task": "plan",
"verdict": "short",
"headline": "Your $1,300 a month falls short of the $1,839 all goals need, leaving Emergency fund and Second car off track.",
"goals": [
{
"id": "g1",
"call": "keep",
"say": "Wedding's full monthly is being met, leaving it on track to reach $18,000 by September 2027.",
"keep_in": "Easy-access savings, so the funds are there right when they're due."
},
{
"id": "g2",
"call": "top_up",
"say": "Emergency fund gets $136 of the $281 it needs each month, a $145 gap, reaching $2,948 instead of $5,000 by December 2027; add $145 more monthly, or accept a $2,948 target.",
"keep_in": "Easy-access savings, so the money is ready for its fixed date."
},
{
"id": "g3",
"call": "pause",
"say": "Second car isn't getting any money yet because the goals ahead of it are using the full $1,300, and no savings have started for it; funding can begin once one of those goals wraps up or the monthly amount increases.",
"keep_in": "Easy-access savings, since its date can move, allowing more flexibility."
}
],
"tradeoff": "Covering the extra $539 each month would bring every goal back on schedule, or Emergency fund and Second car could settle for less by their dates, or simply wait longer.",
"first_month": "In November 2026, $1,164 goes to Wedding and $136 to Emergency fund, with nothing going to Second car for now.",
"checkpoint": "Check back in May 2027, or earlier if the monthly amount or any target shifts, and rerun the plan."
}
Retirement and a university fund (€) - fits if dates move (on track, stretch the date)
Input:
{
"task": "plan",
"currency": "€",
"monthly": "€900",
"goals": "University fund; Retirement pot",
"facts": "verdict: move (rule 2 - every fixed date is covered, and Retirement pot reaches the target later)\nmonthly to share: €900; needed by all goals together: €971; short by €71 a month\ngrowth assumed: 5% a year; targets grow with prices: 2% a year\nsaving starts: November 2026; look again in: May 2027\nfunding order: g1, g2 (money goes down this list; each goal gets what it needs while money is left)\ng1: University fund (education) - target €60,000 in today's money (€79,038 by then) by September 2040 (167 months of saving) - the date is fixed - saved so far €5,000 - needs €287 a month - gets €287 a month - call keep - on track for €79,038 by September 2040 - keep it in: a spread of long-term investments\ng2: Retirement pot (retirement) - target €400,000 in today's money (€713,863 by then) by January 2056 (351 months of saving) - the date can move - saved so far €40,000 - needs €684 a month - gets €613 a month - call stretch - reaches €658,177 by January 2056 - at €613 a month it reaches the target in February 2058, 25 months later - keep it in: a spread of long-term investments"
}
Reply:
{
"task": "plan",
"verdict": "move",
"headline": "Your plan falls short, with €900 against €971 needed each month, as Retirement pot reaches its target later.",
"goals": [
{
"id": "g1",
"call": "keep",
"say": "University fund gets the €287 a month it needs, keeping it on track to reach €79,038 by September 2040.",
"keep_in": "A spread of long-term investments, with time to absorb market swings."
},
{
"id": "g2",
"call": "stretch",
"say": "Retirement pot gets €613 a month instead of the €684 it needs, reaching its target in February 2058, 25 months later than planned.",
"keep_in": "A spread of long-term investments, with time to absorb market swings."
}
],
"tradeoff": "Retirement pot reaching its target later works, or add €71 a month to keep it on its original date.",
"first_month": "In November 2026, put €287 into University fund and €613 into Retirement pot.",
"checkpoint": "Check back in May 2027, sooner if the amount you save or a goal's target shifts, and rerun the numbers."
}
Build the input yourself
Load the engine in node (global.window = global; require("./goalkit.js")), then:
const K = GoalKit;
const form = K.form({ cur: "$", monthly: "900", rate: "3", infl: "", start: "2026-11", goals: [
{ name: "Emergency fund", kind: "emergency", target: "9000", saved: "4000", by: "2027-10", fixed: true },
{ name: "Summer trip", kind: "trip", target: "2400", saved: "300", by: "2027-07", fixed: false }] });
const built = K.buildInput(form); // built.facts holds each goal's need, share, call and the 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, K.title(form)) // the plan as Markdown; K.ics(built) gives monthly reminders
Form keys: cur, monthly, rate (growth, % a year), infl (price rise, % a year, blank for none), start (YYYY-MM) and up to six goals, each with name, kind (emergency, retirement, education, home, car, trip, other), target, saved, by (YYYY-MM) and fixed. The list order is the funding order.
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://jar-shelf.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://jar-shelf.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"]
// jar-shelf.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://jar-shelf.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://jar-shelf.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
}
// JarShelf.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 JarShelf {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://jar-shelf.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://jar-shelf.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://jar-shelf.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://jar-shelf.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":"jar-shelf"}' | 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": "jar-shelf"})["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: "jar-shelf" })).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": "jar-shelf"}, 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", "jar-shelf"), 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: "jar-shelf" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "jar-shelf"])["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 = "jar-shelf" }))!["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": "plan", "currency": "$", "monthly": "$900", "goals": "...", "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": "plan", "currency": "$", "monthly": "$900", "goals": "...", "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: "plan", currency, monthly, goals, 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 jar-shelf:<task>:<inputHash>:a<attempt> (the one task is plan), 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="jar-shelf:plan:$(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 = "jar-shelf:" + 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 = `jar-shelf:${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 := "jar-shelf:" + 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 = "jar-shelf:" + 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 = "jar-shelf:#{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 = "jar-shelf:" . $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 = $"jar-shelf:{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: jar-shelf:plan:$(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": "plan", "currency": "$", "monthly": "$900", "goals": "...", "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": "jar-shelf:plan:" + 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": `jar-shelf:plan:${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", "jar-shelf:plan:"+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", "jar-shelf:plan:" + 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" => "jar-shelf:plan:#{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: jar-shelf:plan:" . 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", $"jar-shelf:plan:{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);