Drive Five Bar Gate from your own code
The one metered step - the tidy pass over one bird walk's notes - is available over HTTP. A natural use is a field-notes app, a bird club's shared sightings sheet or a voice-memo transcriber that already holds what someone saw: your code sends the lines and the effort, and gets back a call for every line, ID checks for the shaky ones, a verdict, comments for the checklist and the steps before submitting.
The split first: the page reads and counts; the model judges; the page checks and re-tallies. Reading each line into a count, marks and a bird, the tally by family, the totals and the flags come from gatekit.js (with birdlist.js), which run in node as well as in the browser. Its check function then applies the reply: every call must name a real line; a rename of a bird the person named, a name on an unsure line and a merge of two different birds are refused; a merge counts the larger number once; the model never writes a count; species and bird totals after tidying are recomputed by the page, and numbers in the drafted comments that are not in the notes are flagged.
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":"five-bar-gate"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object, sent bare: never wrap it as {"input": {…}}.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The body is not a JSON object, or task 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 tidy pass 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 comes first
Every run sends task. There is one task, tidy. It returns title, verdict, summary; lines [{entry, call, as, with, why}], one per input line in order; id_checks [{entry, versus, look_for}], up to four; comments (one to three sentences for the checklist's comments field); before_you_submit (three to five steps); caveats.
| verdict | Means |
|---|---|
ready | Every line is named to species or honestly as a group, counted, and the effort is complete. |
tidy | A handful of calls - an ambiguous name, a missing count, a possible double count, one shaky ID - fix it. |
rework | Most lines are groups or uncounted, or the effort is missing; the list cannot stand as a complete checklist yet. |
| call | Means |
|---|---|
keep | The line stands as written. |
name | Settle an ambiguous word, a nickname or an unknown name to one species (as), by what is written and the region. Never on an unsure line, never over a species the person named. |
spuh | Report it at the level the notes support - as is a "sp." or slash name. |
count | A count or a careful estimate is needed; why says how. The model never writes the number. |
document | Rare, late, or unsure with some support: describe it or attach the photo before submitting. |
merge | Probably the same birds as the earlier line with; the page counts the larger number once. |
Ids are the page's: lines S1…S80, in the order they were written (a line with several birds separated by commas or semicolons becomes several ids).
The declared input schema carries scalars only, so object-valued fields travel JSON-encoded as strings (GateKit.toWire does this). Every field except notes and retry_note is required by the schema; a missing one comes back as a warning from /estimate.
Input fields
| Field | Type | Notes |
|---|---|---|
task | string | Always tidy. |
today | string | YYYY-MM-DD. |
outing | string (JSON) | {place, region, date, start, protocol, minutes, party, party_assumed, complete, distance_km?, distance_text?}. protocol is traveling, stationary or incidental; missing text values read (not given). |
entries | string (JSON) | [{id, text, name, kind, family, count, where?, approx?, plus?, heard?, flyover?, photo?, uncertain?, could_be?}], up to 80. kind is species, group or unknown; count is a number or null; could_be lists the species an ambiguous word may be. |
totals | string (JSON) | {species, other_taxa, birds, lines, families} as the page counted them. |
page_reads | string (JSON) | [{level, code, text, ids[]}], level count, name, effort or info. |
page_verdict | string | The page's own reading: ready, tidy or rework. |
notes | string | Optional, the observer's comments; past 600 characters the middle is cut around a marker. |
retry_note | string | Only on a reformat retry. |
The reply
The job's output.output is a string holding one JSON object. Parse it with GateKit.parseReply, then normalizeTidy, then apply and check it with check(reply, state), which returns {calls, after, before, notes, id_checks}: each line's call and whether the page applied it, the re-tallied rows, families and totals after tidying, the totals as written, and the notes (ok, info, warn, bad).
Worked examples
Replies produced against the app's prompt for the sample walks (made-up walks and counts; lists shortened; the full replies ship in example-result.js). The page replays each of them free from its sample buttons. Inputs are shown with the long fields folded.
task: tidy - Backyard feeder count - ready
Input:
{
"task": "tidy",
"today": "2026-10-19",
"outing": "{\"place\":\"Our backyard feeders\",\"region\":\"Southern Michigan\",\"date\":\"2026-10-11\",\"start\":\"08:00\",\"protocol\":\"stationary\",\"minutes\":45,\"party\":1,\"party_assumed\":false,\"complete\":true}",
"entries": "[{\"id\":\"S1\",\"text\":\"Dark-eyed Junco 9\",\"name\":\"Dark-eyed Junco\",\"kind\":\"species\",\"family\":\"New World sparrows\",\"count\":9,\"where\":\"Feeders\"},{\"id\":\"S2\",\"text\":\"White-throated Sparrow 4\",\"name\":\"White-throated Sparrow\",\"kind\":\"species\",\"family\":\"New World sparrows\",\"count\":4,\"where\":\"Feeders\"},{\"id\":\"S3\",\"text\":\"Black-capped Chickadee 3\",\"name\":\"Black-capped Chickadee\",\"kind\":\"species\",\"family\":\"Chickadees and titmice\",\"count\":3,\"where\":\"Feeders\"},\"... 13 more\"]",
"totals": "{\"species\":16,\"other_taxa\":0,\"birds\":54,\"lines\":16,\"families\":12}",
"page_reads": "[]",
"page_verdict": "ready",
"notes": "Two tube feeders and a platform feeder; watched from the kitchen window and the back step."
}
Reply (lines that are not a plain keep shown first):
{
"task": "tidy",
"title": "Southern Michigan Backyard Feeder List",
"verdict": "ready",
"summary": "This backyard feeder watch is ready to submit: all sixteen lines, from the nine Dark-eyed Juncos to the single Carolina Wren heard singing, are already named to full species with real counts and no page reads to fix. The only thing worth a second look before you submit is the busier feeder counts, like the nine juncos and six goldfinches. After these calls the list still holds sixteen species at species level, matching the page's own count for a complete 45-minute stationary watch.",
"lines": [
{
"entry": "S1",
"call": "keep",
"as": "Dark-eyed Junco",
"with": "",
"why": ""
},
{
"entry": "S2",
"call": "keep",
"as": "White-throated Sparrow",
"with": "",
"why": ""
},
{
"entry": "S3",
"call": "keep",
"as": "Black-capped Chickadee",
"with": "",
"why": ""
},
{
"entry": "S4",
"call": "keep",
"as": "Tufted Titmouse",
"with": "",
"why": ""
},
"... 12 more lines"
],
"id_checks": [],
"comments": "I watched two tube feeders and a platform feeder from the kitchen window and the back step for a stationary 45-minute count in Southern Michigan.",
"before_you_submit": [
"Double-check the busiest counts, like the nine juncos and six goldfinches, before you submit.",
"Confirm Carolina Wren and Ruby-crowned Kinglet look right for your yard this time of year on your regional checklist.",
"Submit as a complete stationary count - the 45 minutes and 08:00 start are already filled in.",
"..."
],
"caveats": [
"This list only covers what's in your notes; any feeder visit you didn't jot down isn't part of the count.",
"A reviewer may still ask for extra detail on any line, even a complete one like this."
]
}
task: tidy - Lakeshore park loop - tidy, with name, merge, sp., count and document calls
Input:
{
"task": "tidy",
"today": "2026-10-19",
"outing": "{\"place\":\"Lakeshore park loop\",\"region\":\"Southeast Michigan, Lake Erie shore\",\"date\":\"2026-10-10\",\"start\":\"07:40\",\"protocol\":\"traveling\",\"minutes\":95,\"party\":2,\"party_assumed\":false,\"complete\":true,\"distance_km\":3.38,\"distance_text\":\"2.1 mi\"}",
"entries": "[{\"id\":\"S1\",\"text\":\"Canada Goose flock of 60 flyover\",\"name\":\"Canada Goose\",\"kind\":\"species\",\"family\":\"Ducks, geese and swans\",\"count\":60,\"where\":\"Parking lot\",\"flyover\":true},{\"id\":\"S2\",\"text\":\"12 Ring-billed Gulls\",\"name\":\"Ring-billed Gull\",\"kind\":\"species\",\"family\":\"Gulls and terns\",\"count\":12,\"where\":\"Parking lot\"},{\"id\":\"S3\",\"text\":\"~40 grackles flyover\",\"name\":\"grackle sp.\",\"kind\":\"group\",\"family\":\"Blackbirds and orioles\",\"count\":40,\"where\":\"Parking lot\",\"approx\":true,\"flyover\":true,\"could_be\":[\"Common Grackle\",\"Great-tailed Grackle\"]},\"... 15 more\"]",
"totals": "{\"species\":12,\"other_taxa\":5,\"birds\":206,\"lines\":18,\"families\":14}",
"page_reads": "[{\"level\":\"count\",\"code\":\"no-count\",\"text\":\"3 lines have no number (\\\"kinglets - lots\\\", \\\"small hawk zipped through\\\", \\\"Belted Kingfisher heard\\\"). A count, or a careful estimate, is worth more than an X.\",\"ids\":[\"S8\",\"S11\",\"S17\"]},{\"level\":\"name\",\"code\":\"ambiguous\",\"text\":\"\\\"~40 grackles flyover\\\" could be Common Grackle or Great-tailed Grackle - your region and what you saw decide it.\",\"ids\":[\"S3\"]},{\"level\":\"name\",\"code\":\"ambiguous\",\"text\":\"\\\"grackles 30 flyover, same flock circling back\\\" could be Common Grackle or Great-tailed Grackle - your region and what you saw decide it.\",\"ids\":[\"S5\"]},\"... 6 more\"]",
"page_verdict": "tidy",
"notes": "Big Day morning. Light north wind, lots moving early over the parking lot."
}
Reply (lines that are not a plain keep shown first):
{
"task": "tidy",
"title": "Lakeshore Park Loop Fall Checklist",
"verdict": "tidy",
"summary": "This list is tidy: most lines, like 'Blue Jay 6' and 'Swamp Sparrow 3', are already full species with real counts, and the region settles the two grackle flocks ('~40 grackles flyover' and 'grackles 30 flyover, same flock circling back') as Common Grackle and 'chickadees x5' as Black-capped Chickadee. What is left is adding counts for 'kinglets - lots,' the 'small hawk zipped through,' and the heard-only Belted Kingfisher, plus a short description to back up the photographed Connecticut Warbler. After these calls the list holds 14 species at species level.",
"lines": [
{
"entry": "S3",
"call": "name",
"as": "Common Grackle",
"with": "",
"why": "Common Grackle is the grackle expected in Michigan; Great-tailed Grackle would be unusual this far east."
},
{
"entry": "S5",
"call": "merge",
"as": "Common Grackle",
"with": "S3",
"why": "Same flock circling back as S3, not a second group of birds."
},
{
"entry": "S6",
"call": "name",
"as": "Black-capped Chickadee",
"with": "",
"why": "Black-capped Chickadee is the expected chickadee in Michigan."
},
{
"entry": "S8",
"call": "spuh",
"as": "kinglet sp.",
"with": "",
"why": "Ruby-crowned and Golden-crowned Kinglets move together in fall and weren't separated here; also no count was given for 'lots'."
},
{
"entry": "S11",
"call": "spuh",
"as": "Accipiter sp.",
"with": "",
"why": "Sharp-shinned and Cooper's Hawks both migrate through and look alike on a quick flyby; also no count was given."
},
"... 13 more lines"
],
"id_checks": [
{
"entry": "S8",
"versus": "Ruby-crowned Kinglet vs. Golden-crowned Kinglet",
"look_for": "Golden-crowned shows a bold black-and-white face stripe with an orange or yellow crown patch, while Ruby-crowned has a plain face with a bold white eye-ring and a chattering call."
},
{
"entry": "S11",
"versus": "Sharp-shinned Hawk vs. Cooper's Hawk",
"look_for": "Cooper's Hawk has a larger, more rounded head on a thicker neck and a longer, rounded tail tip, while Sharp-shinned Hawk's head looks barely wider than its neck with a squared-off tail."
},
"..."
],
"comments": "Big Day morning with a light north wind; lots of birds were moving over the parking lot early, where I had the Canada Geese and the grackles. The two grackle counts (40, then 30) were the same flock circling back, so I'm counting them as one. The kinglets, the small hawk, and the maybe-vireo are reported at group level.",
"before_you_submit": [
"Add a count for the kinglets at S8 ('lots') - even a careful estimate beats leaving it blank.",
"Give a number for the small hawk at S11, or '1' if that's what you saw.",
"Say whether the Belted Kingfisher was one bird calling or more than one.",
"..."
],
"caveats": [
"The grackle and chickadee calls (Common Grackle, Black-capped Chickadee) are regional estimates, not a confirmed ID from field marks.",
"This page matches lines to a short list of common North American birds, so an unusual bird might not be reflected correctly.",
"..."
]
}
task: tidy - Rushed phone notes - rework
Input:
{
"task": "tidy",
"today": "2026-10-19",
"outing": "{\"place\":\"River trail\",\"region\":\"Central Michigan\",\"date\":\"2026-10-17\",\"start\":\"(not given)\",\"protocol\":\"traveling\",\"minutes\":null,\"party\":1,\"party_assumed\":true,\"complete\":true}",
"entries": "[{\"id\":\"S1\",\"text\":\"ducks lots\",\"name\":\"duck sp.\",\"kind\":\"group\",\"family\":\"Ducks, geese and swans\",\"count\":null},{\"id\":\"S2\",\"text\":\"geese\",\"name\":\"goose sp.\",\"kind\":\"group\",\"family\":\"Ducks, geese and swans\",\"count\":null},{\"id\":\"S3\",\"text\":\"some hawks circling high\",\"name\":\"hawk sp.\",\"kind\":\"group\",\"family\":\"Osprey, hawks and eagles\",\"count\":null},\"... 10 more\"]",
"totals": "{\"species\":2,\"other_taxa\":11,\"birds\":3,\"lines\":13,\"families\":11}",
"page_reads": "[{\"level\":\"count\",\"code\":\"no-count\",\"text\":\"11 lines have no number (\\\"ducks lots\\\", \\\"geese\\\", \\\"some hawks circling high\\\" and 8 more). A count, or a careful estimate, is worth more than an X.\",\"ids\":[\"S1\",\"S2\",\"S3\",\"S4\",\"S5\",\"S6\",\"S8\",\"S10\",\"S11\",\"S12\",\"S13\"]},{\"level\":\"name\",\"code\":\"ambiguous\",\"text\":\"\\\"chickadees\\\" could be Black-capped Chickadee or Carolina Chickadee or Mountain Chickadee or Chestnut-backed Chickadee - your region and what you saw decide it.\",\"ids\":[\"S8\"]},{\"level\":\"name\",\"code\":\"ambiguous\",\"text\":\"\\\"crows\\\" could be American Crow or Fish Crow - your region and what you saw decide it.\",\"ids\":[\"S11\"]},\"... 6 more\"]",
"page_verdict": "rework",
"notes": "Typed on my phone while walking the dog, didn't count much."
}
Reply (lines that are not a plain keep shown first):
{
"task": "tidy",
"title": "River Trail Bird Walk Checklist",
"verdict": "rework",
"summary": "This list needs rework: effort is incomplete (no duration or distance given for a traveling count) and ten of the thirteen lines - \"ducks lots\", \"geese\", \"some hawks circling high\", and more - carry no number. The most important fix is adding how long and how far you walked, since the list is marked complete without that effort data. After these calls the list holds four confirmed species (Northern Cardinal, American Robin, Black-capped Chickadee, American Crow), with the Kirtland's Warbler pending a description and eight lines still reported at group level.",
"lines": [
{
"entry": "S1",
"call": "count",
"as": "duck sp.",
"with": "",
"why": "No number for \"ducks lots\" - count a block and step it across the group, or give your best estimate."
},
{
"entry": "S2",
"call": "count",
"as": "goose sp.",
"with": "",
"why": "No number for the geese - give a count or a careful estimate."
},
{
"entry": "S3",
"call": "count",
"as": "hawk sp.",
"with": "",
"why": "No number for \"some hawks\" - give a count or a careful estimate."
},
{
"entry": "S4",
"call": "count",
"as": "gull sp.",
"with": "",
"why": "No number for the seagulls - give a count or a careful estimate."
},
{
"entry": "S5",
"call": "count",
"as": "woodpecker sp.",
"with": "",
"why": "No number for the woodpecker - add a count, even if it's just one."
},
"... 8 more lines"
],
"id_checks": [
{
"entry": "S8",
"versus": "Carolina Chickadee",
"look_for": "Black-capped Chickadee has a cleaner white edge on the wing feathers and a slower, huskier \"chick-a-dee-dee\" call, while Carolina Chickadee's call is faster and higher-pitched, though the two can look nearly identical."
},
{
"entry": "S9",
"versus": "Palm Warbler",
"look_for": "Palm Warbler also pumps its tail and shows yellowish underparts in fall but has a rufous-tinged cap and is far more expected in Michigan in October, while Kirtland's Warbler is larger and grayer above with bold white eye-arcs and heavier streaking on the flanks."
},
"..."
],
"comments": "I typed these notes on my phone while walking the dog and didn't count much. Several lines are reported at group level, and the chickadee and crow are given as the species expected for Central Michigan rather than confirmed in the field. I wasn't sure about the Kirtland's Warbler, so that one needs a closer look before it goes on record.",
"before_you_submit": [
"Add how long you were out - the list says complete but no duration is given.",
"Add how far you walked on this traveling count, or switch to stationary if you stayed in one spot.",
"Give a count or a careful estimate for the still-grouped lines - ducks, geese, hawks, gulls, the woodpecker, the small brown birds, the blackbirds, and the warbler - rather than leaving them as \"lots\", \"some\", or \"a bunch\".",
"..."
],
"caveats": [
"The chickadee, crow, and Kirtland's Warbler calls use general range and season knowledge, not certainty.",
"Your regional records committee may still want more detail on the Kirtland's Warbler, even with a description."
]
}
Build the input yourself
Load the scripts in node (global.window = {}; require("./birdlist.js"); require("./gatekit.js")), then:
const K = GateKit;
const st = K.state({
place: "Lakeshore park loop", region: "Southeast Michigan", date: "2026-10-10", start: "7:40",
minutes: "95", protocol: "traveling", distance: "2.1", dist_unit: "mi", party: "2", complete: "yes",
notes: "",
sightings: "Parking lot:\n~40 grackles flyover\nBlue Jay 6\n\nWoods trail:\nchickadees x5\nkinglets - lots"
});
const { input } = K.buildInput(st, { today: "2026-10-19" });
const wire = K.toWire(input); // POST this to /estimate and /run
// later: const reply = K.normalizeTidy(K.parseReply(job.output.output));
// const { calls, after, before, notes } = K.check(reply, st);
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://five-bar-gate.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://five-bar-gate.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"]
// five-bar-gate.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://five-bar-gate.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://five-bar-gate.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
}
// FiveBarGate.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 FiveBarGate {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://five-bar-gate.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://five-bar-gate.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://five-bar-gate.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://five-bar-gate.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":"five-bar-gate"}' | 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": "five-bar-gate"})["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: "five-bar-gate" })).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": "five-bar-gate"}, 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", "five-bar-gate"), 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: "five-bar-gate" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "five-bar-gate"])["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 = "five-bar-gate" }))!["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": "tidy", "today": "2026-10-19", "outing": "{...}", "entries": "[...]", "totals": "{...}", "page_reads": "[...]", "page_verdict": "tidy"}
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": "tidy", "today": "2026-10-19", "outing": "{...}", "entries": "[...]", "totals": "{...}", "page_reads": "[...]", "page_verdict": "tidy"}
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: "tidy", today, outing, entries, totals, page_reads, page_verdict, notes?}
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 five-bar-gate:tidy:<inputHash>:a<attempt>, 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="five-bar-gate:tidy:$(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 = "five-bar-gate:" + 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 lane's JSON object
print(j["charged_credits"], j.get("truncated"), reply.get("verdict"))
import { createHash } from "node:crypto";
const key = `five-bar-gate:${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 := "five-bar-gate:" + 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 = "five-bar-gate:" + 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 = "five-bar-gate:#{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 = "five-bar-gate:" . $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 = $"five-bar-gate:{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: five-bar-gate:tidy:$(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": "tidy", "today": "2026-10-19", "outing": "{...}", "entries": "[...]", "totals": "{...}", "page_reads": "[...]", "page_verdict": "tidy"}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "five-bar-gate:tidy:" + 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": `five-bar-gate:tidy:${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", "five-bar-gate:tidy:"+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", "five-bar-gate:tidy:" + 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" => "five-bar-gate:tidy:#{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: five-bar-gate:tidy:" . 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", $"five-bar-gate:tidy:{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);