Drive Fair Test from your own code
The one metered step - the teacher's-eye check of one science fair project plan - is available over HTTP. A natural use is a class's project-proposal form, a homework tracker or a teacher's batch of pre-approval drafts: your code sends the plan, read into parts, and gets back a status, quote, reason and fix for every part, a fairer hypothesis, the sign-offs to get before the test starts, next steps, questions a judge may ask and a verdict.
The split first: the page reads and checks; the model judges; the page holds the model to the plan. Reading the plan into parts, the facts (levels, trials, runs, days to the due date), the per-part flags and the sign-off word list come from fairkit.js, which runs in node as well as in the browser. Its check function then compares the reply with the plan: a part the page found missing is never accepted as fair, every quote must be in the plan as written, every sign-off the page's word list found must be listed, and the hypothesis rewrite must be if-then and mention what the plan changes.
Get a token on the token page (step 1 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":"fair-test"}), 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 teacher's-eye check is metered and needs a personal token. |
| 404 | not_found | Unknown job id. |
| 429 | rate_limited | Back off and retry. |
| 5xx | server_error | Transient. Retry with the same Idempotency-Key so a retry never double-bills. |
The task field comes first
Every run sends task. There is one task, precheck. It returns title, verdict, summary; checks [{part, status, says, why, suggest}], one per part in the order question, hypothesis, variables, procedure, trials, data, safety; hypothesis_rewrite (one if-then-because sentence, or empty); sign_offs [{rule, why, before}]; next_steps (three to five); judge_questions (two to four); caveats.
| Verdict | Meaning |
|---|---|
ready | Every part is fair as written, or at most trivial wording; no sign-off outstanding beyond a routine form. |
tweak | The test itself is fair, but some parts need a fix before approval - a unit, more trials, a list of controls, an if-then hypothesis, a form. |
rethink | Not a fair test yet: more than one change, nothing measurable, a demonstration, or most parts missing. |
| Part status | Meaning |
|---|---|
ok | Fair as written. suggest is empty. |
fix | There, but it would not pass as written. suggest names one concrete fix. |
missing | Not in the plan. says is empty. |
Sign-off rule values: human, animal, microbe, chemical, fire, electric, sharp, other. These are rules most fairs share; your fair's rules decide.
The declared input schema carries scalars only, so object-valued fields travel JSON-encoded as strings (FairKit.toWire does this). Every field except notes and retry_note is required by the schema (task, today, grade, due, plan, facts and the four page_* fields); a missing one comes back as a warning on /estimate.
Input fields
| Field | Type | Notes |
|---|---|---|
task | string | Always precheck. |
today | string | YYYY-MM-DD. |
grade | string | k-2, 3-5, 6-8 or 9-12. |
due | string | The pre-approval date, YYYY-MM-DD, or (not given). |
plan | string (JSON) | {title, question, hypothesis, change, measure, same[], control, levels[], materials[], procedure[], trials, data, safety, other} - empty string or list when the page found nothing under that heading. Long text keeps its head and tail around a marker; lists keep 20 items. |
facts | string (JSON) | {levels_n, trials_n, total_runs, same_n, steps_n, materials_n, days_to_due}. |
page_reads | string (JSON) | [{level, part, code, text}]; level is missing, fix, signoff or info. |
page_status | string (JSON) | The page's status per part: ok, fix or missing. |
page_triggers | string (JSON) | [{rule, words[]}] - the sign-off words the page found. |
page_verdict | string | The page's own reading: ready, tweak or rethink. |
notes | string | Optional, the person's own 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 FairKit.parseReply, then normalize, then check it with check(reply, state), which returns {rows, notes}: one row per part (the reply's status, or the page's own reading when the reply skipped the part or called a missing part fair) and the notes (ok, info, warn, bad).
Worked examples
Replies produced against the app's prompt for the sample plans (made-up plans; 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: precheck - Sugar in warm water - ready
Input:
{
"task": "precheck",
"today": "2026-10-19",
"grade": "6-8",
"due": "2026-11-13",
"plan": "{\"title\":\"The Speedy Sugar Test\",\"question\":\"Does the temperature of water change how fast sugar dissolves?\",\"hypothesis\":\"If the water is warmer, then the sugar will dissolve faster, because the water molecules move faster and bump into the sugar more often.\",\"change\":\"water temperature (10, 22, 40, 55 °C)\",\"measure\":\"time for the sugar to fully dissolve, in seconds, with a stopwatch\",\"same\":[\"5 g of granulated sugar each time\",\"200 mL of tap water\",\"the same clear glass cup\",\"stirring once per second with the same spoon\",\"the same person timing\"],\"control\":\"room-temperature water at 22 °C\",\"levels\":[\"10 °C\",\"22 °C\",\"40 °C\",\"55 °C\"],\"materials\":[\"kitchen scale\",\"measuring cup\",\"thermometer\",\"stopwatch\",\"spoon\",\"clear cup\",\"granulated sugar\",\"ice\",\"kettle with an adult\"],\"procedure\":[\"Measure 200 mL of water and bring it to the test temperature (ice for 10 °C; an adult warms water for 40 and 55 °C).\",\"Check the temperature with the thermometer.\",\"Weigh 5 g of sugar on the scale.\",\"Pour the sugar in and start the stopwatch.\",\"Stir once per second until no crystals are left, then stop the stopwatch.\",\"Write the time in the data table, empty and rinse the cup.\",\"Do 5 trials at each temperature.\"],\"trials\":\"\",\"data\":\"a table with 5 trials for each temperature, the average time for each, and a line graph of temperature vs. average time.\",\"safety\":\"an adult handles the kettle and the warm water; wipe up spills right away.\",\"other\":\"\"}",
"facts": "{\"levels_n\":4,\"trials_n\":5,\"total_runs\":20,\"same_n\":5,\"steps_n\":7,\"materials_n\":9,\"days_to_due\":25}",
"page_reads": "[]",
"page_status": "{\"question\":\"ok\",\"hypothesis\":\"ok\",\"variables\":\"ok\",\"procedure\":\"ok\",\"trials\":\"ok\",\"data\":\"ok\",\"safety\":\"ok\"}",
"page_triggers": "[]",
"page_verdict": "ready",
"notes": ""
}
Reply:
{
"task": "precheck",
"title": "The Speedy Sugar Test",
"verdict": "ready",
"summary": "This is ready for approval: it changes one thing on purpose, \"water temperature (10, 22, 40, 55 °C)\", measures \"time for the sugar to fully dissolve, in seconds, with a stopwatch\", and backs the prediction with 5 trials at each of 4 temperatures plus a table and a line graph. Five things are held constant and \"room-temperature water at 22 °C\" gives a clear control to compare against. The one small polish before you hand it in: pull the trial count out of the procedure and data notes into its own \"Trials: 5 per temperature\" line, so a judge can see it at a glance instead of hunting for it.",
"checks": [
{
"part": "question",
"status": "ok",
"says": "Does the temperature of water change how fast sugar dissolves?",
"why": "It asks how changing one thing (water temperature) affects another (dissolve time), and it can be answered just by timing trials - that's a real test, not a demonstration.",
"suggest": ""
},
{
"part": "hypothesis",
"status": "ok",
"says": "If the water is warmer, then the sugar will dissolve faster",
"why": "It's an if-then prediction with a reason (faster-moving molecules), and the stopwatch gives a measurement that could prove it wrong.",
"suggest": ""
},
{
"part": "variables",
"status": "ok",
"says": "water temperature (10, 22, 40, 55 °C)",
"why": "Only temperature changes on purpose, dissolve time is measured in seconds with a stopwatch, five things stay the same, and the 22 °C water is a clear control.",
"suggest": ""
},
{
"part": "procedure",
"status": "ok",
"says": "Weigh 5 g of sugar on the scale.",
"why": "Seven numbered steps with real amounts (5 g, 200 mL) and clear stop/start points - a classmate could follow it and get the same setup.",
"suggest": ""
},
{
"part": "trials",
"status": "ok",
"says": "Do 5 trials at each temperature.",
"why": "Five trials across four temperature levels (20 runs) is enough repeats and enough levels to see a real pattern instead of noise.",
"suggest": ""
},
{
"part": "data",
"status": "ok",
"says": "a table with 5 trials for each temperature...a line graph of temperature vs. average time",
"why": "A table that logs every trial plus an average per temperature, shown as a line graph since temperature is a number line, is exactly how to show whether warmer water dissolves sugar faster.",
"suggest": ""
},
{
"part": "safety",
"status": "ok",
"says": "an adult handles the kettle and the warm water; wipe up spills right away.",
"why": "The main risks here - hot water and spills - are already covered by adult handling and quick cleanup.",
"suggest": ""
}
],
"hypothesis_rewrite": "",
"sign_offs": [],
"next_steps": [
"Add a one-line \"Trials: 5 per temperature\" heading so the count is easy to find.",
"Check your table has a row for each temperature and a column for each of the 5 trials plus an average.",
"Ask your teacher whether adult-supervised kettle use needs any extra form at your fair.",
"..."
],
"judge_questions": [
"Why these four temperatures (10, 22, 40, 55 °C) instead of evenly spaced ones?",
"How did you make sure the water was exactly at the test temperature right before adding the sugar?",
"..."
],
"caveats": [
"Your fair's review committee has the final say on whether kettle use needs its own safety sign-off.",
"Some fairs want the trial count as its own labeled line even when it's clear elsewhere in the plan."
]
}
task: precheck - Paper airplane designs - tweak
Input:
{
"task": "precheck",
"today": "2026-10-19",
"grade": "3-5",
"due": "2026-10-30",
"plan": "{\"title\":\"\",\"question\":\"Which paper airplane design flies the farthest?\",\"hypothesis\":\"I think the dart will be the best.\",\"change\":\"the airplane design (dart, glider, stunt plane)\",\"measure\":\"how far it flies\",\"same\":[\"same paper\"],\"control\":\"\",\"levels\":[\"dart\",\"glider\",\"stunt plane\"],\"materials\":[\"printer paper\",\"measuring tape\",\"masking tape\"],\"procedure\":[\"Fold 3 airplanes from the same paper.\",\"Throw each one in the hallway from the tape line.\",\"Measure where it lands.\",\"Throw each plane 2 times.\"],\"trials\":\"\",\"data\":\"write the distances in my notebook and make a bar graph.\",\"safety\":\"\",\"other\":\"\"}",
"facts": "{\"levels_n\":3,\"trials_n\":2,\"total_runs\":6,\"same_n\":1,\"steps_n\":4,\"materials_n\":3,\"days_to_due\":11}",
"page_reads": "[{\"level\":\"fix\",\"part\":\"hypothesis\",\"code\":\"no_if_then\",\"text\":\"The hypothesis is not in if-then form, so it is hard to tell what result would prove it wrong.\"},{\"level\":\"info\",\"part\":\"hypothesis\",\"code\":\"no_because\",\"text\":\"Adding \\\"because ...\\\" shows the reason for the guess.\"},{\"level\":\"fix\",\"part\":\"hypothesis\",\"code\":\"vague\",\"text\":\"\\\"best\\\" is an opinion word - say what will be measured instead.\"},\"... 5 more\"]",
"page_status": "{\"question\":\"ok\",\"hypothesis\":\"fix\",\"variables\":\"fix\",\"procedure\":\"ok\",\"trials\":\"fix\",\"data\":\"ok\",\"safety\":\"ok\"}",
"page_triggers": "[]",
"page_verdict": "tweak",
"notes": "My brother will help me measure."
}
Reply:
{
"task": "precheck",
"title": "Paper Airplane Design Distance Test",
"verdict": "tweak",
"summary": "This is a tweak, not a rethink: the test changes one thing on purpose (the airplane design) and measures something real, so the core idea is fair. The hypothesis, \"I think the dart will be the best,\" needs an if-then-because form and a measurable word in place of \"best,\" and \"how far it flies\" needs a unit and a tool. The single biggest fix: say how distance will be measured (for example, in centimeters with the measuring tape) and throw each plane at least 3 times instead of 2.",
"checks": [
{
"part": "question",
"status": "ok",
"says": "Which paper airplane design flies the farthest?",
"why": "It asks how one thing (the design) changes another (how far it flies), and distance can be measured, so this is a real test and not a demonstration.",
"suggest": ""
},
{
"part": "hypothesis",
"status": "fix",
"says": "I think the dart will be the best.",
"why": "\"Best\" is an opinion word, not a measurable one, and the sentence is not in if-then form, so there is no clear way a measurement could prove it wrong.",
"suggest": "Rewrite it as 'If the design is a dart, then it will fly farther than the glider and stunt plane, because ...' and finish the because with your own reason."
},
{
"part": "variables",
"status": "fix",
"says": "the airplane design (dart, glider, stunt plane) ... how far it flies",
"why": "Only one thing changes on purpose, which is good, but the measurement has no unit or tool, only one thing is listed as kept the same, and no control or baseline is named.",
"suggest": "Say the distance will be measured in centimeters with the measuring tape, and add two more things you'll keep the same, like the same thrower and the same throwing height."
},
{
"part": "procedure",
"status": "ok",
"says": "Throw each one in the hallway from the tape line.",
"why": "The steps are in order - fold, throw from the same line, measure, repeat - clear enough for a classmate to follow.",
"suggest": ""
},
{
"part": "trials",
"status": "fix",
"says": "Throw each plane 2 times.",
"why": "2 trials per design is below the 3 most fairs expect (5 is safer), so one lucky or unlucky throw could look like a real difference.",
"suggest": "Throw each plane at least 3 times, 5 if there's time, before averaging the distances."
},
{
"part": "data",
"status": "ok",
"says": "write the distances in my notebook and make a bar graph.",
"why": "A bar graph fits comparing the three design categories, and writing each distance down lets the averages per design be found.",
"suggest": ""
},
{
"part": "safety",
"status": "ok",
"says": "",
"why": "Throwing paper airplanes by hand in a hallway has no heat, chemicals, sharp tools or animals involved, so no special safety form is expected here.",
"suggest": ""
}
],
"hypothesis_rewrite": "If the airplane design is a dart, then it will fly farther than the glider and stunt plane, because [your reason].",
"sign_offs": [],
"next_steps": [
"Rewrite the hypothesis in 'If..., then..., because...' form and swap 'best' for a measurable claim.",
"Add a unit and tool to 'how far it flies' - for example, centimeters measured with the measuring tape.",
"List two more things you'll keep the same, like the same thrower and the same throwing height.",
"..."
],
"judge_questions": [
"Why do you think the dart will fly farther than the glider or stunt plane?",
"How did you make sure each throw started from the same spot, height, and angle?",
"..."
],
"caveats": [
"Different fairs may ask for different numbers of trials or sign-off forms - check your fair's rules.",
"This check is advice; your teacher still makes the final approval call."
]
}
task: precheck - Music, fertilizer and tomatoes - rethink, with a sign-off
Input:
{
"task": "precheck",
"today": "2026-10-19",
"grade": "6-8",
"due": "2026-11-13",
"plan": "{\"title\":\"\",\"question\":\"Does music and fertilizer make tomato plants grow better and taste better?\",\"hypothesis\":\"Plants with music and fertilizer will grow better.\",\"change\":\"music and fertilizer\",\"measure\":\"which plants grow better and which tomatoes my friends like\",\"same\":[],\"control\":\"\",\"levels\":[],\"materials\":[],\"procedure\":[\"Grow some tomato plants by the window, play music to some of them and give fertilizer, then have my friends do a taste test of the tomatoes.\"],\"trials\":\"\",\"data\":\"\",\"safety\":\"\",\"other\":\"\"}",
"facts": "{\"levels_n\":0,\"trials_n\":null,\"total_runs\":null,\"same_n\":0,\"steps_n\":1,\"materials_n\":0,\"days_to_due\":25}",
"page_reads": "[{\"level\":\"fix\",\"part\":\"hypothesis\",\"code\":\"no_if_then\",\"text\":\"The hypothesis is not in if-then form, so it is hard to tell what result would prove it wrong.\"},{\"level\":\"fix\",\"part\":\"hypothesis\",\"code\":\"no_because\",\"text\":\"No \\\"because\\\" - most grade 6-12 forms ask for the reason behind the prediction.\"},{\"level\":\"fix\",\"part\":\"hypothesis\",\"code\":\"vague\",\"text\":\"\\\"better\\\" is an opinion word - say what will be measured instead.\"},\"... 10 more\"]",
"page_status": "{\"question\":\"ok\",\"hypothesis\":\"fix\",\"variables\":\"fix\",\"procedure\":\"missing\",\"trials\":\"missing\",\"data\":\"missing\",\"safety\":\"fix\"}",
"page_triggers": "[{\"rule\":\"human\",\"words\":[\"friends\",\"taste test\"]}]",
"page_verdict": "rethink",
"notes": ""
}
Reply:
{
"task": "precheck",
"title": "Music and Fertilizer on Tomato Growth",
"verdict": "rethink",
"summary": "This plan needs a rethink before it goes in for approval. It changes two things at once (\"music and fertilizer\") and measures a result with no unit or tool (\"which plants grow better\"), so a judge couldn't tell what caused what or how it was measured. The single biggest fix is picking just one change to test - music or fertilizer, not both - and saving the other for a second project.",
"checks": [
{
"part": "question",
"status": "ok",
"says": "Does music and fertilizer make tomato plants grow better and taste better?",
"why": "It names a cause and an effect you could in principle measure, which is the right shape for a testable question. The two-change, two-result bundling it has is the variables problem, not the question's form.",
"suggest": ""
},
{
"part": "hypothesis",
"status": "fix",
"says": "Plants with music and fertilizer will grow better.",
"why": "It makes a prediction, good start, but it isn't in \"if ..., then ..., because ...\" form, gives no reason, and \"better\" is an opinion word rather than something you can measure.",
"suggest": "Rewrite it as \"If ..., then ..., because ...\" and swap \"better\" for a measured result, for example height in centimeters."
},
{
"part": "variables",
"status": "fix",
"says": "music and fertilizer ... which plants grow better and which tomatoes my friends like",
"why": "Two things change at once (music and fertilizer), the result has no unit or tool attached, and there is no list of what stays the same or a control plant to compare against.",
"suggest": "Pick one change to test - for example keep fertilizer the same for every plant and only vary whether they hear music - and measure growth in centimeters with a ruler."
},
{
"part": "procedure",
"status": "missing",
"says": "",
"why": "The one line runs several actions together (grow, play music, give fertilizer, taste test) with no numbered steps, amounts, or times, so a classmate couldn't repeat it.",
"suggest": "Break it into numbered steps with amounts and timing, for example how many plants, how much fertilizer, and how long the music plays each day."
},
{
"part": "trials",
"status": "missing",
"says": "",
"why": "No number of trials is given and no levels of the change (like music vs. no music) are listed, so it isn't clear how many times each setup gets tested.",
"suggest": "Pick at least two levels (music vs. no music) and test each one at least 3 times - 5 is safer."
},
{
"part": "data",
"status": "missing",
"says": "",
"why": "There is no mention of where measurements will be written down or how they will be summarized - no table, average, or graph.",
"suggest": "Add a data plan: a table for each trial's result, an average per setup, and a bar or line graph that fits the measurement."
},
{
"part": "safety",
"status": "fix",
"says": "have my friends do a taste test",
"why": "Friends are being used as test subjects in a taste test, which usually needs a sign-off before testing starts, and the plan has no safety section at all.",
"suggest": "Add a short safety note and ask your teacher for the human-participants/taste-test form before anyone tastes anything."
}
],
"hypothesis_rewrite": "If tomato plants hear music while their fertilizer stays the same, then they will grow more than plants that do not hear music, because [your reason].",
"sign_offs": [
{
"rule": "human",
"why": "The plan has friends taste-test the tomatoes (\"have my friends do a taste test\"), which makes them test subjects.",
"before": "Ask your teacher for the human-participants/taste-test sign-off form and get it signed before anyone tastes anything - check your fair's rules."
}
],
"next_steps": [
"Pick one change to test, music or fertilizer, and save the other for a second project.",
"Rewrite the hypothesis as \"If ..., then ..., because ...\" with a measurable result instead of \"better\".",
"List what stays the same (pot size, water amount, sunlight, soil) and name a control plant with no music.",
"..."
],
"judge_questions": [
"How did you make sure only one thing (music or fertilizer) was different between your plant groups?",
"What unit or tool did you use to say one plant grew or tasted \"better\" than another?",
"..."
],
"caveats": [
"Sign-off rules differ by fair - check yours for a taste test with friends.",
"Your teacher's approval is the final call on this plan."
]
}
Build the input yourself
Load the script in node (global.window = {}; require("./fairkit.js")), then:
const K = FairKit;
const st = K.state({
grade: "6-8", due: "2026-11-13", notes: "", today: "2026-10-19",
plan: "Question: Does salt change how fast ice melts?\nHypothesis: If ..., then ..., because ...\n" +
"What I change: salt (0, 5, 10 g)\nWhat I measure: minutes to melt, with a timer\nTrials: 5"
});
const { input } = K.buildInput(st, { today: "2026-10-19" });
const wire = K.toWire(input); // POST this to /estimate and /run
// later: const reply = K.normalize(K.parseReply(job.output.output));
// const { rows, 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://fair-test.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://fair-test.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"]
// fair-test.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://fair-test.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://fair-test.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
}
// FairTest.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 FairTest {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://fair-test.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://fair-test.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://fair-test.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://fair-test.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":"fair-test"}' | 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": "fair-test"})["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: "fair-test" })).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": "fair-test"}, 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", "fair-test"), 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: "fair-test" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "fair-test"])["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 = "fair-test" }))!["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": "precheck", "today": "2026-10-19", "grade": "6-8", "due": "2026-11-13", "plan": "{...}", "facts": "{...}", "page_reads": "[...]", "page_status": "{...}", "page_triggers": "[...]", "page_verdict": "tweak"}
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": "precheck", "today": "2026-10-19", "grade": "6-8", "due": "2026-11-13", "plan": "{...}", "facts": "{...}", "page_reads": "[...]", "page_status": "{...}", "page_triggers": "[...]", "page_verdict": "tweak"}
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: "precheck", today, grade, due, plan, facts, page_reads, page_status, page_triggers, 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 fair-test:precheck:<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="fair-test:precheck:$(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 = "fair-test:" + 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 = `fair-test:${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 := "fair-test:" + 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 = "fair-test:" + 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 = "fair-test:#{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 = "fair-test:" . $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 = $"fair-test:{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: fair-test:precheck:$(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": "precheck", "today": "2026-10-19", "grade": "6-8", "due": "2026-11-13", "plan": "{...}", "facts": "{...}", "page_reads": "[...]", "page_status": "{...}", "page_triggers": "[...]", "page_verdict": "tweak"}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "fair-test:precheck:" + 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": `fair-test:precheck:${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", "fair-test:precheck:"+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", "fair-test:precheck:" + 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" => "fair-test:precheck:#{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: fair-test:precheck:" . 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", $"fair-test:precheck:{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);