← Hull Desk / API
Tokens

Drive Hull Desk from your own code

A reading of the computed hull, not of the material. The model reads the phase diagram your browser (or your script) built; it never recomputes a number. "Stable" means a vertex of the convex hull of the entries you sent, with the energies you sent - not that a phase forms, survives or can be synthesised.

Everything the web page does is available over HTTP. Build the phase diagram with the page's own hullkit.js (pymatgen PhaseDiagram semantics, checked against pymatgen 2026.5.4: the elemental references as the lowest energy per atom of each element, the formation energy per atom of every entry, the lower convex hull, the stable entries, the energy above hull of every entry and the decomposition of every unstable entry and of any composition you ask about), send the facts, and get back a verdict (sound, caveated, unreliable) and either a reading of every metric and of the phase diagram or a pymatgen script that reproduces every number and runs follow-up checks. The natural loop: paste the entries, read, script, fix the entry set, re-check. Hull Desk is derived from the agent skill @k-dense-ai/pymatgen (k-dense-ai/scientific-agent-skills, K-Dense Inc.) and its phase_diagram_generator.py.

Two lanes: the task field

taskwhat you getextra input
interpretA reading of each metric (M1..M5, in order: stable entries, lowest formation energy, unstable entries within kT of the hull, entries far above the hull, compositions with more than one entry), the computed phase diagram in words (which compounds are on the hull, how the closest unstable entries decompose, the answer for each composition you asked about), your claims judged against the facts, and what the hull cannot show.none
scriptThe fixes (rebuilding without named entries, rebuilding with one method only, adding a competing phase whose energy you still have to supply, printing a decomposition, printing equilibrium reaction energies) and one complete Python script: ENTRIES_PATH = "entries.json" read with json, one ComputedEntry per row, PhaseDiagram(entries), an EXPECTED dict of every browser value checked with math.isclose, then the fixes, all inside main().decision: the text of an earlier interpret run (optional)

Both lanes return the same envelope: lane, verdict, headline, tldr, the lane body, next_steps and prescan_responses. Worked requests: interpret, script. The reply shape: output contract.

Input fields

Every field is a string.

fieldrequiredmeaning
taskyesinterpret or script. These are the only two lanes.
factsyesA JSON-encoded string holding the browser's phase diagram - see below. The page builds it with HullKit.buildInput; an API caller normally runs the free page or reproduces it.
titlenoA label for the analysis, up to 160 characters.
contextnoYour notes: the calculation method and correction scheme, where the energies come from, what you want to conclude. Up to 2,500 characters.
questionnoAnswered in tldr as a bullet starting "Answer:". Up to 1,500 characters.
decisionscript onlyPlain text of an earlier interpret run (the page builds it with Recon.decisionText: "Verdict: ...", the headline, one line per metric reading and phase, then the next steps). Up to 5,000 characters.
retry_notenoOnly on a retry after a malformed reply.

The facts string

facts is a JSON string, not an object: the browser builds the phase diagram, serialises the result with JSON.stringify and sends that text. It holds: settings (chemical_system, elements, entry_count, stable_entry_count, input_format strict_json / table / pymatgen_entries, energies_entered_per_atom, dataset_provenance, methods, the pymatgen version, kT_298K_eV 0.025693 and show_unstable_eV 0.2); elemental_references (element, entry_id, energy_eV_per_atom); entries (every stable entry, then the unstable entries closest to the hull, up to 40, each with entry_id, formula, energy_eV_per_atom, formation_energy_eV_per_atom, energy_above_hull_eV_per_atom, on_hull, sometimes method, and for an unstable entry decomposes_to, a list of entry_id, formula and atom fraction); rows_total (all entries, sent or not); composition_analyses (query, reduced_formula, matching_entries, decomposition, or error); metrics (M1.., each metric, value, sometimes fraction, entry, formula, entries or formulas, and basis); flags (F1.. with severity high / medium / low, category, message and refs); browser_verdict; expected (the exact values a pymatgen reproduction must match: stable_entry_count, formation_energy__<ID>, e_above_hull__<ID>) and expected_count; and clipped (what was left out for length).

Illustrative only. The object below is what the page computes for a toy Li-O system of six made-up entries (the energies are invented for this tutorial, not real DFT values; no method is named, so F1 is raised). Some long strings are shortened with "...":

{
  "settings": {
    "chemical_system": "Li-O",
    "elements": ["Li", "O"],
    "entry_count": 6,
    "stable_entry_count": 4,
    "input_format": "table",
    "energies_entered_per_atom": false,
    "dataset_provenance": {"source": "illustrative toy numbers"},
    "methods": [],
    "pymatgen": "2026.5.4 / pymatgen-core 2026.7.16",
    "kT_298K_eV": 0.025693,
    "show_unstable_eV": 0.2
  },
  "elemental_references": [
    {"element": "Li", "entry_id": "li", "energy_eV_per_atom": -1.907},
    {"element": "O", "entry_id": "o2", "energy_eV_per_atom": -4.933}
  ],
  "entries": [
    {"entry_id": "li", "formula": "Li", "energy_eV_per_atom": -1.907,
     "formation_energy_eV_per_atom": 0, "energy_above_hull_eV_per_atom": 0, "on_hull": true},
    {"entry_id": "o2", "formula": "O2", "energy_eV_per_atom": -4.933,
     "formation_energy_eV_per_atom": 0, "energy_above_hull_eV_per_atom": 0, "on_hull": true},
    {"entry_id": "li2o", "formula": "Li2O", "energy_eV_per_atom": -4.771,
     "formation_energy_eV_per_atom": -1.855333333, "energy_above_hull_eV_per_atom": 0, "on_hull": true},
    {"entry_id": "li2o2", "formula": "Li2O2", "energy_eV_per_atom": -4.957,
     "formation_energy_eV_per_atom": -1.537, "energy_above_hull_eV_per_atom": 0, "on_hull": true},
    {"entry_id": "li2o_b", "formula": "Li2O", "energy_eV_per_atom": -4.75,
     "formation_energy_eV_per_atom": -1.834333333, "energy_above_hull_eV_per_atom": 0.021,
     "on_hull": false,
     "decomposes_to": [{"entry_id": "li2o", "formula": "Li2O", "fraction": 1}]},
    {"entry_id": "lio2", "formula": "LiO2", "energy_eV_per_atom": -4.5,
     "formation_energy_eV_per_atom": -0.5756666667, "energy_above_hull_eV_per_atom": 0.449,
     "on_hull": false,
     "decomposes_to": [
       {"entry_id": "li2o2", "formula": "Li2O2", "fraction": 0.666667},
       {"entry_id": "o2", "formula": "O2", "fraction": 0.333333}
     ]}
  ],
  "rows_total": 6,
  "composition_analyses": [
    {"query": "Li3O2", "reduced_formula": "Li3O2", "matching_entries": [],
     "decomposition": [
       {"entry_id": "li2o", "formula": "Li2O", "fraction": 0.6},
       {"entry_id": "li2o2", "formula": "Li2O2", "fraction": 0.4}
     ]}
  ],
  "metrics": [
    {"id": "M1", "metric": "stable_entries", "value": 4, "fraction": 0.6667,
     "basis": "entries on the computed convex hull (vertices), elemental references included, of 6 entries"},
    {"id": "M2", "metric": "lowest_formation_energy", "value": -1.855333333, "entry": "li2o",
     "formula": "Li2O", "basis": "eV/atom relative to the elemental references"},
    {"id": "M3", "metric": "near_hull_unstable", "value": 1, "entries": ["li2o_b"],
     "basis": "unstable entries within kT at 298.15 K (0.025693 eV/atom) of the hull"},
    {"id": "M4", "metric": "far_above_hull", "value": 1,
     "basis": "entries more than 0.2 eV/atom above the hull (the script's plotting cut-off)"},
    {"id": "M5", "metric": "polymorph_compositions", "value": 1, "formulas": ["Li2O"],
     "basis": "reduced formulas with more than one entry; only the lowest of each can be on the hull"}
  ],
  "flags": [
    {"id": "F1", "severity": "medium", "category": "method_not_stated",
     "message": "No calculation method is stated for the dataset or any entry, so nothing shows that the energies share one functional and correction scheme.",
     "refs": []},
    {"id": "F2", "severity": "low", "category": "near_hull",
     "message": "1 unstable entry is within kT at 298.15 K (25.7 meV/atom) of the hull. ...",
     "refs": ["li2o_b"]}
  ],
  "browser_verdict": "caveated",
  "expected": {
    "stable_entry_count": 4,
    "formation_energy__li2o": -1.855333333,
    "formation_energy__li2o2": -1.537,
    "e_above_hull__li2o_b": 0.021,
    "e_above_hull__lio2": 0.449
  },
  "expected_count": 5,
  "clipped": []
}

The free browser page computes this full object for any entry set you paste: the pymatgen skill's strict entries JSON, a table of id, formula, energy_eV rows (total energy of the formula as written), or a list of pymatgen ComputedEntry dicts. To copy it without writing code, open a result on the page and press Download .json: the file carries the exact facts object under browser (the page's saved examples replay for free, so this works before any spend). Send it back as a string: json.dumps(facts), JSON.stringify(facts) or your language's equivalent. Keep the keys and values the browser produced: the reply is reconciled against them, and the script lane copies expected into its reproduction check.

Building the body

The simplest way to get a body that matches the page byte for byte is to run the page's own module in Node. hullkit.js needs hull.js (the phase diagram) and chem.js (formula parsing with pymatgen's rules) next to it, and all three export themselves with module.exports. The entries can be a table (id, formula, energy_eV), the skill's strict schema-1.0 entries JSON, or a JSON list of pymatgen entry dicts; every element needs at least one elemental entry.

// make-body.js - build the exact body the page sends, with the page's own code.
// Save https://hull-desk.skillsafe.ai/hullkit.js, hull.js and chem.js next to this file, then:
//   node make-body.js entries.csv interpret "Li-O hull" "notes" "question" "Li3O2" > body.json
// entries.csv is "id, formula, energy_eV" rows (total energy of the formula as written),
// or the skill's strict entries JSON, or a JSON list of pymatgen ComputedEntry dicts.
const fs = require("fs");
const K = require("./hullkit.js");
const [file, lane = "interpret", title = "", context = "", question = "", queries = "", decision = ""] = process.argv.slice(2);
const set = { lane, title, context, question, queries, decision, basis: "total", data: fs.readFileSync(file, "utf8") };
const X = K.analyze(set);
if (X.empty) throw new Error(X.errors.join("; ") || "no entries");
const body = K.mustBeObject(K.buildInput(X, set));
fs.writeFileSync("entries.json", K.entriesJson(X));   // the dataset the script lane reads
console.error("browser verdict:", X.hint, "| stable:", X.stable.length, "of", X.entries.length, "| flags:", X.flags.map(f => f.id + " " + f.category).join(", "));
console.error("idempotency key: hull-desk:" + body.task + ":" + K.hashInput(body) + ":a1");
process.stdout.write(JSON.stringify(body));
# Or build the body in any language from a facts object you already hold, for example the
# "browser" key of the page's "Download .json" export. facts must go in as a STRING.
import json

export = json.load(open("toy-li-o-interpret.json"))   # the page's .json download
facts = export["browser"]
body = {
    "task": "interpret",
    "title": "Toy Li-O hull",
    "context": "Illustrative toy energies, not real DFT values. ...",
    "question": "Is Li2O2 on the computed hull, and what does Li3O2 decompose to?",
    "facts": json.dumps(facts, separators=(",", ":")),
}
json.dump(body, open("body.json", "w"))

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": {"job_id": "job_...", "status": "queued"}}
{"ok": false, "error": {"code": "payment_required", "message": "..."}}

The token is minted for this app (the guest endpoint takes {"slug":"hull-desk"} in its body), so no slug header is needed afterwards. Send it as Authorization: Bearer ….

The input object IS the request body. There is no {"input": …} wrapper. A wrapped body is answered with an unknown field 'input' warning, and the model never sees your text.

Error codes

statuscodewhat to do
400validation_errorA field is missing or the wrong type. Every field is a string: facts must be a JSON-encoded string, not an object.
401unauthorizedThe token is missing, malformed or expired. Get a new one from the token page.
402payment_requiredThe balance is below min_credits. Call /estimate first and top up.
403forbiddenThe token is valid but not for this app, or a guest token tried a metered run. A guest cannot run; sign in for a personal token.
404not_foundUnknown job id, or the app slug does not exist.
409conflictThe same Idempotency-Key was replayed with a different body. Change the key or send the original input.
429rate_limitedToo many requests. Back off and retry; do not tight-loop.
5xxinternalA server-side failure. Retry with the SAME Idempotency-Key so you are not billed twice.

1. A tiny client

One helper that sends the token, unwraps data and raises on ok: false. The token comes from the token page (Copy token or Copy shell export); step 2 covers the kinds of token and minting one from code.

# Every call is the same three things: the base URL, your bearer token,
# and a JSON body. Keep the token in a shell variable.
BASE="https://api.skillsafe.ai/v1/app-api"
SLUG="hull-desk"
TOKEN="$SKILLSAFE_TOKEN"   # from https://hull-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
}

2. Get a token

The easiest route is the token page: it shows the token this browser already holds, with Copy token and Copy shell export buttons, and a sign-in button for a personal token. A guest token, minted with POST /guest and {"slug":"hull-desk"}, can call /me and /estimate; the run is metered, so /run and /run-stream need a personal token.

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

3. Check the session and the balance

call me
# {"ok":true,"data":{"subject_type":"user","username":"you","credits":51234}}

4. Price the run (free)

/estimate returns the model binding and the credits a run would reserve. It creates no job and charges nothing. Expect model_alias gpt-terra and markup_bps 1000 (a 10% markup). hold_credits is a reservation, not the price: it is held against your balance while the run executes and released afterwards. min_credits is the least balance that can start a run. What you actually pay is charged_credits, reported on the finished job and in the done event, and it is usually far lower than the hold. The body is the input object itself, with no {"input": …} wrapper. /estimate does not validate the body, so check the shape yourself: an object whose every value is a string, task equal to interpret or script, facts non-empty, and facts a JSON string that parses to an object (this is what the page's own guard, HullKit.mustBeObject, refuses to spend without).

# body.json is the input object itself - no {"input": ...} wrapper. Build it with
# make-body.js above, or by hand. estimate does not validate it, so check the shape first:
python3 -c 'import json;b=json.load(open("body.json"));assert isinstance(b,dict) and b.get("task") in ("interpret","script") and all(isinstance(v,str) for v in b.values()) and all(b.get(k,"").strip() for k in ("facts",)) and isinstance(json.loads(b["facts"]),dict)'
INPUT=$(cat body.json)

call estimate "$INPUT"
# {"ok":true,"data":{"model":"...","model_alias":"gpt-terra",
#   "markup_bps":1000,"hold_credits":...,"min_credits":...,"sponsor_enabled":false,
#   "warnings":[]}}
#
# estimate creates no job and charges nothing. hold_credits is RESERVED, not the
# price; charged_credits after the run is the actual cost, usually far lower.

5. Run it, then poll

POST /run returns a job_id; poll GET /jobs/{id} until it is terminal. The reply is a string at data.output.output: JSON.parse it (step 7). Send an Idempotency-Key built from the lane, a hash of the input and the attempt number, hull-desk:<lane>:<hash>:a<attempt> (for example hull-desk:interpret:mt6z7s12xnl3e:a1), so a retried request returns the same job instead of billing a second run. Use one key per distinct input: changed entries, energies, asked compositions or notes (so changed facts) or a changed reading are a new hash, the same entries in the other lane are a new key, and replaying an old key with a different body is a 409. The page uses HullKit.hashInput(body) for the hash (it covers task, title, context, facts, decision and question; make-body.js prints the key); any stable digest of the body works from other languages. Leave retry_note out of the hash and bump the attempt instead.

# Always send an Idempotency-Key derived from the input. A retried request with
# the same key returns the SAME job instead of billing a second run.
LANE=$(printf '%s' "$INPUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["task"])')   # interpret or script
KEY="hull-desk:$LANE:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16):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"])')

while :; do
  OUT=$(call "jobs/$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_...","status":"succeeded",
#   "output":{"output":"{\"lane\":\"interpret\",\"verdict\":\"caveated\",\"headline\":\"...\", ...}"},
#   "charged_credits":...,"truncated":false}}
printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["output"]["output"])' > reply.json

6. Or stream it

POST /run-stream takes the same body and headers and answers with server-sent events: job (the job id), delta (chunks of the reply) and done (the status, charged_credits, truncated and, when present, the full output). A browser page may receive only tick heartbeats and then done, never a delta, so take the reply from done.output.output when it is there, fall back to the concatenated deltas, and fall back again to GET /jobs/{id}.

# Server-sent events. `delta` events carry chunks of the reply; `done` carries the
# status, charged_credits and the truncated flag. Ignore `tick` heartbeats.
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_..."}
# event: delta  {"text":"{\"lane\":\"interpret\",\"verdict\":\"caveated\",\"headline\":\"The"}
# event: done   {"status":"succeeded","charged_credits":...,"truncated":false}

7. Parse the reply

The reply is a JSON object serialised as a string. Parse it, then check the lane.

# The reply is a JSON string inside data.output.output. Pull it out and parse it:
printf '%s' "$JOB" | python3 -c 'import sys,json;r=json.loads(json.load(sys.stdin)["output"]["output"]);print(r["verdict"],r["headline"])'

Invariants worth asserting

The output contract

The model returns one JSON object as the job's output text. Every key of the lane's contract is present; empty sections are [].

{
  "lane": "interpret" | "script",
  "verdict": "sound" | "caveated" | "unreliable",
  "headline": "one sentence",
  "tldr": ["2-5 bullets; one starts \"Answer:\" when a question was asked"],
  // interpret:
  "metrics": [{"id": "M1", "reading": "..."}],          // one per facts.metrics item, same order
  "phases": ["1-5 strings: compounds on the hull, how the closest unstable entries decompose, each asked composition"],
  "claims": [{"claim": "...", "support": "supported|partly|not_supported", "why": "..."}],
  "cautions": ["1-4 strings on what the hull cannot show"],
  // script:
  "fixes": [{"fix": "...", "why": "...", "refs": "F1"}],  // 1-6; refs "" for a plain step
  "script": "import json\nimport math\n...",              // one complete Python script, under 9000 characters
  "assumptions": ["1-4 strings"],
  "checks": ["1-4 strings"],
  // both:
  "next_steps": ["1-5 concrete actions"],
  "prescan_responses": [{"ref": "F1", "verdict": "confirmed|dismissed", "note": "..."}]
}

The script lane's script follows a fixed order: set ENTRIES_PATH = "entries.json" and check it is an existing local file; load it with json.loads and build one ComputedEntry(Composition(row["composition"], strict=True), float(row["energy_eV"]), entry_id=row["entry_id"]) per item of its "entries" list; build PhaseDiagram(entries); define EXPECTED; compute each key with pymatgen (stable_entry_count from len(diagram.stable_entries), formation_energy__<ID> from diagram.get_form_energy_per_atom(entry), e_above_hull__<ID> from diagram.get_e_above_hull(entry)); compare each with math.isclose(got, want, rel_tol=1e-6, abs_tol=1e-6); then apply the fixes. An energy the user did not give is a named constant set to None with an AUTHOR_INPUT_NEEDED comment, and that fix is skipped while it is None. Imports come only from pymatgen.core, pymatgen.analysis.phase_diagram, pymatgen.entries.computed_entries, math, json and pathlib.

Worked example: interpret

Illustrative numbers. The toy Li-O system from the facts section above: six invented entries, not real DFT values. The browser puts Li, O2, Li2O and Li2O2 on the hull (M1: 4 of 6), finds a second Li2O entry 0.021 eV/atom above the hull (within kT, F2, low) and LiO2 0.449 eV/atom above it, and raises F1 (medium) because no method is named, so its read is caveated. The body, with facts abbreviated (send the full string from make-body.js or the page):

{
 "task": "interpret",
 "title": "Toy Li-O hull",
 "context": "Illustrative toy energies, not real DFT values. I want to say that Li2O and Li2O2 are the only computed-stable compounds and that LiO2 is far from the hull.",
 "question": "Is Li2O2 on the computed hull, and what does Li3O2 decompose to?",
 "facts": "{\"settings\":{\"chemical_system\":\"Li-O\",\"elements\":[\"Li\",\"O\"],\"entry_count\":6,\"stable_entry_count\":4,\"...\":\"more\"},\"elemental_references\":[...],\"entries\":[...],\"rows_total\":6,\"composition_analyses\":[{\"query\":\"Li3O2\",\"...\":\"...\"}],\"metrics\":[...],\"flags\":[{\"id\":\"F1\",\"severity\":\"medium\",\"category\":\"method_not_stated\",\"...\":\"...\"},{\"id\":\"F2\",\"severity\":\"low\",\"category\":\"near_hull\",\"...\":\"...\"}],\"browser_verdict\":\"caveated\",\"expected\":{\"stable_entry_count\":4,\"...\":\"4 more\"},\"expected_count\":5,\"clipped\":[]}"
}

An abbreviated reply (the job's output.output, parsed; "..." marks cuts):

{
  "lane": "interpret",
  "verdict": "caveated",
  "headline": "On this toy entry set Li2O and Li2O2 are the only compounds on the computed hull, but no method is stated for the energies.",
  "tldr": [
    "4 of 6 entries are on the computed hull: Li, O2, Li2O (li2o) and Li2O2 (li2o2).",
    "LiO2 (lio2) sits 0.449 eV/atom above the hull and decomposes to Li2O2 and O2.",
    "Answer: yes, Li2O2 is on the computed hull; Li3O2 decomposes to 0.6 Li2O + 0.4 Li2O2 in atom fractions."
  ],
  "metrics": [
    {"id": "M1", "reading": "4 of 6 entries (0.667) are vertices of the computed hull, the two elemental references included."},
    {"id": "M2", "reading": "Li2O (li2o) has the lowest formation energy, -1.86 eV/atom relative to the elemental references."},
    {"id": "M3", "reading": "One unstable entry, li2o_b, lies within kT at 298.15 K of the hull (21 meV/atom)."},
    {"id": "M4", "reading": "One entry, lio2, is more than 0.2 eV/atom above the hull."},
    {"id": "M5", "reading": "Li2O has two entries; only the lower one, li2o, can be on the hull."}
  ],
  "phases": [
    "The computed hull runs Li - Li2O - Li2O2 - O2.",
    "li2o_b is a higher-energy Li2O entry and decomposes entirely to li2o.",
    "Li3O2 has no entry of its own and decomposes to 0.6 Li2O + 0.4 Li2O2 (atom fractions)."
  ],
  "claims": [
    {"claim": "Li2O and Li2O2 are the only computed-stable compounds.", "support": "supported", "why": "They are the only compounds with on_hull true."},
    {"claim": "LiO2 is far from the hull.", "support": "supported", "why": "lio2 is 0.449 eV/atom above the hull, beyond the 0.2 eV/atom cut-off (M4)."}
  ],
  "cautions": [
    "The hull covers only these six entries; a missing competing phase could change it.",
    "It is a 0 K energy hull with no temperature, pressure or entropy terms."
  ],
  "next_steps": ["State the functional and correction scheme for every entry.", "..."],
  "prescan_responses": [
    {"ref": "F1", "verdict": "confirmed", "note": "Neither the facts nor the notes name a method."},
    {"ref": "F2", "verdict": "confirmed", "note": "li2o_b is 0.021 eV/atom above the hull, inside kT."}
  ]
}

A reply must answer F1 and F2 once each in prescan_responses, read M1 to M5 in order, mention Li3O2, judge both claims in context against entries, and stay at caveated or tighter unless it dismisses F1.

Worked example: script

The same toy entries (still illustrative) with the interpret reading handed over as decision (the page fills it with Recon.decisionText of the earlier reply; it may be empty). No question is sent in this lane. The body, with facts and decision abbreviated:

{
 "task": "script",
 "title": "Toy Li-O hull",
 "context": "Illustrative toy energies, not real DFT values. I want to say that Li2O and Li2O2 are the only computed-stable compounds and that LiO2 is far from the hull.",
 "decision": "Verdict: caveated.\nOn this toy entry set Li2O and Li2O2 are the only compounds on the computed hull, ...\n- M1: 4 of 6 entries ...\n...\nNext steps:\n- ...",
 "facts": "{\"settings\":{\"chemical_system\":\"Li-O\",\"...\":\"more\"},\"...\":\"as in the interpret example\",\"browser_verdict\":\"caveated\",\"expected\":{\"stable_entry_count\":4,\"formation_energy__li2o\":-1.855333333,\"formation_energy__li2o2\":-1.537,\"e_above_hull__li2o_b\":0.021,\"e_above_hull__lio2\":0.449},\"expected_count\":5,\"clipped\":[]}"
}

An abbreviated reply; the script string is shown decoded below it:

{
  "lane": "script",
  "verdict": "caveated",
  "headline": "The script rebuilds the toy Li-O hull from entries.json, checks all 5 expected values, then drops li2o_b to show it does not move the hull.",
  "tldr": ["The reproduction check covers stable_entry_count, 2 formation energies and 2 energies above hull.", "..."],
  "fixes": [
    {"fix": "Rebuild the diagram without li2o_b and print how the other entries' energies above hull change.", "why": "It is within kT of the hull; removing it shows whether the stable set depends on it.", "refs": "F2"},
    {"fix": "Print get_equilibrium_reaction_energy for each stable compound.", "why": "Shows how deep each compound sits below its neighbours.", "refs": ""}
  ],
  "script": "import json\nimport math\nfrom pathlib import Path\n...",
  "assumptions": ["entries.json is the file the page wrote for this entry set.", "The energies share one method (F1: not stated)."],
  "checks": ["The reproduction prints no MISMATCH line.", "The stable set is unchanged after dropping li2o_b."],
  "next_steps": ["Save the page's entries.json next to hull_check.py and run python hull_check.py.", "..."],
  "prescan_responses": [
    {"ref": "F1", "verdict": "confirmed", "note": "No method is named; the script cannot check compatibility."},
    {"ref": "F2", "verdict": "confirmed", "note": "Answered by the first fix."}
  ]
}
# hull_check.py - reproduce the browser's hull from entries.json (abbreviated reply script)
import json
import math
from pathlib import Path

from pymatgen.analysis.phase_diagram import PhaseDiagram
from pymatgen.core import Composition
from pymatgen.entries.computed_entries import ComputedEntry

ENTRIES_PATH = "entries.json"

EXPECTED = {
    "stable_entry_count": 4,
    "formation_energy__li2o": -1.855333333,
    "formation_energy__li2o2": -1.537,
    "e_above_hull__li2o_b": 0.021,
    "e_above_hull__lio2": 0.449,
}


def load_entries(path):
    rows = json.loads(Path(path).read_text(encoding="utf-8"))["entries"]
    return [
        ComputedEntry(Composition(row["composition"], strict=True), float(row["energy_eV"]), entry_id=row["entry_id"])
        for row in rows
    ]


def main():
    path = Path(ENTRIES_PATH)
    if not path.is_file():
        raise SystemExit(f"{ENTRIES_PATH} not found: save it from the Hull Desk page first")
    entries = load_entries(path)
    diagram = PhaseDiagram(entries)
    by_id = {e.entry_id: e for e in entries}

    got = {"stable_entry_count": len(diagram.stable_entries)}
    for key in EXPECTED:
        if key.startswith("formation_energy__"):
            got[key] = diagram.get_form_energy_per_atom(by_id[key.split("__", 1)[1]])
        elif key.startswith("e_above_hull__"):
            got[key] = diagram.get_e_above_hull(by_id[key.split("__", 1)[1]])
    bad = [k for k, want in EXPECTED.items() if not math.isclose(got[k], want, rel_tol=1e-6, abs_tol=1e-6)]
    for k in bad:
        print(f"MISMATCH {k}: got {got[k]!r}, expected {EXPECTED[k]!r}")
    print("reproduction:", "OK" if not bad else f"{len(bad)} mismatch(es)")

    # Fix 1 (F2): rebuild without li2o_b.
    reduced = PhaseDiagram([e for e in entries if e.entry_id != "li2o_b"])
    print("stable without li2o_b:", sorted(e.entry_id for e in reduced.stable_entries))
    # Fix 2: equilibrium reaction energy of each stable compound.
    for e in diagram.stable_entries:
        if len(e.composition.elements) > 1:
            print(e.entry_id, diagram.get_equilibrium_reaction_energy(e))


if __name__ == "__main__":
    main()

The reply's script must put all 5 keys of facts.expected into EXPECTED with the browser's values, check each with math.isclose, and answer F2 with a fix from the allowed list. Save the page's entries.json next to the script and run it with python hull_check.py.

Truncation and partial results

If your balance sits between min_credits and hold_credits, the run still executes with a smaller output cap and the job carries "truncated": true. The JSON may then stop mid-object: close it (the page's Recon.closeJson does this) and show the sections that arrived, saying how many of the lane's sections were recovered, rather than treating a clipped reply as complete. A clipped script is not runnable; re-run instead.