Drive Link Ration from your own code
The one metered lane, ration, is available over HTTP. It takes a page's planned posts for a month and how many outbound links the month allows, and returns a verdict - over, fits or needs-posts - with one decision per post: spend (keeps its link and uses a coupon), native (a rewrite that works without the link, and where the link lives instead), as-is (no link to begin with) or cut (too thin to publish). A natural use is a content-calendar script that runs at the start of each month and flags which scheduled posts must lose their links.
The division of labour first: the page reads the posts and counts the links; the model decides and rewrites; the page re-checks the reply. The post reader, the link ledger and the clipping come from rationkit.js, which runs in node as well as in the browser. RationKit.buildInput pulls every link out of each post, keeps the date typed in front, and counts the words left. RationKit.check holds each reply to the posts: one decision per post, no more coupons than the allowance and none left idle while link posts go without, spent posts that really carry a link, no web address or comment-link workaround inside a rewrite, pointers that land on a spent post with the same destination, and no number or weekday the posts never state.
Get a token on the token page (step 2 below also shows how to mint a guest token in code).
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":"link-ration"}), 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 such as task, allowance or posts 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 ration lane is metered and needs a personal token. |
| 404 | not_found | Unknown job id. |
| 429 | rate_limited | Back off and retry. |
| 5xx | server_error | Transient. Retry with the same Idempotency-Key so a retry never double-bills. |
The task field and the verdicts
Every run sends task: ration, the only lane; a missing or unknown task is treated as ration. A post is real when it has four or more words besides its links and date; a link post is a real post that carries a link. The verdict is decided by three ordered, non-overlapping rules - the first that applies wins:
| Order | Verdict | When |
|---|---|---|
| 1 | needs-posts | Fewer than two real posts. posts is empty; asks holds two or three questions. |
| 2 | fits | Link posts are no more than allowance: every link post is spend. |
| 3 | over | More link posts than coupons: exactly allowance posts are spend; every other link post is native. |
The declared input schema carries scalars only, so the array-valued posts field travels JSON-encoded as a string (RationKit.toWire does this). Every value is a string.
Input fields
| Field | Type | Notes |
|---|---|---|
task | string | ration. Required. |
allowance | string | Outbound links the month allows, "1" to "10". Required. |
posts | string (JSON) | [{id, date, text, links, words}] - ids P1.., up to 20 posts of up to 500 characters; date as typed in front of the post or empty; text with links removed; links the URLs found; words the word count of text. Required. |
page, goal, month | string | What the page is, what readers should do, and the month, as typed. Optional. |
clip_note, retry_note | string | What the page cut, when it cut something; and only on a reformat retry, what was wrong. |
The reply
The job's output.output is a string holding one JSON object: task, verdict, title, summary, posts ([{id, action, why, rewrite, where_instead, points_to}] - one per input post, in order; action is spend, native, as-is or cut; only native carries a rewrite and a where_instead of spent-post, about, message or none; points_to names the spent post only with spent-post) and asks. Parse with RationKit.parseReply, then normalize(reply), then check(reply, form), which returns the page's notes.
Worked examples
Replies produced against the app's prompt for the three sample months (made-up pages with .example addresses; long fields and lists shortened; the full replies ship in example-result.js). Together they cover every verdict and every action.
ration - a bakery's November, seven posts, two links: over
Input:
{
"task": "ration",
"page": "Quillby Lane Bakery, a neighbourhood bakery",
"goal": "pie preorders and a weekend hire",
"month": "November",
"allowance": "2",
"posts": "[{\"id\":\"P1\",\"date\":\"Nov 2\",\"text\":\"Holiday pie preorders are open until Nov 20 - pumpkin, pecan and apple, pick up Nov 25 or 26\",\"links\":[\"https://quillbylanebakery.example/preorder\"],\"words\":18},{\"id\":\"P2\",\"date\":\"Nov 5\",\"text\":\"Our sourdough starter recipe, step by step, for anyone baking at home\",\"links\":[\"https://quillbylanebakery.example/blog/starter\"],\"words\":12},{\"id\":\"P3\",\"date\":\"Nov 8\",\"text\":\"We're hiring a weekend counter person - early mornings, friendly, no experience needed\",\"links\":[\"https://quillbyl ... (1103 characters)"
}
Reply:
{
"task": "ration",
"verdict": "over",
"title": "November pies and hiring plan",
"summary": "November has five link posts but only two coupons this month. The pie preorder and the weekend hiring posts keep their links, since both need a click to happen. The rest keep their news without a link, pointing readers to an earlier post, our about section, or a message instead.",
"posts": [
{
"id": "P1",
"action": "spend",
"why": "The link lets a reader place a pie preorder, which can only happen through that click.",
"rewrite": "",
"where_instead": "",
"points_to": ""
},
{
"id": "P2",
"action": "native",
"why": "Readers get the idea of the recipe here, and the rest is easy to find without a click.",
"rewrite": "We're sharing our sourdough starter recipe, step by step, for anyone baking at home. The full recipe is in our about section.",
"where_instead": "about",
"points_to": ""
},
{
"id": "P3",
"action": "spend",
"why": "The link lets someone apply for the weekend counter job, which can only happen there.",
"rewrite": "",
"where_instead": "",
"points_to": ""
},
"... (7 in all)"
],
"asks": []
}
ration - a choir's December, two link posts, three links: fits
Input:
{
"task": "ration",
"page": "Brindlemoor Community Choir",
"goal": "sell concert tickets",
"month": "December",
"allowance": "3",
"posts": "[{\"id\":\"P1\",\"date\":\"Dec 1\",\"text\":\"Tickets for our winter concert on Dec 14 at Brindlemoor Parish Hall are on sale now\",\"links\":[\"https://brindlemoorchoir.example/tickets\"],\"words\":16},{\"id\":\"P2\",\"date\":\"Dec 4\",\"text\":\"Rehearsal photo - the altos finally nailed the descant\",\"links\":[],\"words\":8},{\"id\":\"P3\",\"date\":\"Dec 9\",\"text\":\"We're collecting warm coats for the shelter at every rehearsal this month\",\"links\":[],\"words\":12},{\"id\":\"P4\",\"date\":\"Dec 11\",\"text\":\"Watch last year's concert recording before you come\",\"li ... (660 characters)"
}
Reply:
{
"task": "ration",
"verdict": "fits",
"title": "December concert ticket plan",
"summary": "This month has two link posts and three coupons, so both keep their links as planned. The rehearsal photo and the coat drive update work fine without a link. The thank-you post is only a couple of words, so it's too thin to publish as is.",
"posts": [
{
"id": "P1",
"action": "spend",
"why": "Buying a ticket only happens through this link, so it keeps its spot this month.",
"rewrite": "",
"where_instead": "",
"points_to": ""
},
{
"id": "P2",
"action": "as-is",
"why": "This works fine as a stand-alone update with nothing to click through to.",
"rewrite": "",
"where_instead": "",
"points_to": ""
},
{
"id": "P3",
"action": "as-is",
"why": "This is a stand-alone call for coats and doesn't need a link.",
"rewrite": "",
"where_instead": "",
"points_to": ""
},
"... (5 in all)"
],
"asks": []
}
ration - "sale post", "new arrivals!!" and a bare link: needs-posts
Input:
{
"task": "ration",
"page": "Corner bookshop",
"goal": "",
"month": "",
"allowance": "2",
"posts": "[{\"id\":\"P1\",\"date\":\"\",\"text\":\"sale post\",\"links\":[\"https://cornerbooks.example/sale\"],\"words\":2},{\"id\":\"P2\",\"date\":\"\",\"text\":\"new arrivals!!\",\"links\":[],\"words\":2},{\"id\":\"P3\",\"date\":\"\",\"text\":\"\",\"links\":[\"https://cornerbooks.example\"],\"words\":0}]"
}
Reply:
{
"task": "ration",
"verdict": "needs-posts",
"title": "Not enough posts to plan",
"summary": "Three posts came in, but none of them say enough yet to plan the month around. A few more words in each one would make it clear what deserves a link and what doesn't.",
"posts": [],
"asks": [
"What's happening this month?",
"What do you want readers to do?",
"What would each post say if nobody could click the link?"
]
}
Build the input yourself
Load the script in node (global.window = global; require("./rationkit.js")), then:
const K = RationKit;
const form = K.form({ page: "Quillby Lane Bakery", month: "November", allowance: "2",
posts: "Nov 2: Pie preorders open until Nov 20 https://shop.example/preorder\nNov 5: Our starter recipe https://shop.example/blog\nNov 8: We're hiring a weekend counter person https://shop.example/jobs" });
console.log(K.flags(form), K.lean(form), K.guess(form)); // the flags, the page's lean, the page's own pick
const wire = K.toWire(K.buildInput("ration", form).input); // POST this to /estimate and /run
// then: const plan = K.normalize(K.parseReply(job.output.output));
// const { notes } = K.check(plan, form); // the page's re-check of the reply
// K.schedule(plan, form) // the posting list, links only where a coupon was spent
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://link-ration.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://link-ration.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"]
// link-ration.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://link-ration.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://link-ration.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
}
// LinkRation.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 LinkRation {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://link-ration.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://link-ration.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://link-ration.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://link-ration.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":"link-ration"}' | 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": "link-ration"})["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: "link-ration" })).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": "link-ration"}, 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", "link-ration"), 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: "link-ration" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "link-ration"])["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 = "link-ration" }))!["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": "ration", "page": "Quillby Lane Bakery, a neighbourhood bakery", "month": "November", "allowance": "2", "posts": "[{\"id\":\"P1\",\"date\":\"Nov 2\",\"text\":\"Holiday pie preorders are open until Nov 20 - pumpkin, pecan and apple, pick up Nov 25 or 26\",\"links\":[\"https://quillbylanebakery.example/preorder\"],\"words\":18},{\"id\":\"P2\",\"date\":\"Nov 5\",\"text\":\"Our sourdough starter recipe, step by step, for anyone baking at home\",\"links\":[\"https://quillbylanebakery.example/blog/starter\"],\"words\":12}, ...]"}
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": "ration", "page": "Quillby Lane Bakery, a neighbourhood bakery", "month": "November", "allowance": "2", "posts": "[{\"id\":\"P1\",\"date\":\"Nov 2\",\"text\":\"Holiday pie preorders are open until Nov 20 - pumpkin, pecan and apple, pick up Nov 25 or 26\",\"links\":[\"https://quillbylanebakery.example/preorder\"],\"words\":18},{\"id\":\"P2\",\"date\":\"Nov 5\",\"text\":\"Our sourdough starter recipe, step by step, for anyone baking at home\",\"links\":[\"https://quillbylanebakery.example/blog/starter\"],\"words\":12}, ...]"}
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: "ration", page, month, allowance, posts, ... (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 link-ration:<task>:<inputHash>:a<attempt> (the one lane is ration), 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="link-ration:ration:$(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 = "link-ration:" + 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 = `link-ration:${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 := "link-ration:" + 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 = "link-ration:" + 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 = "link-ration:#{input["task"]}:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1"
job = api("POST", "/run", input, { "Idempotency-Key" => key })
j = nil
loop do
sleep 2
j = api("GET", "/jobs/#{job["job_id"]}")
break if %w[succeeded failed cancelled].include?(j["status"])
end
reply = JSON.parse(j["output"]["output"])
puts [j["charged_credits"], j["truncated"], reply["title"]].inspect
$key = "link-ration:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$job = api("POST", "/run", $input, ["Idempotency-Key" => $key]);
do { sleep(2); $j = api("GET", "/jobs/" . $job["job_id"]); }
while (!in_array($j["status"], ["succeeded", "failed", "cancelled"]));
$reply = json_decode($j["output"]["output"], true);
echo $j["charged_credits"], " ", $reply["title"], "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"link-ration:{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: link-ration:ration:$(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": "ration", "page": "Quillby Lane Bakery, a neighbourhood bakery", "month": "November", "allowance": "2", "posts": "[{\"id\":\"P1\",\"date\":\"Nov 2\",\"text\":\"Holiday pie preorders are open until Nov 20 - pumpkin, pecan and apple, pick up Nov 25 or 26\",\"links\":[\"https://quillbylanebakery.example/preorder\"],\"words\":18},{\"id\":\"P2\",\"date\":\"Nov 5\",\"text\":\"Our sourdough starter recipe, step by step, for anyone baking at home\",\"links\":[\"https://quillbylanebakery.example/blog/starter\"],\"words\":12}, ...]"}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "link-ration:ration:" + 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": `link-ration:ration:${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", "link-ration:ration:"+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", "link-ration:ration:" + 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" => "link-ration:ration:#{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: link-ration:ration:" . 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", $"link-ration:ration:{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);