Drive Bent Feather from your own code
The one metered task, rally, is available over HTTP. It takes 4 to 16 badminton points a recreational player lost in one session - what happened, and whether each ended with their own mistake or the other side's winner - plus the format and the page's own keyword read. It returns a rally stage and a short note for each point, the one binding stage (the stage leaking the most points), why it binds, one thing to do next game with the shots the player already has, what to leave alone, one drill, what to tally next session, and a verdict (own-goal, under-pressure or spread-thin) - with a safety note when a point mentions pain.
The division of labour first: the page reads keywords for stages and pain words and applies a fixed rule; the model reads every point properly and does the coaching; the page then re-checks the verdict and the binding stage against the model's own stage labels and the player's own "ended by" choices, the point count and order, its own pain words, the closing count and the word limits. Build points and page_read the same way (see "Build the input yourself") so the lines match.
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":"bent-feather"}), 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, count, format, points, page_read) 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: reading a session 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 stages and the verdicts
Every run sends task: rally (the one task is rally); a missing or different one is still answered as rally. Each point gets one stage - the stage of the first shot of the player's that went wrong: serve, return, net, lift, attack, defence, move or other. The binding stage is the stage (never other) with the most points; a tie goes to the stage listed earlier. The verdict is checked in this order, stopping at the first match: spread-thin (no stage holds two points), own-goal (at least half of the binding stage's points ended with the player's own mistake), under-pressure (every other case).
Input (every field is a string)
| Field | Required | What it holds |
|---|---|---|
task | yes | Always rally. |
count | yes | How many lost points, 4 to 16, as digits. |
format | yes | singles, doubles or not sure. |
points | yes | Numbered lines, one per point: 1. what happened: my clear landed mid-court and they smashed it | ended by: their winner. "ended by" is my mistake, their winner or not sure. |
page_read | yes | The page's keyword read, one numbered line per point: 1. stage guess: lift; pain word: none. A hint only - the model reads each point itself. |
retry_note | no | Only on the page's one reformat retry, after an unusable reply. |
Reply (one JSON object)
{"task": "rally", "verdict": "own-goal", "binding": "net",
"points": [{"n": 1, "stage": "net", "note": "..."}, ...],
"identify": "...", "exploit": "...", "subordinate": "...", "elevate": "...", "recheck": "...", "safety": "", "closing": "..."}
| Field | What it holds |
|---|---|
verdict | own-goal, under-pressure or spread-thin, by the rule above. |
binding | The binding stage id; empty for spread-thin. |
points | Exactly count entries in input order. Each: n, stage (one id from the list) and note (what went wrong, at most 18 words). |
identify | Why the stage binds, with "N of M" lost points and "K of N" own mistakes, at most 30 words. |
exploit, subordinate, elevate | One thing to do next game with the shots the player has (at most 30 words), what to leave alone (at most 30), one drill with a count or target (at most 35); all empty for spread-thin. |
recheck | What to tally next session, at most 25 words. |
safety | Only when a point mentions pain: stop playing on it and have a doctor or physiotherapist check it, at most 30 words. |
closing | One sentence of at most 20 words restating the verdict; digits only as "N of M". |
The page then checks the reply (FeatherKit.check) and lists any disagreement under "The page's check".
Worked examples
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 - net-tape night (own goal at the net), short clears (under pressure on clears and lifts), all over the place (spread thin) and serve and a twinge (own goal on the serve, with a safety note).
Net-tape night - own goal
Input:
{
"task": "rally",
"count": "7",
"format": "doubles",
"points": "1. what happened: my net shot clipped the tape and dropped on my side | ended by: my mistake\n2. what happened: tried a tight net shot, it went into the net | ended by: my mistake\n3. what happened: net kill went long past the back line | ended by: my mistake\n4. what happened: their smash was too fast to get back | ended by: their winner\n5. what happened: my serve went too high and they smashed it | ended by: their winner\n6. what happened: tumbled the net shot but it floated up and they killed it | ended by: their winner\n7. what happened: smashed straight into the net | ended by: my mistake",
"page_read": "1. stage guess: net; pain word: none\n2. stage guess: net; pain word: none\n3. stage guess: net; pain word: none\n4. stage guess: defence; pain word: none\n5. stage guess: serve; pain word: none\n6. stage guess: net; pain word: none\n7. stage guess: attack; pain word: none"
}
Reply:
{
"task": "rally",
"verdict": "own-goal",
"binding": "net",
"points": [
{
"n": 1,
"stage": "net",
"note": "Net shot clipped the tape and dropped on your own side."
},
{
"n": 2,
"stage": "net",
"note": "Tight net shot went into the net."
},
{
"n": 3,
"stage": "net",
"note": "Net kill went long, past the back line."
},
{
"n": 4,
"stage": "defence",
"note": "Your opponents' smash was too fast to get back."
},
{
"n": 5,
"stage": "serve",
"note": "Serve went too high and your opponents smashed it."
},
{
"n": 6,
"stage": "net",
"note": "Tumbled net shot floated up and your opponents killed it."
},
{
"n": 7,
"stage": "attack",
"note": "Smash went straight into the net."
}
],
"identify": "4 of 7 lost points were at the net, 3 of 4 ended by your own mistake, making it the stage to fix first.",
"exploit": "From the next game, add more height and clearance over the tape on your net shots to give yourself safer margin and cut mistakes at the net.",
"subordinate": "Leave your serve, attack and defence alone for now until your net play improves, or use them to set up safer net chances.",
"elevate": "With a partner, hit 20 net shots in a row, each clearing the tape with a little room before it drops, keeping that margin each time.",
"recheck": "Next session, tally how many lost points are net shots again, and how many of those end in your own mistake.",
"safety": "",
"closing": "Your net play is leaking the most points, 4 of 7, mostly from your own mistakes."
}
Short clears - under pressure
Input:
{
"task": "rally",
"count": "8",
"format": "singles",
"points": "1. what happened: my clear landed mid-court and they smashed it down the line | ended by: their winner\n2. what happened: lifted from the net but it was too short, they killed it | ended by: their winner\n3. what happened: clear from the back fell short, got smashed at my body | ended by: their winner\n4. what happened: cleared out over the back line | ended by: my mistake\n5. what happened: returning their low serve, I hit it into the net | ended by: my mistake\n6. what happened: net shot went wide | ended by: my mistake\n7. what happened: wrong-footed by their drop to the front | ended by: their winner\n8. what happened: they smashed and I couldn't get a racket on it | ended by: their winner",
"page_read": "1. stage guess: lift; pain word: none\n2. stage guess: lift; pain word: none\n3. stage guess: lift; pain word: none\n4. stage guess: lift; pain word: none\n5. stage guess: return; pain word: none\n6. stage guess: net; pain word: none\n7. stage guess: move; pain word: none\n8. stage guess: defence; pain word: none"
}
Reply:
{
"task": "rally",
"verdict": "under-pressure",
"binding": "lift",
"points": [
{
"n": 1,
"stage": "lift",
"note": "Clear landed mid-court and was smashed down the line."
},
{
"n": 2,
"stage": "lift",
"note": "Lift from the net was too short and got killed."
},
{
"n": 3,
"stage": "lift",
"note": "Clear from the back fell short and was smashed at your body."
},
{
"n": 4,
"stage": "lift",
"note": "Clear went out over the back line."
},
{
"n": 5,
"stage": "return",
"note": "Return of their low serve went into the net."
},
{
"n": 6,
"stage": "net",
"note": "Net shot went wide."
},
{
"n": 7,
"stage": "move",
"note": "Wrong-footed by their drop to the front."
},
{
"n": 8,
"stage": "defence",
"note": "Smash came through and you couldn't get a racket on it."
}
],
"identify": "Lift and clear cost 4 of 8 lost points, and only 1 of 4 ended on your own mistake - the rest were smashed after a short lift or clear.",
"exploit": "Add more height and depth to every clear and lift so it lands deeper in the court, taking away the time your opponent needs to smash it.",
"subordinate": "Leave your serve return, net play, footwork and defence alone for now - better lifts will buy your footwork more time to recover forward.",
"elevate": "With a partner, hit 20 clears in a row from the back line aiming for height over them and landing past the back service line each time.",
"recheck": "Next session, tally how many lost points start with a short clear or lift that let your opponent attack early.",
"safety": "",
"closing": "Short clears and lifts under pressure are costing you the most points, 4 of 8 this session."
}
All over the place - spread thin
Input:
{
"task": "rally",
"count": "6",
"format": "not sure",
"points": "1. what happened: served long and out | ended by: my mistake\n2. what happened: net shot went into the tape | ended by: my mistake\n3. what happened: my clear was short and they smashed it | ended by: their winner\n4. what happened: smash went wide | ended by: my mistake\n5. what happened: couldn't block their drive | ended by: their winner\n6. what happened: rushed my return of serve into the net | ended by: my mistake",
"page_read": "1. stage guess: serve; pain word: none\n2. stage guess: net; pain word: none\n3. stage guess: lift; pain word: none\n4. stage guess: attack; pain word: none\n5. stage guess: defence; pain word: none\n6. stage guess: return; pain word: none"
}
Reply:
{
"task": "rally",
"verdict": "spread-thin",
"binding": "",
"points": [
{
"n": 1,
"stage": "serve",
"note": "Served long and out."
},
{
"n": 2,
"stage": "net",
"note": "Net shot went into the tape."
},
{
"n": 3,
"stage": "lift",
"note": "Clear was short and the other side smashed it."
},
{
"n": 4,
"stage": "attack",
"note": "Smash went wide."
},
{
"n": 5,
"stage": "defence",
"note": "Could not block the other side's drive."
},
{
"n": 6,
"stage": "return",
"note": "Rushed return of serve into the net."
}
],
"identify": "No stage holds multiple points yet; your lost points are spread across serve, return, net, lift, attack and defence this session.",
"exploit": "",
"subordinate": "",
"elevate": "",
"recheck": "Log more lost points next session, noting the shot that went wrong in every point.",
"safety": "",
"closing": "No stage cost you more than 1 of 6 lost points, so your points were spread across the whole rally."
}
Serve and a twinge - own goal with a safety note
Input:
{
"task": "rally",
"count": "5",
"format": "doubles",
"points": "1. what happened: my low serve clipped the tape and fell short | ended by: my mistake\n2. what happened: my serve floated high and they pushed it at me | ended by: their winner\n3. what happened: flick serve went out at the back | ended by: my mistake\n4. what happened: smash went into the net and my shoulder felt a twinge | ended by: my mistake\n5. what happened: their drive to my body, I couldn't defend it | ended by: their winner",
"page_read": "1. stage guess: serve; pain word: none\n2. stage guess: serve; pain word: none\n3. stage guess: serve; pain word: none\n4. stage guess: attack; pain word: twinge\n5. stage guess: defence; pain word: none"
}
Reply:
{
"task": "rally",
"verdict": "own-goal",
"binding": "serve",
"points": [
{
"n": 1,
"stage": "serve",
"note": "Low serve clipped the tape and fell short."
},
{
"n": 2,
"stage": "serve",
"note": "Serve floated high and your opponents pushed it at you."
},
{
"n": 3,
"stage": "serve",
"note": "Flick serve went out at the back."
},
{
"n": 4,
"stage": "attack",
"note": "Smash went into the net and your shoulder felt a twinge."
},
{
"n": 5,
"stage": "defence",
"note": "Your opponents' drive to your body, and you could not defend it."
}
],
"identify": "Serve cost the most points, 3 of 5, and 2 of 3 of those ended by your own mistake.",
"exploit": "Give your low serve slightly more height over the tape, and aim flick serves a racket's length inside the back line.",
"subordinate": "Leave your attack and defence as they are for now until your serve stops costing points.",
"elevate": "Serve 20 low serves aiming to clear the tape by a hand's width, then 10 flick serves landing a racket's length inside the back line.",
"recheck": "Next session, tally how many lost points start with your serve, and how many of those are your own mistake.",
"safety": "Your shoulder twinge on the smash means you should stop playing on it and have a doctor or physiotherapist check it.",
"closing": "Your serve is costing you the most, 3 of 5 lost points this session."
}
Build the input yourself
Load the engine in node (global.window = global; require("./featherkit.js"); require("./featherdraw.js")), then:
const K = FeatherKit, D = FeatherDraw;
const built = K.buildInput({ format: "singles", pts: [
{ what: "my clear landed short and they smashed it", end: "theirs" },
{ what: "clear from the back fell short", end: "theirs" },
{ what: "served into the net", end: "mine" },
{ what: "net shot went wide", end: "mine" }] });
built.prescan.stages; // ["lift", "lift", "serve", "net"] - the page's keyword read
built.prescan.verdict; // "under-pressure" - the page's own rule
const wire = K.toWire(built.input); // POST this to /estimate and /run
// then: const reply = K.normalize(K.parseReply(job.output.output));
// K.check(reply, built).notes // the page's re-check of the reply
// K.md(built, reply) // the read 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://bent-feather.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://bent-feather.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"]
// bent-feather.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://bent-feather.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://bent-feather.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
}
// BentFeather.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 BentFeather {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://bent-feather.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://bent-feather.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://bent-feather.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://bent-feather.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":"bent-feather"}' | 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": "bent-feather"})["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: "bent-feather" })).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": "bent-feather"}, 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", "bent-feather"), 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: "bent-feather" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "bent-feather"])["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 = "bent-feather" }))!["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": "rally", "count": "5", "format": "doubles", "points": "1. what happened: my low serve clipped the tape | ended by: my mistake ...
2. ..."} - 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": "rally", "count": "5", "format": "doubles", "points": "1. what happened: my low serve clipped the tape | ended by: my mistake ...
2. ..."} - 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: "rally", count, format, points, page_read} (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 bent-feather:<task>:<inputHash>:a<attempt> (the one task is rally), 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="bent-feather:rally:$(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 = "bent-feather:" + 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 = `bent-feather:${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 := "bent-feather:" + 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 = "bent-feather:" + 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 = "bent-feather:#{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 = "bent-feather:" . $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 = $"bent-feather:{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: bent-feather:rally:$(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": "rally", "count": "5", "format": "doubles", "points": "1. what happened: my low serve clipped the tape | ended by: my mistake ...
2. ..."} - 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": "bent-feather:rally:" + 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": `bent-feather:rally:${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", "bent-feather:rally:"+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", "bent-feather:rally:" + 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" => "bent-feather:rally:#{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: bent-feather:rally:" . 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", $"bent-feather:rally:{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);