← Coord Desk / API
Tokens

Drive Coord Desk from your own code

Everything the web page does is available over HTTP: post one paste of genomic coordinates — BED intervals, a headered table, VCF records, GFF3/GTF features, SAM alignments, a Picard interval_list or region strings — say what you are converting it to, and send the facts the free engine computed from it. What comes back is a senior bioinformatician's review: which basis the source really is in, the shift to the target as two integers, what the assembly evidence supports, an answer to every engine flag, the issues with their fixes, the conversion as runnable commands, what to check afterwards, and a verdict of safe_to_convert, convert_with_fixes or stop_and_check.

The natural use is a pipeline step that refuses to hand a coordinate file to the next tool until its conventions have been checked — the two failures the app exists to prevent are silent ones: an off-by-one shift between 0-based half-open and 1-based closed, and positions or contig names from one assembly used against another. One thing is different from most apps on this platform, and it is the whole of step 4: the arithmetic is not the model's. A free, deterministic engine — the same coords.js the browser runs — parses the paste, decides each format's basis, puts every record on one axis, scores the assembly evidence, normalises VCF alleles, finds off-by-one pairs, previews the conversion and raises the flags. The model is told to quote that object, never to recompute it. An API caller should produce it too.

Base URL and the envelope

Every endpoint lives under https://api.skillsafe.ai/v1/app-api and every response uses the same envelope, so one helper covers the whole API:

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

Send your token as Authorization: Bearer … on every call. The app slug travels in the body of /guest as {"slug": "coord-desk"}; after that the token itself carries the app, so a run needs only Authorization, Content-Type: application/json and the Idempotency-Key described in step 5. There is no slug header.

The request body for /estimate, /run and /run-stream is the input object itself — not wrapped in anything. A body of {"input": {…}} returns 200 and quietly hides every field from the model, so a run that looks fine comes back reviewing nothing. Refuse to send anything that is not a plain JSON object: /estimate happily prices a bare string, a number, null and [] alike.

Error codes

codestatuswhat to do
unauthorized401The token is missing, malformed or expired. Get a new one from the token page.
payment_required402The balance is below min_credits, or a guest token tried a metered run. Call /estimate first, then sign in and top up.
validation_error400The body is not a plain JSON object, or a field is the wrong type — every field of this app's input is a string, facts included. A facts object sent as an object rather than as a JSON string is the common one.
not_found404Unknown job id, or the app slug does not exist. Check the id you polled with.
rate_limited429Too many requests. Back off and retry; do not tight-loop a poll.
internal500A server-side failure. Retry with the same Idempotency-Key so you are not billed twice.

Replaying an Idempotency-Key with a changed body is not a retry and is rejected rather than billed. When you change the input — an edited paste, another target, a different assembly — bump the attempt suffix on the key instead.

The field to get right first: task

Coord Desk has exactly one task, and task is always "review". Send it as the first field of every request body. There is no second lane to choose and nothing to chain: one paste in, one review out.

taskwhat comes back
"review"One JSON object with "lane": "review": a title and summary, the verdict with its reason, the source (format, basis, the start/end shift to the target and whether that agrees with the engine), the assembly call, one coverage entry per engine flag, the issues, the conversion steps as commands, the checks_after list and notes_on_input.

A missing or different task is not an error: the model does the review anyway and says so in notes_on_input. Do not rely on that — a script that sends "review" gets a clean notes_on_input and can treat anything written there as a real remark about the input.

The input

The input is a JSON object — never a bare string, never wrapped in an input key — and every one of its fields is a string, because the transport takes scalars only. That is why facts is a JSON string rather than a nested object.

fieldtypemeaning
taskstring, requiredAlways "review".
recordsstring, requiredThe paste, verbatim: BED; a headered table (the UCSC table browser's #chrom / chromStart / chromEnd, MAF's Start_Position, a hand-made chr / start / end table); VCF; GFF3 or GTF; SAM; a Picard interval_list; or region strings chr:start-end. Formats may be mixed in one paste. Clipped to 24,000 characters — see Clipping.
targetenum stringWhat you are converting to: "bed" (0-based half-open), "region" (1-based closed chr:start-end), "gff" (GFF3, 1-based closed) or "interval_list" (Picard, 1-based closed, with an @SQ header). Default "bed".
contig_styleenum stringThe contig naming you want out: "keep", "ucsc" (chr1, chrM) or "ensembl" (1, MT). Default "keep".
source_basisenum string"auto", or your override for BED-shaped and table input only: "zero" or "one". VCF, GFF/GTF, SAM, interval_list and region strings are 1-based by specification and ignore it. Default "auto".
assemblyenum stringWhat you say the data is on: "unknown", "GRCh37" or "GRCh38". The engine checks it against the file's own evidence and flags a disagreement. Default "unknown".
pipeline_hintstring, optionalWhere the output goes next, free text, at most 300 characters — "GATK -L targets.list", "bedtools intersect with a GRCh38 panel", "CrossMap to hg38". It decides whether a real problem is a blocker or a footnote.
factsstring, strongly recommendedThe engine's digest of the paste, JSON.stringify-ed. Shape and the script that computes it are in step 4. Without it the model reviews the paste alone, replies coverage: [], sets both agrees_with_engine fields to false and says in notes_on_input that nothing was cross-checked.
retry_notestring, optionalOnly on a reformat retry, when a previous reply did not parse: a sentence naming the failure. The model is told to obey it. Bump the attempt suffix on the key when you send one.

A worked example

A UCSC table-browser export headed for a GRCh37 GATK run that wants Ensembl names and region strings. The second record already uses an Ensembl name — the kind of mix that silently drops rows out of an intersect:

{
  "task": "review",
  "records": "#chrom\tchromStart\tchromEnd\tname\nchr1\t11868\t12227\texon1\n1\t69090\t70008\torf1",
  "target": "region",
  "contig_style": "ensembl",
  "source_basis": "auto",
  "assembly": "GRCh37",
  "pipeline_hint": "GATK -L targets.list",
  "facts": "{\"engine\":\"coord-desk/1\",\"clipped\":null,\"lines_total\":3,\"records\":2, ... }"
}

For this paste the engine reads a two-record table whose basis is decided by its header names (the column is named chromStart, so 0-based half-open), states the shift to region as shift_start: 1, shift_end: 0, previews 1:11869-12227 and 1:69091-70008, finds no assembly evidence beyond your declaration, and raises one flag — F-001 mixed-contig-style, severity warn. The reply must answer F-001 in coverage and nothing else.

Clipping

The page sends at most 24,000 characters of records. Header lines (#, @, track, browser) before the first data line are kept whole; then whole data lines are taken alternately from both ends, and the middle is dropped with one marker line in its place. It never cuts inside a line:

# [... 1840 lines (61233 characters) cut from the middle ...]

Clip the same way if you send more — CDCoords.clipMiddle(text, 24000) does exactly this — and keep the marker: the prompt keys on it and says so in notes_on_input. The counts travel in the facts as facts.clipped = {"chars_cut": 61233, "lines_cut": 1840} (it is null when nothing was cut). The facts are computed on the clipped text — the same text the model sees — so line numbers in the facts, and the line numbers the reply cites, are line numbers of records as sent (the marker counts as a line). On an unclipped paste that is simply your paste.

The facts object

In the browser this object is computed for free, before the run, by /coords.js (window.CDCoords). The system prompt tells the model it is correct arithmetic and must not be re-done differently: if the facts say line 7 is [99, 102), it is. Its keys:

keywhat it holds
engineThe engine version, "coord-desk/1".
clippednull, or {chars_cut, lines_cut} when the paste was clipped.
lines_total, records, unreadable_linesLines in the paste, coordinate records read, and lines that fit no supported format.
by_formatRecord counts per format: bed, table, vcf, gff, sam, interval_list, region.
basis[]One entry per format: {format, basis, decided_by, evidence[], records}, where decided_by is spec, header names, evidence, assumed or override.
contig_styles, contigsCounts per naming style (ucsc, ensembl, …) and the first 30 contigs in the order they appear.
assembly{call, confidence, declared_by_user, scores, conflict, evidence[]} — the engine's call, how sure it is, what you declared, the per-assembly scores (GRCh37, GRCh38), whether the evidence conflicts, and up to ten pieces of evidence {points_to, kind, detail, line} (header lengths, reference lines, alt-contig names, RefSeq versions, positions past a chromosome end).
variantsnull without VCF records; otherwise the normalisation results: multi-allelic count, non-minimal alleles with their minimal form, no-ops, duplicates, indels whose left-alignment cannot be checked, symbolic alleles.
off_by_one_pairsUp to six pairs of records in different formats that sit exactly one base apart.
target{format, label, contig_style, shift_from_main_basis, shift_start, shift_end} — the shift from the dominant source basis to the target, as text and as two integers.
conversion{lines, zero_length, not_renamed, mito_renamed, skipped_unmapped, invalid, preview} — the engine's own conversion, with the first eight output lines in preview.
sample_recordsThe first twelve records on the engine's internal axis: {line, format, contig, start0, end0} (0-based half-open), plus pos/ref/alt for VCF, start1 for 1-based formats, and name.
flags[]{id, rule, severity, message, lines} with ids F-001, F-002, … in order, severity error, warn or info, and up to twelve paste line numbers.

The flag rules

So a script can filter on them. Severity is fixed unless the table says otherwise.

ruleseverityraised when
truncatederrorThe paste is longer than the 100,000 lines the engine reads; the checks and the conversion cover the first part only.
parse-errorerrorLines could not be read as coordinates in any supported format.
negative-starterrorA record has a negative start; no convention allows it.
start-after-enderrorA record ends before it starts once put on one axis.
zero-in-one-basederrorA 0 sits in a 1-based start column; the file was probably written 0-based.
basis-assumedinfoHeaderless BED-shaped lines were read as BED (0-based) with nothing to prove it either way.
looks-one-basedwarnBED-shaped records with start == end suggest 1-based single bases; read as 1-based closed.
zero-lengthwarnEmpty BED records (start == end): an insertion site, or more often a 1-based position written into BED.
basis-ambiguouswarnA table's start column name does not state its basis; read as 1-based closed.
override-contradictederrorsource_basis says 1-based but records start at 0.
override-vs-headerwarnsource_basis disagrees with the basis the table header names.
mixed-formatsinfoThe paste mixes formats; each goes on the axis by its own convention.
off-by-one-pairerrorRecords in different formats sit exactly one base apart: one side was converted without its shift.
mixed-contig-stylewarnContig names mix styles (chr1 and 1); the minority drops out of any intersect.
contig-not-in-headererrorRecords use contigs the ##contig / @SQ / ##sequence-region header does not declare.
beyond-lengthwarn or errorRecords end past a chromosome's length on one assembly (error when that is the declared one) or on both.
assembly-conflicterrorThe file's evidence points at both GRCh37 and GRCh38.
assembly-declared-conflicterrorYour assembly disagrees with the file's own evidence.
assembly-unknowninfoNothing names or implies an assembly and you declared none.
t2t-assemblywarnThe header names T2T-CHM13; length checks are off.
non-primary-contiginfoAlt, patch, decoy, unplaced or non-human contigs with no counterpart under a rename.
multiallelicwarnVCF records carry more than one ALT; split them (bcftools norm -m-).
non-minimalwarnAlleles are not in minimal form, so the same variant written two ways will not match.
noop-allelewarnAn ALT is identical to REF after trimming.
duplicate-variantwarnA record repeats a variant already seen once normalised.
left-align-unverifiedinfoIndels cannot be checked for left-alignment without the reference (bcftools norm -f ref.fa).
symbolic-alleleinfoSymbolic or breakend alleles; the interval is POS-1 to INFO/END.
allele-casewarnREF characters outside ACGTN, or lowercase (soft-masked) bases.
unmapped-readinfoSAM records are unmapped and have no interval.
bad-strandwarnA BED sixth column is not +, - or .; the columns may be shifted.
unsortedinfoRecords come before an earlier start on the same contig.
duplicate-intervalinfoAn interval repeats an earlier one exactly.
zero-length-targetwarnEmpty intervals cannot be written as a closed 1-based range; written GFF3-style.
not-renamedwarnContigs kept their names under the rename because no standard mapping exists.
chrM-renameinfo or errorchrM and MT were renamed; an error on GRCh37/hg19, where hg19's chrM (16,571 bp) is not the rCRS (16,569 bp).
no-contig-lengthwarnAn interval_list header needs a contig length the engine does not know; written as LN:0.
not-convertederrorRecords were left out of the conversion because the interval is invalid.

1. Get a token

The easiest route is the token page: it shows the token this browser already holds, with Copy token and Copy shell export buttons, and a sign-in button for a personal token. Nothing on that page needs a developer tool — it reads the same storage the app itself uses and prints the token for you.

A guest token can call /me and /estimate. The review is metered, so a run needs a personal token from signing in. Nothing about the engine is metered: the facts in step 4 cost nothing and need no token at all.

# The token page is the shortest path. It shows the token this browser holds and
# hands you a ready-made shell export:
#
#   https://coord-desk.skillsafe.ai/tokens.html
#   export SKILLSAFE_TOKEN="aut_YOUR_TOKEN"
#
# To mint a guest token from the command line instead. A guest token is enough for
# /me and /estimate; a review run needs a personal token.
curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug": "coord-desk"}'
# {"ok":true,"data":{"token":"aut_...","subject_type":"guest"}}

2. A tiny client

One helper that adds the headers, unwraps data and raises on error. Every later sample builds on it.

# Every call is the same three things: the base URL, your bearer token,
# and a JSON body. Keep the token in a shell variable.
BASE="https://api.skillsafe.ai/v1/app-api"
SLUG="coord-desk"
TOKEN="$SKILLSAFE_TOKEN"   # from https://coord-desk.skillsafe.ai/tokens.html

call() {                  # call <path> [json-body]
  if [ -n "$2" ]; then
    curl -sS -X POST "$BASE/$1" \
      -H "Authorization: Bearer $TOKEN" \
      -H "Content-Type: application/json" \
      -d "$2"
  else
    curl -sS "$BASE/$1" -H "Authorization: Bearer $TOKEN"
  fi
}

3. Check the session and the balance

GET /me tells you whether the token is a guest or a person, and what the balance is: subject_type — "user" or "guest" — plus credits, the wallet balance, and a subject id. There is no username in it, so "signed in" is subject_type === "user" and nothing else; a guest can price a review but cannot start one. Compare credits against min_credits from the next step before you run, so a shortfall surfaces as your own clear message rather than a 402.

call me
# {"ok":true,"data":{"subject_type":"user","subject_id":"usr_...","credits":51234}}

# A guest token answers the same call with subject_type "guest" and cannot run
# a review. Gate on it before you spend a poll loop finding out:
call me | grep -q '"subject_type":"user"' || {
  echo "sign in at https://coord-desk.skillsafe.ai/tokens.html first" >&2
  exit 1
}

4. Compute the facts, then price the run — both free

Computing the facts outside the browser. The engine is one plain script served by this host, /coords.js, and it runs unmodified in Node: set global.window = {}, require it, and call CDCoords.analyze() then CDCoords.toFacts(). No dependencies, no token, no charge. The recipe the page uses is exactly:

clip  = CDCoords.clipMiddle(text, 24000)
facts = JSON.stringify(CDCoords.toFacts(
          CDCoords.analyze(clip.text, { target, style: contig_style, basis: source_basis, assembly }),
          clip))
body.records = clip.text          // the clipped paste, marker line included
body.facts   = facts              // a STRING, not an object

Note the option names: the engine takes style and basis, the request body calls them contig_style and source_basis. Pass the same values to both, or the facts describe a different conversion from the one you asked the model about. The cURL tab writes a ten-line facts.js that prints {records, facts} ready to drop into the body; the other languages either load the engine in-process (JavaScript) or drive that script as a subprocess, which is both the shortest route and the only way to be certain your facts are the ones the app would have produced.

# The paste, saved as in.txt - the worked example used everywhere below.
printf '#chrom\tchromStart\tchromEnd\tname\nchr1\t11868\t12227\texon1\n1\t69090\t70008\torf1\n' > in.txt

# The engine, fetched once and kept next to your script. It is the same file the
# web page loads; re-fetch it when the app is released again.
curl -sS -o coords.js "https://coord-desk.skillsafe.ai/coords.js"

# facts.js - no dependencies, no token, no charge.
# usage: node facts.js <file> [target] [contig_style] [source_basis] [assembly]
cat > facts.js <<'JS'
const fs = require("fs");
global.window = {};                                 // coords.js attaches CDCoords here
require("./coords.js");
const C = window.CDCoords;
const [file, target = "bed", style = "keep", basis = "auto", assembly = "unknown"] = process.argv.slice(2);
const text = fs.readFileSync(file, "utf8").replace(/\r\n?/g, "\n");
const clip = C.clipMiddle(text, C.MAX_TEXT);         // 24,000 chars, whole lines, marker kept
const facts = C.toFacts(C.analyze(clip.text, { target, style, basis, assembly }), clip);
process.stdout.write(JSON.stringify({ records: clip.text, facts: JSON.stringify(facts) }));
JS

node facts.js in.txt region ensembl auto GRCh37 > engine.json
head -c 160 engine.json
# {"records":"#chrom\tchromStart\tchromEnd\tname\nchr1\t11868...","facts":"{\"engine\":\"coord-desk/1\",...

The lazy alternative, and what it costs you. facts is optional, so a body without it runs. What you get back is a review of the paste alone: coverage: [], both agrees_with_engine fields false, a notes_on_input that says nothing was cross-checked, and a shift and assembly call made by reading the text rather than by the engine's rules. Every safeguard in step 7 then has nothing to compare against. If you cannot run the engine, at least know that you are buying an opinion, not a check.

/estimate creates no job and charges nothing. Send it the same body you will run. It returns the reservation and the model binding: hold_credits is what gets held, min_credits is the balance you must clear to start at all, model is gpt-5.6-terra, model_alias is gpt-terra, markup_bps is 1000, and input_checked and warnings[] report what the server made of the body. The hold is a reservation, not the price: it prices the full output cap, so the charged_credits on the settled job is usually far lower. Plan against hold_credits, account against charged_credits. A guest token can call it.

facts is usually the largest field after records: a long paste with many flags is many input tokens whether you read them or not, so re-estimate when the paste changes shape rather than reusing one number for every file.

# Build the body with a JSON encoder, never with string concatenation: the paste has
# tabs and newlines and the facts string has quotes.
python3 - <<'PY' > body.json
import json
eng = json.load(open("engine.json"))
print(json.dumps({
    "task": "review",
    "records": eng["records"],        # the clipped paste
    "target": "region",               # bed | region | gff | interval_list
    "contig_style": "ensembl",        # keep | ucsc | ensembl
    "source_basis": "auto",           # auto | zero | one
    "assembly": "GRCh37",             # unknown | GRCh37 | GRCh38
    "pipeline_hint": "GATK -L targets.list",
    "facts": eng["facts"],            # the STRING from node facts.js
}))
PY

call estimate "$(cat body.json)"
# {"ok":true,"data":{"hold_credits":...,"min_credits":...,"model":"gpt-5.6-terra",
#   "model_alias":"gpt-terra","markup_bps":1000,"input_checked":...,"warnings":[]}}
#
# estimate is FREE. It creates no job and charges nothing. hold_credits is what
# gets RESERVED; charged_credits on the settled job is normally much lower.

5. Run it, then poll

POST /run takes the same body, reserves hold_credits, starts the job and returns a job_id at once; the reply arrives when GET /jobs/{id} reports status: "succeeded". The model's text is the job's output.output string. Send an Idempotency-Key on every run, derived from the input, the task and an attempt number: coord-desk:<task>:<first 16 hex of sha256(body)>:a<attempt>. A retried request with the same key returns the same job instead of billing twice; bump the attempt suffix only when you mean to run again — after a retry_note, a top-up, or an edit to any field.

# Same body as the estimate. The key is slug:task:hash:attempt.
KEY="coord-desk:review:$(shasum -a 256 body.json | cut -c1-16):a1"
JOB=$(curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/run" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  --data-binary @body.json | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')

# Poll until terminal: succeeded | failed | cancelled.
while :; do
  STATE=$(curl -sS "https://api.skillsafe.ai/v1/app-api/jobs/$JOB" \
    -H "Authorization: Bearer $SKILLSAFE_TOKEN")
  STATUS=$(echo "$STATE" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["status"])')
  case "$STATUS" in succeeded|failed|cancelled) break;; esac
  sleep 2
done
echo "$STATE" > state.json
python3 -c 'import json;d=json.load(open("state.json"))["data"];print(d["status"],d.get("charged_credits"),"credits, truncated:",d.get("truncated"));print(d["output"]["output"][:400])'

6. Or stream it

POST /run-stream takes the same body and the same kind of key and answers with server-sent events. From a script you see event: delta frames carrying pieces of the reply and one final event: done with the finished job — its status, charged_credits, truncated and the full output.output. From a browser page the platform sends only tick heartbeats and the done event, which is why the app's progress card advances on elapsed time between the real signals. Concatenate the deltas; parse only when done arrives, and prefer the done event's output.output over your own concatenation when both exist.

curl -sN -X POST "https://api.skillsafe.ai/v1/app-api/run-stream" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: text/event-stream" \
  -H "Idempotency-Key: coord-desk:review:$(shasum -a 256 body.json | cut -c1-16):a1" \
  --data-binary @body.json
# event: job      data: {"job_id":"job_..."}
# event: delta    data: {"text":"{\"lane\":\"review\",\"title\":\"Exon targets, UCSC table to GRCh37 region list\","}
# event: delta    data: {"text":"\"summary\":\"..."}
# event: done     data: {"status":"succeeded","charged_credits":...,"truncated":false,"output":{"output":"{...}"}}

7. Parse the result

The reply is one JSON object, as a string, in output.output — the polled job's, or the done event's. Unwrap it twice: once for the envelope, once for the model's text. The prompt forbids code fences, but parse defensively the way the page does: strip any ``` fence, take everything from the first { to the last }, and JSON.parse that. Then check the reply against the facts you sent before you trust a single field. Four checks catch nearly everything: coverage has exactly the engine's flag ids, in the engine's order; the stated shift equals facts.target.shift_start / shift_end unless source.agrees_with_engine is false (then source.reasoning must say which evidence proves the engine wrong); issues and steps ids run in sequence; and every enum is one of the listed values.

# Unwrap twice, then check coverage, shift and verdict with python.
python3 - state.json body.json <<'PY'
import json, re, sys
state = json.load(open(sys.argv[1]))["data"]
facts = json.loads(json.load(open(sys.argv[2]))["facts"])
t = re.sub(r"^```[a-zA-Z]*\s*|```\s*$", "", state["output"]["output"].strip())
reply = json.loads(t[t.index("{"):t.rindex("}") + 1])         # the model's single JSON object
print("coverage matches flags:", [c["id"] for c in reply["coverage"]] == [f["id"] for f in facts["flags"]])
src = reply["source"]
print("shift:", src["shift_start"], src["shift_end"], "| engine:",
      facts["target"]["shift_start"], facts["target"]["shift_end"], "| agrees:", src["agrees_with_engine"])
print("verdict:", reply["verdict"], "-", reply["verdict_reason"])
PY

The output contract

The reply is ONE JSON object with exactly these keys — no prose around it, no fences. The keys and enum values are exact; a script should treat an unknown value as a defect, not a feature.

{
  "lane": "review",
  "title": string,                   // names the data and the conversion
  "summary": string,                 // two to four sentences
  "verdict": "safe_to_convert" | "convert_with_fixes" | "stop_and_check",
  "verdict_reason": string,          // one sentence
  "source": {
    "format": "bed" | "table" | "vcf" | "gff" | "sam" | "interval_list" | "region" | "mixed",
    "basis": "0-based half-open" | "1-based closed" | "mixed" | "unclear",
    "shift_start": -1 | 0 | 1,       // integer, source basis to target
    "shift_end": -1 | 0 | 1,
    "agrees_with_engine": boolean,
    "reasoning": string
  },
  "assembly": {
    "call": "GRCh37" | "GRCh38" | "T2T-CHM13" | "unknown" | "conflict",
    "agrees_with_engine": boolean,
    "reasoning": string,
    "how_to_confirm": string         // one sentence
  },
  "coverage": [
    { "id": "F-001", "status": "confirmed" | "downgraded" | "dismissed" | "merged",
      "ref": "I-001" | "", "note": string }
  ],
  "issues": [
    { "id": "I-001", "severity": "blocker" | "serious" | "minor", "lines": [integer],
      "what": string, "why_it_matters": string, "fix": string }
  ],
  "steps": [
    { "id": "S-001",
      "tool": "awk" | "bedtools" | "bcftools" | "samtools" | "picard" | "gatk" | "crossmap" | "liftover" | "tabix" | "sort" | "sed" | "other",
      "purpose": string, "command": string }
  ],
  "checks_after": [string],
  "notes_on_input": string           // "" or what was odd about the input
}
keyrules
verdictsafe_to_convert: no error-severity flag is confirmed and the basis and assembly are clear. convert_with_fixes: the conversion is sound once the listed issues are fixed. stop_and_check: something cannot be resolved from the paste — an assembly conflict, an off-by-one pair, positions past a chromosome end, a chrM/MT rename on hg19, a contradicted override — and verdict_reason says who or what must answer it.
sourceformat is what the source really is; basis uses the reply's own spellings ("0-based half-open", without the comma the facts use). The shift is two integers from the source basis to the target — BED to region is +1/0, a 1-based table to BED is -1/0, same basis is 0/0 — and must equal facts.target.shift_start/shift_end unless agrees_with_engine is false and reasoning names the flag or evidence that proves the engine's basis wrong.
assemblyNever claims an assembly the evidence does not support; unknown is an honest answer. how_to_confirm is one sentence: a header line, a known position, asking the source.
coverageExactly one entry per flag in facts.flags, same ids, same order, no other ids. confirmed = real and it matters here; downgraded = real but minor for this pipeline; dismissed = not a problem here, with the reason; merged = same root cause as another flag, named in note. A confirmed or downgraded flag that needs action points at its issue via ref; otherwise ref is "". Empty when no facts were sent.
issuesIds I-001, I-002, … in order, blockers first. lines are integer line numbers of the paste. May be empty only when there is genuinely nothing to fix.
stepsIds S-001, … in order; always at least the conversion itself. One command or one pipeline per step, with placeholder file names (in.bed, out.bed, in.vcf.gz, ref.fa, chain.over.chain.gz); fixes come before conversion (rename contigs, split multi-allelics, normalise against the reference, then convert, then sort and index). The shift in any awk step must be the shift stated in source. No step states a liftover result.
checks_afterAt least one concrete check, e.g. "the first record should read chr1:12,011-12,227" or "bcftools norm -c e reports zero REF mismatches".
notes_on_input"", or what was odd about the input itself: clipped, task missing, unreadable lines, no facts supplied.

Every string is plain text — no Markdown, no HTML — so a command in steps[].command can be copied as it stands. It is still a suggestion from a model: read it before you run it, and run it on copies.

8. Use it as a gate

A coordinate file on its way into a pipeline can be checked on every change. The engine alone (no credits) already fails the obvious cases — any error-severity flag is worth stopping on. The review (metered) adds the judgement: fail the CI step when the verdict is stop_and_check or any issue has severity blocker, and also when the run did not complete, was truncated, or disagrees with the engine.

#!/bin/sh
# CI gate: run the review over targets.bed and fail on stop_and_check or a blocker.
set -e
node facts.js targets.bed region ensembl auto GRCh37 > engine.json
python3 - <<'PY' > body.json
import json
eng = json.load(open("engine.json"))
print(json.dumps({"task": "review", "records": eng["records"], "target": "region",
                  "contig_style": "ensembl", "source_basis": "auto", "assembly": "GRCh37",
                  "pipeline_hint": "GATK -L targets.list", "facts": eng["facts"]}))
PY
# ... run and poll exactly as in step 5, leaving the finished job in state.json ...
python3 - <<'PY'
import json, re, sys
d = json.load(open("state.json"))["data"]
if d["status"] != "succeeded" or d.get("truncated"):
    sys.exit(f"review did not complete: {d['status']} truncated={d.get('truncated')}")
t = re.sub(r"^```[a-zA-Z]*\s*|```\s*$", "", d["output"]["output"].strip())
r = json.loads(t[t.index("{"):t.rindex("}") + 1])
blockers = [i for i in r["issues"] if i["severity"] == "blocker"]
for i in blockers:
    print(f"{i['id']} lines {i['lines']}: {i['what']} -- fix: {i['fix']}", file=sys.stderr)
print("verdict:", r["verdict"], "-", r["verdict_reason"])
if r["verdict"] == "stop_and_check" or blockers:
    sys.exit(2)
PY

Truncation and partial results

When the balance sits between min_credits and hold_credits, the run is not refused: it executes with a reduced output cap and comes back with truncated: true on the finished job and on the streaming done event. What you hold then is a prefix of the reply. The keys come in contract order, so a truncated review may carry the title, summary, verdict, source, assembly and coverage while issues, steps and checks_after — the part that tells you what to fix and how — are absent or cut mid-string.

Check the flag before you treat a reply as complete. A truncated review whose verdict reads convert_with_fixes and whose issues list broke off after one entry parses cleanly once repaired and looks like a short review; a gate reading it would pass a file whose blocker was never written. The page's answer is to close the JSON structure that arrived, render every section that parsed, and say the stream ended early above it; a script should refuse the result or retry. The right retry is a top-up or a smaller paste with the attempt suffix on the Idempotency-Key incremented — never a repair that appends brackets and calls the result a review.

One more honest limit: the model sees the paste clipped from the middle at 24,000 characters, whole lines at a time, with a marker line where the cut was. The engine reads the whole paste, so the facts — counts, flags, the assembly evidence — still describe every line; but the model can only quote what survived, and it says so in notes_on_input. For a file much larger than a paste, run the engine over all of it as a free gate and send the review a representative slice.