Drive Rangoli Rings from your own code
The one metered task, rangoli-check, is available over HTTP. It takes a round rangoli plan - 2 to 7 rings from the centre out, each with a motif, a colour and a repeat count - plus the width, the minutes available, the names of the people drawing, the powder on hand, and the page's own estimates and problem list. It returns a verdict, one fix per problem and one drawing step per ring (who draws it and how), with a headline, a note and a closing line.
The division of labour first: the page estimates powder and minutes and finds every problem; a fixed rule turns those problems into the verdict; the model writes one fix per problem and the centre-out drawing order; the page then re-checks that the verdict follows the rule, that the fixes match the problems in order, that each step goes to the drawer the page's rota names, the word limits, and that no internal id leaks into the prose. Build the input fields the same way (see "Build the input yourself") so your numbers match the page's.
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":"rangoli-rings"}), 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, size_cm, minutes, fold, hands, rings, powder, page_totals, problems) 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 check 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, the input and the verdicts
Every run sends task: rangoli-check (the one task is rangoli-check); a missing or different one is still answered as rangoli-check. The verdict rule, checked in order: a time problem gives trim-it; otherwise a powder problem gives restock-first; otherwise a blend or fold problem gives adjust-first; otherwise draw-it.
| Field | Required | What it holds |
|---|---|---|
task | yes | Always rangoli-check. |
size_cm, minutes, fold | yes | Width across (30 to 240), minutes available (10 to 600), sections (4, 6, 8 or 12), as digits. |
hands | yes | Up to 4 names, comma-separated, or the note that the person planning draws alone. |
rings | yes | One line per ring, centre first: 2: red petals | count 8 | about 25 g | about 14 min | drawn by Ravi. |
powder | yes | One line per colour used: red: need about 55 g, have 40 g. |
page_totals | yes | The page's total minutes and the minutes available. |
problems | yes | One line per problem (P1 powder | ring 5 | red: need about 55 g, have 40 g, short by 15 g) or none. |
retry_note | no | Only on the page's single reformat retry. |
The reply is one JSON object:
{"task": "rangoli-check", "verdict": "restock-first", "headline": "...",
"fixes": [{"ring": "5", "issue": "powder", "change": "..."}],
"steps": [{"ring": "1", "who": "Asha", "how": "..."}],
"note": "...", "closing": "..."}
| Field | What it holds |
|---|---|
verdict | trim-it, restock-first, adjust-first or draw-it, by the rule above. |
headline | The verdict in plain words, at most 16 words. |
fixes | One per problem line, in the same order; ring and issue (time, powder, blend, fold) copied from the line; change at most 25 words. Empty when problems is none. |
steps | One per ring, centre first; who copied from the ring's "drawn by"; how at most 20 words, for the plan as written. |
note | The planner's note to the person planning, at most 40 words. |
closing | One sentence restating the verdict, at most 20 words. |
The page then checks the reply (RangKit.check) and lists any disagreement under "The page's check".
Worked examples - every verdict
These are the four free samples on the page: the exact input and a stand-in model's reply, unedited. Together they cover every verdict - a wide doorway over its time (trim it, with a colour blend as well), lamps for the step short of red powder (restock first), a sixfold star with a blend and an uneven repeat (adjust first), and a small bright plan with no problems (draw it).
Wide doorway, tight time - trim it
Input:
{
"task": "rangoli-check",
"size_cm": "120",
"minutes": "150",
"fold": "8",
"hands": "Meera",
"rings": "1: yellow lotus | count 1 | about 15 g | about 11 min | drawn by Meera\n2: red petals | count 8 | about 40 g | about 21 min | drawn by Meera\n3: orange paisley | count 16 | about 65 g | about 53 min | drawn by Meera\n4: pink lotus | count 16 | about 105 g | about 74 min | drawn by Meera\n5: white dots | count 32 | about 55 g | about 18 min | drawn by Meera\n6: green border line | no count | about 55 g | about 20 min | drawn by Meera",
"powder": "white: need about 55 g, have 400 g\nred: need about 40 g, have 400 g\norange: need about 65 g, have 400 g\nyellow: need about 15 g, have 300 g\npink: need about 105 g, have 500 g\ngreen: need about 55 g, have 300 g",
"page_totals": "about 202 min in all with 1 pair of hands; 150 min available",
"problems": "P1 time | ring 4 | about 202 min in all, 52 over the time you have | slowest: pink lotus, about 74 min; as dots about 14 min\nP2 blend | ring 3 | red petals (ring 2) next to orange paisley: close shades | recolour ring 3 to white"
}
Reply:
{
"task": "rangoli-check",
"verdict": "trim-it",
"headline": "Too many minutes planned - the pink lotus needs a simpler motif first.",
"fixes": [
{
"ring": "4",
"issue": "time",
"change": "Switch the pink lotus to dots: about 74 min now, about 14 min as dots."
},
{
"ring": "3",
"issue": "blend",
"change": "Recolour the orange paisley ring to white, apart from the red petals beside it."
}
],
"steps": [
{
"ring": "1",
"who": "Meera",
"how": "Pinch yellow powder to shape the lotus at the very centre."
},
{
"ring": "2",
"who": "Meera",
"how": "Guide a chalk circle, then pinch red powder into 8 petals evenly around it."
},
{
"ring": "3",
"who": "Meera",
"how": "Mark a wider guide circle and pinch orange powder into 16 paisleys around the ring."
},
{
"ring": "4",
"who": "Meera",
"how": "Chalk the next circle, then pinch pink powder into 16 lotuses spaced evenly around it."
},
{
"ring": "5",
"who": "Meera",
"how": "Guide a thin circle and dot white powder 32 times evenly around the ring."
},
{
"ring": "6",
"who": "Meera",
"how": "Finish with a steady green powder line tracing the outer border, unbroken all the way round."
}
],
"note": "You're pressed for time, so lightening the pink lotus into quick dots is the first move - it's your biggest time-taker, and trimming it clears real minutes.",
"closing": "The plan runs over on time, so something needs trimming before you draw it."
}
Lamps for the step - restock first
Input:
{
"task": "rangoli-check",
"size_cm": "80",
"minutes": "90",
"fold": "8",
"hands": "Asha, Ravi",
"rings": "1: yellow diya lamps | count 1 | about 10 g | about 6 min | drawn by Asha\n2: red petals | count 8 | about 25 g | about 14 min | drawn by Ravi\n3: green leaves | count 16 | about 40 g | about 23 min | drawn by Asha\n4: white dots | count 24 | about 30 g | about 9 min | drawn by Ravi\n5: red border line | no count | about 30 g | about 11 min | drawn by Asha",
"powder": "white: need about 30 g, have 150 g\nred: need about 55 g, have 40 g\nyellow: need about 10 g, have 100 g\ngreen: need about 40 g, have 200 g",
"page_totals": "about 45 min in all with 2 pairs of hands; 90 min available",
"problems": "P1 powder | ring 5 | red: need about 55 g, have 40 g, short by 15 g"
}
Reply:
{
"task": "rangoli-check",
"verdict": "restock-first",
"headline": "Restock red powder - the red petals and red border line need more.",
"fixes": [
{
"ring": "5",
"issue": "powder",
"change": "Get at least 15 g more red powder - the red petals and the red border line share this colour, so restock before drawing."
}
],
"steps": [
{
"ring": "1",
"who": "Asha",
"how": "Pinch yellow powder between thumb and fingers and shape 1 diya lamp at the very centre."
},
{
"ring": "2",
"who": "Ravi",
"how": "Guide a chalk circle, then pinch red powder to press 8 petals evenly round the ring."
},
{
"ring": "3",
"who": "Asha",
"how": "Mark a guide circle, then pinch green powder to shape 16 leaves evenly around the ring."
},
{
"ring": "4",
"who": "Ravi",
"how": "Tap small pinches of white powder to dot 24 even dots right round the ring."
},
{
"ring": "5",
"who": "Asha",
"how": "Trail red powder steadily around the outer edge in a continuous border line to frame the rangoli."
}
],
"note": "You'll want 15 g more red powder before you start - the petals and the border line draw on the same colour, so topping up stops it running short partway through.",
"closing": "Restock the red powder before drawing, since the petals and border line still need more of it."
}
Sixfold star - adjust first
Input:
{
"task": "rangoli-check",
"size_cm": "70",
"minutes": "80",
"fold": "6",
"hands": "Kabir, Tara, Ishaan",
"rings": "1: orange lotus | count 1 | about 5 g | about 6 min | drawn by Kabir\n2: yellow petals | count 6 | about 20 g | about 11 min | drawn by Tara\n3: blue triangles | count 10 | about 30 g | about 13 min | drawn by Ishaan\n4: white dots | count 18 | about 20 g | about 7 min | drawn by Kabir\n5: magenta border line | no count | about 20 g | about 8 min | drawn by Tara",
"powder": "white: need about 20 g, have 100 g\norange: need about 5 g, have 100 g\nyellow: need about 20 g, have 100 g\nmagenta: need about 20 g, have 100 g\nblue: need about 30 g, have 150 g",
"page_totals": "about 26 min in all with 3 pairs of hands; 80 min available",
"problems": "P1 blend | ring 2 | orange lotus (ring 1) next to yellow petals: close shades | recolour ring 2 to white\nP2 fold | ring 3 | 10 triangles in 6 sections | change the count to 12"
}
Reply:
{
"task": "rangoli-check",
"verdict": "adjust-first",
"headline": "Adjust the yellow petals' colour and the blue triangles' count before you start drawing.",
"fixes": [
{
"ring": "2",
"issue": "blend",
"change": "Recolour the yellow petals to white, since next to the orange lotus their shades read too close together."
},
{
"ring": "3",
"issue": "fold",
"change": "Change the blue triangles' count to 12, so the triangles repeat evenly across the 6 sections."
}
],
"steps": [
{
"ring": "1",
"who": "Kabir",
"how": "Pinch orange powder and shape 1 lotus at the very centre, smoothing the petals round."
},
{
"ring": "2",
"who": "Tara",
"how": "Pinch yellow powder and press 6 petals evenly around this ring, overlapping slightly."
},
{
"ring": "3",
"who": "Ishaan",
"how": "Mark 10 blue triangles pointing outward, spacing them evenly around the circle with a steady hand."
},
{
"ring": "4",
"who": "Kabir",
"how": "Dot white powder 18 times around the ring using fingertip taps, spaced evenly."
},
{
"ring": "5",
"who": "Tara",
"how": "Trace a steady magenta line fully around the outer edge as the border."
}
],
"note": "You'll get much better contrast if you recolour the yellow petals to white, since beside the orange lotus they currently sit in close, similar shades, keeping the rings from standing apart clearly to the eye.",
"closing": "Make these adjustments before you pick up powder and begin drawing the rangoli."
}
Small and bright - draw it
Input:
{
"task": "rangoli-check",
"size_cm": "60",
"minutes": "60",
"fold": "8",
"hands": "Nila, Dev",
"rings": "1: pink lotus | count 1 | about 10 g | about 6 min | drawn by Nila\n2: white petals | count 8 | about 25 g | about 12 min | drawn by Dev\n3: green leaves | count 16 | about 35 g | about 21 min | drawn by Nila\n4: yellow dots | count 24 | about 25 g | about 8 min | drawn by Dev",
"powder": "white: need about 25 g, have 120 g\nyellow: need about 25 g, have 100 g\npink: need about 10 g, have 80 g\ngreen: need about 35 g, have 150 g",
"page_totals": "about 35 min in all with 2 pairs of hands; 60 min available",
"problems": "none"
}
Reply:
{
"task": "rangoli-check",
"verdict": "draw-it",
"headline": "Ready to draw - the pink lotus centre through the yellow dots edge all check out.",
"fixes": [],
"steps": [
{
"ring": "1",
"who": "Nila",
"how": "Nila pinches pink powder and shapes 1 lotus to fill the solid centre circle."
},
{
"ring": "2",
"who": "Dev",
"how": "Dev draws a chalk guide circle, then pinches white powder to shape the 8 petals evenly."
},
{
"ring": "3",
"who": "Nila",
"how": "Nila works round the ring, pinching green powder to shape the 16 leaves evenly spaced."
},
{
"ring": "4",
"who": "Dev",
"how": "Dev presses yellow powder between thumb and fingers, placing the 24 dots evenly around the edge."
}
],
"note": "Your pink lotus at the centre makes a strong focal point, and the yellow dots form a clean frame at the outer edge.",
"closing": "The rings are ready to draw, from the centre out, exactly as planned."
}
Build the input yourself
Load the engine in node (global.window = global; require("./rangkit.js")), then:
const K = RangKit;
const built = K.buildInput(K.form({ size: "60", minutes: "45", hands: "Mina", fold: "8",
rings: [{ motif: "lotus", colour: "pink", count: "1" }, { motif: "dots", colour: "white", count: "16" }],
stock: { pink: "50", white: "80" } }));
built.input.problems; // the page's problem lines, or "none"
const wire = K.toWire(built.input); // POST this to /estimate and /run
// then: const reply = K.normalize(K.parseReply(job.output.output));
// K.verdictFrom(built.facts) // the verdict the page's rule gives
// K.check(reply, built).notes // the page's re-check of the reply
// K.md(built, reply) // the plan 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://rangoli-rings.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://rangoli-rings.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"]
// rangoli-rings.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://rangoli-rings.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://rangoli-rings.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
}
// RangoliRings.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 RangoliRings {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://rangoli-rings.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://rangoli-rings.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://rangoli-rings.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://rangoli-rings.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":"rangoli-rings"}' | 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": "rangoli-rings"})["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: "rangoli-rings" })).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": "rangoli-rings"}, 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", "rangoli-rings"), 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: "rangoli-rings" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "rangoli-rings"])["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 = "rangoli-rings" }))!["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": "rangoli-check", "size_cm": "80", "minutes": "90", "fold": "8", "hands": "Asha, Ravi", "rings": "1: yellow diya lamps | count 1 | ...", "powder": "...", "page_totals": "...", "problems": "..."}
# - 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": "rangoli-check", "size_cm": "80", "minutes": "90", "fold": "8", "hands": "Asha, Ravi", "rings": "1: yellow diya lamps | count 1 | ...", "powder": "...", "page_totals": "...", "problems": "..."}
# - 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: "rangoli-check", size_cm, minutes, fold, hands, rings, powder, page_totals, problems} (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 rangoli-rings:<task>:<inputHash>:a<attempt> (the one task is rangoli-check), 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="rangoli-rings:rangoli-check:$(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 = "rangoli-rings:" + 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 = `rangoli-rings:${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 := "rangoli-rings:" + 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 = "rangoli-rings:" + 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 = "rangoli-rings:#{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 = "rangoli-rings:" . $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 = $"rangoli-rings:{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: rangoli-rings:rangoli-check:$(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": "rangoli-check", "size_cm": "80", "minutes": "90", "fold": "8", "hands": "Asha, Ravi", "rings": "1: yellow diya lamps | count 1 | ...", "powder": "...", "page_totals": "...", "problems": "..."}
# - 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": "rangoli-rings:rangoli-check:" + 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": `rangoli-rings:rangoli-check:${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", "rangoli-rings:rangoli-check:"+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", "rangoli-rings:rangoli-check:" + 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" => "rangoli-rings:rangoli-check:#{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: rangoli-rings:rangoli-check:" . 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", $"rangoli-rings:rangoli-check:{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);