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.
| HTTP | error.code | What it means / what to do |
|---|---|---|
400 | VALIDATION_ERROR | The 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. |
401 | UNAUTHORIZED | No 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. |
402 | INSUFFICIENT_CREDITS | The 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. |
404 | NOT_FOUND | A wrong path, or a job id that does not belong to this token. |
409 | CONFLICT | An 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). |
429 | RATE_LIMITED | Too 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:
| What | What happens |
|---|---|
event: error on /run-stream | The 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 s | The 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.
task | What it does | verdict | Use 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 | ready | Before 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 | incomplete | When 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:
| Field | Type | Required | Meaning |
|---|---|---|---|
task | string | yes | "audit" or "card" (the lanes above). |
rows | string | yes | The 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. |
facts | string | yes | The 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. |
note | string | no | What 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_handoff | string | no | card 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_note | string | no | Sent 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:
- redacted: email addresses become
[email](addresses atexample.,test.,invalid.andlocalhost.domains are kept), phone numbers become[phone](555 numbers are kept), and the value afterpassword,api_key,secret_key,access_token,auth_token,client_secretand the like becomes[credential]; - clipped: a string over 1,200 characters (
CLIP_STRING) keeps its first 800 and last 300 characters around[... N characters cut by the page ...].
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:
| Key | Contents |
|---|---|
dataset | form (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. |
flags | Every 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. |
sent | rows (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_candidate | The audit verdict the flags point at (the page's own reading): blocked with any high flag, needs_work with any medium, otherwise ready. |
card | The 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). |
notes | Plain 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:"audit".verdict:blocked(any finding is high) |needs_work(the worst is medium) |ready(nothing above low; low findings and questions do not stop a run).headline: one sentence.dataset: one or two sentences — what the rows teach and how they are shaped.areas[]: exactly four entries, in the order format, template, content, mix:{area, status, note}withstatussound|fragile|broken(brokenwhen a high finding sits in that area,fragilefor a medium one; the findings decide the ratings).format: valid JSON, the shape the method takes, roles and turn order, no empty turns, ShareGPT converted, pairs, labels and verifier fields that can train (DC-01 to DC-05, DC-07, DC-16);template: conversations kept as messages for the trainer to template and mask, no flat text or typed markers, the named template, rows withinmax_length, packing (DC-06, DC-12);content: what the sampled answers teach — correctness, refusals, canned answers, duplicates, contradictions, personal data, system prompts (DC-08 to DC-10, DC-15, DC-17 to DC-19);mix: the row floor, the real share, the eval split (DC-11, DC-13, DC-14).findings[]:{id, flags[], rows[], severity, area, title, evidence, why, fix, drop[]}.idis a catalogue id (DC-01..DC-19) orother;flagslists the pageuids it confirms ([]for something the page did not flag — an answer that is factually wrong, a response cut off mid-sentence, a chosen worse than its rejected, a reply in the wrong language);rowsare the rows it is about, fromfacts.sent.rowsor from a flag it carries ([]only for a whole-dataset finding: DC-13, DC-14, a DC-16 or DC-19 flag with no rows, orotherabout the mix);severityishigh(the run cannot start or trains on something wrong: a line that is not JSON, rows shaped for another method, no assistant turn or an unknown role, an empty response, flat text for conversational SFT, train prompts in the eval split, real data under 25%, a chosen identical to its rejected, KTO labels that are not booleans, an answer that is wrong in a way the model would learn as right) |medium(it trains but degrades: turn order, an empty prompt, template markers in the content, ShareGPT not converted, mixed shapes, duplicates, one prompt with different answers, rows overmax_length, under 1,000 SFT rows, refusals, personal data, canned answers, GRPO prompts with no answer) |low(hygiene: a few duplicates, near-duplicates, very short responses, varying system prompts), and a finding that carries a page flag takes that flag's severity unless the sampled rows show the flag overstates it;evidenceis text copied exactly from one sampled row the finding names — part of one string value as a person reads it, at most about 200 characters, never joined from two rows, never a placeholder quoted as data (for an empty response, the prompt it answers; for a line that is not JSON, part of the fragment),""only when none of its rows was sampled;fixis the concrete change.drop[]: the rows this finding says to remove from the training file, each also inrows— only rows that cannot train well as they are and whose fix is not a small edit: a refusal, an empty or cut-off response, a wrong answer, a canned answer, a train row whose prompt is in the eval split. Lines that are not JSON and exact duplicates are left out by the page's clean export anyway, and ShareGPT rows are converted there, so they are never listed.[]when the fix is to edit, convert, re-template or re-generate, or when the finding is about the whole dataset.flag_answers[]:{flag, status, reason}, exactly one per non-info page flag,statusconfirmed(a finding carries that uid) |dismissed(no finding carries it, with the reason).plan[]: the findings in the order to apply them — JSON, shape, empty responses, flat text and masking, eval overlap and the real floor first, then duplicates, contradictions, refusals, lengths and personal data, then hygiene — ending with decoding and reading a few templated rows (and 5-10 packed sequences when packing is on) and writing the dataset card.questions[](plain strings: what the input cannot answer, and anything about the model, the trainer or the plan rather than the rows) andsummary(two to four sentences, answeringnoteif one was sent).
task: "card"
task:"card".verdict:complete(all six fields complete and every checklist item passes) |incomplete.headline: one sentence.fields[]: exactly six, in this order, each{field, status, value, source, needs}.provenance— where every row came from: real sources, and for synthetic rows the teacher or generator and the method, from the note and the provenance values infacts(completeonly when every row's origin is accounted for).counts—rows_valid, androws_after_clean(oraudit_handoff.rows_after_drops) as the rows that will train, and the eval split's rows, written exactly asfactsgives them (completewhenever the page counted rows).synthetic_ratio— the measured ratio,facts.card.synthetic_ratio.real_shareexactly, against the 25% floor, and which rows count as real (general-domain replay rows count), or a ratio the note states and says how it was measured;missingwhen neither gives one.dedup— exact, semantic or both; the page ran an exact pass, and a semantic pass is added only when the note says one ran.template— the exact template string or identifier;completewhen it is named and the note says the same template is used at inference and eval.packing— whether packing is used,max_length, and the 5-10 packed-sequence inspection;completewhen packing is off andmax_lengthis known, or packing is on,max_lengthis known and the note says the inspection was done.valuestates only what the sources say; for apartialormissingfield,needsnames exactly what the engineer must supply.checklist[]: exactly six items, in this order, each{item, status, reason}:format(failon a DC-01, DC-02, DC-03, DC-05 or DC-16 flag above low, or DC-07 rows still unconverted),template_first(passwhen the rows are kept structured for the trainer to template per example and no DC-06 flag is raised),loss_mask(passonly when the note says the loss is masked to assistant turns or completions and no high DC-06 flag is raised; otherwisefailorunknown),packing_inspected(passwhen packing is off or the note says the inspection was done),real_floor(passorfailfrom the known ratio,unknownwhen there is none),card_complete(passwhen all six fields are complete).card_markdown: the whole card, starting# Dataset card(with the dataset's name when the note gives one), then## Provenance,## Counts,## Synthetic/real ratio,## Dedup method,## Template used,## Packing config,## Phase 2 exit checklistand, when the verdict is incomplete,## Open itemslisting eachneeds.questions[]andsummary, as in the audit lane; with an openaudit_handoff, the summary names the audit findings to close before training.
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.
- Both lanes: the reply is the lane you asked for (one written for the other task is a disagreement), it carries a valid verdict for that lane, and it writes no email address and repeats nothing the page redacted (every email address, phone number and credential value the free check found in the rows; addresses at example, test, invalid and localhost domains are allowed).
- Audit: the areas table. Exactly four rows, in the order format, template, content, mix, each with a valid status; an area rated differently from what its findings make it (
brokenfor a high finding,fragilefor a medium one) is pointed out. - Audit: every finding is checked against the sample. Its
idis in the catalogue (DC-01..DC-20, orother; DC-20 is context and never a finding); its area is valid and, for a catalogue id, the area that id belongs to; everyuidit carries is a non-info flag the page raised, and one that carries a high flag at a lower severity is pointed out; every row it names was sampled or is listed by a flag it carries; itsevidence, whitespace collapsed, is part of one string value of a sampled row it names (or of the fragment of a not-JSON line); every row indropis also in itsrows, and a drop of a row the clean export already leaves out is pointed out. A finding with no rows is pointed out unless it is about the whole dataset (DC-13, DC-14, DC-16, DC-19 orother). - Audit: every flag is answered once, consistently, and the verdict fits. Every non-info flag answered exactly once and no other; a
confirmedanswer needs a finding carrying that uid; adismissedone must not be carried by a finding and should give a reason. The verdict must fit the worst severity (blockedwith a high finding,needs_workwith a medium, otherwiseready). - Audit: the drops are applied and re-checked. With the rows still in the box, the page applies its clean-up and every finding's drops (
Sftkit.clean) and runs the free check again on what is left, with the same settings and eval split: it reports how many rows remain and how many high and medium flags are still raised, and points it out when the drops take an SFT set under the ~1,000-row floor. - Card: the six fields and their numbers. Six fields, in order, each with a valid status; a complete field with the source
noneis a disagreement, and a partial or missing field that says nothing about what is needed is pointed out. Thecountsvalue must carry the page'srows_validand the rows that will train (audit_handoff.rows_after_drops, orrows_after_clean); thesynthetic_ratiovalue must carry the page's measuredreal_sharewhen the rows carry a provenance field, and may not be complete without one unless its source is the note; a template may not be named from nowhere; and a completededupfield while exact duplicates remain and the counts do not say they are removed is pointed out. - Card: the checklist agrees with the page's flags. Six items, in order.
formatmay not pass against a DC-01, DC-02, DC-03, DC-05 or DC-16 flag above low or any DC-07 flag;template_firstmay not pass against a DC-06 flag, norloss_maskagainst a high one;real_floormay not pass when the page measures real data under 25%, nor fail when it measures 25% or more;card_completemust pass exactly when all six fields are complete; and the verdict must follow the fields and the checklist. - Card: the Markdown card. It must carry
# Dataset card, the six field headings and## Phase 2 exit checklist, and## Open itemswhen the verdict is incomplete.
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}'
import hashlib, json, time, urllib.parse
import requests # pip install requests
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # step 2: paste your token here
class ApiError(Exception):
def __init__(self, status, code, message, details=None):
super().__init__(f"{status} {code}: {message}")
self.status, self.code, self.details = status, code, details
def api(method, path, body=None, headers=None):
h = {"Content-Type": "application/json"}
if TOKEN:
h["Authorization"] = "Bearer " + TOKEN
h.update(headers or {})
r = requests.request(method, BASE + path, headers=h,
data=None if body is None else json.dumps(body))
try:
env = r.json()
except ValueError:
env = {}
if not r.ok:
err = env.get("error") or {}
raise ApiError(r.status_code, err.get("code"), err.get("message") or r.reason, err.get("details"))
return env.get("data")
// sft-desk.mjs -- Node 18+ (global fetch), run with: node sft-desk.mjs
import { readFileSync } from "node:fs";
import { createHash } from "node:crypto";
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN"; // step 2
async function api(method, path, body, headers = {}) {
const h = { "Content-Type": "application/json", ...headers };
if (TOKEN) h.Authorization = "Bearer " + TOKEN;
const res = await fetch(BASE + path, {
method,
headers: h,
body: body === undefined ? undefined : JSON.stringify(body),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) {
const err = new Error((json.error && json.error.message) || res.statusText);
err.status = res.status;
err.code = json.error && json.error.code;
err.details = json.error && json.error.details;
throw err;
}
return json.data;
}
package main
import (
"bufio"
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"time"
)
const base = "https://api.skillsafe.ai/v1/app-api"
var token = "YOUR_TOKEN" // step 2: paste your token here
type apiError struct {
Status int
Code string
Message string
}
func (e *apiError) Error() string { return fmt.Sprintf("%d %s: %s", e.Status, e.Code, e.Message) }
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func newRequest(method, path string, body any, headers map[string]string) (*http.Request, error) {
var buf []byte
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return nil, err
}
buf = b
}
req, err := http.NewRequest(method, base+path, bytes.NewReader(buf))
if err != nil {
return nil, err
}
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)
}
return req, nil
}
func errorFrom(res *http.Response, env envelope) error {
e := &apiError{Status: res.StatusCode, Message: http.StatusText(res.StatusCode)}
if env.Error != nil {
e.Code = env.Error.Code
if env.Error.Message != "" {
e.Message = env.Error.Message
}
}
return e
}
// api sends body as JSON and decodes the envelope's data into out (out may be nil).
func api(method, path string, body any, headers map[string]string, out any) error {
req, err := newRequest(method, path, body, headers)
if err != nil {
return err
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
var env envelope
_ = json.NewDecoder(res.Body).Decode(&env)
if res.StatusCode < 200 || res.StatusCode > 299 {
return errorFrom(res, env)
}
if out != nil && len(env.Data) > 0 {
return json.Unmarshal(env.Data, out)
}
return nil
}
func main() {
// steps 2-6 go here; remove any import a step you skip no longer uses
_, _, _, _, _ = bufio.NewScanner, sha256.Sum256, hex.EncodeToString, url.PathEscape, strings.Index
_, _ = time.Second, os.ReadFile
}
// SftDesk.java -- Java 17+, needs com.fasterxml.jackson.core:jackson-databind on the classpath
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class SftDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "YOUR_TOKEN"; // step 2: paste your token here
static final HttpClient HTTP = HttpClient.newHttpClient();
static final ObjectMapper JSON = new ObjectMapper();
static class ApiException extends Exception {
final int status;
final String code;
ApiException(int status, String code, String message) {
super(status + " " + code + ": " + message);
this.status = status;
this.code = code;
}
}
static HttpRequest.Builder request(String method, String path, Object body, Map<String, String> headers) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Content-Type", "application/json")
.method(method, body == null ? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(JSON.writeValueAsString(body)));
if (token != null && !token.isEmpty()) b.header("Authorization", "Bearer " + token);
if (headers != null) headers.forEach(b::header);
return b;
}
static ApiException errorFrom(int status, JsonNode env) {
JsonNode err = env.path("error");
return new ApiException(status, err.path("code").asText(null), err.path("message").asText("HTTP " + status));
}
static JsonNode api(String method, String path, Object body, Map<String, String> headers) throws Exception {
HttpResponse<String> res = HTTP.send(request(method, path, body, headers).build(), HttpResponse.BodyHandlers.ofString());
JsonNode env;
try { env = JSON.readTree(res.body()); } catch (Exception e) { env = JSON.createObjectNode(); }
if (env == null) env = JSON.createObjectNode();
if (res.statusCode() / 100 != 2) throw errorFrom(res.statusCode(), env);
return env.path("data");
}
public static void main(String[] args) throws Exception {
// steps 2-6 go here (makeInput from step 4 goes next to api())
}
}
require "digest"
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
$token = "YOUR_TOKEN" # step 2: paste your token here
class ApiError < StandardError
attr_reader :status, :code
def initialize(status, code, message)
super("#{status} #{code}: #{message}")
@status = status
@code = code
end
end
def build_request(method, path, body = nil, headers = {})
uri = URI(BASE + path)
req = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri)
req["Content-Type"] = "application/json"
req["Authorization"] = "Bearer #{$token}" if $token && !$token.empty?
headers.each { |k, v| req[k] = v }
req.body = JSON.generate(body) unless body.nil?
[uri, req]
end
def api(method, path, body = nil, headers = {})
uri, req = build_request(method, path, body, headers)
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
envelope = (JSON.parse(res.body) rescue {})
envelope = {} unless envelope.is_a?(Hash)
unless res.is_a?(Net::HTTPSuccess)
err = envelope["error"] || {}
raise ApiError.new(res.code.to_i, err["code"], err["message"] || res.message)
end
envelope["data"]
end
<?php
// needs the curl extension
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = "YOUR_TOKEN"; // step 2: paste your token here
class ApiError extends Exception {
public $status;
public $apiCode;
public function __construct($status, $code, $message) {
parent::__construct("$status $code: $message");
$this->status = $status;
$this->apiCode = $code;
}
}
function headers_for(array $extra = []): array {
global $TOKEN;
$h = ["Content-Type: application/json"];
if ($TOKEN) $h[] = "Authorization: Bearer $TOKEN";
foreach ($extra as $k => $v) $h[] = "$k: $v";
return $h;
}
function api(string $method, string $path, $body = null, array $headers = []) {
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => headers_for($headers),
CURLOPT_RETURNTRANSFER => true,
]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
$raw = curl_exec($ch);
if ($raw === false) throw new RuntimeException(curl_error($ch));
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
$env = json_decode($raw);
if (!is_object($env)) $env = new stdClass();
if ($status < 200 || $status > 299) {
$e = $env->error ?? new stdClass();
throw new ApiError($status, $e->code ?? null, $e->message ?? "HTTP $status");
}
return $env->data ?? null;
}
// Program.cs -- .NET 6+ console app (implicit usings on) with top-level statements; steps 2-6 (with the MakeInput local function) go ABOVE this class
using System.Text;
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 = "YOUR_TOKEN"; // step 2: paste your token here
public static readonly HttpClient Http = new HttpClient();
public class ApiException : Exception
{
public int Status;
public string? Code;
public ApiException(int status, string? code, string message) : base($"{status} {code}: {message}")
{
Status = status;
Code = code;
}
}
public static HttpRequestMessage Request(string method, string path, object? body = null, IDictionary<string, string>? headers = null)
{
var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
if (body != null) req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
if (!string.IsNullOrEmpty(Token)) req.Headers.TryAddWithoutValidation("Authorization", "Bearer " + Token);
if (headers != null) foreach (var kv in headers) req.Headers.TryAddWithoutValidation(kv.Key, kv.Value);
return req;
}
public static ApiException ErrorFrom(HttpResponseMessage res, JsonNode? env)
{
var err = env?["error"];
return new ApiException((int)res.StatusCode, err?["code"]?.GetValue<string>(),
err?["message"]?.GetValue<string>() ?? res.ReasonPhrase ?? "error");
}
public static async Task<JsonNode?> Call(string method, string path, object? body = null, IDictionary<string, string>? headers = null)
{
using var req = Request(method, path, body, headers);
using var res = await Http.SendAsync(req);
var text = await res.Content.ReadAsStringAsync();
JsonNode? env = null;
try { env = JsonNode.Parse(text); } catch (JsonException) { }
if (!res.IsSuccessStatusCode) throw ErrorFrom(res, env);
return env?["data"];
}
}
Step 2 — a token
Two kinds work:
-
Personal — needed for a run. Both lanes are metered in credits on
gpt-terra, so a run needs a signed-in (user) token (the page says “The audit run is metered, so it needs an account.”, or the same of the card run) unless the estimate sayssponsor_enabled. Sign in and copy your token from /tokens.html, and paste it in place of the placeholderYOUR_TOKENin the step 1 helper (the samples never read it from anywhere else). -
Guest — enough for
/meand/estimate.POST /guestwith{"slug": "sft-desk"}and noAuthorizationheader, exactly assdk.jsguest()does. The response'sdatacarriestokenandguest_id. Reuse a token you already hold rather than minting a new one each time.
# 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}..."
# personal token (needed for a run): paste it from /tokens.html in place of YOUR_TOKEN and skip this
if TOKEN in ("", "YOUR_TOKEN"):
TOKEN = "" # no Authorization header on /guest
guest = api("POST", "/guest", {"slug": "sft-desk"})
TOKEN = guest["token"]
print("guest", guest.get("guest_id"))
// personal token (needed for a run): paste it from /tokens.html into TOKEN and skip this
if (!TOKEN || TOKEN === "YOUR_TOKEN") {
TOKEN = ""; // no Authorization header on /guest
const guest = await api("POST", "/guest", { slug: "sft-desk" });
TOKEN = guest.token;
console.log("guest", guest.guest_id);
}
// inside main -- personal token (needed for a run): paste it from /tokens.html in place of YOUR_TOKEN and skip this
if token == "" || token == "YOUR_TOKEN" {
token = "" // no Authorization header on /guest
var guest struct {
Token string `json:"token"`
GuestID string `json:"guest_id"`
}
if err := api("POST", "/guest", map[string]string{"slug": "sft-desk"}, nil, &guest); err != nil {
panic(err)
}
token = guest.Token
fmt.Println("guest", guest.GuestID)
}
// inside main -- personal token (needed for a run): paste it from /tokens.html in place of YOUR_TOKEN and skip this
if (token.isEmpty() || token.equals("YOUR_TOKEN")) {
token = ""; // no Authorization header on /guest
JsonNode guest = api("POST", "/guest", Map.of("slug", "sft-desk"), null);
token = guest.path("token").asText();
System.out.println("guest " + guest.path("guest_id").asText());
}
# personal token (needed for a run): paste it from /tokens.html in place of YOUR_TOKEN and skip this
if $token.empty? || $token == "YOUR_TOKEN"
$token = "" # no Authorization header on /guest
guest = api("POST", "/guest", { slug: "sft-desk" })
$token = guest["token"]
puts "guest #{guest["guest_id"]}"
end
// personal token (needed for a run): paste it from /tokens.html in place of YOUR_TOKEN and skip this
if ($TOKEN === "" || $TOKEN === "YOUR_TOKEN") {
$TOKEN = ""; // no Authorization header on /guest
$guest = api("POST", "/guest", ["slug" => "sft-desk"]);
$TOKEN = $guest->token;
echo "guest {$guest->guest_id}\n";
}
// top-level statements (above the Api class) -- personal token (needed for a run): paste it from /tokens.html in place of YOUR_TOKEN and skip this
if (Api.Token == "" || Api.Token == "YOUR_TOKEN")
{
Api.Token = ""; // no Authorization header on /guest
var guest = await Api.Call("POST", "/guest", new { slug = "sft-desk" });
Api.Token = guest!["token"]!.GetValue<string>();
Console.WriteLine($"guest {guest["guest_id"]}");
}
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'
me = api("GET", "/me")
print(me["subject_type"], me.get("subject_id"), me.get("credits"))
signed_in = me["subject_type"] == "user"
const me = await api("GET", "/me");
console.log(me.subject_type, me.subject_id, me.credits);
const signedIn = me.subject_type === "user";
var me struct {
SubjectType string `json:"subject_type"`
SubjectID any `json:"subject_id"`
Credits any `json:"credits"`
}
if err := api("GET", "/me", nil, nil, &me); err != nil {
panic(err)
}
fmt.Println(me.SubjectType, me.SubjectID, me.Credits)
signedIn := me.SubjectType == "user"
_ = signedIn
JsonNode me = api("GET", "/me", null, null);
System.out.println(me.path("subject_type").asText() + " " + me.path("subject_id").asText() + " " + me.path("credits"));
boolean signedIn = me.path("subject_type").asText().equals("user");
me = api("GET", "/me")
puts [me["subject_type"], me["subject_id"], me["credits"]].join(" ")
signed_in = me["subject_type"] == "user"
$me = api("GET", "/me");
echo $me->subject_type, " ", $me->subject_id ?? "", " ", $me->credits ?? "", "\n";
$signedIn = $me->subject_type === "user";
var me = await Api.Call("GET", "/me");
Console.WriteLine($"{me?["subject_type"]} {me?["subject_id"]} {me?["credits"]}");
var signedIn = me?["subject_type"]?.GetValue<string>() == "user";
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:
| Field | Meaning |
|---|---|
hold_credits | A 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_credits | The 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_enabled | When true, a caller who is not signed in may run; otherwise the page asks them to sign in. |
warnings | Input 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})'
def make_input(task, input_path, note=None):
"""The body the page sends: task, rows, facts as a JSON STRING, an optional note, and
(card lane only) the audit_handoff string -- every value a string."""
with open(input_path, encoding="utf-8") as f:
built = json.load(f) # build-input.js (or handoff.js) output
inp = {
"task": task,
"rows": built["rows"], # the redacted sample, one "#<row> <json>" line per row
"facts": json.dumps(built["facts"], separators=(",", ":"), ensure_ascii=False), # a STRING, like JSON.stringify
}
n = (note or "").strip()
if n:
inp["note"] = n[:2000] # optional; the page caps it at 2000 chars
if task == "card" and built.get("audit_handoff"):
inp["audit_handoff"] = built["audit_handoff"] # optional; already a JSON string
return inp
inp = make_input("audit", "helpdesk-input.json",
"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?")
est = api("POST", "/estimate", inp)
print("hold", est.get("hold_credits"), "min", est.get("min_credits"),
"sponsored", est.get("sponsor_enabled"), "warnings", est.get("warnings") or [])
// The body the page sends: task, rows, facts as a JSON STRING, an optional note,
// and (card lane only) the audit_handoff string -- every value a string.
function makeInput(task, inputPath, note) {
const built = JSON.parse(readFileSync(inputPath, "utf8")); // build-input.js (or handoff.js) output
const input = {
task,
rows: built.rows, // the redacted sample the page sends
facts: JSON.stringify(built.facts), // a STRING: the page's free check
};
const n = (note || "").trim();
if (n) input.note = n.length > 2000 ? n.slice(0, 2000) : n; // optional; max 2000 chars
if (task === "card" && built.audit_handoff) input.audit_handoff = built.audit_handoff; // optional
return input;
}
const input = makeInput("audit", "helpdesk-input.json",
"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?");
const est = await api("POST", "/estimate", input);
console.log("hold", est.hold_credits, "min", est.min_credits,
"sponsored", est.sponsor_enabled, "warnings", est.warnings || []);
// makeInput builds the body the page sends: task, rows, facts as a JSON STRING,
// an optional note and (card lane only) the audit_handoff string.
makeInput := func(task, inputPath, note string) map[string]any {
raw, err := os.ReadFile(inputPath) // build-input.js (or handoff.js) output
if err != nil {
panic(err)
}
var built struct {
Rows string `json:"rows"`
Facts json.RawMessage `json:"facts"`
AuditHandoff string `json:"audit_handoff"`
}
if err := json.Unmarshal(raw, &built); err != nil {
panic(err)
}
var facts bytes.Buffer
if err := json.Compact(&facts, built.Facts); err != nil { // keeps key order; travels as a STRING
panic(err)
}
in := map[string]any{"task": task, "rows": built.Rows, "facts": facts.String()}
if n := []rune(strings.TrimSpace(note)); len(n) > 0 {
if len(n) > 2000 {
n = n[:2000] // optional; the page caps it at 2000 chars
}
in["note"] = string(n)
}
if task == "card" && built.AuditHandoff != "" {
in["audit_handoff"] = built.AuditHandoff // optional; already a JSON string
}
return in
}
input := makeInput("audit", "helpdesk-input.json",
"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?")
var est struct {
HoldCredits any `json:"hold_credits"`
MinCredits any `json:"min_credits"`
SponsorEnabled bool `json:"sponsor_enabled"`
Warnings []any `json:"warnings"`
}
if err := api("POST", "/estimate", input, nil, &est); err != nil {
panic(err)
}
fmt.Printf("hold %v min %v sponsored %v warnings %v\n", est.HoldCredits, est.MinCredits, est.SponsorEnabled, est.Warnings)
// ---- add to the SftDesk class, next to api(): the body the page sends
static Map<String, Object> makeInput(String task, String inputPath, String note) throws Exception {
JsonNode built = JSON.readTree(Files.readString(Path.of(inputPath))); // build-input.js (or handoff.js) output
Map<String, Object> in = new LinkedHashMap<>();
in.put("task", task);
in.put("rows", built.path("rows").asText()); // the redacted sample the page sends
in.put("facts", JSON.writeValueAsString(built.path("facts"))); // a STRING, compact
String n = note == null ? "" : note.strip();
if (!n.isEmpty()) in.put("note", n.length() > 2000 ? n.substring(0, 2000) : n); // optional; max 2000 chars
if (task.equals("card") && built.hasNonNull("audit_handoff")) in.put("audit_handoff", built.get("audit_handoff").asText());
return in;
}
// ---- inside main
Map<String, Object> input = makeInput("audit", "helpdesk-input.json",
"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?");
JsonNode est = api("POST", "/estimate", input, null);
System.out.println("hold " + est.path("hold_credits") + " min " + est.path("min_credits")
+ " sponsored " + est.path("sponsor_enabled").asBoolean(false) + " warnings " + est.path("warnings"));
# The body the page sends: task, rows, facts as a JSON STRING, an optional note,
# and (card lane only) the audit_handoff string -- every value a string.
def make_input(task, input_path, note = nil)
built = JSON.parse(File.read(input_path, encoding: "UTF-8")) # build-input.js (or handoff.js) output
input = {
task: task,
rows: built["rows"], # the redacted sample the page sends
facts: JSON.generate(built["facts"]) # a STRING, compact
}
n = note.to_s.strip
input[:note] = n[0, 2000] unless n.empty? # optional; max 2000 chars
input[:audit_handoff] = built["audit_handoff"] if task == "card" && !built["audit_handoff"].to_s.empty?
input
end
input = make_input("audit", "helpdesk-input.json",
"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?")
est = api("POST", "/estimate", input)
puts "hold #{est["hold_credits"]} min #{est["min_credits"]} " \
"sponsored #{est["sponsor_enabled"]} warnings #{(est["warnings"] || []).inspect}"
// The body the page sends: task, rows, facts as a JSON STRING, an optional note,
// and (card lane only) the audit_handoff string -- every value a string.
function make_input(string $task, string $inputPath, string $note = ""): array {
// decode to objects (not arrays) so an empty {} stays {} when re-encoded
$built = json_decode(file_get_contents($inputPath)); // build-input.js (or handoff.js) output
$input = [
"task" => $task,
"rows" => $built->rows, // the redacted sample the page sends
"facts" => json_encode($built->facts, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), // a STRING
];
$n = trim($note);
if ($n !== "") $input["note"] = mb_substr($n, 0, 2000); // optional; max 2000 chars
if ($task === "card" && !empty($built->audit_handoff)) $input["audit_handoff"] = $built->audit_handoff;
return $input;
}
$input = make_input("audit", "helpdesk-input.json",
"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?");
$est = api("POST", "/estimate", $input);
echo "hold ", $est->hold_credits ?? "-", " min ", $est->min_credits ?? "-",
" sponsored ", json_encode($est->sponsor_enabled ?? false),
" warnings ", json_encode($est->warnings ?? []), "\n";
// The body the page sends: task, rows, facts as a JSON STRING, an optional note,
// and (card lane only) the audit_handoff string -- every value a string.
static Dictionary<string, string> MakeInput(string task, string inputPath, string? note = null)
{
var compact = new JsonSerializerOptions { Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
var built = JsonNode.Parse(File.ReadAllText(inputPath))!; // build-input.js (or handoff.js) output
var input = new Dictionary<string, string>
{
["task"] = task,
["rows"] = built["rows"]!.GetValue<string>(), // the redacted sample the page sends
["facts"] = built["facts"]!.ToJsonString(compact), // a STRING, compact
};
var n = (note ?? "").Trim();
if (n.Length > 0) input["note"] = n.Length > 2000 ? n[..2000] : n; // optional; max 2000 chars
var handoff = built["audit_handoff"]?.GetValue<string>();
if (task == "card" && !string.IsNullOrEmpty(handoff)) input["audit_handoff"] = handoff;
return input;
}
var input = MakeInput("audit", "helpdesk-input.json",
"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?");
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"hold {est?["hold_credits"]} min {est?["min_credits"]} " +
$"sponsored {est?["sponsor_enabled"]} warnings {est?["warnings"]?.ToJsonString() ?? "[]"}");
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:
output.output— the model's reply as a string: the JSON object from the output contract of the lane you asked for. Parse it yourself (from the first{to the last}). The samples print the verdict and headline, each area's status, each finding'sid,rows,severityanddrop, then each flag answer. Keep the text:handoff.jsandrecheck.jsread it.truncated—truewhen the reply was cut short because the balance ran out. The page then closes the cut-off JSON, shows what arrived as a partial result (“N of 9 sections recovered” for an audit, of 7 for a card), and says it was cut short by the available balance.charged_credits— the real cost of the run, as opposed to the estimate'shold_creditsreservation. The page re-reads/meafterwards for the new balance.error— on afailedjob.
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}]}'
body = json.dumps(inp)
key = "sft-desk:audit:" + hashlib.sha256(body.encode()).hexdigest()[:16] + ":a1"
job_id = api("POST", "/run", inp, {"Idempotency-Key": key})["job_id"]
deadline = time.time() + 180 # 1 s interval, 180 s client-side limit
while True:
job = api("GET", "/jobs/" + urllib.parse.quote(job_id, safe=""))
if job["status"] in ("succeeded", "failed"):
break
if time.time() > deadline:
raise TimeoutError("job timed out")
time.sleep(1)
if job["status"] == "failed":
raise SystemExit(f"run failed: {job.get('error')}")
print("truncated:", job.get("truncated") is True, "charged:", job.get("charged_credits"))
text = (job.get("output") or {}).get("output") or ""
reply = json.loads(text[text.index("{"): text.rindex("}") + 1])
print(reply["verdict"], "-", reply["headline"])
for s in reply.get("areas", []):
print("area", s["area"], s["status"])
for f in reply.get("findings", []):
print(f["id"], "rows", f.get("rows") or [], f["severity"], "drop", f.get("drop") or [])
for a in reply.get("flag_answers", []):
print("answer", a["flag"], a["status"])
const key = "sft-desk:audit:" +
createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16) + ":a1";
const { job_id } = await api("POST", "/run", input, { "Idempotency-Key": key });
let job;
const deadline = Date.now() + 180000; // 1 s interval, 180 s client-side limit
for (;;) {
job = await api("GET", "/jobs/" + encodeURIComponent(job_id));
if (job.status === "succeeded" || job.status === "failed") break;
if (Date.now() > deadline) throw new Error("job timed out");
await new Promise((r) => setTimeout(r, 1000));
}
if (job.status === "failed") throw new Error("run failed: " + JSON.stringify(job.error));
console.log("truncated:", job.truncated === true, "charged:", job.charged_credits);
const text = (job.output && job.output.output) || "";
const reply = JSON.parse(text.slice(text.indexOf("{"), text.lastIndexOf("}") + 1));
console.log(reply.verdict, "-", reply.headline);
for (const s of reply.areas || []) console.log("area", s.area, s.status);
for (const f of reply.findings || []) console.log(f.id, "rows", f.rows || [], f.severity, "drop", f.drop || []);
for (const a of reply.flag_answers || []) console.log("answer", a.flag, a.status);
inputJSON, _ := json.Marshal(input)
sum := sha256.Sum256(inputJSON)
key := "sft-desk:audit:" + hex.EncodeToString(sum[:8]) + ":a1"
var started struct {
JobID string `json:"job_id"`
}
if err := api("POST", "/run", input, map[string]string{"Idempotency-Key": key}, &started); err != nil {
panic(err)
}
type jobT struct {
Status string `json:"status"`
Output *struct {
Output string `json:"output"`
} `json:"output"`
Truncated bool `json:"truncated"`
ChargedCredits any `json:"charged_credits"`
Error any `json:"error"`
}
var job jobT
deadline := time.Now().Add(180 * time.Second) // 1 s interval, 180 s client-side limit
for {
job = jobT{}
if err := api("GET", "/jobs/"+url.PathEscape(started.JobID), nil, nil, &job); err != nil {
panic(err)
}
if job.Status == "succeeded" || job.Status == "failed" {
break
}
if time.Now().After(deadline) {
panic("job timed out")
}
time.Sleep(time.Second)
}
if job.Status == "failed" {
panic(fmt.Sprintf("run failed: %v", job.Error))
}
fmt.Println("truncated:", job.Truncated, "charged:", job.ChargedCredits)
text := ""
if job.Output != nil {
text = job.Output.Output
}
var reply map[string]any
if a, b := strings.Index(text, "{"), strings.LastIndex(text, "}"); a >= 0 && b > a {
if err := json.Unmarshal([]byte(text[a:b+1]), &reply); err != nil {
panic(err)
}
}
fmt.Println(reply["verdict"], "-", reply["headline"])
areas, _ := reply["areas"].([]any)
for _, e := range areas {
s, _ := e.(map[string]any)
fmt.Println("area", s["area"], s["status"])
}
findings, _ := reply["findings"].([]any)
for _, e := range findings {
f, _ := e.(map[string]any)
fmt.Println(f["id"], "rows", f["rows"], f["severity"], "drop", f["drop"])
}
answers, _ := reply["flag_answers"].([]any)
for _, e := range answers {
a, _ := e.(map[string]any)
fmt.Println("answer", a["flag"], a["status"])
}
byte[] digest = MessageDigest.getInstance("SHA-256").digest(JSON.writeValueAsBytes(input));
String key = "sft-desk:audit:" + HexFormat.of().formatHex(digest, 0, 8) + ":a1";
String jobId = api("POST", "/run", input, Map.of("Idempotency-Key", key)).path("job_id").asText();
JsonNode job;
long deadline = System.currentTimeMillis() + 180_000; // 1 s interval, 180 s client-side limit
while (true) {
job = api("GET", "/jobs/" + URLEncoder.encode(jobId, StandardCharsets.UTF_8), null, null);
String s = job.path("status").asText();
if (s.equals("succeeded") || s.equals("failed")) break;
if (System.currentTimeMillis() > deadline) throw new RuntimeException("job timed out");
Thread.sleep(1000);
}
if (job.path("status").asText().equals("failed")) throw new RuntimeException("run failed: " + job.path("error"));
System.out.println("truncated: " + job.path("truncated").asBoolean(false) + ", charged: " + job.path("charged_credits"));
String text = job.path("output").path("output").asText("");
JsonNode reply = JSON.readTree(text.substring(text.indexOf('{'), text.lastIndexOf('}') + 1));
System.out.println(reply.path("verdict").asText() + " - " + reply.path("headline").asText());
for (JsonNode s : reply.path("areas")) {
System.out.println("area " + s.path("area").asText() + " " + s.path("status").asText());
}
for (JsonNode f : reply.path("findings")) {
System.out.println(f.path("id").asText() + " rows " + f.path("rows") + " " + f.path("severity").asText() + " drop " + f.path("drop"));
}
for (JsonNode a : reply.path("flag_answers")) {
System.out.println("answer " + a.path("flag").asText() + " " + a.path("status").asText());
}
key = "sft-desk:audit:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1"
job_id = api("POST", "/run", input, { "Idempotency-Key" => key })["job_id"]
deadline = Time.now + 180 # 1 s interval, 180 s client-side limit
job = nil
loop do
job = api("GET", "/jobs/#{URI.encode_www_form_component(job_id)}")
break if %w[succeeded failed].include?(job["status"])
raise "job timed out" if Time.now > deadline
sleep 1
end
raise "run failed: #{job["error"].inspect}" if job["status"] == "failed"
puts "truncated: #{job["truncated"] == true}, charged: #{job["charged_credits"]}"
text = (job["output"] || {})["output"].to_s
reply = JSON.parse(text[text.index("{")..text.rindex("}")])
puts "#{reply["verdict"]} - #{reply["headline"]}"
(reply["areas"] || []).each { |s| puts "area #{s["area"]} #{s["status"]}" }
(reply["findings"] || []).each { |f| puts "#{f["id"]} rows #{(f["rows"] || []).inspect} #{f["severity"]} drop #{(f["drop"] || []).inspect}" }
(reply["flag_answers"] || []).each { |a| puts "answer #{a["flag"]} #{a["status"]}" }
$key = "sft-desk:audit:" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$jobId = api("POST", "/run", $input, ["Idempotency-Key" => $key])->job_id;
$deadline = time() + 180; // 1 s interval, 180 s client-side limit
while (true) {
$job = api("GET", "/jobs/" . rawurlencode($jobId));
if (in_array($job->status, ["succeeded", "failed"], true)) break;
if (time() > $deadline) throw new RuntimeException("job timed out");
sleep(1);
}
if ($job->status === "failed") throw new RuntimeException("run failed: " . json_encode($job->error ?? null));
echo "truncated: ", json_encode(($job->truncated ?? false) === true), ", charged: ", $job->charged_credits ?? "-", "\n";
$text = $job->output->output ?? "";
$reply = json_decode(substr($text, strpos($text, "{"), strrpos($text, "}") - strpos($text, "{") + 1));
echo $reply->verdict, " - ", $reply->headline, "\n";
foreach ($reply->areas ?? [] as $s) echo "area ", $s->area, " ", $s->status, "\n";
foreach ($reply->findings ?? [] as $f) echo $f->id, " rows ", json_encode($f->rows ?? []), " ", $f->severity, " drop ", json_encode($f->drop ?? []), "\n";
foreach ($reply->flag_answers ?? [] as $a) echo "answer ", $a->flag, " ", $a->status, "\n";
var digest = System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(input)));
var key = "sft-desk:audit:" + Convert.ToHexString(digest, 0, 8).ToLowerInvariant() + ":a1";
var started = await Api.Call("POST", "/run", input, new Dictionary<string, string> { ["Idempotency-Key"] = key });
var jobId = started!["job_id"]!.GetValue<string>();
JsonNode job;
var deadline = DateTime.UtcNow.AddSeconds(180); // 1 s interval, 180 s client-side limit
while (true)
{
job = (await Api.Call("GET", "/jobs/" + Uri.EscapeDataString(jobId)))!;
var s = job["status"]?.GetValue<string>();
if (s == "succeeded" || s == "failed") break;
if (DateTime.UtcNow > deadline) throw new TimeoutException("job timed out");
await Task.Delay(1000);
}
if (job["status"]?.GetValue<string>() == "failed") throw new Exception($"run failed: {job["error"]?.ToJsonString()}");
var truncated = job["truncated"] is JsonValue t && t.TryGetValue(out bool tb) && tb;
Console.WriteLine($"truncated: {truncated}, charged: {job["charged_credits"]}");
var text = job["output"]?["output"]?.GetValue<string>() ?? "";
var reply = JsonNode.Parse(text[text.IndexOf('{')..(text.LastIndexOf('}') + 1)])!;
Console.WriteLine($"{reply["verdict"]} - {reply["headline"]}");
foreach (var s in reply["areas"]?.AsArray() ?? new JsonArray())
Console.WriteLine($"area {s?["area"]} {s?["status"]}");
foreach (var f in reply["findings"]?.AsArray() ?? new JsonArray())
Console.WriteLine($"{f?["id"]} rows {f?["rows"]?.ToJsonString() ?? "[]"} {f?["severity"]} drop {f?["drop"]?.ToJsonString() ?? "[]"}");
foreach (var a in reply["flag_answers"]?.AsArray() ?? new JsonArray())
Console.WriteLine($"answer {a?["flag"]} {a?["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:
| Event | Data | What the SDK does |
|---|---|---|
delta | {text} | Passes text to onDelta. |
job | job info | Passes it to onJob (the page uses it to advance its progress display). |
done | the final payload: {job_id, status, charged_credits, output} | Keeps it as the result. |
pending | a payload | Treated 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'
# the card worked example: prefs-input.json from build-input.js, the note in prefs-note.txt
# (for audited rows, pass handoff.js's output instead: make_input adds its audit_handoff)
with open("prefs-note.txt", encoding="utf-8") as f:
prefs_note = f.read()
card_inp = make_input("card", "prefs-input.json", prefs_note)
card_key = "sft-desk:card:" + hashlib.sha256(json.dumps(card_inp).encode()).hexdigest()[:16] + ":a1"
h = {"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN, "Idempotency-Key": card_key}
done, failure, streamed = None, None, ""
with requests.post(BASE + "/run-stream", headers=h, data=json.dumps(card_inp), stream=True) as r:
if "text/event-stream" not in r.headers.get("content-type", ""):
try:
env = r.json()
except ValueError:
env = {}
if not r.ok:
err = env.get("error") or {}
raise ApiError(r.status_code, err.get("code"), err.get("message") or r.reason)
done = env.get("data")
else:
event, data = "message", ""
for line in r.iter_lines(decode_unicode=True):
if line == "": # a blank line ends a frame
if data:
try:
payload = json.loads(data)
except ValueError:
payload = None
if event == "delta" and payload is not None:
streamed += payload.get("text") or ""
elif event == "job":
print("job", payload)
elif event in ("done", "pending"):
done = payload
elif event == "error":
failure = payload
event, data = "message", ""
elif line.startswith("event:"):
event = line[6:].strip()
elif line.startswith("data:"):
data += line[5:].strip()
if failure:
raise SystemExit(f"{failure.get('code')}: {failure.get('message')} (job {failure.get('job_id')})")
done = done or {}
full = (done.get("output") or {}).get("output") or streamed
print("truncated:", done.get("truncated") is True, "charged:", done.get("charged_credits"))
result = json.loads(full[full.index("{"): full.rindex("}") + 1])
print(result["verdict"], "-", result["headline"])
for fl in result.get("fields", []):
print("field", fl["field"], fl["status"], fl.get("source"), "-", fl.get("needs") or "nothing needed")
for c in result.get("checklist", []):
print("check", c["item"], c["status"], "-", c.get("reason"))
for q in result.get("questions", []):
print("question", q)
print(result.get("card_markdown", ""))
// the card worked example: prefs-input.json from build-input.js, the note in prefs-note.txt
// (for audited rows, pass handoff.js's output instead: makeInput adds its audit_handoff)
const cardInput = makeInput("card", "prefs-input.json", readFileSync("prefs-note.txt", "utf8"));
const cardKey = "sft-desk:card:" +
createHash("sha256").update(JSON.stringify(cardInput)).digest("hex").slice(0, 16) + ":a1";
const res = await fetch(BASE + "/run-stream", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer " + TOKEN, "Idempotency-Key": cardKey },
body: JSON.stringify(cardInput),
});
let done = null, failure = null, streamed = "";
if (!(res.headers.get("content-type") || "").includes("text/event-stream")) {
const json = await res.json().catch(() => ({}));
if (!res.ok) throw new Error((json.error && json.error.message) || res.statusText);
done = json.data;
} else {
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
for (;;) {
const chunk = await reader.read();
if (chunk.done) break;
buffer += decoder.decode(chunk.value, { stream: true });
let idx;
while ((idx = buffer.indexOf("\n\n")) >= 0) {
const frame = buffer.slice(0, idx);
buffer = buffer.slice(idx + 2);
let event = "message", dataStr = "";
for (const line of frame.split("\n")) {
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) dataStr += line.slice(5).trim();
}
if (!dataStr) continue;
let data;
try { data = JSON.parse(dataStr); } catch { continue; }
if (event === "delta") streamed += data.text || "";
else if (event === "job") console.log("job", data);
else if (event === "done" || event === "pending") done = data;
else if (event === "error") failure = data;
}
}
}
if (failure) throw new Error(`${failure.code}: ${failure.message} (job ${failure.job_id})`);
const full = (done && done.output && done.output.output) || streamed;
console.log("truncated:", !!(done && done.truncated), "charged:", done && done.charged_credits);
const result = JSON.parse(full.slice(full.indexOf("{"), full.lastIndexOf("}") + 1));
console.log(result.verdict, "-", result.headline);
for (const fl of result.fields || []) console.log("field", fl.field, fl.status, fl.source, "-", fl.needs || "nothing needed");
for (const c of result.checklist || []) console.log("check", c.item, c.status, "-", c.reason);
for (const q of result.questions || []) console.log("question", q);
console.log(result.card_markdown || "");
// the card worked example: prefs-input.json from build-input.js, the note in prefs-note.txt
// (for audited rows, pass handoff.js's output instead: makeInput adds its audit_handoff)
noteBytes, err := os.ReadFile("prefs-note.txt")
if err != nil {
panic(err)
}
cardInput := makeInput("card", "prefs-input.json", string(noteBytes))
cardJSON, _ := json.Marshal(cardInput)
cardSum := sha256.Sum256(cardJSON)
cardKey := "sft-desk:card:" + hex.EncodeToString(cardSum[:8]) + ":a1"
sreq, err := newRequest("POST", "/run-stream", cardInput, map[string]string{"Idempotency-Key": cardKey})
if err != nil {
panic(err)
}
sres, err := http.DefaultClient.Do(sreq)
if err != nil {
panic(err)
}
defer sres.Body.Close()
var done, failure map[string]any
var streamed strings.Builder
if !strings.Contains(sres.Header.Get("Content-Type"), "text/event-stream") {
var env envelope
_ = json.NewDecoder(sres.Body).Decode(&env)
if sres.StatusCode < 200 || sres.StatusCode > 299 {
panic(errorFrom(sres, env))
}
_ = json.Unmarshal(env.Data, &done)
} else {
sc := bufio.NewScanner(sres.Body)
sc.Buffer(make([]byte, 64*1024), 16*1024*1024) // the done frame can be long
event, data := "message", ""
for sc.Scan() {
line := sc.Text()
switch {
case line == "": // a blank line ends a frame
if data != "" {
var p map[string]any
if json.Unmarshal([]byte(data), &p) == nil {
switch event {
case "delta":
if t, ok := p["text"].(string); ok {
streamed.WriteString(t)
}
case "job":
fmt.Println("job", p)
case "done", "pending":
done = p
case "error":
failure = p
}
}
}
event, data = "message", ""
case strings.HasPrefix(line, "event:"):
event = strings.TrimSpace(line[6:])
case strings.HasPrefix(line, "data:"):
data += strings.TrimSpace(line[5:])
}
}
}
if failure != nil {
panic(fmt.Sprintf("%v: %v (job %v)", failure["code"], failure["message"], failure["job_id"]))
}
full := streamed.String()
if out, ok := done["output"].(map[string]any); ok {
if s, ok := out["output"].(string); ok && s != "" {
full = s
}
}
fmt.Println("truncated:", done["truncated"] == true, "charged:", done["charged_credits"])
var result map[string]any
if a, b := strings.Index(full, "{"), strings.LastIndex(full, "}"); a >= 0 && b > a {
if err := json.Unmarshal([]byte(full[a:b+1]), &result); err != nil {
panic(err)
}
}
fmt.Println(result["verdict"], "-", result["headline"])
fields, _ := result["fields"].([]any)
for _, e := range fields {
fl, _ := e.(map[string]any)
needs, _ := fl["needs"].(string)
if needs == "" {
needs = "nothing needed"
}
fmt.Println("field", fl["field"], fl["status"], fl["source"], "-", needs)
}
checklist, _ := result["checklist"].([]any)
for _, e := range checklist {
c, _ := e.(map[string]any)
fmt.Println("check", c["item"], c["status"], "-", c["reason"])
}
questions, _ := result["questions"].([]any)
for _, q := range questions {
fmt.Println("question", q)
}
cardMarkdown, _ := result["card_markdown"].(string)
fmt.Println(cardMarkdown)
// the card worked example: prefs-input.json from build-input.js, the note in prefs-note.txt
// (for audited rows, pass handoff.js's output instead: makeInput adds its audit_handoff)
Map<String, Object> cardInput = makeInput("card", "prefs-input.json", Files.readString(Path.of("prefs-note.txt")));
byte[] cardDigest = MessageDigest.getInstance("SHA-256").digest(JSON.writeValueAsBytes(cardInput));
String cardKey = "sft-desk:card:" + HexFormat.of().formatHex(cardDigest, 0, 8) + ":a1";
HttpRequest sreq = request("POST", "/run-stream", cardInput, Map.of("Idempotency-Key", cardKey)).build();
HttpResponse<Stream<String>> sres = HTTP.send(sreq, HttpResponse.BodyHandlers.ofLines());
JsonNode done = null, failure = null;
StringBuilder streamed = new StringBuilder();
if (!sres.headers().firstValue("content-type").orElse("").contains("text/event-stream")) {
JsonNode env = JSON.readTree(sres.body().collect(Collectors.joining("\n")));
if (env == null) env = JSON.createObjectNode();
if (sres.statusCode() / 100 != 2) throw errorFrom(sres.statusCode(), env);
done = env.path("data");
} else {
String event = "message";
StringBuilder data = new StringBuilder();
Iterator<String> it = sres.body().iterator();
while (it.hasNext()) {
String line = it.next();
if (line.isEmpty()) { // a blank line ends a frame
if (data.length() > 0) {
JsonNode p = null;
try { p = JSON.readTree(data.toString()); } catch (Exception ignored) { }
if (p != null) {
switch (event) {
case "delta": streamed.append(p.path("text").asText("")); break;
case "job": System.out.println("job " + p); break;
case "done": case "pending": done = p; break;
case "error": failure = p; break;
default: break;
}
}
}
event = "message";
data.setLength(0);
} else if (line.startsWith("event:")) event = line.substring(6).trim();
else if (line.startsWith("data:")) data.append(line.substring(5).trim());
}
}
if (failure != null) {
throw new RuntimeException(failure.path("code").asText() + ": " + failure.path("message").asText()
+ " (job " + failure.path("job_id").asText() + ")");
}
String full = done == null ? "" : done.path("output").path("output").asText("");
if (full.isEmpty()) full = streamed.toString();
System.out.println("truncated: " + (done != null && done.path("truncated").asBoolean(false))
+ ", charged: " + (done == null ? "-" : done.path("charged_credits").toString()));
JsonNode result = JSON.readTree(full.substring(full.indexOf('{'), full.lastIndexOf('}') + 1));
System.out.println(result.path("verdict").asText() + " - " + result.path("headline").asText());
for (JsonNode fl : result.path("fields")) {
String needs = fl.path("needs").asText("");
System.out.println("field " + fl.path("field").asText() + " " + fl.path("status").asText() + " " + fl.path("source").asText()
+ " - " + (needs.isEmpty() ? "nothing needed" : needs));
}
for (JsonNode c : result.path("checklist")) {
System.out.println("check " + c.path("item").asText() + " " + c.path("status").asText() + " - " + c.path("reason").asText());
}
for (JsonNode q : result.path("questions")) System.out.println("question " + q.asText());
System.out.println(result.path("card_markdown").asText(""));
# the card worked example: prefs-input.json from build-input.js, the note in prefs-note.txt
# (for audited rows, pass handoff.js's output instead: make_input adds its audit_handoff)
card_input = make_input("card", "prefs-input.json", File.read("prefs-note.txt", encoding: "UTF-8"))
card_key = "sft-desk:card:#{Digest::SHA256.hexdigest(JSON.generate(card_input))[0, 16]}:a1"
uri, req = build_request("POST", "/run-stream", card_input, { "Idempotency-Key" => card_key })
done = failure = nil
streamed = +""
Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 300) do |http|
http.request(req) do |res|
unless res["content-type"].to_s.include?("text/event-stream")
envelope = (JSON.parse(res.body) rescue {})
unless res.is_a?(Net::HTTPSuccess)
raise ApiError.new(res.code.to_i, envelope.dig("error", "code"), envelope.dig("error", "message") || res.message)
end
done = envelope["data"]
next
end
buf = +""
res.read_body do |chunk|
buf << chunk
while (i = buf.index("\n\n"))
frame = buf.slice!(0, i + 2)
event = "message"
data = +""
frame.split("\n").each do |line|
if line.start_with?("event:") then event = line[6..].strip
elsif line.start_with?("data:") then data << line[5..].strip
end
end
next if data.empty?
payload = (JSON.parse(data) rescue nil)
next if payload.nil?
case event
when "delta" then streamed << payload["text"].to_s
when "job" then puts "job #{payload.inspect}"
when "done", "pending" then done = payload
when "error" then failure = payload
end
end
end
end
end
raise "#{failure["code"]}: #{failure["message"]} (job #{failure["job_id"]})" if failure
done ||= {}
full = (done["output"] || {})["output"].to_s
full = streamed if full.empty?
puts "truncated: #{done["truncated"] == true}, charged: #{done["charged_credits"]}"
result = JSON.parse(full[full.index("{")..full.rindex("}")])
puts "#{result["verdict"]} - #{result["headline"]}"
(result["fields"] || []).each do |fl|
needs = fl["needs"].to_s
puts "field #{fl["field"]} #{fl["status"]} #{fl["source"]} - #{needs.empty? ? "nothing needed" : needs}"
end
(result["checklist"] || []).each { |c| puts "check #{c["item"]} #{c["status"]} - #{c["reason"]}" }
(result["questions"] || []).each { |q| puts "question #{q}" }
puts result["card_markdown"].to_s
// the card worked example: prefs-input.json from build-input.js, the note in prefs-note.txt
// (for audited rows, pass handoff.js's output instead: make_input adds its audit_handoff)
$cardInput = make_input("card", "prefs-input.json", file_get_contents("prefs-note.txt"));
$cardKey = "sft-desk:card:" . substr(hash("sha256", json_encode($cardInput)), 0, 16) . ":a1";
$done = null; $failure = null; $streamed = ""; $buf = ""; $ctype = "";
$handle = function (string $event, string $data) use (&$done, &$failure, &$streamed) {
$p = json_decode($data);
if ($p === null) return; // not JSON: skipped, as sdk.js does
switch ($event) {
case "delta": $streamed .= $p->text ?? ""; break;
case "job": echo "job ", json_encode($p), "\n"; break;
case "done": case "pending": $done = $p; break;
case "error": $failure = $p; break;
}
};
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => headers_for(["Idempotency-Key" => $cardKey]),
CURLOPT_POSTFIELDS => json_encode($cardInput, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$ctype) {
if (stripos($line, "content-type:") === 0) $ctype = strtolower(trim(substr($line, 13)));
return strlen($line);
},
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$buf, &$ctype, $handle) {
$buf .= $chunk; // collects the response in memory
if (strpos($ctype, "text/event-stream") === false) return strlen($chunk); // plain JSON: parse at the end
while (($i = strpos($buf, "\n\n")) !== false) {
$frame = substr($buf, 0, $i);
$buf = substr($buf, $i + 2);
$event = "message"; $data = "";
foreach (explode("\n", $frame) as $line) {
if (strpos($line, "event:") === 0) $event = trim(substr($line, 6));
elseif (strpos($line, "data:") === 0) $data .= trim(substr($line, 5));
}
if ($data !== "") $handle($event, $data);
}
return strlen($chunk);
},
]);
if (curl_exec($ch) === false) throw new RuntimeException(curl_error($ch));
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if (strpos($ctype, "text/event-stream") === false) {
$env = json_decode($buf) ?? new stdClass();
if ($status < 200 || $status > 299) {
throw new ApiError($status, $env->error->code ?? null, $env->error->message ?? "HTTP $status");
}
$done = $env->data ?? null;
}
if ($failure) throw new RuntimeException(($failure->code ?? "") . ": " . ($failure->message ?? "") . " (job " . ($failure->job_id ?? "") . ")");
$full = $done->output->output ?? "";
if ($full === "") $full = $streamed;
echo "truncated: ", json_encode(($done->truncated ?? false) === true), ", charged: ", $done->charged_credits ?? "-", "\n";
$result = json_decode(substr($full, strpos($full, "{"), strrpos($full, "}") - strpos($full, "{") + 1));
echo $result->verdict, " - ", $result->headline, "\n";
foreach ($result->fields ?? [] as $fl) {
$needs = $fl->needs ?? "";
echo "field ", $fl->field, " ", $fl->status, " ", $fl->source ?? "", " - ", $needs === "" ? "nothing needed" : $needs, "\n";
}
foreach ($result->checklist ?? [] as $c) echo "check ", $c->item, " ", $c->status, " - ", $c->reason ?? "", "\n";
foreach ($result->questions ?? [] as $q) echo "question ", $q, "\n";
echo $result->card_markdown ?? "", "\n";
// the card worked example: prefs-input.json from build-input.js, the note in prefs-note.txt
// (for audited rows, pass handoff.js's output instead: MakeInput adds its audit_handoff)
var cardInput = MakeInput("card", "prefs-input.json", File.ReadAllText("prefs-note.txt"));
var cardDigest = System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(cardInput)));
var cardKey = "sft-desk:card:" + Convert.ToHexString(cardDigest, 0, 8).ToLowerInvariant() + ":a1";
using var sreq = Api.Request("POST", "/run-stream", cardInput, new Dictionary<string, string> { ["Idempotency-Key"] = cardKey });
using var sres = await Api.Http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
JsonNode? done = null, failure = null;
var streamed = new StringBuilder();
if (sres.Content.Headers.ContentType?.MediaType != "text/event-stream")
{
JsonNode? env = null;
try { env = JsonNode.Parse(await sres.Content.ReadAsStringAsync()); } catch (JsonException) { }
if (!sres.IsSuccessStatusCode) throw Api.ErrorFrom(sres, env);
done = env?["data"];
}
else
{
using var reader = new StreamReader(await sres.Content.ReadAsStreamAsync());
var ev = "message";
var data = new StringBuilder();
string? line;
while ((line = await reader.ReadLineAsync()) != null)
{
if (line.Length == 0) // a blank line ends a frame
{
if (data.Length > 0)
{
JsonNode? p = null;
try { p = JsonNode.Parse(data.ToString()); } catch (JsonException) { }
if (p != null)
{
switch (ev)
{
case "delta": streamed.Append(p["text"]?.GetValue<string>() ?? ""); break;
case "job": Console.WriteLine($"job {p.ToJsonString()}"); break;
case "done": case "pending": done = p; break;
case "error": failure = p; break;
}
}
}
ev = "message";
data.Clear();
}
else if (line.StartsWith("event:")) ev = line[6..].Trim();
else if (line.StartsWith("data:")) data.Append(line[5..].Trim());
}
}
if (failure != null) throw new Exception($"{failure["code"]}: {failure["message"]} (job {failure["job_id"]})");
var full = done?["output"]?["output"]?.GetValue<string>();
if (string.IsNullOrEmpty(full)) full = streamed.ToString();
var cut = done?["truncated"] is JsonValue tv && tv.TryGetValue(out bool cutShort) && cutShort;
Console.WriteLine($"truncated: {cut}, charged: {done?["charged_credits"]}");
var result = JsonNode.Parse(full[full.IndexOf('{')..(full.LastIndexOf('}') + 1)])!;
Console.WriteLine($"{result["verdict"]} - {result["headline"]}");
foreach (var fl in result["fields"]?.AsArray() ?? new JsonArray())
{
var needs = fl?["needs"]?.GetValue<string>() ?? "";
Console.WriteLine($"field {fl?["field"]} {fl?["status"]} {fl?["source"]} - {(needs == "" ? "nothing needed" : needs)}");
}
foreach (var c in result["checklist"]?.AsArray() ?? new JsonArray())
Console.WriteLine($"check {c?["item"]} {c?["status"]} - {c?["reason"]}");
foreach (var q in result["questions"]?.AsArray() ?? new JsonArray()) Console.WriteLine($"question {q}");
Console.WriteLine(result["card_markdown"]?.GetValue<string>() ?? "");
What the page does that you now do
- Reads the rows in the browser with the settings and the eval split and builds
rowsandfactsfrom them (sftkit.js): the sample of up to 40 rows, flagged rows first, with email addresses, phone numbers and credential values redacted and long strings clipped; you send the same two strings. - Prices every input with
/estimateand refuses to run belowmin_credits, or on a paste with no row it can read. - Sends each attempt with an input- and lane-derived
Idempotency-Key, and retries once withretry_notewhen a reply does not parse. - Closes a cut-off JSON reply and shows what arrived when
truncatedis true or the stream ends early. - Hands an audit to the card lane as
audit_handoff, with the rows left after the drops and its clean-up counted, and only while the rows and the settings are unchanged. - Checks the reply against the rows (
recon.js), in the browser: the rows each finding names, every quote against the sample, the drops, flag answers and verdict; the card's counts and real share against its own numbers, each checklist item against its flags, the verdict and the Markdown headings; and personal data or credentials repeated from the rows. It then applies the drops and its clean-up and runs the free check again.recheck.jsabove runs the same checks for you.
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.