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
| endpoint | method | metered | what it does |
|---|---|---|---|
/guest | POST | no | Mints a guest token from {"slug":"clade-desk"}. Returns 201. |
/me | GET | no | Who the token belongs to, and the credit balance. |
/estimate | POST | no — but authenticated | Prices a run. Creates no job, charges nothing, and 401s without a token. |
/run | POST | yes | Starts a run, returns {"job_id"}. |
/job/{job_id} | GET | no | Job status and, once terminal, the output. |
/run-stream | POST | yes | The same run as Server-Sent Events. |
Error codes
| code | status | what it means here, and what to do |
|---|---|---|
UNAUTHORIZED | 401 | No 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. |
FORBIDDEN | 403 | The 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_FOUND | 404 | An 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_ERROR | 400 | The 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_CREDITS | 402 | The 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_LIMITED | 429 | Too 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. |
INTERNAL | 500 | A 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.
task | reads | adds to the output |
|---|---|---|
align-qc | alignment, seq_type, notes, focus, prescan_facts; tree only as context | sites, taxa_actions, trim_recipe |
tree-audit | tree, support_kind, notes, focus, prescan_facts; alignment as corroboration | rooting, nodes, topology_flags, support_statement |
figure-spec | tree, journal, support_kind, notes, focus; alignment for panel sizing | panels, 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)'; }
import json, urllib.error, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from https://clade-desk.skillsafe.ai/tokens.html
def call(path, body=None, extra_headers=None):
"""POST when a body is given, GET otherwise. Returns the unwrapped `data`,
or raises with the API error code. No X-App-Slug header: the slug lives
only in the POST /guest body."""
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(
f"{BASE}/{path}", data=data, method="POST" if body is not None else "GET")
req.add_header("Authorization", f"Bearer {TOKEN}")
if body is not None:
req.add_header("Content-Type", "application/json")
for k, v in (extra_headers or {}).items():
req.add_header(k, v)
try:
with urllib.request.urlopen(req) as r:
payload = json.load(r)
except urllib.error.HTTPError as e:
payload = json.load(e)
if not payload.get("ok"):
err = payload.get("error", {})
raise RuntimeError(f"{err.get('code')}: {err.get('message')} {err.get('details') or ''}")
return payload["data"]
// Node 18+ or any browser. No X-App-Slug header: the slug lives only in the
// POST /guest body.
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // from https://clade-desk.skillsafe.ai/tokens.html
async function call(path, body, extraHeaders) {
const res = await fetch(`${BASE}/${path}`, {
method: body ? "POST" : "GET",
headers: {
Authorization: `Bearer ${TOKEN}`,
...(body ? { "Content-Type": "application/json" } : {}),
...(extraHeaders || {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const payload = await res.json();
if (!payload.ok) {
throw new Error(`${payload.error.code}: ${payload.error.message}`);
}
return payload.data;
}
package main
import (
"bufio"
"bytes"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
const base = "https://api.skillsafe.ai/v1/app-api"
// From https://clade-desk.skillsafe.ai/tokens.html. Replace the literal, or read
// it from the environment with os.Getenv("SKILLSAFE_TOKEN").
var token = "YOUR_TOKEN"
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
// call POSTs when body is non-nil and GETs otherwise. No X-App-Slug header.
func call(path string, body any, headers map[string]string) (json.RawMessage, error) {
method := http.MethodGet
var rdr io.Reader
if body != nil {
method = http.MethodPost
b, _ := json.Marshal(body)
rdr = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, base+"/"+path, rdr)
req.Header.Set("Authorization", "Bearer "+token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
for k, v := range headers {
req.Header.Set(k, v)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var env envelope
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return nil, err
}
if !env.OK {
return nil, fmt.Errorf("%s: %s", env.Error.Code, env.Error.Message)
}
return env.Data, nil
}
import java.net.URI;
import java.net.http.*;
import java.util.Map;
// No X-App-Slug header: the slug lives only in the POST /guest body.
final class CladeDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
// From https://clade-desk.skillsafe.ai/tokens.html
static final String TOKEN = "YOUR_TOKEN";
static final HttpClient HTTP = HttpClient.newHttpClient();
/** POSTs when body is non-null, GETs otherwise. Returns the raw envelope JSON. */
static String call(String path, String jsonBody, Map<String, String> headers)
throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + "/" + path))
.header("Authorization", "Bearer " + TOKEN);
if (jsonBody == null) {
b = b.GET();
} else {
b = b.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
}
if (headers != null) {
for (var e : headers.entrySet()) b = b.header(e.getKey(), e.getValue());
}
HttpResponse<String> res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
String body = res.body();
if (!body.contains("\"ok\":true")) throw new RuntimeException(body);
return body;
}
}
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from https://clade-desk.skillsafe.ai/tokens.html
# POSTs when body is given, GETs otherwise. No X-App-Slug header: the slug
# lives only in the POST /guest body.
def call(path, body = nil, headers = {})
uri = URI("#{BASE}/#{path}")
req = body ? Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
if body
req["Content-Type"] = "application/json"
req.body = JSON.generate(body)
end
headers.each { |k, v| req[k] = v }
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
payload = JSON.parse(res.body)
raise "#{payload['error']['code']}: #{payload['error']['message']}" unless payload["ok"]
payload["data"]
end
<?php
// No X-App-Slug header: the slug lives only in the POST /guest body.
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // from https://clade-desk.skillsafe.ai/tokens.html
/** POSTs when $body is given, GETs otherwise. Returns the unwrapped data. */
function call(string $path, ?array $body = null, array $headers = []): array {
$ch = curl_init(BASE . "/" . $path);
$h = ["Authorization: Bearer " . TOKEN];
if ($body !== null) {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
$h[] = "Content-Type: application/json";
}
foreach ($headers as $k => $v) { $h[] = "$k: $v"; }
curl_setopt($ch, CURLOPT_HTTPHEADER, $h);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$payload = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($payload["ok"])) {
throw new RuntimeException($payload["error"]["code"] . ": " . $payload["error"]["message"]);
}
return $payload["data"];
}
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
// No X-App-Slug header: the slug lives only in the POST /guest body.
static class CladeDesk {
const string Base = "https://api.skillsafe.ai/v1/app-api";
// From https://clade-desk.skillsafe.ai/tokens.html
const string Token = "YOUR_TOKEN";
static readonly HttpClient Http = new();
/// POSTs when json is non-null, GETs otherwise. Returns the unwrapped data.
public static async Task<JsonElement> Call(
string path, string? json = null, Dictionary<string, string>? headers = null) {
var req = new HttpRequestMessage(
json is null ? HttpMethod.Get : HttpMethod.Post, $"{Base}/{path}");
req.Headers.Add("Authorization", $"Bearer {Token}");
if (json is not null) {
req.Content = new StringContent(json, Encoding.UTF8, "application/json");
}
if (headers is not null) {
foreach (var kv in headers) req.Headers.Add(kv.Key, kv.Value);
}
var res = await Http.SendAsync(req);
var payload = await res.Content.ReadFromJsonAsync<JsonElement>();
if (!payload.GetProperty("ok").GetBoolean()) {
throw new Exception(payload.GetProperty("error").GetProperty("code").GetString());
}
return payload.GetProperty("data");
}
}
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"])')
# The slug goes in the BODY. There is no X-App-Slug header. Success is 201.
req = urllib.request.Request(
f"{BASE}/guest",
data=json.dumps({"slug": "clade-desk"}).encode(),
method="POST")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r:
guest = json.load(r)["data"] # r.status == 201
TOKEN = guest["token"]
print(guest["guest_id"], "expires", guest["expires_at"])
// The slug goes in the BODY. There is no X-App-Slug header. Success is 201.
const res = await fetch("https://api.skillsafe.ai/v1/app-api/guest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "clade-desk" }),
});
const guest = (await res.json()).data; // res.status === 201
const TOKEN = guest.token;
console.log(guest.guest_id, "expires", guest.expires_at);
// The slug goes in the BODY. There is no X-App-Slug header. Success is 201.
guestReq, _ := http.NewRequest(http.MethodPost,
base+"/guest", strings.NewReader(`{"slug":"clade-desk"}`))
guestReq.Header.Set("Content-Type", "application/json")
guestRes, err := http.DefaultClient.Do(guestReq)
if err != nil {
panic(err)
}
defer guestRes.Body.Close()
var guestEnv struct {
Data struct {
Token string `json:"token"`
GuestID string `json:"guest_id"`
ExpiresAt string `json:"expires_at"`
} `json:"data"`
}
_ = json.NewDecoder(guestRes.Body).Decode(&guestEnv)
token = guestEnv.Data.Token // now every later call is authenticated
fmt.Println(guestRes.StatusCode, guestEnv.Data.GuestID, guestEnv.Data.ExpiresAt)
// The slug goes in the BODY. There is no X-App-Slug header. Success is 201.
var http = HttpClient.newHttpClient();
var guestReq = HttpRequest.newBuilder(
URI.create("https://api.skillsafe.ai/v1/app-api/guest"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"clade-desk\"}"))
.build();
HttpResponse<String> guest = http.send(guestReq, HttpResponse.BodyHandlers.ofString());
System.out.println(guest.statusCode()); // 201
System.out.println(guest.body());
// {"ok":true,"data":{"token":"sk_guest_...","guest_id":"gst_7f21c9",
// "expires_at":"2026-09-03T18:22:11Z"}}
# The slug goes in the BODY. There is no X-App-Slug header. Success is 201.
uri = URI("#{BASE}/guest")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req.body = JSON.generate({ "slug" => "clade-desk" })
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
guest = JSON.parse(res.body)["data"]
puts "#{res.code} #{guest['guest_id']} expires #{guest['expires_at']}"
# TOKEN is a constant above; in real code hold the token in a variable.
<?php
// The slug goes in the BODY. There is no X-App-Slug header. Success is 201.
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/guest");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(["slug" => "clade-desk"]));
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json"]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$guest = json_decode(curl_exec($ch), true)["data"];
curl_close($ch);
echo $guest["token"], " ", $guest["guest_id"], " ", $guest["expires_at"], PHP_EOL;
// The slug goes in the BODY. There is no X-App-Slug header. Success is 201.
using var http = new HttpClient();
var guestReq = new HttpRequestMessage(
HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/guest");
guestReq.Content = new StringContent(
"{\"slug\":\"clade-desk\"}", Encoding.UTF8, "application/json");
var guestRes = await http.SendAsync(guestReq);
var guest = (await guestRes.Content.ReadFromJsonAsync<JsonElement>())
.GetProperty("data");
Console.WriteLine((int)guestRes.StatusCode); // 201
Console.WriteLine(guest.GetProperty("token").GetString());
Console.WriteLine(guest.GetProperty("expires_at").GetString());
3. Check the session with GET /me
GET /me returns exactly three fields:
| field | type | meaning |
|---|---|---|
subject_type | string | "guest" or "user". This is the field you branch on. |
subject_id | string | The opaque id of whoever the token belongs to — the same value whichever type it is. Log it; do not parse it. |
credits | number | The 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")'
me = call("me")
print(me["subject_type"], me["subject_id"], me["credits"])
# Branch on subject_type -- there is no is_guest field.
if me["subject_type"] == "guest":
print("guest session")
const me = await call("me");
console.log(me.subject_type, me.subject_id, me.credits);
// Branch on subject_type -- there is no is_guest field, and reading one
// gives undefined, which is falsy and therefore quietly wrong.
if (me.subject_type === "guest") console.log("guest session");
raw, err := call("me", nil, nil)
if err != nil {
panic(err)
}
var me struct {
SubjectType string `json:"subject_type"`
SubjectID string `json:"subject_id"`
Credits int `json:"credits"`
}
_ = json.Unmarshal(raw, &me)
// Branch on SubjectType; there is no is_guest field in the payload.
fmt.Println(me.SubjectType, me.SubjectID, me.Credits)
System.out.println(CladeDesk.call("me", null, null));
// {"ok":true,"data":{"subject_type":"guest","subject_id":"gst_7f21c9","credits":1200}}
//
// Branch on subject_type. There is no user_id and no is_guest field.
me = call("me")
puts "#{me['subject_type']} #{me['subject_id']} #{me['credits']}"
# Branch on subject_type -- there is no is_guest field.
puts "guest session" if me["subject_type"] == "guest"
<?php
$me = call("me");
echo $me["subject_type"], " ", $me["subject_id"], " ", $me["credits"], PHP_EOL;
// Branch on subject_type -- there is no is_guest field.
if ($me["subject_type"] === "guest") { echo "guest session", PHP_EOL; }
var me = await CladeDesk.Call("me");
Console.WriteLine(me.GetProperty("subject_type").GetString());
Console.WriteLine(me.GetProperty("subject_id").GetString());
Console.WriteLine(me.GetProperty("credits").GetInt32());
// Branch on subject_type; there is no user_id and no is_guest property, and
// asking for one throws KeyNotFoundException rather than returning false.
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
| field | type | meaning |
|---|---|---|
task | string, required | The 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. |
alignment | string | FASTA 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. |
tree | string | A 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. |
notes | string | Free-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_type | string | "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_kind | string | "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. |
journal | string | "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. |
focus | string, optional | A 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_facts | object | What the browser measured before the call. See below. |
clip_note | string | What 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
| field | value for this app | meaning |
|---|---|---|
model | gpt-5.6-terra | The exact model the run is bound to. |
model_alias | gpt-terra | The stable alias. Pin your logs to this if you want them to survive a model bump. |
markup_bps | 1000 | The app's markup in basis points — 1000 bps is 10%. |
hold_credits | varies with input size | What 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_credits | varies | The balance you must clear for the run to be accepted at all. Compare it against credits from /me. |
sponsor_enabled | boolean | Whether 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}}
# The body is the input object itself. Not wrapped in "input".
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);"
)
INPUT = {
"task": "align-qc",
"alignment": ALIGNMENT,
"tree": TREE,
"seq_type": "auto",
"support_kind": "ufboot",
"journal": "generic",
"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"
),
"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"},
],
},
}
est = call("estimate", INPUT)
print(est["model"], est["model_alias"], est["markup_bps"])
print("hold", est["hold_credits"], "min", est["min_credits"],
"sponsored", est["sponsor_enabled"])
assert me["credits"] >= est["min_credits"], "top up before running"
// The body is the input object itself. Not wrapped in "input".
const ALIGNMENT = [
">Homo_sapiens", "ATGGCCTTGAAGCTGA",
">Pan_troglodytes", "ATGGCCTTGAAGCTGA",
">Mus_musculus", "ATGGCTTTGAA-CTGA",
">Rattus_norvegicus", "ATGGCTTTGAA-CTGA",
">Gallus_gallus", "ATGGCNTTNAAKCTGA",
">Xenopus_tropicalis", "ATG---TTGAAGCT-A",
].join("\n") + "\n";
const 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);";
const INPUT = {
task: "align-qc",
alignment: ALIGNMENT,
tree: TREE,
seq_type: "auto",
support_kind: "ufboot",
journal: "generic",
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",
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" },
],
},
};
const est = await call("estimate", INPUT);
console.log(est.model, est.model_alias, est.markup_bps);
console.log("hold", est.hold_credits, "min", est.min_credits, est.sponsor_enabled);
// The body is the input object itself. Not wrapped in "input".
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);"
input := map[string]any{
"task": "align-qc",
"alignment": alignment,
"tree": tree,
"seq_type": "auto",
"support_kind": "ufboot",
"journal": "generic",
"notes": "MAFFT v7.520 --auto; IQ-TREE 2.2.2.7, GTR+F+G4, 1000 ufboot replicates; outgroup Xenopus_tropicalis",
"focus": "decide whether to trim before we infer the published tree",
"clip_note": "",
"prescan_facts": map[string]any{
"verdict": "attention",
"seq_type": "dna",
"support_kind": "ufboot",
"counts": map[string]any{"taxa": 6, "sites": 16, "tips": 6, "flags": 2, "resources": 2, "taxaWithFindings": 3},
"thresholds": []any{map[string]any{"kind": "ufboot", "scale": 100, "threshold": 95}},
"flags": []any{
map[string]any{"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": []string{"Gallus_gallus"}, "entity_total": 1},
map[string]any{"id": "ALN-GAP-BLOCK", "severity": "medium",
"label": "Internal gap block in 1 taxon",
"detail": "Xenopus_tropicalis is gapped at columns 4-6",
"entities": []string{"Xenopus_tropicalis"}, "entity_total": 1},
},
"resources": []any{
map[string]any{"id": "RES-ALN", "label": "FASTA, 6 records x 16 columns", "detail": "2 gap columns"},
map[string]any{"id": "RES-TREE", "label": "Newick, 6 tips, 3 internal nodes", "detail": "dual-scale labels"},
},
},
}
raw, err = call("estimate", input, nil)
if err != nil {
panic(err)
}
var est struct {
Model string `json:"model"`
ModelAlias string `json:"model_alias"`
MarkupBps int `json:"markup_bps"`
HoldCredits int `json:"hold_credits"`
MinCredits int `json:"min_credits"`
SponsorEnabled bool `json:"sponsor_enabled"`
}
_ = json.Unmarshal(raw, &est)
fmt.Println(est.Model, est.ModelAlias, est.MarkupBps, est.HoldCredits, est.MinCredits)
// The body is the input object itself. Not wrapped in "input".
String alignment = String.join("\n",
">Homo_sapiens", "ATGGCCTTGAAGCTGA",
">Pan_troglodytes", "ATGGCCTTGAAGCTGA",
">Mus_musculus", "ATGGCTTTGAA-CTGA",
">Rattus_norvegicus", "ATGGCTTTGAA-CTGA",
">Gallus_gallus", "ATGGCNTTNAAKCTGA",
">Xenopus_tropicalis", "ATG---TTGAAGCT-A") + "\n";
String 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);";
// Build the JSON with whatever mapper you already use; shown literally here so
// the shape is unambiguous. Note there is no enclosing "input" key.
String input = """
{"task":"align-qc",
"alignment":"%s",
"tree":"%s",
"seq_type":"auto",
"support_kind":"ufboot",
"journal":"generic",
"notes":"MAFFT v7.520 --auto; IQ-TREE 2.2.2.7, GTR+F+G4, 1000 ufboot replicates",
"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":1,"resources":1,"taxaWithFindings":1},
"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}],
"resources":[{"id":"RES-ALN","label":"FASTA, 6 records x 16 columns",
"detail":"2 gap columns"}]}}
""".formatted(alignment.replace("\n", "\\n"), tree);
System.out.println(CladeDesk.call("estimate", input, null));
// {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra",
// "markup_bps":1000,"hold_credits":742,"min_credits":120,
// "sponsor_enabled":false}}
# The body is the input object itself. Not wrapped in "input".
ALIGNMENT = [
">Homo_sapiens", "ATGGCCTTGAAGCTGA",
">Pan_troglodytes", "ATGGCCTTGAAGCTGA",
">Mus_musculus", "ATGGCTTTGAA-CTGA",
">Rattus_norvegicus", "ATGGCTTTGAA-CTGA",
">Gallus_gallus", "ATGGCNTTNAAKCTGA",
">Xenopus_tropicalis", "ATG---TTGAAGCT-A"
].join("\n") + "\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);"
INPUT = {
"task" => "align-qc",
"alignment" => ALIGNMENT,
"tree" => TREE,
"seq_type" => "auto",
"support_kind" => "ufboot",
"journal" => "generic",
"notes" => "MAFFT v7.520 --auto; IQ-TREE 2.2.2.7, GTR+F+G4, 1000 ufboot replicates; " \
"outgroup Xenopus_tropicalis; no trimming applied yet",
"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" => 1,
"resources" => 1, "taxaWithFindings" => 1 },
"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 }],
"resources" => [{ "id" => "RES-ALN", "label" => "FASTA, 6 records x 16 columns",
"detail" => "2 gap columns" }]
}
}
est = call("estimate", INPUT)
puts "#{est['model']} hold=#{est['hold_credits']} min=#{est['min_credits']}"
<?php
// The body is the input object itself. Not wrapped in "input".
$alignment = implode("\n", [
">Homo_sapiens", "ATGGCCTTGAAGCTGA",
">Pan_troglodytes", "ATGGCCTTGAAGCTGA",
">Mus_musculus", "ATGGCTTTGAA-CTGA",
">Rattus_norvegicus", "ATGGCTTTGAA-CTGA",
">Gallus_gallus", "ATGGCNTTNAAKCTGA",
">Xenopus_tropicalis", "ATG---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);";
$input = [
"task" => "align-qc",
"alignment" => $alignment,
"tree" => $tree,
"seq_type" => "auto",
"support_kind" => "ufboot",
"journal" => "generic",
"notes" => "MAFFT v7.520 --auto; IQ-TREE 2.2.2.7, GTR+F+G4, 1000 ufboot replicates",
"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" => 1,
"resources" => 1, "taxaWithFindings" => 1],
"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,
]],
"resources" => [[
"id" => "RES-ALN", "label" => "FASTA, 6 records x 16 columns",
"detail" => "2 gap columns",
]],
],
];
$est = call("estimate", $input);
echo $est["model"], " hold=", $est["hold_credits"], " min=", $est["min_credits"], PHP_EOL;
// The body is the input object itself. Not wrapped in "input".
var alignment = string.Join("\n", new[] {
">Homo_sapiens", "ATGGCCTTGAAGCTGA",
">Pan_troglodytes", "ATGGCCTTGAAGCTGA",
">Mus_musculus", "ATGGCTTTGAA-CTGA",
">Rattus_norvegicus", "ATGGCTTTGAA-CTGA",
">Gallus_gallus", "ATGGCNTTNAAKCTGA",
">Xenopus_tropicalis", "ATG---TTGAAGCT-A",
}) + "\n";
var 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);";
var input = new Dictionary<string, object?> {
["task"] = "align-qc",
["alignment"] = alignment,
["tree"] = tree,
["seq_type"] = "auto",
["support_kind"] = "ufboot",
["journal"] = "generic",
["notes"] = "MAFFT v7.520 --auto; IQ-TREE 2.2.2.7, GTR+F+G4, 1000 ufboot replicates",
["focus"] = "decide whether to trim before we infer the published tree",
["clip_note"] = "",
["prescan_facts"] = new Dictionary<string, object?> {
["verdict"] = "attention",
["seq_type"] = "dna",
["support_kind"] = "ufboot",
["counts"] = new Dictionary<string, int> {
["taxa"] = 6, ["sites"] = 16, ["tips"] = 6,
["flags"] = 1, ["resources"] = 1, ["taxaWithFindings"] = 1,
},
["thresholds"] = new[] { new Dictionary<string, object> {
["kind"] = "ufboot", ["scale"] = 100, ["threshold"] = 95 } },
["flags"] = new[] { new Dictionary<string, object> {
["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"] = new[] { "Gallus_gallus" }, ["entity_total"] = 1 } },
["resources"] = new[] { new Dictionary<string, object> {
["id"] = "RES-ALN", ["label"] = "FASTA, 6 records x 16 columns",
["detail"] = "2 gap columns" } },
},
};
var inputJson = JsonSerializer.Serialize(input);
var est = await CladeDesk.Call("estimate", inputJson);
Console.WriteLine(est.GetProperty("model").GetString()); // gpt-5.6-terra
Console.WriteLine(est.GetProperty("model_alias").GetString()); // gpt-terra
Console.WriteLine(est.GetProperty("hold_credits").GetInt32());
Console.WriteLine(est.GetProperty("min_credits").GetInt32());
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"])'
import hashlib, time
# INPUT is the flat object from step 4 -- it is the whole body, unwrapped.
canonical = json.dumps(INPUT, sort_keys=True, separators=(",", ":"))
digest = hashlib.sha256(canonical.encode()).hexdigest()[:16]
key = f"clade-desk:{digest}:{INPUT['task']}:a1"
job_id = call("run", INPUT, {"Idempotency-Key": key})["job_id"]
while True:
job = call(f"job/{job_id}")
if job["status"] == "succeeded":
break
if job["status"] == "failed":
raise RuntimeError(job.get("error"))
time.sleep(2)
review = json.loads(job["output"]["output"])
# Cheap guard against the wrapped-body mistake: the lane must round-trip.
assert review["lane"] == INPUT["task"], "lane mismatch -- was the body wrapped?"
# The reconciliation contract: every prescan flag id, exactly once.
sent = [f["id"] for f in INPUT["prescan_facts"]["flags"]]
got = [r["fact_id"] for r in review["reconciliation"]]
assert sorted(sent) == sorted(got), (sent, got)
print(review["verdict"], review["headline"])
print("charged", job.get("charged_credits"))
import { createHash } from "node:crypto";
// INPUT is the flat object from step 4 -- it is the whole body, unwrapped.
const canonical = JSON.stringify(INPUT, Object.keys(INPUT).sort());
const digest = createHash("sha256").update(canonical).digest("hex").slice(0, 16);
const key = `clade-desk:${digest}:${INPUT.task}:a1`;
const { job_id } = await call("run", INPUT, { "Idempotency-Key": key });
let job;
for (;;) {
job = await call(`job/${job_id}`);
if (job.status === "succeeded") break;
if (job.status === "failed") throw new Error(JSON.stringify(job.error));
await new Promise((r) => setTimeout(r, 2000));
}
const review = JSON.parse(job.output.output);
if (review.lane !== INPUT.task) throw new Error("lane mismatch -- wrapped body?");
console.log(review.verdict, review.headline, "charged", job.charged_credits);
// input is the flat map from step 4 -- it is the whole body, unwrapped.
canonical, _ := json.Marshal(input) // encoding/json sorts map keys
sum := sha256.Sum256(canonical)
key := fmt.Sprintf("clade-desk:%x:%s:a1", sum[:8], input["task"])
raw, err = call("run", input, map[string]string{"Idempotency-Key": key})
if err != nil {
panic(err)
}
var started struct {
JobID string `json:"job_id"`
}
_ = json.Unmarshal(raw, &started)
var job struct {
Status string `json:"status"`
ChargedCredits int `json:"charged_credits"`
Output struct {
Output string `json:"output"`
} `json:"output"`
}
for {
raw, err = call("job/"+started.JobID, nil, nil)
if err != nil {
panic(err)
}
_ = json.Unmarshal(raw, &job)
if job.Status == "succeeded" || job.Status == "failed" {
break
}
time.Sleep(2 * time.Second)
}
var review struct {
Lane string `json:"lane"`
Verdict string `json:"verdict"`
Headline string `json:"headline"`
}
_ = json.Unmarshal([]byte(job.Output.Output), &review)
if review.Lane != input["task"] {
panic("lane mismatch -- was the body wrapped in an input key?")
}
fmt.Println(review.Verdict, review.Headline, job.ChargedCredits)
import java.security.MessageDigest;
import java.util.HexFormat;
// `input` is the flat JSON string from step 4 -- the whole body, unwrapped.
var sha = MessageDigest.getInstance("SHA-256").digest(input.getBytes("UTF-8"));
var key = "clade-desk:" + HexFormat.of().formatHex(sha).substring(0, 16) + ":align-qc:a1";
var started = CladeDesk.call("run", input, Map.of("Idempotency-Key", key));
// {"ok":true,"data":{"job_id":"job_8c31de"}}
var jobId = started.replaceAll(".*\"job_id\":\"([^\"]+)\".*", "$1");
String job;
while (true) {
job = CladeDesk.call("job/" + jobId, null, null);
if (job.contains("\"status\":\"succeeded\"")) break;
if (job.contains("\"status\":\"failed\"")) throw new RuntimeException(job);
Thread.sleep(2000);
}
System.out.println(job);
// data.output.output is the review JSON, as a string. Parse it with your mapper
// and check that its "lane" equals the "task" you sent.
require "digest"
# INPUT is the flat hash from step 4 -- it is the whole body, unwrapped.
canonical = JSON.generate(INPUT.sort.to_h)
digest = Digest::SHA256.hexdigest(canonical)[0, 16]
key = "clade-desk:#{digest}:#{INPUT['task']}:a1"
job_id = call("run", INPUT, { "Idempotency-Key" => key })["job_id"]
job = nil
loop do
job = call("job/#{job_id}")
break if job["status"] == "succeeded"
raise job.fetch("error", "failed").to_s if job["status"] == "failed"
sleep 2
end
review = JSON.parse(job["output"]["output"])
raise "lane mismatch -- wrapped body?" unless review["lane"] == INPUT["task"]
puts "#{review['verdict']} #{review['headline']} charged=#{job['charged_credits']}"
<?php
// $input is the flat array from step 4 -- it is the whole body, unwrapped.
ksort($input);
$digest = substr(hash("sha256", json_encode($input)), 0, 16);
$key = "clade-desk:{$digest}:{$input['task']}:a1";
$started = call("run", $input, ["Idempotency-Key" => $key]);
$jobId = $started["job_id"];
do {
$job = call("job/" . $jobId);
if ($job["status"] === "failed") {
throw new RuntimeException(json_encode($job));
}
if ($job["status"] !== "succeeded") { sleep(2); }
} while ($job["status"] !== "succeeded");
$review = json_decode($job["output"]["output"], true);
if ($review["lane"] !== $input["task"]) {
throw new RuntimeException("lane mismatch -- was the body wrapped?");
}
echo $review["verdict"], " ", $review["headline"], PHP_EOL;
echo "charged ", $job["charged_credits"] ?? 0, PHP_EOL;
using System.Security.Cryptography;
// inputJson is the flat JSON from step 4 -- the whole body, unwrapped.
var sha = Convert.ToHexString(
SHA256.HashData(Encoding.UTF8.GetBytes(inputJson))).ToLowerInvariant();
var key = $"clade-desk:{sha[..16]}:align-qc:a1";
var started = await CladeDesk.Call(
"run", inputJson, new Dictionary<string, string> { ["Idempotency-Key"] = key });
var jobId = started.GetProperty("job_id").GetString();
JsonElement job;
while (true) {
job = await CladeDesk.Call($"job/{jobId}");
var status = job.GetProperty("status").GetString();
if (status == "succeeded") break;
if (status == "failed") throw new Exception(job.ToString());
await Task.Delay(2000);
}
var review = JsonDocument.Parse(
job.GetProperty("output").GetProperty("output").GetString()!).RootElement;
if (review.GetProperty("lane").GetString() != "align-qc") {
throw new Exception("lane mismatch -- was the body wrapped in an input key?");
}
Console.WriteLine(review.GetProperty("verdict").GetString());
Console.WriteLine(review.GetProperty("headline").GetString());
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}
# Server-sent events: the review arrives in chunks, so a UI can show progress.
req = urllib.request.Request(
f"{BASE}/run-stream", data=json.dumps(INPUT).encode(), method="POST")
req.add_header("Authorization", f"Bearer {TOKEN}")
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key)
req.add_header("Accept", "text/event-stream")
raw, done, event = "", {}, None
stage = "reading the alignment"
with urllib.request.urlopen(req) as stream:
for line in stream:
line = line.decode().rstrip("\n")
if line.startswith("event: "):
event = line[7:]
elif line.startswith("data: "):
payload = json.loads(line[6:])
if event == "delta":
raw += payload.get("text", "")
# Stage detection by key name, not by parsing partial JSON.
for name, label in (('"findings"', "listing findings"),
('"sites"', "choosing the keep window"),
('"trim_recipe"', "writing the trim recipe")):
if name in raw:
stage = label
elif event == "job":
print("job", payload["job_id"])
elif event == "done":
done = payload
review = json.loads(raw)
print(stage, "->", review["verdict"], review["headline"])
print(done.get("status"), "charged", done.get("charged_credits"))
// Server-sent events over fetch. Accumulate the deltas, parse once at the end.
const res = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": key,
Accept: "text/event-stream",
},
body: JSON.stringify(INPUT),
});
const reader = res.body.getReader();
const dec = new TextDecoder();
let buffer = "";
let raw = "";
let event = null;
let done = {};
for (;;) {
const { value, done: finished } = await reader.read();
if (finished) break;
buffer += dec.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop();
for (const line of lines) {
if (line.startsWith("event: ")) event = line.slice(7).trim();
else if (line.startsWith("data: ")) {
const payload = JSON.parse(line.slice(6));
if (event === "delta") raw += payload.text || "";
else if (event === "job") console.log("job", payload.job_id);
else if (event === "done") done = payload;
}
}
}
const review = JSON.parse(raw);
console.log(review.verdict, review.headline, "charged", done.charged_credits);
// Server-sent events: read the response line by line rather than decoding JSON.
body, _ := json.Marshal(input)
streamReq, _ := http.NewRequest(http.MethodPost, base+"/run-stream", bytes.NewReader(body))
streamReq.Header.Set("Authorization", "Bearer "+token)
streamReq.Header.Set("Content-Type", "application/json")
streamReq.Header.Set("Idempotency-Key", key)
streamReq.Header.Set("Accept", "text/event-stream")
streamRes, err := http.DefaultClient.Do(streamReq)
if err != nil {
panic(err)
}
defer streamRes.Body.Close()
var sb strings.Builder
var event string
scanner := bufio.NewScanner(streamRes.Body)
scanner.Buffer(make([]byte, 0, 1024*1024), 8*1024*1024)
for scanner.Scan() {
line := scanner.Text()
switch {
case strings.HasPrefix(line, "event: "):
event = strings.TrimSpace(line[7:])
case strings.HasPrefix(line, "data: "):
var payload struct {
Text string `json:"text"`
JobID string `json:"job_id"`
Status string `json:"status"`
ChargedCredits int `json:"charged_credits"`
}
_ = json.Unmarshal([]byte(line[6:]), &payload)
switch event {
case "delta":
sb.WriteString(payload.Text)
case "job":
fmt.Println("job", payload.JobID)
case "done":
fmt.Println(payload.Status, "charged", payload.ChargedCredits)
}
}
}
var streamed map[string]any
_ = json.Unmarshal([]byte(sb.String()), &streamed)
fmt.Println(streamed["verdict"], streamed["headline"])
// Server-sent events with the JDK client: stream the body as lines.
var streamReq = HttpRequest.newBuilder(URI.create(CladeDesk.BASE + "/run-stream"))
.header("Authorization", "Bearer " + CladeDesk.TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.header("Accept", "text/event-stream")
.POST(HttpRequest.BodyPublishers.ofString(input))
.build();
var sb = new StringBuilder();
var event = new String[] { "" };
CladeDesk.HTTP.send(streamReq, HttpResponse.BodyHandlers.ofLines())
.body()
.forEach(line -> {
if (line.startsWith("event: ")) {
event[0] = line.substring(7).trim();
} else if (line.startsWith("data: ") && event[0].equals("delta")) {
// {"text":"..."} -- unescape with your JSON mapper in real code.
sb.append(line.substring(6));
} else if (line.startsWith("data: ") && event[0].equals("done")) {
System.out.println(line);
}
});
System.out.println(sb.length() + " bytes of delta payload");
# Server-sent events: stream the response body in chunks.
uri = URI("#{BASE}/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = key
req["Accept"] = "text/event-stream"
req.body = JSON.generate(INPUT)
raw = ""
event = nil
done = {}
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
buffer = ""
res.read_body do |chunk|
buffer += chunk
while (nl = buffer.index("\n"))
line = buffer.slice!(0, nl + 1).chomp
if line.start_with?("event: ")
event = line[7..].strip
elsif line.start_with?("data: ")
payload = JSON.parse(line[6..])
case event
when "delta" then raw += payload["text"].to_s
when "job" then puts "job #{payload['job_id']}"
when "done" then done = payload
end
end
end
end
end
end
review = JSON.parse(raw)
puts "#{review['verdict']} #{review['headline']} charged=#{done['charged_credits']}"
<?php
// Server-sent events with a write callback. Accumulate, then parse once.
$raw = "";
$event = null;
$pending = "";
$ch = curl_init(BASE . "/run-stream");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($input));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer " . TOKEN,
"Content-Type: application/json",
"Idempotency-Key: " . $key,
"Accept: text/event-stream",
]);
curl_setopt($ch, CURLOPT_WRITEFUNCTION, function ($ch, $chunk) use (&$raw, &$event, &$pending) {
$pending .= $chunk;
while (($nl = strpos($pending, "\n")) !== false) {
$line = rtrim(substr($pending, 0, $nl));
$pending = substr($pending, $nl + 1);
if (str_starts_with($line, "event: ")) {
$event = trim(substr($line, 7));
} elseif (str_starts_with($line, "data: ")) {
$payload = json_decode(substr($line, 6), true);
if ($event === "delta") { $raw .= $payload["text"] ?? ""; }
if ($event === "done") { echo "charged ", $payload["charged_credits"] ?? 0, PHP_EOL; }
}
}
return strlen($chunk);
});
curl_exec($ch);
curl_close($ch);
$review = json_decode($raw, true);
echo $review["verdict"], " ", $review["headline"], PHP_EOL;
// Server-sent events: read the stream line by line, accumulate, parse once.
var streamReq = new HttpRequestMessage(HttpMethod.Post, "run-stream") {
Content = new StringContent(inputJson, Encoding.UTF8, "application/json"),
};
streamReq.RequestUri = new Uri("https://api.skillsafe.ai/v1/app-api/run-stream");
streamReq.Headers.Add("Authorization", $"Bearer YOUR_TOKEN");
streamReq.Headers.Add("Idempotency-Key", key);
streamReq.Headers.Add("Accept", "text/event-stream");
using var http2 = new HttpClient();
using var streamRes = await http2.SendAsync(
streamReq, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await streamRes.Content.ReadAsStreamAsync());
var sb = new StringBuilder();
string? evt = null;
while (await reader.ReadLineAsync() is string line) {
if (line.StartsWith("event: ")) {
evt = line[7..].Trim();
} else if (line.StartsWith("data: ")) {
var payload = JsonDocument.Parse(line[6..]).RootElement;
if (evt == "delta" && payload.TryGetProperty("text", out var t)) {
sb.Append(t.GetString());
} else if (evt == "done") {
Console.WriteLine(payload.ToString());
}
}
}
var streamed = JsonDocument.Parse(sb.ToString()).RootElement;
Console.WriteLine(streamed.GetProperty("verdict").GetString());
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
| field | type | meaning |
|---|---|---|
lane | string | Echoes the task you sent. Assert on this — it is the one cheap check that catches a wrapped request body. |
subject | string | A short identification of what was reviewed, e.g. "6-taxon COI alignment, 16 columns". |
verdict | string | "clean", "attention" or "blocking". blocking means do not proceed to the next step of the analysis as things stand. |
headline | string | One sentence a colleague could read out. Safe to put in a CI summary line. |
summary | array of strings | The short-form account, one point per element. No nesting, so it renders anywhere. |
findings | array 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. |
reconciliation | array 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. |
assumptions | array of strings | What had to be assumed to answer at all — unstated substitution model, unknown gene, assumed reading frame. Read these before acting on the findings. |
open_questions | array of strings | What the reviewer would ask you if it could. Usually answerable by adding a sentence to notes and re-running. |
methods_sentence | string | One 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
| where | allowed values |
|---|---|
verdict | clean · attention · blocking |
findings[].severity | critical · high · medium · low |
taxa_actions[].action | keep · trim · drop · resequence |
rooting.status | rooted · unrooted · midpoint · unclear |
nodes[].call | solid · weak · collapse |
align-qc adds
| field | type | meaning |
|---|---|---|
sites | object | {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_actions | array | [{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_recipe | object | {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
| field | type | meaning |
|---|---|---|
rooting | object | {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. |
nodes | array | [{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_flags | array | [{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_statement | string | One sentence stating what the support values are and how they should be reported. This is the sentence reviewers ask for. |
figure-spec adds
| field | type | meaning |
|---|---|---|
panels | array | [{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_rules | array | [{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. |
palette | array | [{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. |
typography | object | {tip_labels, support_labels, scale_bar, font} — sizes and styles for each class of text, plus one font recommendation. |
scale_bar | object | {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. |
caption | string | A draft caption naming the method, the model, the support type and the replicate count. Check it against your own methods before submission. |
export_checklist | array of strings | Things to verify before you submit: vector format, embedded fonts, colour-blind safety, minimum line weight, panel letter placement. |
Invariants worth asserting
review.lane === input.task— catches a wrapped request body.- The set of
reconciliation[].fact_idequals the set ofprescan_facts.flags[].id, with no duplicates and no extras. - Every
findings[].prescan_idsentry is an id you actually sent. verdictis one of the three strings, andfindings[].severityone of the four.- For
align-qc:1 <= sites.keep_from <= sites.keep_to <= counts.sites, and everydrop_rangesentry lies inside the keep window. - For
tree-audit: everynodes[].supportstring appears verbatim in the Newick you sent. - For
figure-spec: everypalette[].hexmatches^#[0-9a-fA-F]{6}$.
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"
INPUT = {
"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"},
],
},
}
digest = hashlib.sha256(
json.dumps(INPUT, sort_keys=True, separators=(",", ":")).encode()).hexdigest()[:16]
job_id = call("run", INPUT, {"Idempotency-Key": f"clade-desk:{digest}:align-qc:a1"})["job_id"]
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"
INPUT = {
"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": ALIGNMENT, # the same FASTA as example 1
"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 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",
"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"},
],
},
}
digest = hashlib.sha256(
json.dumps(INPUT, sort_keys=True, separators=(",", ":")).encode()).hexdigest()[:16]
job_id = call("run", INPUT, {"Idempotency-Key": f"clade-desk:{digest}:tree-audit:a1"})["job_id"]
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"
INPUT = {
"task": "figure-spec",
"journal": "nature", # the only lane that reads this
"seq_type": "dna",
"support_kind": "ufboot",
"tree": TREE, # the same Newick as examples 1 and 2
"alignment": ALIGNMENT, # used for the inset panel
"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": "at 6 pt the longest labels 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"},
],
},
}
digest = hashlib.sha256(
json.dumps(INPUT, sort_keys=True, separators=(",", ":")).encode()).hexdigest()[:16]
job_id = call("run", INPUT, {"Idempotency-Key": f"clade-desk:{digest}:figure-spec:a1"})["job_id"]
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
| symptom | cause | what to do |
|---|---|---|
| 200, a normal-looking hold, and an output that answers a question you did not ask | The 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 anything | Pricing is free but authenticated. | Mint the token first (step 2), then estimate. |
| Every call 401s after a while | expires_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 wrong | support_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 reconciliation | A 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 run | The 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 one | keep_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 wrong | Usually 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.