← SFT Desk / API
Tokens

Drive SFT Desk from your own code

The page reads the rows of one fine-tuning dataset in the browser for free — JSONL (one JSON object per line) or one JSON array, up to 50,000 rows and 12 million characters, plus the eval split if you paste it — and works out each row's shape: ChatML messages, ShareGPT conversations (from/value), instruct (instruction/output), prompt/completion, preference pairs (prompt/chosen/rejected), KTO (prompt/completion/label), prompt-only rows and flat text. It holds the rows to the training method you pick (SFT, DPO, ORPO, KTO, GRPO) or the one the main shape implies, estimates each row's length at about four characters per token, compares the prompts with the eval split, and runs 19 deterministic checks drawn from the dataset-curation skill (catalogue ids DC-01..DC-19, plus DC-20 for context): lines that are not JSON, rows shaped for another method or mixed shapes, conversations with no assistant turn or an unknown role, turn order, empty turns and responses, flat text or chat-template markers typed into the content (which defeat assistant-only loss), ShareGPT rows not converted, exact and near duplicates, one prompt trained toward different answers, train prompts that are also in the eval split, rows longer than max_length, SFT sets under about 1,000 rows, real data under 25%, refusals and boilerplate, preference, KTO and GRPO rows that cannot train, personal data and credentials, canned answers and system prompts that vary. Nothing is uploaded for that: every flag is a reading of the text.

Its two paid steps are available over HTTP. You send one JSON object with task set to "audit" or "card", a redacted sample of the rows as rows and the page's check as a facts string, and the model replies with one JSON object: an audit that rates the four areas (format, template, content, mix), answers every flag, quotes each finding from a sampled row and names the rows to drop, or a dataset card with the six fields the run is gated on, the phase exit checklist and the card as Markdown.

Base URL: https://api.skillsafe.ai/v1/app-api. Every call sends Content-Type: application/json and, once you have a token, Authorization: Bearer <token>. The paths below (/guest, /me, /estimate, /run, /jobs/{job_id}, /run-stream) are relative to it, exactly as sdk.js calls them. A plain-text summary of the app for agents is at /llms.txt. Both lanes run on the gpt-terra model. A run needs a signed-in (user) token and is metered: the platform places a hold on your credits when the run starts and then charges only what the run used. /estimate is free.

The response envelope

Responses are JSON of the form {"ok": ..., "data": ..., "error": {...}}. The page's SDK (sdk.js) decides success from the HTTP status, not from ok: on a 2xx it returns data; otherwise it raises an error whose message is error.message (or the HTTP status text when the body carries none), with status, code (error.code) and details (error.details) attached. A body that is not JSON is treated as {}.

{ "ok": true,  "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "details": { ... } } }

Error codes

Branch on the HTTP status and error.code, show error.message. The page handles 401 and 402 by name; the rest reach it as a generic failure.

HTTPerror.codeWhat it means / what to do
400VALIDATION_ERRORThe body is not a JSON object, a required field (task, rows, facts) is missing, or a declared field has the wrong type. Every field is a string; sending facts or audit_handoff as an object, or rows as an array, is the usual cause.
401UNAUTHORIZEDNo token, or one that expired or was revoked. The page forgets the signed-in user and says “Your session expired - sign in again.” Get a new token.
402INSUFFICIENT_CREDITSThe balance cannot cover the run. The page says “Not enough credits for this run.” with a top-up link, and tries never to submit into a 402: it disables the run button when the /me balance is below the estimate's min_credits.
404NOT_FOUNDA wrong path, or a job id that does not belong to this token.
409CONFLICTAn Idempotency-Key replayed with a different body. Send the original body, or use a new key (the page bumps its :a<attempt> suffix for a retry).
429RATE_LIMITEDToo many requests. Back off with a delay; do not tight-loop, and never resend a metered run without its original Idempotency-Key.

Failures that do not arrive as an HTTP status:

WhatWhat happens
event: error on /run-streamThe SDK raises an error carrying the event's message, code and job_id.
Job status: "failed"Polling stops; the job's error field says why.
Polling past 180 sThe SDK's own client-side limit (waitForJob, 1 s interval, 180 000 ms default): it throws “job timed out”. The job itself is not cancelled.

The task field: two lanes

task routes the run, and it takes exactly two values: "audit" and "card". Each lane has its own reply contract, and a reply never mixes them. The skill's instructions tell the model to do the audit lane when task is missing or anything else (and the page's own Sftkit.buildInput turns any lane that is not "card" into "audit"), so a typo silently gets you an audit: always send one of the two values.

taskWhat it doesverdictUse it when
"audit"Reviews the dataset the way a senior ML engineer reviews training data before launching a run: checks the shape against the method, what the loss will be computed on once the chat template is applied, what repeats, contradicts or leaks, what each sampled answer actually teaches, and whether the mix and the eval split can be trusted. It says what the rows teach and how they are shaped, rates the four areas (format, template, content, mix) as sound, fragile or broken, confirms or dismisses every page flag, adds what rules cannot see (an answer that is wrong or does not answer its prompt, a response that ignores its system prompt, a label leaked into the prompt, a chosen worse than its rejected), quotes each finding from a sampled row, names the rows to drop, and gives a plan — what stops the run or trains on the wrong thing first, then what degrades it, then hygiene — that ends with decoding a few templated rows (and 5-10 packed sequences when packing is on) and writing the dataset card.blocked | needs_work | readyBefore a training run: will the trainer load every line, does the shape fit the method, will assistant-only loss mask what it should, are eval prompts in the train split, what does a sampled answer teach that it should not, and which rows should go.
"card"Writes the dataset card the run is gated on: exactly six fields, in order — provenance, counts, synthetic_ratio, dedup, template, packing — each complete, partial or missing with its value, its source (page, note, audit, page+note, none) and what is still needed; the six-item phase exit checklist (format, template_first, loss_mask, packing_inspected, real_floor, card_complete: pass, fail or unknown); and the whole card as Markdown. It fills each field from the page's facts, your note and the audit handoff, and marks what none of them says instead of guessing it.complete | incompleteWhen the rows are in shape and you need the record: where every row came from, how many rows train, the real share against the 25% floor, how it was deduplicated, which template and which packing config.

The page chains the two: after an audit, “Write the dataset card from this audit” switches to the card lane on the same rows and adds an audit_handoff string (below) with the audit's verdict, headline, findings, the rows it said to drop and the row count once those drops and the page's own clean-up are applied. Over the API that is a second run with task: "card", the same rows and facts, and that string. The page drops the handoff as soon as the rows or the settings are edited. (“Put the clean rows in the box” is the other action: it replaces the paste with the clean export and the audit's drops applied, and runs the free check again; that is a local step, not a run.)

Guards the page applies before a run and the API does not: it refuses to price or run a paste it cannot read (“Nothing pasted yet.”, “No row was read: paste JSONL (one JSON object per line) or one JSON array of objects.”, “No row is a JSON object the page can read.”), and it warns before a run on a dataset that says little (“Only N rows read: the audit can run, but a dataset this small says little. Paste the whole file if you have it.”, for fewer than 5 rows) or that may not be training rows at all (“No row has a shape the page recognises (messages, conversations, instruction/output, prompt/completion, prompt/chosen/rejected, prompt/completion/label, prompt, text): check that these are training rows.”).

Input fields

This is exactly what the page sends (sftkit.js buildInput(lane, A, note, handoffText), passed through mustBeObject; app.js currentInput and run), and what input-schema.json declares. The platform's input schema is scalar-only, so every value is a string. buildInput emits them in the order task, rows, facts, note, audit_handoff:

FieldTypeRequiredMeaning
taskstringyes"audit" or "card" (the lanes above).
rowsstringyesThe sample, one row per line: #<row> <json>, where <row> is the row's line number in the pasted file (its position for a JSON array), redacted and clipped (below). A line #<row> (not JSON) ... shows the start of a line that did not parse. Every rows number and every evidence quote in an audit reply is checked against this text.
factsstringyesThe page's free check, Sftkit.facts(A), JSON-encoded into a string: JSON.stringify(facts) in JavaScript, json.dumps(facts) in Python, and so on. Sending the object itself is a 400. The model is told to parse it. It counts every row; rows is only a sample.
notestringnoWhat the engineer says: the goal, where the rows came from, the model and template, how the trainer is configured (loss masking, packing, the packed-sequence inspection), a question. The page trims it, cuts it at 2000 characters and sends it only when it is not blank. It is context, not evidence about the rows: when it disagrees with facts or rows the model says so. But it is the source for what the rows cannot show — provenance, the template used at inference, the loss mask, the inspection — which is why a card without a note is mostly missing.
audit_handoffstringnocard lane only: a JSON string built from an audit reply on these rows (recon.js cardHandoff, below). buildInput drops it for the audit lane.
retry_notestringnoSent only by the page's automatic reformat retry: when a reply does not parse as JSON (even after the page tries to close a cut-off object), the page runs once more with “Your previous reply was not the single valid JSON object the instructions require (...). Reply again with ONLY the JSON object for task '<lane>' - no prose, no code fences; every array present (empty arrays where there is nothing to say).” A normal call never sends it.

mustBeObject keeps only string values, and drops an empty string except for rows; it is buildInput that leaves a blank note out, which is why an empty note is simply absent. The two shapes, every value a string:

{ "task": "audit", "rows": "#1 {...}\n#2 {...}\n...", "facts": "{\"dataset\":{...},\"flags\":[...],...}", "note": "..." }
{ "task": "card",  "rows": "#1 {...}\n#2 {...}\n...", "facts": "{\"dataset\":{...},\"flags\":[...],...}", "note": "...", "audit_handoff": "{\"verdict\":\"...\",...}" }

The rows string

rows is not the dataset: it is the sample Sftkit.sample(A) picks. First the rows the flags point at (up to two per flag that is not info), then rows spread evenly across the file, until 40 rows (SAMPLE_ROWS); in row order, each line is "#" + row + " " + JSON.stringify(row), and once 5 rows are in, a row that would take the sample past 24,000 characters (SAMPLE_CHARS) is skipped. Up to two lines that are not JSON follow at the end, each as #<row> (not JSON) and its first 160 characters. Before a row is written, every string value in it is:

So the model sees only these rows; the rest of the dataset reaches it only through facts, and it is told to say “in the sampled rows” for anything it judges from them. A row's number is its line in the file (blank lines count), so row 5 is line 5 of your JSONL, not the fifth valid row. Two lines of the helpdesk example below:

#14 {"messages":[{"role":"system","content":"You are the support assistant for Lumaro thermostats. Answer in two to four sentences. Do not share links."},{"role":"user","content":"How do I lock the thermostat so kids cannot change it?"},{"role":"assistant","content":""}]}
#5 (not JSON) {"messages": [{"role": "system", "content": "You are the support assistant for Lumaro thermostats. Answer in two to four sentences. Do not share links."}, {"rol

Send the text you want the reply to quote: every finding's rows must be sampled or listed by a flag the finding carries, and its evidence must be part of one string value of a row it names. The page never sends the rows unredacted, and neither should you: a fine-tuned model can repeat training text, and the reply is not allowed to write an email address either way.

The facts string

facts is produced by the page's free check: Sftkit.analyze(text, options) reads the rows, profiles each one, compares them with the eval split and runs the rules, and Sftkit.facts(A) turns that analysis into the object below (it does not depend on the lane: an audit and a card run of the same rows carry the same facts). options are the four settings next to the paste box and the eval split: method (auto, sft, dpo, orpo, kto, grpo), template (the chat template's name, free text), max_length, packing (on, off, unknown) and evalText. Its top-level keys, in the order built:

KeyContents
datasetform (jsonl or array), rows_read, rows_valid, parse_errors, shape (the main row shape: messages, sharegpt, instruct, prompt_completion, preference, kto, prompt_only, text), shapes (rows per shape), method and method_source (“chosen on the page” or “inferred from the row shape”), template, max_length, packing, tokens_estimated (total, mean, p50, p90, max — estimates, not the model's tokenizer), packing_estimate (packed_sequences, padding_waste), eval (rows, overlap), provenance (the field the rows carry — source, origin, synthetic ... — with real, synthetic, unmarked and real_share in percent), exact_duplicates, near_duplicates, system_prompts, kto_labels.
flagsEvery flag the page raised: uid (F1, F2, ... in severity order), id (DC-01..DC-20), sev (high | medium | low | info), rows (up to 20 row numbers; [] for a flag about the whole dataset), count (every row it covers), text, detail. An audit must answer every non-info flag by its uid.
sentrows (the row numbers in the rows string, including the lines that are not JSON), count (valid rows sent), of (valid rows in the file), not_json.
verdict_candidateThe audit verdict the flags point at (the page's own reading): blocked with any high flag, needs_work with any medium, otherwise ready.
cardThe dataset-card facts the page can compute: counts (rows_valid, rows_after_clean — the rows the clean export keeps —, parse_errors, eval_rows, eval_overlap), dedup (the page's exact method, exact_duplicates, near_duplicates_flagged, semantic: "not run by the page"), synthetic_ratio (from the provenance field, or null), template, packing (packing, max_length, estimated_packed_sequences).
notesPlain sentences: how many rows were profiled and sent, what was redacted and clipped, and what was not read (a paste over 12 million characters, rows past 50,000).

The simplest source is sftkit.js itself: the page loads it from /sftkit.js, and it also runs in Node (it sets module.exports). Its buildInput is the function the page calls, so this script prints exactly what the page would send as rows and facts, with the facts decoded so you can read them; the step 4 samples compact them back into the page's string byte for byte. Pass the rows file, the settings as JSON and, optionally, the eval split, and redirect the output into a file:

// build-input.js -- run: node build-input.js helpdesk.jsonl '{"method":"auto","template":"qwen2.5 chat template (ChatML)","max_length":2048,"packing":"on"}' helpdesk-eval.jsonl > helpdesk-input.json
// (sftkit.js saved next to it)
const fs = require("fs");
const Sftkit = require("./sftkit.js");
const opts = JSON.parse(process.argv[3] || "{}");                                  // method, template, max_length, packing
if (process.argv[4]) opts.evalText = fs.readFileSync(process.argv[4], "utf8");     // the eval split, optional
const A = Sftkit.analyze(fs.readFileSync(process.argv[2], "utf8"), opts);
if (!A.ok || !A.rows_ok) { console.error(A.errors.join("\n") || "No row is a JSON object the page can read."); process.exit(1); }
const inp = Sftkit.buildInput("audit", A, "", null);                               // rows and facts do not depend on the lane
const facts = JSON.parse(inp.facts);
console.log(JSON.stringify({ rows: inp.rows, facts }, null, 2));
const real = A.flags.filter((f) => f.sev !== "info");
console.error(A.rows_ok + " valid row(s) of " + A.rows_total + " (" + A.shape + ", " + A.method + "); " +
  real.length + " flags (" + A.counts.high + " high, " + A.counts.medium + " medium, " + A.counts.low + " low); verdict_candidate " + A.candidate +
  "; sample: " + facts.sent.count + " row(s) + " + facts.sent.not_json.length + " not JSON");

For the helpdesk example it reports 25 valid row(s) of 26 (messages, sft); 10 flags (4 high, 5 medium, 1 low); verdict_candidate blocked; sample: 25 row(s) + 1 not JSON; for the prefs example (settings {"method":"dpo","template":"llama-3.1 instruct","max_length":1024,"packing":"off"}, no eval file), 16 valid row(s) of 16 (preference, dpo); 0 flags (0 high, 0 medium, 0 low); verdict_candidate ready; sample: 16 row(s) + 0 not JSON. For both worked examples its rows and facts are exactly what the page sends.

Without the page's check. The platform only checks that facts and rows are strings, so a minimal facts works — "{}", or a hand-built one such as {"flags": [], "notes": ["No page check was run; judge the rows directly."]} — and the model still reads the rows you send. But you lose what the check adds: with no flags there is nothing to confirm or dismiss, with no dataset the model knows neither the method, the shape counts, the lengths nor the eval overlap of the rows it did not see, with no sent nothing says which rows are in the sample, and with no card every number in a card has to come from your note. Build it with sftkit.js as above when you can.

The audit_handoff string

Only for task: "card", and only for the rows the audit was about, sent again unchanged. The page builds it from the normalised audit reply res (recon.js normalize, below) and the analysis A of the audited rows, in recon.js cardHandoff:

const drops = Recon.dropsOf(res);                  // every finding's drop rows, first mention wins, in finding order
const c = Sftkit.clean(A, drops);                  // the clean export: not-JSON lines, exact duplicates and the drops left out
JSON.stringify({ verdict: res.verdict, headline: res.headline,
  findings: res.findings.map(f => ({ id: f.id, severity: f.severity, rows: f.rows.slice(0, 10), title: f.title, fix: f.fix, drop: f.drop })),
  drops: drops.map(d => d.row), rows_after_drops: c.kept })

That is {"verdict", "headline", "findings": [{id, severity, rows, title, fix, drop}], "drops", "rows_after_drops"}. rows_after_drops is the number of rows that will train once the page's clean-up (lines that are not JSON and exact duplicates left out, the first copy kept, ShareGPT rows converted) and the audit's drops are applied; the card's counts field must carry it in place of facts.card.counts.rows_after_clean. The handoff is a previous reply, not evidence: the model checks it against facts, and an open audit (verdict blocked or needs_work) does not change the fields but is named in the card's summary. This script prints the card input in the same shape as build-input.js, so the samples read it the same way:

// handoff.js -- run: node handoff.js helpdesk.jsonl '{"method":"auto","template":"qwen2.5 chat template (ChatML)","max_length":2048,"packing":"on"}' helpdesk-reply.txt helpdesk-eval.jsonl > helpdesk-card-input.json
// (sftkit.js and recon.js saved next to it)
const fs = require("fs");
const Sftkit = require("./sftkit.js");
const Recon = require("./recon.js");
const opts = JSON.parse(process.argv[3] || "{}");
if (process.argv[5]) opts.evalText = fs.readFileSync(process.argv[5], "utf8");
const A = Sftkit.analyze(fs.readFileSync(process.argv[2], "utf8"), opts);                          // the audited rows, same settings
const res = Recon.normalize(Recon.parseResult(fs.readFileSync(process.argv[4], "utf8")), "audit");  // the audit's output.output
const ho = Recon.cardHandoff(A, res);
const inp = Sftkit.buildInput("card", A, "", ho.handoff);
console.log(JSON.stringify({ rows: inp.rows, facts: JSON.parse(inp.facts), audit_handoff: inp.audit_handoff }, null, 2));
console.error(ho.drops + " row(s) to drop; " + ho.kept + " rows after them and the page's clean-up");

Save the helpdesk audit's output.output (below) as helpdesk-reply.txt; it reports 5 row(s) to drop; 19 rows after them and the page's clean-up: rows 14, 23, 25, 16 and 21 go, row 20 (an exact duplicate of row 2) and line 5 (not JSON) were already left out, so 19 of the 25 valid rows remain. The handoff string ends ..."drops":[14,23,25,16,21],"rows_after_drops":19}.

The output contracts

The model is instructed (SKILL.md) to reply with one JSON object and nothing else — no prose, no code fences — with every array of its lane present ([] when empty). The run's output text is that object. In both lanes it must ground everything in rows, facts and note: facts counts every row and rows is a sample, so anything judged from the sample is said to be “in the sampled rows”, and no count, share or pattern for the whole dataset is stated that facts does not give. It never invents a data source, a teacher or base model, a date, a licence, a count, a ratio, a tokenizer or a trainer setting: what the input does not say is unknown, and goes into questions (or, in the card, into a partial or missing field). It never writes personal data or a credential, never tries to recover what [email], [phone] or [credential] replaced, and names a trainer argument (such as assistant_only_loss=True) only when it is sure the argument exists with that name. The 25% real floor is never called met or missed when facts.dataset.provenance is null and the note gives no numbers, and the ~1,000-row floor is for SFT only: no finding is raised about the size of a DPO, ORPO, KTO or GRPO set. Every page flag whose sev is not info is answered exactly once (audit lane: in flag_answers), and an info flag is never reported as a finding.

task: "audit"

task: "card"

How the page reads a reply (recon.js parseResult and normalize): it strips a code fence and parses from the first { to the last }. A reply is read as the lane that was asked for, unless it names the other lane and carries that lane's body (a fields array for card, a findings array for audit), and a reply written for the other lane counts as a disagreement. An unknown verdict shows as needs_work or incomplete and counts as a disagreement; an unknown severity becomes medium; ids like DC5 or dc 5 become DC-05; flags keeps only F<n> values; rows and drop keep positive integers (a leading # is ignored); findings with no title, evidence, rows and why are dropped; an unknown field source becomes none; questions is capped at 10 and plan at 30. An audit reply carries 9 sections (verdict, headline, dataset, areas, findings, flag_answers, plan, questions, summary) and a card reply 7 (verdict, headline, fields, checklist, card_markdown, questions, summary), which is what the page counts when it recovers a partial reply.

Worked examples

Both are examples the page ships (the example buttons on the app page; the full rows, settings and eval split are in /example.js, ids helpdesk and prefscard, and the replies in /example-result.js), built exactly as the page builds them: Sftkit.buildInput(lane, Sftkit.analyze(raw, settings), note, null), with the real model reply the page replays for each. The request bodies cut the long rows and facts strings with a visible ..., and the replies are pretty-printed excerpts, with ... where entries were left out. Every value shown is copied from the input or the reply. The products, people and rows are invented; the one customer email in the helpdesk rows was redacted by the page to [email] before it was sent.

Audit: a thermostat support-bot SFT set with a broken line, a pre-rendered row, an empty answer, eval prompts in the train split and a customer's email

26 lines of JSONL for an SFT run of a small Qwen2.5 instruct model, settings {"method":"auto","template":"qwen2.5 chat template (ChatML)","max_length":2048,"packing":"on"}, and a 6-row eval split. 23 rows are ChatML messages with the same system prompt; row 9 is still in ShareGPT from/value shape, row 12 is a flat text field already rendered with ChatML markers, and line 5 is cut off mid-object. Row 14's assistant turn is empty, row 16 is a refusal, row 18 carries a customer's email address, row 20 repeats row 2, row 24 ends on a user turn, and rows 23 and 25 ask prompts that are also in the eval split. The page's free check raises 10 flags (4 high, 5 medium, 1 low) plus the DC-20 context flag; its own reading of the verdict is blocked. The engineer's note: “Support assistant for our Lumaro thermostats: SFT of a small Qwen2.5 instruct model, rows exported from resolved tickets and cleaned up by the support leads. We want to train next week. Is this file ready?”

The rows string is 10,631 characters, 26 lines: all 25 valid rows (the whole file fits the sample) and line 5 as a not-JSON line at the end. The first three lines, three of the flagged rows, and the last line:

#1 {"messages":[{"role":"system","content":"You are the support assistant for Lumaro thermostats. Answer in two to four sentences. Do not share links."},{"role":"user","content":"How do I pair my Lumaro T3 with the app?"},{"role":"assistant","content":"Open the Lumaro app, tap Add device and hold the thermostat's centre button for five seconds until the ring pulses blue. The app finds it over Bluetooth and then asks for your Wi-Fi network. Pairing usually takes under two minutes."}]}
#2 {"messages":[{"role":"system","content":"You are the support assistant for Lumaro thermostats. Answer in two to four sentences. Do not share links."},{"role":"user","content":"My thermostat shows E4. What does that mean?"},{"role":"assistant","content":"E4 means the thermostat cannot read its temperature sensor. Remove the faceplate, check that the small sensor cable is fully seated, and put the faceplate back. If E4 stays on the screen, contact support so we can replace the sensor."}]}
#3 {"messages":[{"role":"system","content":"You are the support assistant for Lumaro thermostats. Answer in two to four sentences. Do not share links."},{"role":"user","content":"Can I set a different temperature for weekends?"},{"role":"assistant","content":"Yes. In the app, open Schedule, tap Weekend and drag the temperature blocks for Saturday and Sunday. The weekday schedule is not changed."}]}
...
#12 {"text":"<|im_start|>system\nYou are the support assistant for Lumaro thermostats. Answer in two to four sentences. Do not share links.<|im_end|>\n<|im_start|>user\nHow do I update the firmware?<|im_end|>\n<|im_start|>assistant\nUpdates install automatically overnight when the thermostat is on Wi-Fi. You can also check for one in Settings, About, Check for update.<|im_end|>"}
...
#18 {"messages":[{"role":"system","content":"You are the support assistant for Lumaro thermostats. Answer in two to four sentences. Do not share links."},{"role":"user","content":"Please update my account, my email is [email] and my thermostat will not connect."},{"role":"assistant","content":"I have noted the address. For the connection, restart the thermostat from Settings, Restart and make sure your router uses 2.4 GHz, which the T3 needs."}]}
...
#21 {"messages":[{"role":"system","content":"You are the support assistant for Lumaro thermostats. Answer in two to four sentences. Do not share links."},{"role":"user","content":"How long do the batteries last in the T3?"},{"role":"assistant","content":"The batteries last about ten years, so you never need to change them."}]}
...
#5 (not JSON) {"messages": [{"role": "system", "content": "You are the support assistant for Lumaro thermostats. Answer in two to four sentences. Do not share links."}, {"rol

Its facts (5,305 characters as a string), decoded, as an excerpt: the dataset block, every flag without its detail sentence, and the card block (the full object also carries sent — rows 1 to 26, count 25 of 25, not_json [5] —, verdict_candidate "blocked" and the two notes):

{
  "dataset": {
    "form": "jsonl",
    "rows_read": 26,
    "rows_valid": 25,
    "parse_errors": 1,
    "shape": "messages",
    "shapes": { "messages": 23, "sharegpt": 1, "text": 1 },
    "method": "sft",
    "method_source": "inferred from the row shape",
    "template": "qwen2.5 chat template (ChatML)",
    "max_length": 2048,
    "packing": "on",
    "tokens_estimated": { "total": 2321, "mean": 93, "p50": 91, "p90": 110, "max": 160 },
    "packing_estimate": { "max_length": 2048, "packed_sequences": 2, "padding_waste": 0.95458984375 },
    "eval": { "rows": 6, "overlap": 2 },
    "provenance": { "field": null, "real": 0, "synthetic": 0, "unmarked": 25, "real_share": null },
    "exact_duplicates": 1,
    "near_duplicates": 0,
    "system_prompts": 1,
    "kto_labels": null
  },
  "flags": [
    {"uid":"F1","id":"DC-01","sev":"high","rows":[5],"count":1,"text":"1 line not valid JSON objects"},
    {"uid":"F2","id":"DC-05","sev":"high","rows":[14],"count":1,"text":"1 row with an empty response"},
    {"uid":"F3","id":"DC-06","sev":"high","rows":[12],"count":1,"text":"1 row as a flat text field among conversation rows"},
    {"uid":"F4","id":"DC-11","sev":"high","rows":[23,25],"count":2,"text":"2 train rows whose prompt is also in the eval split"},
    {"uid":"F5","id":"DC-04","sev":"medium","rows":[24],"count":1,"text":"1 row ending on a user turn"},
    {"uid":"F6","id":"DC-07","sev":"medium","rows":[9],"count":1,"text":"1 row in ShareGPT from/value shape"},
    {"uid":"F7","id":"DC-13","sev":"medium","rows":[],"count":0,"text":"25 rows - under the ~1,000-row floor for SFT"},
    {"uid":"F8","id":"DC-15","sev":"medium","rows":[16],"count":1,"text":"1 response with a refusal or assistant boilerplate (I'm sorry, but I can't)"},
    {"uid":"F9","id":"DC-17","sev":"medium","rows":[18],"count":1,"text":"1 row with an email address"},
    {"uid":"F10","id":"DC-08","sev":"low","rows":[20],"count":1,"text":"1 row that repeats an earlier row exactly (after whitespace and case)"},
    {"uid":"F11","id":"DC-20","sev":"info","rows":[],"count":0,"text":"context"}
  ],
  ...,
  "card": {
    "counts": { "rows_valid": 25, "rows_after_clean": 24, "parse_errors": 1, "eval_rows": 6, "eval_overlap": 2 },
    "dedup": { "method": "exact match after collapsing whitespace and case (page)", "exact_duplicates": 1, "near_duplicates_flagged": 0, "semantic": "not run by the page" },
    "synthetic_ratio": null,
    "template": "qwen2.5 chat template (ChatML)",
    "packing": { "packing": "on", "max_length": 2048, "estimated_packed_sequences": 2 }
  },
  ...
}

Note what the facts say on their own: no row carries a provenance field (provenance.field is null), so the real share is unknown and the model must not call the 25% floor met or missed. The request body (POST /estimate, then POST /run or /run-stream with Idempotency-Key: sft-desk:audit:a7cfca43-4393:a1 as the page sends it):

{
  "task": "audit",
  "rows": "#1 {\"messages\":[{\"role\":\"system\",\"content\":\"You are the support assistant for Lumaro thermostats. Answer in two to four sentences. Do not share links.\"},{\"role\":\"user\",\"content\":\"How do I pair my Lumaro T3 with the app?\"},{\"role\":\"assistant\",\"content\":\"Open the Lumaro app, tap Add device and hold the thermostat's centre button for five seconds until the ring pulses blue. The app finds it over Bluetooth and then asks for your Wi-Fi network. Pairing usually takes under two minutes.\"}]}\n#2 {\"messages\":[{\"role\":\"system...",
  "facts": "{\"dataset\":{\"form\":\"jsonl\",\"rows_read\":26,\"rows_valid\":25,\"parse_errors\":1,\"shape\":\"messages\",\"shapes\":{\"messages\":23,\"sharegpt\":1,\"text\":1},\"method\":\"sft\",\"method_source\":\"inferred from the row shape\",\"template\":\"qwen2.5 chat template (ChatML)\",\"max_length\":2048,\"packing\":\"on\",\"tokens_estimated\":{\"total\":2321,\"mean\":93,\"p50\":91...",
  "note": "Support assistant for our Lumaro thermostats: SFT of a small Qwen2.5 instruct model, rows exported from resolved tickets and cleaned up by the support leads. We want to train next week. Is this file ready?"
}

The reply (output.output), an excerpt: the verdict, the headline, the dataset sentence, the four areas, the empty-response finding, the eval-overlap finding, the finding the page did not flag (two rows that contradict each other), the first two flag answers, the last two plan steps, the first question and the summary:

{
  "task": "audit",
  "verdict": "blocked",
  "headline": "Blocked: a malformed line, an empty response, a pre-rendered flat-text row and a train/eval overlap must be fixed before these 25 Lumaro support rows can train.",
  "dataset": "25 valid rows of single- and multi-turn Lumaro thermostat support conversations (role/content messages, one ShareGPT row, one pre-rendered flat-text row) meant to teach a Qwen2.5 assistant to answer setup, troubleshooting and account questions in two to four sentences.",
  "areas": [
    {
      "area": "format",
      "status": "broken",
      "note": "Line 5 fails to parse, row 14's assistant turn is empty, row 24 ends on an unanswered user turn, and row 9 is still in ShareGPT shape."
    },
    {
      "area": "template",
      "status": "broken",
      "note": "Row 12 is a pre-rendered ChatML text field, which defeats assistant-only loss masking for that row."
    },
    {
      "area": "content",
      "status": "broken",
      "note": "In the sampled rows, row 21 gives a battery-life answer that contradicts row 4, row 16 is a refusal, row 18 carries a redacted email, and row 20 exactly repeats row 2."
    },
    {
      "area": "mix",
      "status": "broken",
      "note": "Two train rows (23, 25) also sit in the eval split, the set is far under the ~1,000-row SFT floor, and no row carries a provenance field so the real-data share is unmeasured."
    }
  ],
  "findings": [
    ...,
    {
      "id": "DC-05",
      "flags": ["F2"],
      "rows": [14],
      "severity": "high",
      "area": "format",
      "title": "Row 14 has an empty assistant response",
      "evidence": "How do I lock the thermostat so kids cannot change it?",
      "why": "An empty assistant turn teaches the model to emit nothing for this kind of question.",
      "fix": "Write a real answer describing the child-lock steps, or drop the row.",
      "drop": [14]
    },
    ...,
    {
      "id": "DC-11",
      "flags": ["F4"],
      "rows": [23, 25],
      "severity": "high",
      "area": "mix",
      "title": "Rows 23 and 25 leak into the eval split",
      "evidence": "How do I change from Celsius to Fahrenheit?",
      "why": "Evaluating on a prompt the model was also trained on measures memorisation, not the skill, inflating the reported eval score.",
      "fix": "Remove rows 23 and 25 from the train split (or their matches from eval) and record the overlap as closed in the card.",
      "drop": [23, 25]
    },
    ...,
    {
      "id": "DC-10",
      "flags": [],
      "rows": [4, 21],
      "severity": "high",
      "area": "content",
      "title": "Rows 4 and 21 give contradictory battery-life answers",
      "evidence": "The batteries last about ten years, so you never need to change them.",
      "why": "In the sampled rows, row 4 says the T3's two AA batteries last about a year and describes the customer changing them, while row 21 (a near-identical question) says batteries last ten years and are never changed. Trained on both, the model could confidently give customers the wrong, ten-year answer.",
      "fix": "Remove or rewrite row 21 to match the accurate ~1-year AA battery life in row 4; do not train on both as if they are both correct.",
      "drop": [21]
    }
  ],
  "flag_answers": [
    { "flag": "F1", "status": "confirmed", "reason": "Line 5 is a truncated, unparseable fragment." },
    { "flag": "F2", "status": "confirmed", "reason": "Row 14's assistant content is an empty string." },
    ...
  ],
  "plan": [
    ...,
    "Decode and read 5-10 packed sequences before the full run, since packing is on.",
    "Grow the set toward the ~1,000-row SFT floor over time, then write the dataset card."
  ],
  "questions": [
    "No row carries a provenance field, so the 25% real-data floor cannot be measured: can you confirm none of these 25 rows were synthetically generated or paraphrased, so the real share can be recorded rather than left unknown?",
    ...
  ],
  "summary": "This file is not ready to train. In the sampled rows alone there are four run-blocking issues (an unparseable line, an empty response, a flat-text row that would leak loss across the whole sequence, and two train rows duplicated in the eval split) plus a factual contradiction between two battery-life answers, an unconverted ShareGPT row, a refusal, an exposed email placeholder and one exact duplicate. Fix the format and template issues first, then the eval overlap and the contradictory answer; separately, 25 rows is far under the ~1,000-row SFT floor and the real/synthetic mix is completely unmeasured, so the 25% collapse-guard floor cannot yet be confirmed met."
}

The reply has 11 findings and confirms all 10 flags. Ten carry one page flag each, in flag order: DC-01 (F1, line 5), DC-05 (F2, row 14), DC-06 (F3, row 12: keep it as messages), DC-11 (F4, rows 23 and 25), DC-04 (F5, row 24), DC-07 (F6, row 9, which the clean export converts), DC-13 (F7, no rows), DC-15 (F8, row 16), DC-17 (F9, row 18: replace the real address in the source file) and DC-08 (F10, row 20, which the clean export already leaves out). The eleventh, DC-10 with flags: [], is what the rules could not see: rows 4 and 21 answer the same battery question two different ways. Five rows are dropped in all — 14, 23, 25, 16 and 21 — and every other fix is an edit, a conversion or a note for the source file, which is why the handoff above says 19 rows remain. The second question asks whether the assistant in row 18 should really record account changes from an unverified chat.

Card: the dataset card for a DPO set of engineering Q&A pairs, every field documented

16 lines of JSONL, each a preference pair {"prompt", "chosen", "rejected", "source"}, with settings {"method":"dpo","template":"llama-3.1 instruct","max_length":1024,"packing":"off"} and no eval split in the box. Eight rows carry "source":"human-annotated" and eight "source":"synthetic", so the page measures the real share itself (50%). The free check raises no flag but the DC-20 context flag; its reading of the verdict is ready. This card runs without an audit before it, so it carries no audit_handoff; everything the rows cannot show comes from the engineer's note, which is long because the card needs it:

“Dataset card for the engineering-answers DPO set. Provenance: the 8 rows marked human-annotated were written by our annotation team (for each prompt one engineer wrote the chosen answer and a deliberately vague or wrong rejected answer, and a second engineer reviewed the pair; August 2026 batch); the 8 rows marked synthetic have a chosen answer written by our in-house teacher checkpoint helio-70b-sft-v3 via rejection sampling (four candidates per prompt, judge-filtered) and a rejected answer sampled from the base model. Template: the Llama 3.1 Instruct chat template from the tokenizer, the same string at inference and in our eval harness. Loss: DPOTrainer computes the loss on the chosen and rejected completion tokens only; prompt tokens are masked. Packing is off, max_length 1024. Dedup: exact match, then a semantic pass at 0.92 cosine similarity, before export. No eval split in this file: the eval set is a separate held-out file of 200 prompts.”

The rows string is 7,104 characters, 16 lines (every row). The first three:

#1 {"prompt":"Explain what an index does in a database.","chosen":"An index is a sorted structure, usually a B-tree, that maps column values to the rows that hold them. A query that filters or sorts on an indexed column can jump to the matching rows instead of reading the whole table. The cost is extra storage and slower writes, because every insert and update must also update the index.","rejected":"An index makes the database faster. You should add indexes to all your columns.","source":"human-annotated"}
#2 {"prompt":"What is the difference between a process and a thread?","chosen":"A process has its own memory space; threads are units of execution inside a process and share its memory. Starting a thread is cheaper than starting a process, and threads can share data directly, which is why they need locks, while processes communicate through pipes, sockets or shared memory set up on purpose.","rejected":"A process and a thread are the same thing; the words are used interchangeably.","source":"human-annotated"}
#3 {"prompt":"Why does my Python loop over a list of a million numbers run slowly?","chosen":"Each iteration runs interpreted bytecode and works on boxed number objects, so a million iterations add up. Move the work into built-ins or a vectorised library: sum(values) instead of a manual loop, or NumPy for arithmetic on whole arrays, which runs the loop in compiled code.","rejected":"Python is slow. Rewrite the program in C.","source":"synthetic"}
...

Its facts (1,790 characters as a string), decoded, as an excerpt: the parts of dataset the card reads, the one flag without its detail, and the whole card block (the full object also carries the shape counts, the token and packing estimates, sent — rows 1 to 16 —, verdict_candidate "ready" and the two notes):

{
  "dataset": {
    "form": "jsonl",
    "rows_read": 16,
    "rows_valid": 16,
    "parse_errors": 0,
    "shape": "preference",
    ...,
    "method": "dpo",
    "method_source": "chosen on the page",
    "template": "llama-3.1 instruct",
    "max_length": 1024,
    "packing": "off",
    ...,
    "eval": { "rows": 0, "overlap": 0 },
    "provenance": { "field": "source", "real": 8, "synthetic": 8, "unmarked": 0, "real_share": 50 },
    "exact_duplicates": 0,
    ...
  },
  "flags": [
    {"uid":"F1","id":"DC-20","sev":"info","rows":[],"count":0,"text":"context"}
  ],
  ...,
  "card": {
    "counts": { "rows_valid": 16, "rows_after_clean": 16, "parse_errors": 0, "eval_rows": 0, "eval_overlap": 0 },
    "dedup": { "method": "exact match after collapsing whitespace and case (page)", "exact_duplicates": 0, "near_duplicates_flagged": 0, "semantic": "not run by the page" },
    "synthetic_ratio": { "field": "source", "real": 8, "synthetic": 8, "unmarked": 0, "real_share": 50 },
    "template": "llama-3.1 instruct",
    "packing": { "packing": "off", "max_length": 1024, "estimated_packed_sequences": 2 }
  },
  ...
}

The request body (Idempotency-Key: sft-desk:card:da601ea3-2876:a1):

{
  "task": "card",
  "rows": "#1 {\"prompt\":\"Explain what an index does in a database.\",\"chosen\":\"An index is a sorted structure, usually a B-tree, that maps column values to the rows that hold them. A query that filters or sorts on an indexed column can jump to the matching rows instead of reading the whole table. The cost is extra storage and slower writes, because every insert and update must also update the index.\",\"rejected\":\"An index makes the database faster. You should add indexes to all your columns.\",\"source\":\"human-annotated\"}\n#2 {\"pr...",
  "facts": "{\"dataset\":{\"form\":\"jsonl\",\"rows_read\":16,\"rows_valid\":16,\"parse_errors\":0,\"shape\":\"preference\",\"shapes\":{\"preference\":16},\"method\":\"dpo\",\"method_source\":\"chosen on the page\",\"template\":\"llama-3.1 instruct\",\"max_length\":1024,\"packing\":\"off\",\"tokens_estimated\":{\"total\":1475,\"mean\":92,\"p50\":92,\"p90\":103,\"max\":105},\"packing_estimat...",
  "note": "Dataset card for the engineering-answers DPO set. Provenance: the 8 rows marked human-annotated were written by our annotation team (for each prompt one engineer wrote the chosen answer and a deliberately vague or wrong rejected answer, and a second engineer reviewed the pair; August 2026 batch); ... No eval split in this file: the eval set is a separate held-out file of 200 prompts."
}

(The note is shown cut at the ...; the request carries the whole note quoted above.) With an audit before it, the same body would add "audit_handoff", the string handoff.js prints. The reply (output.output), an excerpt: the verdict, the headline, the first three fields and the last, the first three checklist items and the last, the start of the Markdown card, the question and the summary:

{
  "task": "card",
  "verdict": "complete",
  "headline": "All six dataset-card fields are complete and the phase 2 exit checklist passes for this 16-row DPO preference set.",
  "fields": [
    {
      "field": "provenance",
      "status": "complete",
      "value": "16 rows total. 8 rows (source: human-annotated) were written by the annotation team: for each prompt one engineer wrote the chosen answer and a deliberately vague or wrong rejected answer, reviewed by a second engineer (August 2026 batch) - counted as real. 8 rows (source: synthetic) have a chosen answer from the in-house teacher checkpoint helio-70b-sft-v3 via rejection sampling (four candidates per prompt, judge-filtered), with the rejected answer sampled from the base model.",
      "source": "page+note",
      "needs": ""
    },
    {
      "field": "counts",
      "status": "complete",
      "value": "16 rows read, 16 valid (rows_valid=16), 0 parse errors. 16 rows will train after the clean export (rows_after_clean=16). No eval-split rows are in this file (eval_rows=0); the note says the eval set is a separate held-out file of 200 prompts.",
      "source": "page",
      "needs": ""
    },
    {
      "field": "synthetic_ratio",
      "status": "complete",
      "value": "real_share = 50% (8 of 16 rows real, 8 of 16 synthetic, field \"source\"). The 8 human-annotated rows count as real; the 8 synthetic rows (teacher-generated chosen, base-model-sampled rejected) count as synthetic. 50% is above the 25% real-data floor.",
      "source": "page",
      "needs": ""
    },
    ...,
    {
      "field": "packing",
      "status": "complete",
      "value": "Packing is off; max_length is 1024. (For reference only: the page estimates about 2 packed sequences and ~91% padding waste at max_length 1024 if packing were ever turned on - not applicable since packing is off.)",
      "source": "page+note",
      "needs": ""
    }
  ],
  "checklist": [
    { "item": "format", "status": "pass", "reason": "16 preference rows (prompt/chosen/rejected) match the DPO method; no DC-01/02/03/05/16 flag is raised." },
    { "item": "template_first", "status": "pass", "reason": "Rows are kept as structured prompt/chosen/rejected pairs for the trainer to template per example; no DC-06 flag." },
    { "item": "loss_mask", "status": "pass", "reason": "Note states DPOTrainer computes the loss on the chosen and rejected completion tokens only, with prompt tokens masked." },
    ...,
    { "item": "card_complete", "status": "pass", "reason": "All six fields above are complete." }
  ],
  "card_markdown": "# Dataset card: engineering-answers DPO set\n\n## Provenance\n16 rows total, all accounted for. 8 rows (source: human-annotated) were written by the annotation team: ...\n\n## Phase 2 exit checklist\n- format: pass - 16 preference rows (prompt/chosen/rejected) match the DPO method; no DC-01/02/03/05/16 flag is raised.\n...",
  "questions": [
    "What is the specific base-model checkpoint (name/version) used to sample the rejected answers for the 8 synthetic rows?"
  ],
  "summary": "This is a 16-row DPO preference dataset (prompt/chosen/rejected), split evenly between human-annotated and teacher-generated synthetic rows, using the Llama 3.1 Instruct template with packing off. Provenance, counts, the 50% real/synthetic ratio, dedup (exact + semantic), template and packing are all documented from facts and the note, so all six card fields are complete and the phase 2 exit checklist passes. No audit_handoff was supplied for this run."
}

The two fields left out of the excerpt are dedup (complete, page+note: the page's exact pass found 0 duplicates, then the note's semantic pass at 0.92 cosine similarity) and template (complete, page+note: the llama-3.1 instruct template from the tokenizer, and the note says the same string is used at inference and in the eval harness); the checklist items left out, packing_inspected and real_floor, pass because packing is off and the measured real share is 50%. The verdict is complete because every field is complete and every item passes. Without the note, provenance would have no teacher or method for the synthetic rows, loss_mask would be unknown and the verdict incomplete.

The page checks the reply against the rows; the API does not

What comes back from the API is the model's raw text in output.output. The page holds it to account in the browser before showing it (recon.js reconcile, with the facts, the rows and the handoff it sent, and the analysis of the rows still in the box): it checks every row number and every quote against the sample, every number in a card against its own counts, and re-runs its checks on the rows that are left once the audit's drops are applied. None of that happens in the API. A direct API caller gets the reply unchecked and must do the checking itself.

recon.js also runs in Node, so you can hold a reply to exactly the page's standard. Save /sftkit.js and /recon.js next to this script and pass the file you built the body from (build-input.js or handoff.js output), the reply text and the lane; to re-check the drops as well, add the rows file, the settings and the eval split. It prints every statement and the count, and, for an audit with the rows given, the clean rows with the drops applied:

// recheck.js -- run: node recheck.js helpdesk-input.json helpdesk-reply.txt audit helpdesk.jsonl '{"method":"auto","template":"qwen2.5 chat template (ChatML)","max_length":2048,"packing":"on"}' helpdesk-eval.jsonl > helpdesk-clean.jsonl
// (sftkit.js and recon.js saved next to it; the last three arguments are optional)
const fs = require("fs");
const Sftkit = require("./sftkit.js");
const Recon = require("./recon.js");
const sent = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));                                // rows, facts (and audit_handoff) you sent
const lane = process.argv[4] || "audit";
const res = Recon.normalize(Recon.parseResult(fs.readFileSync(process.argv[3], "utf8")), lane);    // output.output
let A0 = null;                                                                                     // the rows themselves, for the re-check
if (process.argv[5]) {
  const opts = JSON.parse(process.argv[6] || "{}");
  if (process.argv[7]) opts.evalText = fs.readFileSync(process.argv[7], "utf8");
  A0 = Sftkit.analyze(fs.readFileSync(process.argv[5], "utf8"), opts);
}
const r = Recon.reconcile(res, { facts: sent.facts, rows: sent.rows, handoff: sent.audit_handoff || null, A0 });
r.notes.forEach((n) => console.error(n.kind + ": " + n.text));
console.error(r.disagreements + " disagreement(s) in " + r.statements + " statements checked");
if (r.after) r.after.flags.forEach((f) => console.error("still raised: " + f.id + " " + f.sev + " rows " + (f.rows.join(",") || "-") + " " + f.text));
if (r.after) console.log(r.after.text.replace(/\n$/, ""));                                          // the clean rows, drops applied

For the helpdesk audit reply, with the rows given, it reports:

ok: The areas table rates format, template, content and mix once each.
ok: Every one of the 11 findings names rows the page sampled or flagged, and its evidence is in them.
ok: The verdict (blocked) fits the worst finding.
ok: Every one of the page's 10 flags is answered once.
info: With the page's clean-up and the audit's 5 drops, 19 of 25 rows remain; the free check on them raises 1 high, 3 medium (before: 4 high, 5 medium).
ok: The reply writes no email address and repeats nothing the page redacted.
0 disagreement(s) in 5 statements checked
still raised: DC-06 high rows 11 1 row as a flat text field among conversation rows
still raised: DC-04 medium rows 18 1 row ending on a user turn
still raised: DC-13 medium rows - 19 rows - under the ~1,000-row floor for SFT
still raised: DC-17 medium rows 15 1 row with an email address

The rows in the still raised lines are lines of the clean file, not of the original: they are the flat-text row 12, the row 24 that ends on a user turn and the row 18 with the email address, which the audit said to edit rather than drop — the drops and the clean-up close the not-JSON line, the empty answer, the eval overlap, the ShareGPT row, the refusal, the duplicate and the contradictory answer, and the rest is edits for the engineer. Without the rows, the info line says instead “The dataset is not in the box, so the 5 dropped rows were not re-checked.” For the prefs card reply (node recheck.js prefs-input.json prefs-reply.txt card):

ok: The card carries the six required fields, in order.
ok: The counts field carries the page's row counts.
ok: The synthetic/real field carries the page's measured real share (50%).
ok: The exit checklist has its six items, in order.
ok: The verdict (complete) follows the fields and the checklist.
ok: The Markdown card carries every required heading.
ok: The reply writes no email address and repeats nothing the page redacted.
0 disagreement(s) in 7 statements checked

The page does not load a tokenizer or run a trainer. After that, apply the drops and the edits, decode and read a few rows with the model's own chat template (and 5-10 packed sequences when packing is on), check the real token lengths against max_length, and make sure the card's numbers are the ones of the file you actually train on, before you start the run.

Step 1 — a tiny client helper

One function per language that does what sdk.js does for every call: JSON in, bearer token if you have one, data out on a 2xx, an error carrying status, error.code and error.message otherwise. The later steps assume this helper is in the same file (for Go, Java and C#, the later snippets go inside main / the top-level statements; Java's makeInput from step 4 goes in the class). The token itself comes from /tokens.html (step 2).

BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="YOUR_TOKEN"   # step 2: paste your token here

# api METHOD PATH [extra curl args...] -- prints the whole envelope; pipe to jq
api() {
  method="$1"; path="$2"; shift 2
  curl -sS -X "$method" "$BASE$path" \
    -H "Content-Type: application/json" \
    -H "Authorization: Bearer $TOKEN" "$@"
}
# success: jq '.data'      failure: jq '.error | {code, message}'

Step 2 — a token

Two kinds work:

# personal token (needed for a run): copy it from /tokens.html, then
#   put it in TOKEN (in place of YOUR_TOKEN) and skip the rest of this block
# guest token (enough for /me and /estimate): no Authorization header on this one call
TOKEN=$(curl -sS -X POST "$BASE/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"sft-desk"}' | jq -r '.data.token')
echo "guest token: ${TOKEN:0:12}..."

Step 3 — GET /me

Who the token belongs to and what it can spend. sdk.js documents the shape as {subject_type, subject_id, credits, profile?}. The page treats the caller as signed in only when subject_type is "user", shows credits as the balance (1 credit = $0.0001, so it divides by 10 000 for the dollar figure), and compares it with the estimate in step 4 before it lets you run.

api GET /me | jq '.data // .error'

Step 4 — build the input, then POST /estimate

First a small makeInput that does what buildInput does: task, then rows as the sample the page would send, facts as a compact JSON string, the note when it is not blank, and — for the card lane only — the audit_handoff string when the file carries one. It reads the file build-input.js (or handoff.js) printed. The samples build the audit input of the helpdesk example from helpdesk-input.json; step 6 reuses makeInput for the prefs (card) example. Then price it. The body of /estimate is the same input you will send to /run; no job is created and nothing is charged — /estimate is free. From the response the page reads:

FieldMeaning
hold_creditsA reservation, not a price: the worst case the platform holds while the run is in flight. The page shows it as “reserves N credits ($...) for the audit run” (or the card run). What you pay is the job's charged_credits after settlement (step 5). A balance below the hold is allowed, but the page warns that the reply may be cut short (truncated: true).
min_creditsThe smallest balance that can start a run. Below it the page disables the run button and names the shortfall, because the call would be a 402.
sponsor_enabledWhen true, a caller who is not signed in may run; otherwise the page asks them to sign in.
warningsInput warnings. They do not refuse the call; check them before you run.

The page re-prices whenever the rows, the settings, the note or the lane change. The amounts grow with the size of rows and facts (and of the note and the handoff), so read them from the call rather than hard-coding them.

# helpdesk-input.json: printed by build-input.js (see "The facts string").
# (.facts | tojson) turns the facts into ONE compact string, as JSON.stringify does.
BODY=$(jq -c --arg note "Support assistant for our Lumaro thermostats: SFT of a small Qwen2.5 instruct model, rows exported from resolved tickets and cleaned up by the support leads. We want to train next week. Is this file ready?" \
  '{task: "audit", rows, facts: (.facts | tojson), note: $note}' helpdesk-input.json)
# No page check at hand? facts: "{}" is accepted as a string, but the reply then has no page flags to answer.
# Card lane after an audit: read handoff.js's output instead and add its string:
#   '{task: "card", rows, facts: (.facts | tojson), note: $note} + (if .audit_handoff then {audit_handoff} else {} end)'

api POST /estimate -d "$BODY" \
  | jq '.error // (.data | {hold_credits, min_credits, sponsor_enabled, warnings})'

Step 5 — POST /run, then poll the job (audit)

Send the same input to /run with an Idempotency-Key header, using a personal token. Its data carries a job_id. Poll GET /jobs/{job_id} the way sdk.js waitForJob does: every second until status is succeeded or failed, giving up on the client side after 180 seconds. On the finished job, read:

Idempotency key. Resending a request with the same key must not start a second paid run, so derive the key from the input, not from the clock — and include the lane, so an audit and a card run of the same rows never share a key. The page uses sft-desk:<lane>:<inputHash>:a<attempt> (app.js run), where <lane> is audit or card, <inputHash> is a 32-bit djb2 hash of JSON.stringify(input) in hex, a dash, and the string's length in hex (app.js inputHash, over the input without retry_note), and <attempt> is 1 for a normal run and 2 for the automatic reformat retry (which also adds retry_note). For the two worked examples that is sft-desk:audit:a7cfca43-4393:a1 (helpdesk audit) and sft-desk:card:da601ea3-2876:a1 (prefs card). The format is the page's convention, not a requirement: any stable, unique string does. The samples below use the lane and a SHA-256 prefix.

The page itself uses /run-stream (step 6), not /run; both take the same body and header.

KEY="sft-desk:audit:$(printf '%s' "$BODY" | shasum -a 256 | cut -c1-16):a1"

JOB=$(api POST /run -H "Idempotency-Key: $KEY" -d "$BODY" | jq -r '.data.job_id')

for i in $(seq 180); do                       # 1 s interval, 180 s client-side limit
  J=$(api GET "/jobs/$JOB")
  S=$(printf '%s' "$J" | jq -r '.data.status')
  if [ "$S" = "succeeded" ] || [ "$S" = "failed" ]; then break; fi
  sleep 1
done

printf '%s' "$J" | jq '.data | {status, truncated, charged_credits, error}'
printf '%s' "$J" | jq -r '.data.output.output' \
  | jq '{verdict, headline, areas: [(.areas // [])[] | {area, status}],
         findings: [(.findings // [])[] | {id, rows, severity, drop}],
         flag_answers: [(.flag_answers // [])[] | {flag, status}]}'

Step 6 — POST /run-stream (Server-Sent Events, card)

The alternative to step 5, and what the page uses: same kind of body, same Idempotency-Key header, one request. Run one or the other for a given input, not both. To show the second lane, the samples build the card input of the prefs example (with its own key) and stream it; to stream the audit input instead, pass the step 4 input and key. The prefs note is long, so the samples read it from prefs-note.txt (the note quoted above, saved as plain text). The response is text/event-stream. sdk.js splits it into frames on a blank line, takes the event name from an event: line (default message) and concatenates the data: lines, skips frames with no data or with data that is not JSON, and handles these events:

EventDataWhat the SDK does
delta{text}Passes text to onDelta.
jobjob infoPasses it to onJob (the page uses it to advance its progress display).
donethe final payload: {job_id, status, charged_credits, output}Keeps it as the result.
pendinga payloadTreated exactly like done. If it carries a job_id and no output, poll that job as in step 5.
error{message, code, job_id}After the stream ends, raises an error with those fields.

If the response is not text/event-stream, it is an ordinary JSON envelope: the SDK returns its data (an idempotent replay returns this way) or raises the error on a non-2xx status. Handle both shapes.

From the final payload the page reads output.output (falling back to the concatenated delta text), truncated and charged_credits. Browsers receive progress ticks rather than deltas from this endpoint, so the page relies on the final payload; if the stream fails part-way, it tries to recover a partial reply from whatever delta text arrived. The samples print the verdict and headline, each field (field, status, source and what it still needs), each checklist item, each question, and then the Markdown card itself. To hold the card to the page's standard, keep the reply text and run recheck.js with the lane card. To write the card of rows you audited, read handoff.js's output instead of prefs-input.json: makeInput then carries its audit_handoff.

# the card worked example: prefs-input.json from build-input.js, the note in prefs-note.txt
# (for audited rows, read handoff.js's output instead; the + (...) adds its audit_handoff)
CBODY=$(jq -c --rawfile note prefs-note.txt \
  '{task: "card", rows, facts: (.facts | tojson), note: ($note | rtrimstr("\n"))} + (if .audit_handoff then {audit_handoff} else {} end)' \
  prefs-input.json)
CKEY="sft-desk:card:$(printf '%s' "$CBODY" | shasum -a 256 | cut -c1-16):a1"

# -N turns off buffering; the whole stream lands in $STREAM
STREAM=$(api POST /run-stream -N -H "Idempotency-Key: $CKEY" -d "$CBODY")

# frames look like:
#   event: job
#   data: {...}
#
#   event: done
#   data: {"job_id":"...","status":"...","charged_credits":...,"output":{"output":"{\"task\":\"card\",...}"}}

# the final reply text, from the done (or pending) frame:
REPLY=$(printf '%s\n' "$STREAM" \
  | awk '/^event:/{ev=$2} /^data:/ && (ev=="done" || ev=="pending"){sub(/^data: */,""); print}' \
  | jq -r '.output.output')
# an idempotent replay answers with a plain JSON envelope instead: REPLY=$(printf '%s' "$STREAM" | jq -r '.data.output.output')
printf '%s' "$REPLY" | jq '{verdict, headline,
  fields: [(.fields // [])[] | {field, status, source, needs}],
  checklist: [(.checklist // [])[] | {item, status}], questions}'
printf '%s' "$REPLY" | jq -r '.card_markdown'

What the page does that you now do

Or skip all of this and use SFT Desk in the browser: the check, the flags, the length and packing estimate, the dataset-card facts, the exact sample a run would be sent, the clean JSONL export with its drop log, and the Markdown and CSV exports are free.