← Clade Desk / API
Tokens

Drive Clade Desk from your own code

Clade Desk reads a multiple sequence alignment and a tree and returns one of three structured reviews: an alignment QC pass that says which columns to keep and which taxa to trim, a tree audit that grades rooting, node support and topology, or a figure spec that pins down panels, palette, typography and a caption for a journal. Everything the web page does is available over HTTP, so the natural uses are a pipeline step that re-runs QC whenever an aligner finishes, a nightly sweep that audits every tree in a gene-family directory, and a submission checklist that generates the figure spec for each panel of a manuscript.

The reply is always one JSON object — no prose, no Markdown, no code fences — so it drops straight into a script. Everything below is the exact contract the app itself speaks; there is no second, friendlier API behind it.

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": { ... } } }

On success read data; on failure read error.code and branch on that — the message is for humans and details carries whatever the endpoint could say about the specific field or job that failed. A client that checks ok once, in one place, never has to check it again.

There is no X-App-Slug header

The slug clade-desk travels in exactly one place: the JSON body of POST /guest. After that the token itself carries the app binding, and the only headers any request needs are Content-Type: application/json and Authorization: Bearer <token> (plus Idempotency-Key on run calls). If you have copied a snippet from another SkillSafe app that sets X-App-Slug, delete that line; it is not read here and its presence hides the real cause when something 401s.

Endpoints

endpointmethodmeteredwhat it does
/guestPOSTnoMints a guest token from {"slug":"clade-desk"}. Returns 201.
/meGETnoWho the token belongs to, and the credit balance.
/estimatePOSTno — but authenticatedPrices a run. Creates no job, charges nothing, and 401s without a token.
/runPOSTyesStarts a run, returns {"job_id"}.
/job/{job_id}GETnoJob status and, once terminal, the output.
/run-streamPOSTyesThe same run as Server-Sent Events.

Error codes

codestatuswhat it means here, and what to do
UNAUTHORIZED401No token, a malformed token, or an expired one — expires_at from POST /guest has passed. This is also what you get for calling /estimate before you have minted anything. Mint a fresh token, or copy one from the token page.
FORBIDDEN403The token is real but not allowed to do this: a token minted for a different app, or a guest token attempting something reserved for a signed-in account. Re-mint against {"slug":"clade-desk"}, or sign in for a personal token.
NOT_FOUND404An unknown job_id in GET /job/{job_id}, or a path that does not exist. Job ids are opaque; do not construct them, only echo back what /run gave you.
VALIDATION_ERROR400The body is not valid JSON, or task is missing or is not one of align-qc, tree-audit, figure-spec, or a field has the wrong type — a list where a string belongs, a string where prescan_facts should be an object. details names the field. Note that a body wrapped in an "input" key does not land here; see the warning in step 5.
INSUFFICIENT_CREDITS402The balance is below min_credits. Call /estimate first, compare against credits from /me, and top up rather than retrying — a retry cannot succeed on its own.
RATE_LIMITED429Too many requests in too little time. Back off exponentially and retry; do not tight-loop. When polling GET /job/{job_id}, two seconds between polls is plenty.
INTERNAL500A server-side failure. Retry with the same Idempotency-Key so the retry cannot be billed as a second run.

task — the one field to get right first

task is the only required field in the input object and it is the field that decides everything else: which prompt runs, which extra keys come back, and which of the other inputs are even read. Send exactly one of three strings.

taskreadsadds to the output
align-qcalignment, seq_type, notes, focus, prescan_facts; tree only as contextsites, taxa_actions, trim_recipe
tree-audittree, support_kind, notes, focus, prescan_facts; alignment as corroborationrooting, nodes, topology_flags, support_statement
figure-spectree, journal, support_kind, notes, focus; alignment for panel sizingpanels, display_rules, palette, typography, scale_bar, caption, export_checklist

align-qc — is this alignment fit to infer a tree from?

Give it FASTA in alignment. It returns a keep-window over the columns (sites.keep_from / sites.keep_to, 1-based and inclusive), the ranges it wants dropped and why, a per-taxon verdict in taxa_actions (keep / trim / drop / resequence), and a runnable trim_recipe naming a tool and a command line. Use it before inference, and again after, if the aligner was re-run.

tree-audit — how much of this topology should you believe?

Give it a Newick or NHX string in tree and tell it what the support values are with support_kind. It returns a rooting verdict with a recommended outgroup, a per-node call of solid / weak / collapse, topology flags that name the taxa involved, and a support_statement you can paste into a methods section. nodes[].support comes back as the verbatim string from your tree — "98.7/100" stays "98.7/100" — because a dual-scale label loses its meaning the moment something averages it into a float.

figure-spec — what does the publishable figure look like?

The only lane that reads journal. It returns panel layout with sizes, display rules (which supports to print, where to collapse, how to order tips), a hex palette with a stated role per colour, typography for tip labels, support labels and the scale bar, a scale-bar decision, a draft caption, and an export_checklist to walk before submission. The spec is instructions for a renderer, not an image: nothing here draws anything for you.

Sending a task outside that set is a VALIDATION_ERROR. Sending no task at all is also a VALIDATION_ERROR — there is no default lane, deliberately, because guessing between QC and a figure spec would silently bill for the wrong review.

1. A tiny client

One helper that adds the two headers, unwraps data and raises on error. Because the envelope never changes, this is the only place in your program that has to know what a SkillSafe response looks like.

# Every call is the same three things: the base URL, your bearer token, and a JSON
# body. No X-App-Slug header anywhere -- the slug only appears in POST /guest.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="YOUR_TOKEN"    # from https://clade-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
}

# Unwrap with anything that reads JSON; these docs use python3 -c for portability.
ok() { python3 -c 'import sys,json;d=json.load(sys.stdin);sys.exit(0 if d.get("ok") else 1)'; }

2. Mint a token

The shortest path is the token page: it prints the token this browser already holds, with copy buttons for the raw token and for a ready-made shell export, and a sign-in button if you want a personal token instead of a guest one. Nothing on that page needs a developer tool — it reads the same storage the app uses.

From code, POST /guest with the body {"slug":"clade-desk"} mints one. Success is 201, not 200, so a client that tests status == 200 will reject a perfectly good token; test ok instead. The response is {"token","guest_id","expires_at"}: keep token, log guest_id if you need to correlate runs, and watch expires_at — once it passes, every call comes back UNAUTHORIZED and the fix is to mint again, not to retry.

This is the only request in the whole API that mentions the slug, and it carries it in the body rather than in a header. Nothing after this point needs to know the app's name.

# The slug goes in the BODY. There is no X-App-Slug header.
curl -sS -i -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"clade-desk"}'

# HTTP/2 201
# {"ok":true,"data":{"token":"sk_guest_...","guest_id":"gst_7f21c9",
#                    "expires_at":"2026-09-03T18:22:11Z"}}

TOKEN=$(curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"clade-desk"}' \
  | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["token"])')

3. Check the session with GET /me

GET /me returns exactly three fields:

fieldtypemeaning
subject_typestring"guest" or "user". This is the field you branch on.
subject_idstringThe opaque id of whoever the token belongs to — the same value whichever type it is. Log it; do not parse it.
creditsnumberThe credit balance available to this subject, in credits.

There is no user_id and no is_guest

Two field names that other APIs would have here are absent. There is no user_id — the identifier is subject_id regardless of subject type — and there is no is_guest boolean. Branch on subject_type === "guest". Code written against is_guest reads undefined, which is falsy, so a guest session silently presents itself as a signed-in one and the failure surfaces much later as a confusing 403.

Compare credits against min_credits from the next step before you start a run, so a shortfall becomes your own clear message instead of an INSUFFICIENT_CREDITS 402 in the middle of a pipeline.

call me
# {"ok":true,"data":{"subject_type":"guest","subject_id":"gst_7f21c9","credits":1200}}

# Branch on subject_type; there is no is_guest field to test.
call me | python3 -c 'import sys,json
d=json.load(sys.stdin)["data"]
print("guest session" if d["subject_type"]=="guest" else "signed in", d["credits"], "credits")'

4. Price the run with POST /estimate

/estimate is free — it creates no job and charges nothing — but it is authenticated. Calling it before you have a token returns UNAUTHORIZED (401), not a price. That trips people up because pricing feels like a public operation; it is not. Mint the token in step 2 first, then estimate.

The body of /estimate is the run input object itself — the exact same object you will later send to /run. That is deliberate: the price depends on the size of the alignment and the tree, so estimating a different object than you run tells you nothing.

The run input object

fieldtypemeaning
taskstring, requiredThe lane: "align-qc", "tree-audit" or "figure-spec". See the section above — it decides which other fields are read and which extra keys come back. Nothing else in this object is required.
alignmentstringFASTA text of the aligned sequences: >name header lines and sequence lines with gap characters in place. Unaligned FASTA is accepted and will be reported as a finding rather than silently aligned. This is the only evidence the align-qc lane has.
treestringA Newick or NHX string, one tree, terminated with ;. Internal-node labels are read as support values verbatim. May be "" when the tree was withheld for size — say so in clip_note rather than sending a truncated Newick, because a Newick cut mid-string parses as a different topology.
notesstringFree-text run notes, and the highest-value optional field in the object. Name the aligner and version, the tree method, the substitution model, the support method, the replicate count, the outgroup, and any trimming already applied. Every one of those changes the advice: the same 62 support value means something different under 100 bootstrap replicates than under 1000 ultrafast bootstrap replicates.
seq_typestring"auto", "dna", "rna" or "protein". "auto" lets the character composition decide, which is right almost always; set it explicitly when the alignment is short enough that composition is ambiguous, or when a codon alignment should be judged as DNA rather than as protein.
support_kindstring"auto", "bootstrap", "ufboot", "alrt", "posterior" or "jackknife". What the numbers on the internal nodes are. This changes the thresholds outright: 95 is unremarkable for ufboot and strong for bootstrap, and a posterior value lives on 0–1 rather than 0–100. Getting this wrong is the single most common way to get confidently wrong node calls.
journalstring"generic", "nature", "science", "cell", "plos" or "bmc". Only the figure-spec lane reads it; it sets column widths, figure-size limits and caption conventions. In the other two lanes it is ignored, not an error.
focusstring, optionalA one-line steer: "we care most about the rodent clade", "reviewer two asked about the outgroup", "target a single-column panel". Emphasis, not exclusivity — a critical finding elsewhere is never suppressed to honour a focus.
prescan_factsobjectWhat the browser measured before the call. See below.
clip_notestringWhat you cut before sending, in plain words: "alignment truncated to the first 400 columns of 3,142", "tree omitted, 1.2 MB". Honesty here is load-bearing: the review reasons about the whole dataset from a sample, and it can only caveat what it knows was removed. Silence reads as completeness.

prescan_facts, and why it is worth sending

In the browser, prescan_facts is computed for free before the run by a local FASTA and Newick reader. Its shape:

{
  "verdict": "clean" | "attention" | "blocking",
  "counts": { "taxa": 6, "sites": 16, "tips": 6, "flags": 4,
              "resources": 3, "taxaWithFindings": 5 },
  "seq_type": "dna",
  "support_kind": "ufboot",
  "thresholds": [ { "kind": "ufboot", "scale": 100, "threshold": 95 } ],
  "flags": [ { "id": "ALN-AMBIG", "severity": "medium",
               "label": "Ambiguity codes in 1 sequence",
               "detail": "Gallus_gallus has N at columns 6 and 9, K at column 12",
               "entities": ["Gallus_gallus"], "entity_total": 1 } ],
  "resources": [ { "id": "RES-ALN", "label": "FASTA, 6 records x 16 columns",
                   "detail": "no ragged ends; 2 gap columns" } ]
}

A caller driving the API directly does not have to reproduce any of that. Sending prescan_facts: {"verdict":"clean","counts":{},"flags":[],"resources":[]} is legitimate and the review still runs — the model reads alignment and tree either way.

What you give up is the reconciliation that this app is built around: every id you send in flags comes back exactly once in reconciliation, with addressed and a note. That turns a fact your own tooling already established into something the review is held to. An entry with addressed: false is a legitimate answer — a flag deliberately set aside, with the reason stated — and it is a completely different thing from silence. A flag you sent that never appears at all is a failed run, not a passing one, and asserting that in your pipeline is the cheapest quality gate available. Declaring "flags": [] means there is nothing to reconcile and nothing to assert, so the review is graded only against itself.

What /estimate returns

fieldvalue for this appmeaning
modelgpt-5.6-terraThe exact model the run is bound to.
model_aliasgpt-terraThe stable alias. Pin your logs to this if you want them to survive a model bump.
markup_bps1000The app's markup in basis points — 1000 bps is 10%.
hold_creditsvaries with input sizeWhat gets reserved when the run starts. It prices the full output cap, so the amount actually charged is usually well below it. Budget against this number.
min_creditsvariesThe balance you must clear for the run to be accepted at all. Compare it against credits from /me.
sponsor_enabledbooleanWhether the app is covering this run. When true, the hold may not come out of your own balance.
# The body is the input object itself. Not wrapped in "input".
read -r -d '' INPUT <<'JSON'
{"task":"align-qc",
 "seq_type":"auto",
 "support_kind":"ufboot",
 "alignment":">Homo_sapiens\nATGGCCTTGAAGCTGA\n>Pan_troglodytes\nATGGCCTTGAAGCTGA\n>Mus_musculus\nATGGCTTTGAA-CTGA\n>Rattus_norvegicus\nATGGCTTTGAA-CTGA\n>Gallus_gallus\nATGGCNTTNAAKCTGA\n>Xenopus_tropicalis\nATG---TTGAAGCT-A\n",
 "tree":"((Homo_sapiens:0.011,Pan_troglodytes:0.013)98.7/100:0.032,(Mus_musculus:0.081,Rattus_norvegicus:0.074)62/88:0.051,(Gallus_gallus:0.204,Xenopus_tropicalis:0.288)77/95:0.061);",
 "notes":"MAFFT v7.520 --auto; IQ-TREE 2.2.2.7, GTR+F+G4, 1000 ultrafast bootstrap replicates plus SH-aLRT; outgroup Xenopus_tropicalis; no trimming applied yet",
 "journal":"generic",
 "focus":"decide whether to trim before we infer the published tree",
 "clip_note":"",
 "prescan_facts":{"verdict":"attention",
   "counts":{"taxa":6,"sites":16,"tips":6,"flags":2,"resources":2,"taxaWithFindings":3},
   "seq_type":"dna","support_kind":"ufboot",
   "thresholds":[{"kind":"ufboot","scale":100,"threshold":95}],
   "flags":[
     {"id":"ALN-AMBIG","severity":"medium","label":"Ambiguity codes in 1 sequence",
      "detail":"Gallus_gallus: N at columns 6 and 9, K at column 12",
      "entities":["Gallus_gallus"],"entity_total":1},
     {"id":"ALN-GAP-BLOCK","severity":"medium","label":"Internal gap block in 1 taxon",
      "detail":"Xenopus_tropicalis is gapped at columns 4-6",
      "entities":["Xenopus_tropicalis"],"entity_total":1}],
   "resources":[
     {"id":"RES-ALN","label":"FASTA, 6 records x 16 columns","detail":"2 gap columns, no ragged ends"},
     {"id":"RES-TREE","label":"Newick, 6 tips, 3 internal nodes","detail":"dual-scale support labels"}]}}
JSON

call estimate "$INPUT"
# {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra",
#                    "markup_bps":1000,"hold_credits":742,"min_credits":120,
#                    "sponsor_enabled":false}}

5. Run it, then poll

The request body is the input object itself — do not wrap it in "input"

Send {"task":"align-qc","alignment":"...","tree":"..."} as the whole body. Do not send {"input":{"task":"align-qc", ...}}.

This matters more than a normal shape mistake because the wrapped form does not fail. It returns 200, you get a job_id, /estimate quotes a plausible hold_credits, and the run proceeds — but the model never sees a task field, because task is now nested one level down where nothing reads it. You pay full price for a run against a payload the prompt cannot read, and what comes back is a confidently-shaped object built on nothing. There is no error code for this. The only defence is to send the input object flat, and to assert on the way back that lane in the output equals the task you sent.

POST /run returns {"job_id"}. Poll GET /job/{job_id} until status is terminal — succeeded or failed — with a couple of seconds between polls. The review JSON is the string at data.output.output; parse it and you have the object described in the output contract below. The terminal job also carries charged_credits, which is the real price and is normally well under hold_credits.

Idempotency

Always send an Idempotency-Key header on /run and /run-stream. A retried request carrying the same key returns the same job rather than billing a second run, which is exactly what makes a retry after a dropped connection safe. The app derives its key the same way every time, and you should copy the scheme:

clade-desk:<stable digest of the input>:<lane>:a<attempt>

clade-desk:5774d3bb:align-qc:a1          <- what the web app itself sends
clade-desk:9f2c41a7be0d3155:align-qc:a1  <- what the samples below send

The exact digest is yours to choose — the platform only needs it to be stable for a given input. The web app uses a 32-bit FNV-1a hash of the concatenated task, alignment, tree, notes, alphabet, support method, journal and focus fields, which is why its digests are eight hex characters rather than sixteen. The samples below use a SHA-256 prefix over the canonical JSON, which is the better choice for a client you are writing from scratch.

Three parts, each doing a job. The content hash means the same input retried is recognised as the same run. The lane means the same alignment sent to align-qc and to tree-audit are two different runs and not a replay of one another — without it, switching task on identical data would collide. The attempt counter is what you bump deliberately when you want a genuinely new run of the same input, for instance after a reply that failed to parse. Hash the input canonically — sorted keys, no incidental whitespace — or two logically identical bodies will produce two keys and two bills.

# $INPUT is the flat input object from step 4. It is the ENTIRE body -- there is
# no {"input": ...} wrapper.
KEY="clade-desk:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16):align-qc:a1"

JOB=$(curl -sS -X POST "$BASE/run" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d "$INPUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')

# Poll GET /job/{job_id} until the status is terminal.
while :; do
  OUT=$(call "job/$JOB")
  STATUS=$(printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["status"])')
  [ "$STATUS" = "succeeded" ] && break
  [ "$STATUS" = "failed" ] && echo "$OUT" && exit 1
  sleep 2
done

# {"ok":true,"data":{"job_id":"job_8c31de","status":"succeeded",
#   "output":{"output":"{\"lane\":\"align-qc\",\"verdict\":\"attention\", ...}"},
#   "charged_credits":214}}
printf '%s' "$OUT" \
  | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["output"]["output"])' \
  | python3 -c 'import sys,json
r=json.load(sys.stdin)
assert r["lane"]=="align-qc", "wrong lane back -- was the body wrapped in input?"
print(r["verdict"], r["headline"])'

6. Or stream it with POST /run-stream

POST /run-stream is the same run over Server-Sent Events, with the same body, the same Idempotency-Key and the same billing. Send Accept: text/event-stream and read three kinds of event: a job event with the job_id, a series of delta events each carrying {"text": "..."} — a chunk of the review JSON — and a final done event carrying status and charged_credits.

Accumulate the delta text and parse once at the end. Do not try to parse the partial string as you go: JSON is not incrementally parseable, and the app's own progress display does not attempt it. It watches for key names appearing in the accumulating text instead — the arrival of "findings", then "sites", then "trim_recipe" is what advances the stage indicator. Substring matching on the quoted key name costs nothing and cannot throw.

If the connection drops mid-stream, do not start a new run: re-send with the same Idempotency-Key, or simply poll GET /job/{job_id} with the id from the job event. The run continues server-side either way, and you are billed once.

# Same body, same key, streamed. -N disables curl's output buffering.
curl -N -X POST "$BASE/run-stream" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -H "Accept: text/event-stream" \
  -d "$INPUT"

# event: job    {"job_id":"job_8c31de"}
# event: delta  {"text":"{\"lane\":\"align-qc\",\"subject\":\"6-taxon"}
# event: delta  {"text":" COI alignment, 16 columns\",\"verdict\":\"attention\","}
# event: delta  {"text":"\"findings\":[{\"id\":\"F1\",\"title\":\"Ambiguity"}
# event: done   {"status":"succeeded","charged_credits":214}

The output contract

data.output.output is a string containing one JSON object. No prose before it, no prose after it, no Markdown, no ```json fence. Parse it directly. If what you got does not parse, treat it as a failed run and retry with the attempt counter in the Idempotency-Key bumped — do not try to repair it by stripping characters, because a body that parses after surgery is not necessarily the body the model meant.

The common envelope — present in every lane

fieldtypemeaning
lanestringEchoes the task you sent. Assert on this — it is the one cheap check that catches a wrapped request body.
subjectstringA short identification of what was reviewed, e.g. "6-taxon COI alignment, 16 columns".
verdictstring"clean", "attention" or "blocking". blocking means do not proceed to the next step of the analysis as things stand.
headlinestringOne sentence a colleague could read out. Safe to put in a CI summary line.
summaryarray of stringsThe short-form account, one point per element. No nesting, so it renders anywhere.
findingsarray of objects{id, title, severity, where, evidence, why, fix, prescan_ids}. severity is "critical", "high", "medium" or "low". where names a column range, a node or a taxon; evidence quotes what was actually seen; fix is an action, not an aspiration; prescan_ids lists the prescan_facts.flags[].id values this finding covers, which is how a finding is traced back to a measurement.
reconciliationarray of objects{fact_id, addressed, note}one row per prescan_facts.flags[].id, each id exactly once. addressed: false with a reason is a valid outcome; a missing id is a broken run. Assert the set equality in your pipeline.
assumptionsarray of stringsWhat had to be assumed to answer at all — unstated substitution model, unknown gene, assumed reading frame. Read these before acting on the findings.
open_questionsarray of stringsWhat the reviewer would ask you if it could. Usually answerable by adding a sentence to notes and re-running.
methods_sentencestringOne sentence, written for a methods section, describing what was done and with what tools. Edit it, do not paste it blind — it reflects what you said in notes.

Enums, in one place

whereallowed values
verdictclean · attention · blocking
findings[].severitycritical · high · medium · low
taxa_actions[].actionkeep · trim · drop · resequence
rooting.statusrooted · unrooted · midpoint · unclear
nodes[].callsolid · weak · collapse

align-qc adds

fieldtypemeaning
sitesobject{keep_from, keep_to, drop_ranges, rationale}. keep_from and keep_to are 1-based, inclusive column numbers — {keep_from: 4, keep_to: 15} keeps twelve columns, including both 4 and 15. Off-by-one here silently shifts a reading frame, so convert once, in one function, and test it. drop_ranges is [{from, to, why}] with the same convention, for ranges inside the keep window. rationale is the reasoning for the window as a whole.
taxa_actionsarray[{taxon, action, why}], with action one of keep, trim, drop, resequence. Every taxon in the alignment appears, including the ones with nothing wrong — a keep row is information, not filler, because it tells you the taxon was considered.
trim_recipeobject{tool, command, note}. A named tool, a command line consistent with the window above, and a note about what the command does not handle. Read the command before running it; it is generated text, not a tested script.

tree-audit adds

fieldtypemeaning
rootingobject{status, recommended_outgroup, why}, status one of rooted, unrooted, midpoint, unclear. A trifurcation at the base of a Newick string is the usual reason for unrooted, and unclear is an honest answer when the string alone cannot tell you.
nodesarray[{node, support, call, why}]. support is the verbatim string read off the tree: "98.7/100" stays exactly that, and "62/88" is not averaged into 75. call is solid, weak or collapse, judged against the support_kind you declared. collapse means present it as a polytomy rather than pretending to a resolution the data does not support.
topology_flagsarray[{flag, taxa, why}]. Named suspicions — long-branch attraction, a rogue taxon, non-monophyly of an expected group — each with the taxa involved so you can act on it without guessing.
support_statementstringOne sentence stating what the support values are and how they should be reported. This is the sentence reviewers ask for.

figure-spec adds

fieldtypemeaning
panelsarray[{id, content, size}]. One entry per panel, id being the panel letter, content what goes in it, size a physical size or column count consistent with the journal you named.
display_rulesarray[{rule, value, why}]. The decisions a renderer needs made for it: which support values to print, below what value to collapse, how to order tips, whether to show branch lengths.
palettearray[{role, hex, use}]. A role name, a hex value, and where to use it. Roles rather than raw colours, so the palette survives a house-style change.
typographyobject{tip_labels, support_labels, scale_bar, font} — sizes and styles for each class of text, plus one font recommendation.
scale_barobject{show, unit, length}. show is a boolean; unit says what the length means (substitutions per site, millions of years); length is the bar's value.
captionstringA draft caption naming the method, the model, the support type and the replicate count. Check it against your own methods before submission.
export_checklistarray of stringsThings to verify before you submit: vector format, embedded fonts, colour-blind safety, minimum line weight, panel letter placement.

Invariants worth asserting

Worked example 1 — align-qc

A six-taxon COI fragment, sixteen aligned columns, straight out of MAFFT. Two things were measured in advance and declared in prescan_facts.flags, so two rows must come back in reconciliation. The request body below is the complete object; nothing is elided and nothing wraps it.

read -r -d '' INPUT <<'JSON'
{"task":"align-qc",
 "seq_type":"dna",
 "support_kind":"ufboot",
 "journal":"generic",
 "alignment":">Homo_sapiens\nATGGCCTTGAAGCTGA\n>Pan_troglodytes\nATGGCCTTGAAGCTGA\n>Mus_musculus\nATGGCTTTGAA-CTGA\n>Rattus_norvegicus\nATGGCTTTGAA-CTGA\n>Gallus_gallus\nATGGCNTTNAAKCTGA\n>Xenopus_tropicalis\nATG---TTGAAGCT-A\n",
 "tree":"((Homo_sapiens:0.011,Pan_troglodytes:0.013)98.7/100:0.032,(Mus_musculus:0.081,Rattus_norvegicus:0.074)62/88:0.051,(Gallus_gallus:0.204,Xenopus_tropicalis:0.288)77/95:0.061);",
 "notes":"COI barcode fragment. MAFFT v7.520 --auto, no manual editing. IQ-TREE 2.2.2.7, GTR+F+G4 chosen by ModelFinder, 1000 ultrafast bootstrap replicates plus SH-aLRT. Intended outgroup Xenopus_tropicalis. No trimming applied yet.",
 "focus":"decide what to trim before we infer the tree we will publish",
 "clip_note":"",
 "prescan_facts":{"verdict":"attention",
   "counts":{"taxa":6,"sites":16,"tips":6,"flags":2,"resources":2,"taxaWithFindings":3},
   "seq_type":"dna","support_kind":"ufboot",
   "thresholds":[{"kind":"ufboot","scale":100,"threshold":95},
                 {"kind":"gap_fraction","scale":1,"threshold":0.5}],
   "flags":[
     {"id":"ALN-AMBIG","severity":"medium","label":"Ambiguity codes in 1 sequence",
      "detail":"Gallus_gallus: N at columns 6 and 9, K at column 12",
      "entities":["Gallus_gallus"],"entity_total":1},
     {"id":"ALN-GAP-BLOCK","severity":"medium","label":"Internal gap block in 1 taxon",
      "detail":"Xenopus_tropicalis is gapped at columns 4-6 and 15",
      "entities":["Xenopus_tropicalis"],"entity_total":1}],
   "resources":[
     {"id":"RES-ALN","label":"FASTA, 6 records x 16 columns",
      "detail":"no ragged ends; gaps at columns 4-6, 12, 15"},
     {"id":"RES-TREE","label":"Newick, 6 tips, 3 internal nodes",
      "detail":"dual-scale internal labels, basal trifurcation"}]}}
JSON

KEY="clade-desk:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16):align-qc:a1"

curl -sS -X POST "$BASE/run" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d "$INPUT"

What comes back, abbreviated in the long text fields but complete in shape:

{
  "lane": "align-qc",
  "subject": "6-taxon COI fragment, 16 aligned columns, DNA",
  "verdict": "attention",
  "headline": "Usable after masking columns 4-6 and 12 and re-checking the Gallus_gallus read.",
  "summary": [
    "Four of six sequences are clean across all 16 columns.",
    "Gallus_gallus carries three ambiguity codes, two of them in the same codon.",
    "Xenopus_tropicalis is gapped at columns 4-6, which is where the only gap block sits.",
    "Homo_sapiens and Pan_troglodytes are identical here, so this fragment cannot separate them."
  ],
  "findings": [
    {
      "id": "F1",
      "title": "Ambiguity codes cluster in one codon of Gallus_gallus",
      "severity": "medium",
      "where": "Gallus_gallus, columns 6, 9 and 12",
      "evidence": "ATGGCNTTNAAKCTGA -- N at 6, N at 9, K at 12",
      "why": "Three ambiguous sites in a 16-column fragment is 19% of the sequence, and two of them fall in the same codon, so the amino-acid translation of that codon is unconstrained.",
      "fix": "Mask columns 6, 9 and 12 for this taxon, or re-call the base from the trace if the chromatogram is available.",
      "prescan_ids": ["ALN-AMBIG"]
    },
    {
      "id": "F2",
      "title": "Gap block at columns 4-6 is carried by a single taxon",
      "severity": "medium",
      "where": "columns 4-6, Xenopus_tropicalis only",
      "evidence": "ATG---TTGAAGCT-A",
      "why": "A gap present in exactly one taxon is more often an alignment artefact or a sequencing dropout than a real indel, and it is the outgroup carrying it, which is where a spurious long branch would do the most damage.",
      "fix": "Drop columns 4-6 for all taxa; a three-column loss costs little and removes the only taxon-specific gap block.",
      "prescan_ids": ["ALN-GAP-BLOCK"]
    },
    {
      "id": "F3",
      "title": "Two sequences are identical over the whole fragment",
      "severity": "low",
      "where": "Homo_sapiens, Pan_troglodytes",
      "evidence": "both ATGGCCTTGAAGCTGA",
      "why": "The 98.7/100 support on that pair comes from the rest of the locus, not from these columns; on this fragment alone the pair is unresolved.",
      "fix": "Keep both, and do not report the human-chimp node from this fragment. Extend the locus if that node matters.",
      "prescan_ids": []
    }
  ],
  "reconciliation": [
    { "fact_id": "ALN-AMBIG", "addressed": true,
      "note": "Covered by F1; masking recommended rather than dropping the taxon." },
    { "fact_id": "ALN-GAP-BLOCK", "addressed": true,
      "note": "Covered by F2; columns 4-6 dropped for all taxa." }
  ],
  "assumptions": [
    "The fragment is protein-coding COI in frame starting at column 1, as implied by the ATG at columns 1-3.",
    "The 16 columns are a contiguous excerpt of a longer locus, since clip_note was empty but a 16-column barcode is implausible."
  ],
  "open_questions": [
    "Is a chromatogram available for the Gallus_gallus read, or is the N a consensus of pooled reads?",
    "Was MAFFT run on the whole locus and this window extracted afterwards, or on the window itself?"
  ],
  "methods_sentence": "Sequences were aligned with MAFFT v7.520 (--auto) and columns 4-6 and 12 were masked prior to inference; the resulting alignment of 6 taxa and 12 retained columns was analysed under GTR+F+G4 in IQ-TREE 2.2.2.7 with 1000 ultrafast bootstrap replicates and SH-aLRT.",
  "sites": {
    "keep_from": 1,
    "keep_to": 16,
    "drop_ranges": [
      { "from": 4, "to": 6, "why": "Gap block carried by Xenopus_tropicalis alone (F2)." },
      { "from": 12, "to": 12, "why": "Gapped in the two rodents and ambiguous (K) in Gallus_gallus; three of six taxa uninformative." }
    ],
    "rationale": "Both ends are clean and aligned across all six taxa, so the window is the full fragment; the two interior ranges are removed rather than the flanks, which is why keep_from and keep_to are 1 and 16."
  },
  "taxa_actions": [
    { "taxon": "Homo_sapiens", "action": "keep", "why": "Complete, unambiguous, no gaps." },
    { "taxon": "Pan_troglodytes", "action": "keep", "why": "Complete and identical to Homo_sapiens over this window (F3)." },
    { "taxon": "Mus_musculus", "action": "keep", "why": "Single gap at column 12, which is dropped anyway." },
    { "taxon": "Rattus_norvegicus", "action": "keep", "why": "As Mus_musculus." },
    { "taxon": "Gallus_gallus", "action": "resequence", "why": "Three ambiguity codes in 16 columns (F1); masking works for now, a re-call is the real fix." },
    { "taxon": "Xenopus_tropicalis", "action": "trim", "why": "Sole carrier of the columns 4-6 gap block (F2); trimming those columns keeps the taxon." }
  ],
  "trim_recipe": {
    "tool": "trimAl 1.4.1",
    "command": "trimal -in coi.fasta -selectcols { 3-5,11 }",
    "note": "trimAl column indices are 0-based, so 1-based columns 4-6 and 12 are 3-5 and 11. The command prints to stdout; add -out to save the result. It removes columns for every taxon, which is what F2 asks for, and does nothing about the Gallus_gallus ambiguity codes at columns 6 and 9 -- mask those separately."
  }
}

Worked example 2 — tree-audit

The same six taxa, now asking about the topology. The tree carries dual-scale internal labels and a basal trifurcation, and two of the six branches are much longer than the rest — all three of which the prescan measured, so all three ids must be reconciled. Note that support_kind is declared as "ufboot" while the labels are actually SH-aLRT/UFboot pairs; the audit says so rather than quietly using the wrong threshold.

read -r -d '' INPUT <<'JSON'
{"task":"tree-audit",
 "seq_type":"dna",
 "support_kind":"ufboot",
 "journal":"generic",
 "tree":"((Homo_sapiens:0.011,Pan_troglodytes:0.013)98.7/100:0.032,(Mus_musculus:0.081,Rattus_norvegicus:0.074)62/88:0.051,(Gallus_gallus:0.204,Xenopus_tropicalis:0.288)77/95:0.061);",
 "alignment":">Homo_sapiens\nATGGCCTTGAAGCTGA\n>Pan_troglodytes\nATGGCCTTGAAGCTGA\n>Mus_musculus\nATGGCTTTGAA-CTGA\n>Rattus_norvegicus\nATGGCTTTGAA-CTGA\n>Gallus_gallus\nATGGCNTTNAAKCTGA\n>Xenopus_tropicalis\nATG---TTGAAGCT-A\n",
 "notes":"IQ-TREE 2.2.2.7, GTR+F+G4, 1000 ultrafast bootstrap replicates and SH-aLRT with 1000 replicates; internal labels are written as aLRT/UFboot. Intended outgroup Xenopus_tropicalis. Tree written unrooted straight from IQ-TREE.",
 "focus":"is the rodent node reportable, and is the amphibian-bird pairing real",
 "clip_note":"alignment shown is a 16-column excerpt of the 657-column locus the tree was built from",
 "prescan_facts":{"verdict":"attention",
   "counts":{"taxa":6,"sites":16,"tips":6,"flags":3,"resources":2,"taxaWithFindings":4},
   "seq_type":"dna","support_kind":"ufboot",
   "thresholds":[{"kind":"ufboot","scale":100,"threshold":95},
                 {"kind":"alrt","scale":100,"threshold":80}],
   "flags":[
     {"id":"TREE-UNROOTED","severity":"medium","label":"Basal trifurcation",
      "detail":"three lineages descend from the root node, so the string encodes an unrooted tree",
      "entities":[],"entity_total":0},
     {"id":"TREE-LOWSUPP","severity":"high","label":"1 internal node below threshold on both scales",
      "detail":"the (Mus_musculus,Rattus_norvegicus) node is labelled 62/88",
      "entities":["Mus_musculus","Rattus_norvegicus"],"entity_total":2},
     {"id":"TREE-LONGBRANCH","severity":"high","label":"2 terminal branches over 3x the median",
      "detail":"Gallus_gallus 0.204 and Xenopus_tropicalis 0.288 against a median terminal length of 0.0435; the two sit together",
      "entities":["Gallus_gallus","Xenopus_tropicalis"],"entity_total":2}],
   "resources":[
     {"id":"RES-TREE","label":"Newick, 6 tips, 3 internal nodes","detail":"dual-scale internal labels"},
     {"id":"RES-NOTES","label":"Run notes name 4 methods",
      "detail":"IQ-TREE 2.2.2.7, GTR+F+G4, 1000 UFboot, SH-aLRT"}]}}
JSON

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

curl -sS -X POST "$BASE/run" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d "$INPUT"

The reply, again abbreviated in the prose fields and complete in shape:

{
  "lane": "tree-audit",
  "subject": "6-tip DNA tree, 3 internal nodes, aLRT/UFboot labels",
  "verdict": "blocking",
  "headline": "Do not report the (Gallus,Xenopus) group: it pairs the two longest branches and is the classic long-branch artefact.",
  "summary": [
    "The tree is unrooted -- a basal trifurcation, not a rooted three-taxon split.",
    "One node of three is solid, one is weak, one should be collapsed.",
    "The declared support_kind is ufboot but the labels are aLRT/UFboot pairs, so two thresholds apply, not one.",
    "The amphibian-bird pairing conflicts with accepted vertebrate relationships and is unsupported at the aLRT scale."
  ],
  "findings": [
    {
      "id": "F1",
      "title": "(Gallus_gallus,Xenopus_tropicalis) joins the two longest terminal branches",
      "severity": "critical",
      "where": "the 77/95 node",
      "evidence": "Gallus_gallus:0.204 and Xenopus_tropicalis:0.288 against a median terminal length of 0.0435",
      "why": "Under a single-locus GTR model, the two fastest-evolving lineages attract each other; this grouping also contradicts the accepted amniote-amphibian split, so the simpler explanation is an artefact rather than a discovery.",
      "fix": "Re-infer under a site-heterogeneous or partitioned model, add a second amphibian and a second bird to break the branches, and check whether the grouping survives.",
      "prescan_ids": ["TREE-LONGBRANCH"]
    },
    {
      "id": "F2",
      "title": "The rodent node is not reportable as resolved",
      "severity": "high",
      "where": "the 62/88 node",
      "evidence": "SH-aLRT 62 against a threshold of 80, UFboot 88 against 95 -- below both",
      "why": "Failing both scales is different from failing one; there is no reading of these numbers under which the node is supported.",
      "fix": "Collapse it to a polytomy in the published figure, or report Mus and Rattus as an unresolved pair and say why.",
      "prescan_ids": ["TREE-LOWSUPP"]
    },
    {
      "id": "F3",
      "title": "support_kind understates what the labels contain",
      "severity": "medium",
      "where": "input.support_kind, all three internal labels",
      "evidence": "support_kind is \"ufboot\" but every label is of the form 98.7/100",
      "why": "Judging 62/88 as a single UFboot value would read it as 62 and reject the node for the wrong reason; the first number is SH-aLRT on its own scale.",
      "fix": "Keep sending the verbatim labels, and set support_kind to alrt when you want the first number judged; the audit reads both scales when the label is a pair.",
      "prescan_ids": []
    },
    {
      "id": "F4",
      "title": "The tree is unrooted, so no direction of evolution can be read off it",
      "severity": "medium",
      "where": "the root node",
      "evidence": "three lineages descend from the outermost pair of parentheses",
      "why": "Statements about which group is basal or ancestral are meaningless on an unrooted tree, and a renderer that draws it as rooted invents a root silently.",
      "fix": "Root on Xenopus_tropicalis as the notes intend, and state in the caption that the tree was rooted a posteriori.",
      "prescan_ids": ["TREE-UNROOTED"]
    }
  ],
  "reconciliation": [
    { "fact_id": "TREE-UNROOTED", "addressed": true,
      "note": "F4; rooting on the declared outgroup recommended." },
    { "fact_id": "TREE-LOWSUPP", "addressed": true,
      "note": "F2; collapse recommended, judged against both scales." },
    { "fact_id": "TREE-LONGBRANCH", "addressed": true,
      "note": "F1; treated as the blocking finding rather than a caveat." }
  ],
  "assumptions": [
    "The labels are SH-aLRT/UFboot in that order, which is IQ-TREE's default when both are requested.",
    "Branch lengths are substitutions per site, as GTR+F+G4 implies.",
    "The 657-column locus named in clip_note is a single gene, not a concatenation."
  ],
  "open_questions": [
    "Was ModelFinder allowed to consider partitioned or site-heterogeneous models, or only the standard set?",
    "Is a second amphibian or bird available to break the two long branches?"
  ],
  "methods_sentence": "Phylogenetic inference was performed in IQ-TREE 2.2.2.7 under GTR+F+G4 selected by ModelFinder, with branch support assessed by 1000 ultrafast bootstrap replicates and SH-aLRT (1000 replicates); the resulting unrooted tree was rooted a posteriori on Xenopus_tropicalis and nodes with SH-aLRT below 80 or UFboot below 95 were collapsed.",
  "rooting": {
    "status": "unrooted",
    "recommended_outgroup": "Xenopus_tropicalis",
    "why": "The string has a basal trifurcation, so it is unrooted as written. The notes name Xenopus_tropicalis as the intended outgroup and it is the deepest-diverging taxon present -- but see F1: it also carries the longest branch, so rooting on it and the long-branch artefact are entangled and should be resolved together."
  },
  "nodes": [
    { "node": "(Homo_sapiens,Pan_troglodytes)", "support": "98.7/100", "call": "solid",
      "why": "Above threshold on both scales; nothing about this node is in question." },
    { "node": "(Mus_musculus,Rattus_norvegicus)", "support": "62/88", "call": "collapse",
      "why": "Below 80 aLRT and below 95 UFboot -- fails both, so present as a polytomy." },
    { "node": "(Gallus_gallus,Xenopus_tropicalis)", "support": "77/95", "call": "weak",
      "why": "UFboot just reaches 95 while aLRT does not reach 80; combined with F1 the support is not evidence that the grouping is real." }
  ],
  "topology_flags": [
    { "flag": "long-branch attraction", "taxa": ["Gallus_gallus", "Xenopus_tropicalis"],
      "why": "The two longest terminal branches in the tree are sisters, and the grouping they form is not the accepted one." },
    { "flag": "unresolved rodent pair", "taxa": ["Mus_musculus", "Rattus_norvegicus"],
      "why": "Support fails on both scales at this locus; the pair is uncontroversial elsewhere, which is a further sign this locus is under-informative." },
    { "flag": "identical sequences in a supported clade", "taxa": ["Homo_sapiens", "Pan_troglodytes"],
      "why": "The 98.7/100 label is carried by the wider locus; the 16-column excerpt cannot separate the two taxa at all." }
  ],
  "support_statement": "Node support is reported as SH-aLRT / ultrafast bootstrap from 1000 replicates each, computed in IQ-TREE 2.2.2.7; nodes are treated as supported only when SH-aLRT is at least 80 and UFboot at least 95, and nodes failing either criterion are shown collapsed."
}

Worked example 3 — figure-spec

The same tree, now on its way into a manuscript. This is the only lane that reads journal, and it is the field that decides the physical sizes in panels[].size. The prescan contributes two facts here, one about support and one about label length, and both come back reconciled.

read -r -d '' INPUT <<'JSON'
{"task":"figure-spec",
 "journal":"nature",
 "seq_type":"dna",
 "support_kind":"ufboot",
 "tree":"((Homo_sapiens:0.011,Pan_troglodytes:0.013)98.7/100:0.032,(Mus_musculus:0.081,Rattus_norvegicus:0.074)62/88:0.051,(Gallus_gallus:0.204,Xenopus_tropicalis:0.288)77/95:0.061);",
 "alignment":">Homo_sapiens\nATGGCCTTGAAGCTGA\n>Pan_troglodytes\nATGGCCTTGAAGCTGA\n>Mus_musculus\nATGGCTTTGAA-CTGA\n>Rattus_norvegicus\nATGGCTTTGAA-CTGA\n>Gallus_gallus\nATGGCNTTNAAKCTGA\n>Xenopus_tropicalis\nATG---TTGAAGCT-A\n",
 "notes":"Figure 2 of a submission to Nature. IQ-TREE 2.2.2.7, GTR+F+G4, 1000 UFboot plus SH-aLRT, labels written aLRT/UFboot. Rooted on Xenopus_tropicalis for display. Tip labels are binomials in full. House style is greyscale-safe.",
 "focus":"single-column panel, with an inset of the 16-column alignment window",
 "clip_note":"alignment is the 16-column excerpt used for the inset; the tree comes from the full 657-column locus",
 "prescan_facts":{"verdict":"attention",
   "counts":{"taxa":6,"sites":16,"tips":6,"flags":2,"resources":2,"taxaWithFindings":2},
   "seq_type":"dna","support_kind":"ufboot",
   "thresholds":[{"kind":"ufboot","scale":100,"threshold":95},
                 {"kind":"alrt","scale":100,"threshold":80}],
   "flags":[
     {"id":"TREE-LOWSUPP","severity":"high","label":"1 internal node below threshold on both scales",
      "detail":"the (Mus_musculus,Rattus_norvegicus) node is labelled 62/88",
      "entities":["Mus_musculus","Rattus_norvegicus"],"entity_total":2},
     {"id":"TIP-LABEL-LONG","severity":"low","label":"Longest tip label is 19 characters",
      "detail":"Xenopus_tropicalis and Rattus_norvegicus are 18 and 17 characters; at 6 pt they occupy roughly 22 mm",
      "entities":["Xenopus_tropicalis","Rattus_norvegicus"],"entity_total":2}],
   "resources":[
     {"id":"RES-TREE","label":"Newick, 6 tips, 3 internal nodes","detail":"dual-scale internal labels"},
     {"id":"RES-ALN","label":"FASTA, 6 records x 16 columns","detail":"inset candidate"}]}}
JSON

KEY="clade-desk:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16):figure-spec:a1"

curl -sS -X POST "$BASE/run" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d "$INPUT"

The reply. Note that the spec is instructions for a renderer, not an image:

{
  "lane": "figure-spec",
  "subject": "6-tip tree with alignment inset, single-column Nature figure",
  "verdict": "attention",
  "headline": "Fits one column at 89 mm if the rodent node is drawn collapsed and tip labels are italic at 6 pt.",
  "summary": [
    "Two panels: the tree at 89 mm wide, an alignment inset beneath it.",
    "Print support only on nodes that pass both thresholds; draw the failing node as a polytomy.",
    "Palette is four roles, all distinguishable in greyscale, as the notes require.",
    "The longest tip label sets the right margin, so the tree's drawable width is 63 mm, not 89 mm."
  ],
  "findings": [
    {
      "id": "F1",
      "title": "A node that fails both support thresholds must not be drawn resolved",
      "severity": "high",
      "where": "panel a, the (Mus_musculus,Rattus_norvegicus) node",
      "evidence": "62/88 against thresholds of 80 aLRT and 95 UFboot",
      "why": "A bifurcation drawn at full weight reads as a claim; a reader cannot tell it apart from the 98.7/100 node without checking the labels, which most readers will not do.",
      "fix": "Draw it as a polytomy and say so in the caption. Do not print the 62/88 label on a collapsed node.",
      "prescan_ids": ["TREE-LOWSUPP"]
    },
    {
      "id": "F2",
      "title": "Tip labels consume a quarter of the column width",
      "severity": "low",
      "where": "panel a, right margin",
      "evidence": "longest label 19 characters, roughly 22 mm at 6 pt italic",
      "why": "At 89 mm total there is no room to discover this at layout time; the tree has to be drawn into 63 mm from the start or the labels will be clipped or shrunk below the legibility floor.",
      "fix": "Reserve 22 mm plus a 4 mm gutter for labels and lay the tree out in the remaining 63 mm. Do not abbreviate the binomials to buy space.",
      "prescan_ids": ["TIP-LABEL-LONG"]
    }
  ],
  "reconciliation": [
    { "fact_id": "TREE-LOWSUPP", "addressed": true,
      "note": "F1; the node is specified as a collapsed polytomy in display_rules." },
    { "fact_id": "TIP-LABEL-LONG", "addressed": true,
      "note": "F2; 22 mm reserved, which is why panel a's drawable width is 63 mm." }
  ],
  "assumptions": [
    "Nature single-column width is 89 mm and double-column 183 mm; confirm against the current author guide before final submission.",
    "The figure is Figure 2 and will be numbered as such, per the notes.",
    "Greyscale safety means the print edition, so the palette is specified with luminance separation and not only hue separation."
  ],
  "open_questions": [
    "Should the inset show all 16 columns, or only the 12 retained after trimming?",
    "Is there a house requirement for panel letters to be bold lower case, or is upper case acceptable?"
  ],
  "methods_sentence": "The maximum-likelihood tree inferred in IQ-TREE 2.2.2.7 under GTR+F+G4 was rooted on Xenopus_tropicalis and drawn with nodes failing SH-aLRT 80 or UFboot 95 collapsed; the inset shows the 16-column window of the alignment used for illustration.",
  "panels": [
    { "id": "a", "content": "Rooted maximum-likelihood tree, 6 tips, support printed only on passing nodes, scale bar bottom left.", "size": "89 x 62 mm (single column; 63 mm drawable width after the 22 mm label margin and a 4 mm gutter)" },
    { "id": "b", "content": "Alignment inset: the 16-column window, one row per taxon, gaps as light rules, ambiguity codes boxed.", "size": "89 x 26 mm" }
  ],
  "display_rules": [
    { "rule": "print support labels", "value": "only where SH-aLRT >= 80 and UFboot >= 95", "why": "Printing a failing value invites the reader to weigh it; collapsing states the conclusion instead (F1)." },
    { "rule": "collapse below threshold", "value": "(Mus_musculus,Rattus_norvegicus) drawn as a polytomy", "why": "62/88 fails both scales." },
    { "rule": "tip order", "value": "ladderised, longest branch to the bottom", "why": "Puts Xenopus_tropicalis at the root end and keeps the two long branches adjacent rather than straddling the panel." },
    { "rule": "branch lengths", "value": "drawn to scale, with a scale bar", "why": "The long-branch pattern is part of what the figure has to show honestly." },
    { "rule": "minimum line weight", "value": "0.25 pt", "why": "Below that, branches disappear in print at 89 mm." }
  ],
  "palette": [
    { "role": "branches", "hex": "#1A1A1A", "use": "All branch strokes and the scale bar." },
    { "role": "supported-node", "hex": "#2A6EBB", "use": "Filled circle on nodes that pass both thresholds; luminance 45%, so it stays distinct in greyscale." },
    { "role": "collapsed-node", "hex": "#8C8C8C", "use": "Open marker at the collapsed polytomy." },
    { "role": "inset-highlight", "hex": "#D9A21B", "use": "Boxes around ambiguity codes in panel b only." }
  ],
  "typography": {
    "tip_labels": "6 pt italic for the binomial, sentence case, right-aligned against the 22 mm margin",
    "support_labels": "5 pt roman, offset 0.6 mm above the branch, never overlapping a node marker",
    "scale_bar": "5 pt roman, label centred beneath the bar",
    "font": "Helvetica Neue or Arial -- Nature's figures are set in a grotesque, and matching it avoids a font substitution at production"
  },
  "scale_bar": { "show": true, "unit": "substitutions per site", "length": 0.05 },
  "caption": "Figure 2 | Maximum-likelihood phylogeny of the six sampled vertebrates. The tree was inferred from a 657-column alignment under GTR+F+G4 in IQ-TREE 2.2.2.7 and rooted on Xenopus tropicalis. Filled circles mark nodes with SH-aLRT >= 80 and ultrafast bootstrap >= 95 from 1000 replicates each; the rodent node, which failed both criteria, is shown collapsed. Scale bar, 0.05 substitutions per site. Inset (b), the 16-column window of the alignment, with ambiguity codes boxed.",
  "export_checklist": [
    "Export as vector PDF or EPS; do not submit a raster tree.",
    "Embed or outline all fonts, and check that the italic binomials survived the export.",
    "Verify no stroke is thinner than 0.25 pt after scaling to 89 mm.",
    "Convert to greyscale once and confirm the supported and collapsed node markers are still distinguishable.",
    "Confirm total width is exactly 89 mm and that nothing sits in the bleed.",
    "Check the panel letters are outside the plot area and match the caption's a and b."
  ]
}

Failure modes worth knowing before you build on this

symptomcausewhat to do
200, a normal-looking hold, and an output that answers a question you did not askThe body was wrapped in an "input" key, so task was invisible to the prompt.Send the input object flat. Assert lane === task on the way back. This is the one failure with no error code.
UNAUTHORIZED on /estimate before you have run anythingPricing is free but authenticated.Mint the token first (step 2), then estimate.
Every call 401s after a whileexpires_at on the guest token has passed.Mint a new guest token, or use a personal token from the token page. Retrying the same expired token cannot recover.
Node calls look confidently wrongsupport_kind does not match what the numbers actually are — posterior probabilities on a 0–1 scale judged as bootstrap percentages, or an aLRT/UFboot pair judged as one value.Declare support_kind honestly and keep the labels verbatim in the Newick.
A prescan_facts flag you sent is missing from reconciliationA failed run, not a lenient one.Retry with the attempt counter in the Idempotency-Key bumped. Assert set equality so this can never pass silently.
Two bills for what you believe was one runThe Idempotency-Key was derived from a non-canonical serialisation, so a re-send produced a different key.Hash with sorted keys and no incidental whitespace; keep the lane and the attempt counter in the key.
A column range in sites looks shifted by onekeep_from and keep_to are 1-based and inclusive; most libraries are 0-based and half-open.Convert in exactly one place and test it. A silent off-by-one here shifts a reading frame.
VALIDATION_ERROR with nothing obviously wrongUsually a missing or misspelled task, or prescan_facts sent as a JSON string instead of an object.Read error.details; it names the field.

Where the review comes from

Clade Desk is a derived work. The reasoning it applies is drawn from three published skills: tree handling and node-level manipulation from @k-dense-ai/etetoolkit, alignment and sequence-level analysis from @k-dense-ai/scikit-bio, and figure and display conventions from @k-dense-ai/scientific-visualization. Reading those three is the fastest way to understand why a particular finding is phrased the way it is.