Drive Patch or Pull from your own code
The one metered task, sort-chats, is available over HTTP. It takes up to eight group chats - each with a name, what it is for, its volume band, how much you read it, when you last posted, how often it has things you act on, whether you would miss it and whether its alerts are on - plus an optional priority, and returns a plain-words reason for each chat's call and a short note you could post in the chats you mute, set to tags only, or step out of.
The division of labour first: the page decides; the model explains. The page scores each chat's signal and noise and picks its call before the run, and sends all of it in facts. The model copies each call verbatim and never changes one.
Base URL and the envelope
Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response uses one envelope:
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "..." } }
The token is minted for this app (the guest call takes {"slug":"patch-or-pull"}), 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, chats, priority, facts) is missing. |
| 401 | unauthorized | No token, or a stale one. Mint a guest token or sign in again. |
| 402 | insufficient_credits | The balance is under min_credits. Price with /estimate first. |
| 403 | forbidden | A guest token tried to run: the reasons are metered and need a personal sign-in. |
| 404 | not_found | Unknown job id. |
| 429 | rate_limited | Back off and retry. |
| 5xx | server_error | Transient. Retry with the same Idempotency-Key so a retry never double-bills. |
The task field, the input and the calls
Every run sends task: sort-chats (the one task is sort-chats); a missing or different one is still answered as sort-chats. All fields are strings.
| Field | Required | What it holds |
|---|---|---|
task | yes | Always sort-chats. |
chats | yes | One numbered line per chat (2 to 8): name | for: ... | volume: ... | reading: ... | you last posted: ... | has things you act on: ... | would miss it: ... | alerts now: .... Volume is one of Under 5 a day; 5 to 30 a day; 30 to 100 a day; Over 100 a day. Reading: I read it all; I skim it; It piles up unread. Last posted: This week; This month; Longer ago or never. Act on: Often; Sometimes; Rarely or never. Miss it: Yes; Not sure; No. Alerts: Alerts on; Already muted. |
priority | yes | What matters most right now, up to 120 characters, or not given. |
facts | yes | The page's lines: per chat its signal (0-8) with the four parts, its noise (0-3) and its verdict; then the count of each verdict. |
retry_note | no | Only on the page's one reformat retry. |
Signal is the sum of four parts, each 0-2: would miss it (Yes 2, Not sure 1, No 0), has things to act on (Often 2, Sometimes 1, Rarely 0), last posted (This week 2, This month 1, Longer 0) and reading (all 2, skim 1, piles up 0). Noise is the volume band, 0-3. The call, first match wins: signal 6+ with noise 0-1 is keep-ringing; signal 6+ otherwise is tag-only; signal 3+, or act-on Often, is mute-and-skim; otherwise miss-it No is step-out; the rest are archive. The fixed order means there are no ties.
The reply
{"task": "sort-chats", "headline": "...", "chats": [{"name": "...", "verdict": "mute-and-skim", "why": "...", "note": "..."}], "priority_fit": "...", "closing": "..."}
| Field | What it holds |
|---|---|
headline | At most 22 words summing up the board; counts match facts. |
chats[] | One per input chat, same order and exact names: verdict copied from facts, why (at most 45 words, from that chat's own answers) and note (at most 20 words, first person, for tag-only, mute-and-skim and step-out only; otherwise empty). |
priority_fit | At most 45 words on how the board serves the priority; empty when it is not given. |
closing | One sentence pointing to the first change; never claims anything is done. |
The page then checks the reply (ChatKit.check) - each call against its own, every chat present, notes present only where wanted, notes with time words, contact-like details or "already done" claims, chats it did not list, internal ids in prose, a priority line with no priority, and word limits - and lists any disagreement under "The page's check". The first step for each call is fixed text on the page, not model output.
Worked examples - all five calls
These are the 3 free samples on the page: the exact input the page sends and the reply that replays for free. Together they cover all five calls.
Four chats, too much buzzing - calls: keep it ringing, only when tagged, mute and skim, step out
Input:
{
"task": "sort-chats",
"chats": "1. Flat 4B | for: bins, parcels and repairs | volume: 5 to 30 a day | reading: I read it all | you last posted: This week | has things you act on: Often | would miss it: Yes | alerts now: Alerts on\n2. Book club | for: picking the next book and where to meet | volume: 30 to 100 a day | reading: I skim it | you last posted: This month | has things you act on: Sometimes | would miss it: Yes | alerts now: Alerts on\n3. School run parents | for: lifts and pick-up swaps | volume: Over 100 a day | reading: I read it all | you last posted: This week | has things you act on: Often | would miss it: Not sure | alerts now: Alerts on\n4. Old job farewell | for: one leaving do that already happened | volume: Under 5 a day | reading: It piles up unread | you last posted: Longer ago or never | has things you act on: Rarely or never | would miss it: No | alerts now: Alerts on",
"priority": "fewer buzzes while I study for my evening course",
"facts": "1. Flat 4B: signal 8 of 8 (miss 2, act-on 2, posted 2, read 2); noise 1 of 3; verdict keep-ringing\n2. Book club: signal 5 of 8 (miss 2, act-on 1, posted 1, read 1); noise 2 of 3; verdict mute-and-skim\n3. School run parents: signal 7 of 8 (miss 1, act-on 2, posted 2, read 2); noise 3 of 3; verdict tag-only\n4. Old job farewell: signal 0 of 8 (miss 0, act-on 0, posted 0, read 0); noise 0 of 3; verdict step-out\ncounts: keep-ringing 1, tag-only 1, mute-and-skim 1, step-out 1, archive 0 (of 4 chats)"
}
Reply:
{
"task": "sort-chats",
"headline": "One chat you keep ringing, one is only when tagged, one you mute and skim, and one you step out of.",
"chats": [
{
"name": "Flat 4B",
"verdict": "keep-ringing",
"why": "You read every message here, it often has things for you to act on like bins, parcels and repairs, and you said you'd miss it, so it makes sense to keep it ringing at 5 to 30 a day.",
"note": ""
},
{
"name": "Book club",
"verdict": "mute-and-skim",
"why": "This chat is for picking the next book and where to meet. You skim it and it sometimes has things to act on, so with 30 to 100 a day coming through, mute and skim suits it best.",
"note": "Hi everyone, please mention me directly if you need my input here."
},
{
"name": "School run parents",
"verdict": "tag-only",
"why": "This chat is for lifts and pick-up swaps. You read all of it and it often has things to act on, but you are not sure you would miss it, and with over 100 a day coming through, only when tagged fits better.",
"note": "Hi all, please tag me directly if something needs my help here."
},
{
"name": "Old job farewell",
"verdict": "step-out",
"why": "This chat was for one leaving do that already happened, it piles up unread, rarely has anything to act on, and you said you would not miss it, so stepping out fits here.",
"note": "Thank you all - wishing you well, and goodbye."
}
],
"priority_fit": "Keeping just one chat ringing, with the rest set to only when tagged, mute and skim, or stepped out of, should give you fewer buzzes while you study for your evening course.",
"closing": "A good first step is to turn down the alerts on Book club so it moves to mute and skim."
}
Family, hobbies and a deals group - calls: keep it ringing, only when tagged, step out, archive it
Input:
{
"task": "sort-chats",
"chats": "1. Cousins | for: family photos and news | volume: 30 to 100 a day | reading: I skim it | you last posted: Longer ago or never | has things you act on: Rarely or never | would miss it: Not sure | alerts now: Alerts on\n2. Allotment swap | for: swapping seedlings and tools | volume: Under 5 a day | reading: I read it all | you last posted: This month | has things you act on: Sometimes | would miss it: Yes | alerts now: Already muted\n3. Climbing wall crew | for: who is going and when | volume: 30 to 100 a day | reading: I skim it | you last posted: This week | has things you act on: Often | would miss it: Yes | alerts now: Alerts on\n4. Deals and coupons | for: shared offers | volume: Over 100 a day | reading: It piles up unread | you last posted: Longer ago or never | has things you act on: Rarely or never | would miss it: No | alerts now: Alerts on",
"priority": "not given",
"facts": "1. Cousins: signal 2 of 8 (miss 1, act-on 0, posted 0, read 1); noise 2 of 3; verdict archive\n2. Allotment swap: signal 6 of 8 (miss 2, act-on 1, posted 1, read 2); noise 0 of 3; verdict keep-ringing\n3. Climbing wall crew: signal 7 of 8 (miss 2, act-on 2, posted 2, read 1); noise 2 of 3; verdict tag-only\n4. Deals and coupons: signal 0 of 8 (miss 0, act-on 0, posted 0, read 0); noise 3 of 3; verdict step-out\ncounts: keep-ringing 1, tag-only 1, mute-and-skim 0, step-out 1, archive 1 (of 4 chats)"
}
Reply:
{
"task": "sort-chats",
"headline": "Among your chats, one keeps ringing, one switches to only when tagged, one steps out, and one gets archived.",
"chats": [
{
"name": "Cousins",
"verdict": "archive",
"why": "You only skim Cousins, posted there longer ago or never, and rarely have things to act on, so even though you're not sure you'd miss it, archiving fits best while you stay a member.",
"note": ""
},
{
"name": "Allotment swap",
"verdict": "keep-ringing",
"why": "You read every message in Allotment swap, sometimes find things to act on there, and say you would miss it, so alerts come back on even though it's quiet, at under 5 a day.",
"note": ""
},
{
"name": "Climbing wall crew",
"verdict": "tag-only",
"why": "Climbing wall crew often has things you act on and you'd miss it, but you only skim it and it's busy, at 30 to 100 a day, so you'll hear only when someone tags you.",
"note": "Please tag me directly if you need me for something here."
},
{
"name": "Deals and coupons",
"verdict": "step-out",
"why": "In Deals and coupons, messages pile up unread, you rarely or never act on it, and you said you wouldn't miss it, so with it being very busy, at over 100 a day, stepping out fits best.",
"note": "Thanks for having me in this group - wishing you all well as I step out."
}
],
"priority_fit": "",
"closing": "Start by archiving Cousins, the first change this board is pointing you toward."
}
One very noisy street group - calls: keep it ringing, mute and skim, archive it
Input:
{
"task": "sort-chats",
"chats": "1. Maple Road notices | for: bin days, lost cats and roadworks | volume: Over 100 a day | reading: I skim it | you last posted: Longer ago or never | has things you act on: Sometimes | would miss it: Not sure | alerts now: Alerts on\n2. Uni friends | for: catching up | volume: 30 to 100 a day | reading: It piles up unread | you last posted: Longer ago or never | has things you act on: Rarely or never | would miss it: Yes | alerts now: Already muted\n3. Board game night | for: who hosts and what to bring | volume: 5 to 30 a day | reading: I read it all | you last posted: This week | has things you act on: Often | would miss it: Yes | alerts now: Alerts on",
"priority": "less scrolling late at night",
"facts": "1. Maple Road notices: signal 3 of 8 (miss 1, act-on 1, posted 0, read 1); noise 3 of 3; verdict mute-and-skim\n2. Uni friends: signal 2 of 8 (miss 2, act-on 0, posted 0, read 0); noise 2 of 3; verdict archive\n3. Board game night: signal 8 of 8 (miss 2, act-on 2, posted 2, read 2); noise 1 of 3; verdict keep-ringing\ncounts: keep-ringing 1, tag-only 0, mute-and-skim 1, step-out 0, archive 1 (of 3 chats)"
}
Reply:
{
"task": "sort-chats",
"headline": "Of your three chats, one stays loud, one gets muted for skimming, and one moves out of your main list.",
"chats": [
{
"name": "Maple Road notices",
"verdict": "mute-and-skim",
"why": "Maple Road notices is a busy one that you only skim, and although it sometimes has things worth acting on, you're not sure you would miss it.",
"note": "Hey everyone, please tag or mention me directly if something needs my attention here."
},
{
"name": "Uni friends",
"verdict": "archive",
"why": "Uni friends piles up unread and rarely has anything for you to act on, even though you would miss it being around. That combination points to tucking it out of your main list.",
"note": ""
},
{
"name": "Board game night",
"verdict": "keep-ringing",
"why": "Board game night often has things for you to act on, you read every message there, and you would miss it, all within 5 to 30 messages a day.",
"note": ""
}
],
"priority_fit": "Muting your busiest chat and moving another out of your main list means fewer alerts competing for your attention, helping with less scrolling late at night.",
"closing": "Start by turning off alerts for Maple Road notices so you can check it on your own terms."
}
Build the input yourself
Load the engine in node (global.window = global; require("./chatkit.js")), then:
const K = ChatKit;
const form = K.form({ priority: "", chats: [
{ name: "Flat 4B", purpose: "bins and parcels", volume: "5-30", read: "all", posted: "week", need: "often", miss: "yes", alerts: "on" },
{ name: "Deals", purpose: "", volume: "100+", read: "pile", posted: "longer", need: "rarely", miss: "no", alerts: "on" }] });
const built = K.buildInput(form); // built.facts holds each chat's signal, noise and call
const wire = K.toWire(built.input); // POST this to /estimate and /run
// then: const reply = K.normalize(K.parseReply(job.output.output));
// K.check(reply, built).notes // the page's re-check of the reply
// K.md(built, reply, K.title(form)) // the board 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://patch-or-pull.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://patch-or-pull.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"]
// patch-or-pull.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://patch-or-pull.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://patch-or-pull.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
}
// PatchOrPull.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 PatchOrPull {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://patch-or-pull.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://patch-or-pull.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://patch-or-pull.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://patch-or-pull.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":"patch-or-pull"}' | 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": "patch-or-pull"})["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: "patch-or-pull" })).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": "patch-or-pull"}, 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", "patch-or-pull"), 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: "patch-or-pull" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "patch-or-pull"])["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 = "patch-or-pull" }))!["token"]!.GetValue<string>();
Step 3 — GET /me
Who the token is (subject_type: user or guest) and the credit balance.
api GET /me | jq '.data | {subject_type, credits}'
me = api("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
me, _ := api("GET", "/me", nil, nil)
fmt.Println(me["subject_type"], me["credits"])
JsonNode me = api("GET", "/me", null, null);
System.out.println(me.get("subject_type") + " " + me.get("credits"));
me = api("GET", "/me")
puts [me["subject_type"], me["credits"]].inspect
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"] ?? "", "\n";
var me = await Api.Call("GET", "/me");
Console.WriteLine($"{me!["subject_type"]} {me["credits"]}");
Step 4 — POST /estimate
Free. The body is the run input itself, the bare object (never wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model_alias reads gpt-terra.
# input.json: the run input object - {"task": "sort-chats", "chats": "1. Flat 4B | for: bins, parcels and repairs | ...", "priority": "not given", ...}
# - 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": "sort-chats", "chats": "1. Flat 4B | for: bins, parcels and repairs | ...", "priority": "not given", ...}
# - 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: "sort-chats", chats, priority, facts} (see the field table)
const est = await api("POST", "/estimate", input);
console.log(est.hold_credits, est.min_credits, est.model, est.warnings);
raw, _ := os.ReadFile("input.json") // add "os" to the imports
var input map[string]any
json.Unmarshal(raw, &input)
est, _ := api("POST", "/estimate", input, nil)
fmt.Println(est["hold_credits"], est["min_credits"], est["model"])
Map<String, Object> input = M.readValue(new java.io.File("input.json"), Map.class);
JsonNode est = api("POST", "/estimate", input, null);
System.out.println(est.get("hold_credits") + " " + est.get("min_credits") + " " + est.get("model"));
input = JSON.parse(File.read("input.json"))
est = api("POST", "/estimate", input)
puts [est["hold_credits"], est["min_credits"], est["model"]].inspect
$input = json_decode(file_get_contents("input.json"), true);
$est = api("POST", "/estimate", $input);
echo $est["hold_credits"], " ", $est["min_credits"], " ", $est["model"], "\n";
var input = JsonNode.Parse(File.ReadAllText("input.json"))!;
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"{est!["hold_credits"]} {est["min_credits"]} {est["model"]}");
Step 5 — POST /run, then poll the job
Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses patch-or-pull:<task>:<inputHash>:a<attempt> (the one task is sort-chats), 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="patch-or-pull:sort-chats:$(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 = "patch-or-pull:" + 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"), len(reply.get("chats", [])))
import { createHash } from "node:crypto";
const key = `patch-or-pull:${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.chats || []).length);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "patch-or-pull:" + 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 = "patch-or-pull:" + 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 = "patch-or-pull:#{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["chats"] || []).length].inspect
$key = "patch-or-pull:" . $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"], " ", count($reply["chats"] ?? []), "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"patch-or-pull:{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: patch-or-pull:sort-chats:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json
rev = json.load(open("input.json")) # the same run input as input.json: {"task": "sort-chats", "chats": "1. Flat 4B | for: bins, parcels and repairs | ...", "priority": "not given", ...}
# - 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": "patch-or-pull:sort-chats:" + 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": `patch-or-pull:sort-chats:${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", "patch-or-pull:sort-chats:"+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", "patch-or-pull:sort-chats:" + 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" => "patch-or-pull:sort-chats:#{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: patch-or-pull:sort-chats:" . 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", $"patch-or-pull:sort-chats:{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);