← Pipette Desk / API
Tokens

Drive Pipette Desk from your own code

Everything the web page does is available over HTTP: send a liquid-handling protocol — normally Python against the Opentrons Protocol API v2 — with an optional plate map, and get back the same structured review the browser renders. Four lanes share one request shape and one response envelope: audit asks whether the protocol is safe to start, dryrun says what to run with water and what would make you stop, runsheet writes the protocols.io-shaped document the operator holds at the bench, and eln writes the Benchling-shaped record afterwards.

The natural uses are a pre-flight gate in the lab's own tooling — refuse to queue a protocol whose audit comes back blocked — and a batch pass over a directory of protocols that reports which ones would overflow a well or run out of tips before anyone loads a reagent.

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": "...", "status": 402, "details": { ... } } }

There is no X-App-Slug header. The browser SDK this app ships sends exactly two headers on a normal call — Content-Type: application/json and Authorization: Bearer … — plus Idempotency-Key on a run and Accept: text/event-stream on a stream. The slug pipette-desk appears in one place only: the body of POST /guest. A token is already bound to its app, so nothing downstream needs to be told the slug again, and a header that looks like it should work is simply ignored.

The endpoints, in the order a caller uses them:

callauthcostwhat it does
POST /guestnonefreeMints a guest token for one app. Answers 201 with {token, guest_id, expires_at}.
GET /metokenfreeReturns {subject_type, subject_id, credits} and nothing else.
POST /estimatetokenfreePrices an input. Creates no job and charges nothing — but it is authenticated, so it has to come after the token.
POST /runtokenmeteredStarts a review. Returns {job_id, status}.
GET /jobs/{job_id}tokenfreePolls one job. The terminal job carries output.output, charged_credits and truncated.
POST /run-streamtokenmeteredThe same run as server-sent events: job, delta, done, and error on a failure.

Error codes

codestatuswhat to do
unauthorized401The token is missing, malformed or past its expires_at. Mint another with POST /guest, or copy a personal one from the token page.
payment_required402The balance is below min_credits. Call /estimate first — it is free — and top up before you run.
forbidden403The token is valid but not for this app, or a guest token tried a metered run. Mint the token against pipette-desk and sign in for a metered lane.
not_found404Unknown job_id, or the app slug in the /guest body does not exist. Check for a typo in slug.
conflict409The same Idempotency-Key was replayed with a different body. Bump the attempt suffix, or resend the original input.
validation_error422The body is not the shape the app expects — a missing task, or a prescan_facts that is not an object. A body that is not valid JSON at all comes back as a 400.
rate_limited429Too many requests. Back off and retry with the same key; do not tight-loop.
internal5xxA server-side failure, reported as server_error on a plain 500. Retry with the SAME Idempotency-Key so a half-finished run is not billed twice.

One failure mode has no error code at all, and it is the one worth reading twice: wrapping the input in an input key. See the request body below — it returns 200, reserves a plausible hold, and the model never sees a single field.

The task field, before anything else

This app is four apps behind one endpoint. task chooses which one you get, it is always present in a well-formed request, and it changes the shape of body in the reply. Everything else in the input — the protocol, the plate map, the robot, the goal, the context, the prescan facts — is identical across all four. Send the same input four times with four different task values and you get four documents about one protocol.

taskthe question it answerswhat body carries
auditIs this protocol safe to start right now?deck_review[], volume_review[], tip_budget, liquid_review[], blocking_issues[], before_you_run[]
dryrunWhat do I run with water, what do I watch, and what makes me stop? And what would it take to run this liquid handling somewhere else?dry_run_steps[], calibration_checks[], abort_triggers[], portability[], water_run_volume_ul
runsheetWhat does the person at the bench hold, if they never read the Python?protocol_title, estimated_duration_min, materials[], before_you_start[], steps[], after_the_run[]
elnWhat gets written down afterwards, and which inventory moved?entry_title, entry_sections[], inventory_moves[], registry_links[], deviations_to_log[], attachments_to_keep[]

The lanes are not interchangeable and they are not additive. A reply never blends two lanes' body shapes — a merged body fails to render — and the lane's own question decides what counts as a finding. A step whose result cannot be observed is a documentation finding in dryrun; the same protocol's overflowing well is a volume finding in audit and, in eln, the same fact arrives as an inventory_moves[].status of short. Report the same fact at the same severity in every lane, and let the lane decide where it lands.

If task is absent or unrecognised the run does not fail: the model picks the lane the input best fits — a protocol alone is audit, a protocol plus a plate map and a goal of handoff is runsheet — sets lane to what it chose, and says so in the first sentence of summary. Do not rely on that. Send the lane.

A worked request for each lane

Four bodies, one per lane, each a complete request you can POST as-is. They are deliberately small: a real protocol is the whole file, and the only thing that changes between these four is task, goal and how much supporting material is worth sending.

audit — is this safe to start?

The lane a caller reaches for first, and the one worth gating on. Send the protocol and, when you have it, the plate map: without a plate map the liquid accounting has no starting volumes, so liquid_review[].status can never come back ok.

{
  "task": "audit",
  "protocol": "from opentrons import protocol_api\n\nmetadata = {\"protocolName\": \"qPCR plate setup\", \"apiLevel\": \"2.15\"}\n\ndef run(ctx):\n    tips = ctx.load_labware(\"opentrons_96_tiprack_300ul\", 1)\n    plate = ctx.load_labware(\"nest_96_wellplate_200ul_flat\", 2)\n    res = ctx.load_labware(\"nest_12_reservoir_15ml\", 3)\n    p300 = ctx.load_instrument(\"p300_single_gen2\", \"right\", tip_racks=[tips])\n    for col in plate.columns():\n        p300.transfer(180, res[\"A1\"], col, new_tip=\"never\")\n",
  "platemap": "labware\twell\treagent\tlot\tvolume_ul\tconcentration\texpiry\nnest_12_reservoir_15ml\tA1\tmaster mix\tMM-2291\t12000\t2x\t2026-11-30\n",
  "robot": "ot2",
  "goal": "first_run",
  "context": "First time on this instrument. The reservoir was filled from a 15 mL aliquot this morning; we have one spare tip rack on the bench but the protocol does not pause.",
  "prescan_facts": {
    "resources": [
      { "id": "protocol_name", "label": "Protocol: qPCR plate setup" },
      { "id": "robot", "label": "Robot: OT-2 (inferred from the pipettes)" },
      { "id": "tips_required", "label": "12 tips required, 96 available in assigned racks" }
    ],
    "flags": [
      { "id": "tip_reuse_single_reagent", "uid": "PD-001", "severity": "low",
        "area": "contamination", "label": "new_tip=\"never\" across 12 transfers",
        "detail": "One reagent goes to 12 destinations with one tip.",
        "line": 12, "evidence": "p300.transfer(180, res[\"A1\"], col, new_tip=\"never\")" },
      { "id": "source_depletion", "uid": "PD-002", "severity": "blocking",
        "area": "reagent", "label": "master mix draws 17280 ul from a 12000 ul channel",
        "detail": "12 columns x 8 channels x 180 ul against the declared starting volume.",
        "line": 12, "evidence": "nest_12_reservoir_15ml A1, declared 12000 ul" }
    ]
  }
}

dryrun — what to run with water

The same protocol, asking a different question. goal of validation is the usual pairing; the plate map matters less here, because the lane is about what you can observe, not about whether the arithmetic closes.

{
  "task": "dryrun",
  "protocol": "from opentrons import protocol_api\n\nmetadata = {\"protocolName\": \"qPCR plate setup\", \"apiLevel\": \"2.15\"}\n\ndef run(ctx):\n    tips = ctx.load_labware(\"opentrons_96_tiprack_300ul\", 1)\n    plate = ctx.load_labware(\"nest_96_wellplate_200ul_flat\", 2)\n    res = ctx.load_labware(\"nest_12_reservoir_15ml\", 3)\n    p300 = ctx.load_instrument(\"p300_single_gen2\", \"right\", tip_racks=[tips])\n    for col in plate.columns():\n        p300.transfer(180, res[\"A1\"], col, new_tip=\"never\")\n",
  "robot": "ot2",
  "goal": "validation",
  "context": "Instrument came back from service last week, so labware offsets are all new. We can spare one plate of water.",
  "prescan_facts": {
    "resources": [
      { "id": "runtime", "label": "Estimated run time 21m 40s" },
      { "id": "commands", "label": "12 liquid-handling commands parsed" }
    ],
    "flags": [
      { "id": "no_drop_tip", "uid": "PD-001", "severity": "medium",
        "area": "contamination", "label": "No drop_tip call in the protocol",
        "detail": "Tips are picked up but never dropped, so the run ends holding one.",
        "line": null, "evidence": "" }
    ]
  }
}

runsheet — the document at the bench

Pair this with goal: "handoff". The plate map earns its place here: every reagent it names becomes a materials row, with the lot when the map gives one, and a reagent with no plate-map row is listed with its spec saying the identity was not supplied.

{
  "task": "runsheet",
  "protocol": "from opentrons import protocol_api\n\nmetadata = {\"protocolName\": \"qPCR plate setup\", \"apiLevel\": \"2.15\"}\n\ndef run(ctx):\n    tips = ctx.load_labware(\"opentrons_96_tiprack_300ul\", 1)\n    plate = ctx.load_labware(\"nest_96_wellplate_200ul_flat\", 2)\n    res = ctx.load_labware(\"nest_12_reservoir_15ml\", 3)\n    p300 = ctx.load_instrument(\"p300_single_gen2\", \"right\", tip_racks=[tips])\n    ctx.pause(\"Seal the plate, then resume\")\n    for col in plate.columns():\n        p300.transfer(180, res[\"A1\"], col, new_tip=\"always\")\n",
  "platemap": "labware\twell\treagent\tlot\tvolume_ul\tconcentration\texpiry\nnest_12_reservoir_15ml\tA1\tmaster mix\tMM-2291\t12000\t2x\t2026-11-30\nnest_96_wellplate_200ul_flat\tA1\tsample 001\tS-4471\t20\t\t\n",
  "robot": "ot2",
  "goal": "handoff",
  "context": "Handing this to a second-year student who has run the instrument twice. They need the manual steps spelled out, including the seal-and-resume pause.",
  "prescan_facts": {
    "resources": [
      { "id": "pauses", "label": "1 operator pause" },
      { "id": "platemap_rows", "label": "2 plate-map rows read, 2 sent" }
    ],
    "flags": []
  }
}

eln — the record afterwards

The one lane whose verdict is capped by what you send: with no plate map, inventory_moves is [], unassessable says why, and the verdict is at best clear_with_notes — an ELN entry that cannot name a lot is not a complete record. Send the plate map for this lane if you send it for any of them.

{
  "task": "eln",
  "protocol": "from opentrons import protocol_api\n\nmetadata = {\"protocolName\": \"qPCR plate setup\", \"apiLevel\": \"2.15\"}\n\ndef run(ctx):\n    tips = ctx.load_labware(\"opentrons_96_tiprack_300ul\", 1)\n    plate = ctx.load_labware(\"nest_96_wellplate_200ul_flat\", 2)\n    res = ctx.load_labware(\"nest_12_reservoir_15ml\", 3)\n    p300 = ctx.load_instrument(\"p300_single_gen2\", \"right\", tip_racks=[tips])\n    for col in plate.columns()[:6]:\n        p300.transfer(180, res[\"A1\"], col, new_tip=\"always\")\n",
  "platemap": "labware\twell\treagent\tlot\tvolume_ul\tconcentration\texpiry\nnest_12_reservoir_15ml\tA1\tmaster mix\tMM-2291\t12000\t2x\t2026-11-30\nnest_96_wellplate_200ul_flat\tA1\tsample 001\tS-4471\t20\t\t\n",
  "robot": "ot2",
  "goal": "repeat_run",
  "context": "Ran on 2026-08-18. Only six columns were filled because the plate was half consumed; the reviewer wants the deviation recorded.",
  "prescan_facts": {
    "resources": [
      { "id": "platemap_rows", "label": "2 plate-map rows read, 2 sent" },
      { "id": "commands", "label": "6 liquid-handling commands parsed" }
    ],
    "flags": [
      { "id": "platemap_unmatched", "uid": "PD-001", "severity": "low",
        "area": "inventory", "label": "1 plate-map row matches no labware in the protocol",
        "detail": "sample 001 is declared but nothing in the protocol aspirates from it.",
        "line": null, "evidence": "nest_96_wellplate_200ul_flat A1, sample 001" }
    ]
  }
}

Running all four lanes over one protocol

Because only task changes, the useful shape in code is one base object and a loop. These samples use the call helper built in step 2 below; each one costs a separate metered run, so give each lane its own Idempotency-Key — the lane is part of the body, so replaying one key across two lanes is a 409 conflict.

# One protocol, four lanes. The body is the input object itself - no "input" key.
BASE="https://api.skillsafe.ai/v1/app-api"
PROTOCOL='from opentrons import protocol_api

metadata = {"protocolName": "qPCR plate setup", "apiLevel": "2.15"}

def run(ctx):
    tips = ctx.load_labware("opentrons_96_tiprack_300ul", 1)
    plate = ctx.load_labware("nest_96_wellplate_200ul_flat", 2)
    res = ctx.load_labware("nest_12_reservoir_15ml", 3)
    p300 = ctx.load_instrument("p300_single_gen2", "right", tip_racks=[tips])
    for col in plate.columns():
        p300.transfer(180, res["A1"], col, new_tip="never")'

for LANE in audit dryrun runsheet eln; do
  INPUT=$(LANE="$LANE" PROTOCOL="$PROTOCOL" python3 -c '
import json, os
print(json.dumps({
    "task": os.environ["LANE"],
    "protocol": os.environ["PROTOCOL"],
    "robot": "ot2",
    "goal": "first_run",
    "context": "One spare tip rack on the bench; the protocol does not pause.",
    "prescan_facts": {"resources": [], "flags": []},
}))')

  # A separate key per lane: the lane is part of the body, so one key for two
  # lanes is a 409 conflict.
  KEY="pipette-desk:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16):$LANE:a1"

  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"])'
done

The request body

The body of /estimate, /run and /run-stream is the input object itself. It is never wrapped in an input key, never in a body key, and never in {"data": …}. This is worth stating flatly because the wrong shape does not fail: sending {"input": {"task": "audit", …}} returns 200, reserves a plausible-looking hold, produces a job that succeeds, and charges you — while the model receives an object with none of the fields it is told to read. What comes back is a fluent review of nothing. There is no error code for this. The only defence is sending the object flat, which is what every sample on this page does.

fieldtypemeaning
taskenum, requiredWhich lane to run: audit, dryrun, runsheet or eln. Always present in a well-formed request. See the lanes.
protocolstringThe protocol source, normally Python against Protocol API v2. May be clipped in the middle, and the cut is announced in-band with a # [... N characters cut ...] comment. The browser cuts the middle rather than the head or the tail, because the metadata and the deck setup are at the top and the liquid handling is in the middle and at the bottom — neither end is safe to drop. When the marker is present, the reply says in summary that the middle was not read and the checks that depend on it go in unassessable.
platemapstring, optionalA tab-separated plate map with the columns labware, well, reagent, lot, volume_ul, concentration, expiry. May be a golden-ratio sample of a longer map; if so, a # comment line says how many rows were read and how many were sent. Absent is a legitimate answer, and it is not the same thing as empty — the reply must say "no plate map supplied", never "the plate map is empty".
robotenumWhat the operator says they are running on: ot2, flex or unknown. Sending unknown honestly is better than guessing: a stated robot that contradicts the pipettes the protocol loads is itself a finding, and a guess buries it.
goalenumWhat this run is for: first_run, repeat_run, handoff, troubleshoot or validation. It shifts emphasis, not correctness — a handoff spells out the manual steps, a troubleshoot leans on what the operator says went wrong last time.
contextstringFree text from the operator — deadlines, reagent substitutions, instrument quirks, what went wrong last time. Often the most decisive thing in the whole input, and it is not decorative: every claim in it comes back as one context_notes entry marked honoured, contradicted or unverifiable. A stated constraint with no effect anywhere in the protocol is itself a finding.
prescan_factsobject{resources: [{id, label}], flags: [{id, uid, severity, area, label, detail, line, evidence}]} — what the browser's own parser already computed. resources is context; flags is a contract. See below.
retry_notestring, optionalSend only on a retry, after a reply failed to parse or came back truncated. The instruction is obeyed exactly, so make it concrete: "the previous reply was truncated, return the same findings but at most six dry-run steps".

Fields that are absent are absent. Nothing is defaulted on your behalf beyond the lane fallback described above, and nothing in the reply may assume a field you did not send — a liquid_review row cannot be ok without a starting volume, and a remaining_ul is never computed from a starting volume that was not supplied.

prescan_facts, and the reconciliation contract

In the browser, prescan_facts comes from DeckScan, the free in-page reader that parses the protocol before anyone signs in. It builds a deck map, resolves the pipettes and their real volume envelopes, budgets tips against the racks the pipettes were actually given, multiplies commands inside loops by their iteration count, and keeps a per-well ledger of what each source gives out and each destination takes in. resources is what it read — the protocol name, the robot, the API level, the labware per slot, the pipettes with their ranges, the tip budget, the run-time estimate, the plate-map row count. flags is what fired.

An API caller does not have to reproduce any of that. Sending {"resources": [], "flags": []} is legitimate, and the review still works — the model reads protocol and platemap either way.

What makes it worth sending is the contract. Every uid you put in flags comes back exactly once in the response's reconciliation array — no more, no fewer — each with a status:

statusmeans
confirmedThe reviewer agrees, at the same or a higher severity. A useful confirmed adds the consequence the flag itself does not state.
adjustedReal, but the severity or the reading changes — and the note says what changed it. A volume over the pipette maximum inside transfer() is split automatically and is slow, not broken; the same volume in a bare aspirate() fails at run time.
set_asideNot a problem here, with the reason that makes it harmless. A set_aside with no reason is worse than no entry at all.
not_applicableThe flag does not apply to this lane's question.

That turns a fact your own tooling established into something the reply is held to. A uid that never appears is a failed run, not a passing one, and a uid you did not send must not appear either. Asserting both directions in your client is three lines and it catches the one failure this app cares most about — a plausible report that quietly dropped the blocking fact you handed it. The check is written out in step 6.

uid is positional and stable within one request: the browser numbers flags PD-001, PD-002, … in the order they fired. id is the stable name of the check itself, and it is what you match on across runs. Both travel; the reconciliation keys on flag_uid.

These are the check ids DeckScan raises, with the severity and area it raises them at:

idseverityareafires when
no_pipetteblockingapiload_instrument was never called, so nothing in the protocol can move liquid.
mount_conflictblockingdeckTwo pipettes are loaded on the same mount.
deck_slot_conflictblockingdeckTwo pieces of labware, or labware and a module, claim one slot.
ot2_trash_slot_usedblockingdeckAn OT-2 protocol loads labware into the fixed trash slot.
well_out_of_rangeblockingdeckA well address does not exist in that labware's format. The Protocol API raises before any liquid moves.
volume_above_maxblocking or lowvolumeA volume exceeds the pipette's maximum. blocking in a bare aspirate() or dispense(); low inside transfer(), which splits it automatically and is merely slow.
well_overfill, well_overfill_moreblockingvolumeA destination well takes in more than its derived working capacity. The _more variant is the roll-up when too many wells overflow to list individually.
source_depletion, source_depletion_moreblockingreagentA source gives out more than the plate map declares it holds.
no_tips_availableblockingtipsThe tip budget exceeds what the assigned racks hold, with no refill pause.
missing_api_levelhighapiNo apiLevel in metadata, so the run's semantics depend on the app version.
robot_pipette_mismatchhighapiThe declared robot and the pipette load names disagree — a Flex pipette on a stated OT-2, or the reverse.
flex_missing_trashhighdeckA Flex protocol never loads a trash bin or waste chute.
tip_reuse_across_reagentshighcontaminationOne tip carries more than one reagent — real cross-contamination, not a style note.
volume_below_minhigh or mediumvolumeA volume is below the pipette's minimum; medium when it is within about 10 per cent of the floor.
zero_volume_transfermediumvolumeA liquid-handling call moves zero microlitres.
unresolved_locationmediumvolumeA destination depends on values this reader cannot evaluate, so that liquid is left out of the accounting rather than guessed at.
uncertain_loopmediumvolumeA loop's iteration count is not statically known, so the multiplier is a lower bound.
tip_reuse_unverifiedmediumcontaminationnew_tip is left at a value whose carry-over cannot be judged from the source alone.
no_drop_tipmediumcontaminationTips are picked up and never dropped.
unknown_labwaremediumdeckA load name this reader has no geometry for, so its capacity checks are unassessable.
unknown_pipettemediumapiA pipette load name outside the known envelope table; its volume checks go to unassessable.
platemap_missing_reagentmediumreagentThe protocol draws from a position the plate map does not name.
long_runtimemedium or lowtimingThe estimate crosses a working-day threshold.
tip_reuse_single_reagentlowcontaminationnew_tip="never" with one reagent — worth naming, and not cross-contamination.
unused_labwarelowdeckLabware is loaded and never touched, so a slot is occupied for nothing.
platemap_unmatchedlowinventoryA plate-map row matches no labware in the protocol.
platemap_parse_warningslowinventoryRows the plate-map parser could not fully read — an unparseable volume, an unknown well address.
no_platemapinforeagentNo plate map was supplied, so lot traceability and depletion cannot be checked at all.

The scanner also emits its own unassessable items — the volume range of an unknown pipette, the working capacity of unknown labware, the iteration count of a dynamic loop, carry-over for an unjudgeable new_tip, and reagent depletion when no plate map arrived. Those are not flags and they are not part of the reconciliation contract; the reply is expected to carry them forward into its own unassessable array rather than quietly pretending the check was made.

1. Get a token

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

For a program, POST /guest mints one. The body is {"slug": "pipette-desk"} — this is the one and only place the slug appears — and the call answers 201 Created:

HTTP/1.1 201 Created

{ "ok": true, "data": {
    "token": "sk_guest_...",
    "guest_id": "gst_...",
    "expires_at": "2026-08-27T09:14:02Z"
} }

Three things follow from that shape. expires_at is real, so a long-lived worker re-mints rather than caching forever; a 401 on a previously good token usually means it lapsed. guest_id is worth keeping — it is what lets a later sign-in migrate the guest wallet, and it is the only handle you have on an anonymous session. And a guest token is enough for /me and /estimate but not for a metered run: a review is metered, so /run and /run-stream want a personal token from signing in. A guest attempting a run gets 403 forbidden, not a 402.

# The token page is the shortest path for a person. It shows the token this
# browser holds and hands you a ready-made shell export:
#
#   https://pipette-desk.skillsafe.ai/tokens.html
#   export SKILLSAFE_TOKEN="..."
#
# To mint a guest token from the command line instead. The slug goes in the BODY;
# there is no X-App-Slug header anywhere in this API.
curl -sS -i -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"pipette-desk"}'

# HTTP/1.1 201 Created
# {"ok":true,"data":{"token":"sk_guest_...","guest_id":"gst_...",
#                    "expires_at":"2026-08-27T09:14:02Z"}}

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

# A guest token covers /me and /estimate. A metered review needs a personal
# token from signing in on the token page.

2. A tiny client

One helper that adds the two headers, unwraps data and raises on error. Two headers is the whole story: Content-Type and Authorization. If you find yourself reaching for X-App-Slug, the token already carries the app.

# 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"
TOKEN="$SKILLSAFE_TOKEN"   # from https://pipette-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
}

# No X-App-Slug. The slug was only ever needed by POST /guest.

3. Check the session and the balance

GET /me returns three fields and nothing else:

{ "ok": true, "data": {
    "subject_type": "guest",
    "subject_id": "gst_...",
    "credits": 1200
} }

Read that literally, because the fields people expect are not there. There is no user_id — the identifier is subject_id whichever kind of subject it is, so a guest's subject_id is its guest_id and a signed-in person's is their user id. There is no is_guest flag, so a truthiness test on it is silently false for everybody, which reads as "this is a real user" for a guest token. Branch on subject_type, which is guest or user. There is no username, no email and no plan field either; if you need a display name, you need your own.

credits is the wallet balance. Compare it against min_credits from step 4 before you run, so a shortfall becomes your own clear message instead of a 402 in the middle of a batch.

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

# Branch on subject_type. There is no is_guest field to test, and no user_id -
# the id is subject_id for both kinds of subject.
call me | python3 -c '
import sys, json
me = json.load(sys.stdin)["data"]
kind = me["subject_type"]                       # "guest" or "user"
print(kind, me["subject_id"], me["credits"], "credits")
if kind == "guest":
    print("guest token: /me and /estimate only, no metered run")
'

4. Price the run — free, but authenticated

POST /estimate creates no job and charges nothing. It is still an authenticated call, which is the ordering trap: it has to come after step 1. A caller who builds an input, prices it and only then goes looking for a token gets a 401 on the free call and reads it as a broken endpoint. Mint the token first, price second.

The body is the input object from above, flat. What comes back:

fieldmeaning
modelThe exact model the run will bind to.
model_aliasThe stable alias that binding came from — what to log, since the concrete model moves under it.
markup_bpsThe app's markup in basis points; 1000 is ten per cent.
hold_creditsWhat gets reserved when the run starts. Priced against the full output cap, so it is an upper bound, not the price.
min_creditsThe balance you must clear for the run to start at all. Compare this against credits from /me.
sponsor_enabledWhether the app is covering this run rather than your wallet.

The hold is a reservation. The charged_credits you see on the settled job is normally far lower — often a small fraction of it, because an audit of a clean protocol says so in a few hundred tokens while the cap allows for a runsheet with forty steps. Budget against hold_credits; report against charged_credits.

Estimates differ by lane. runsheet and eln are the wordy ones — a materials list, numbered steps, prose entry sections — so if you are running all four lanes over one protocol, price all four rather than multiplying the audit figure by four.

# The body is the input object itself. No "input" key - wrapping it returns 200
# with a plausible hold while the model sees none of these fields.
INPUT='{"task":"audit","protocol":"from opentrons import protocol_api\n\nmetadata = {\"protocolName\": \"qPCR plate setup\", \"apiLevel\": \"2.15\"}\n\ndef run(ctx):\n    tips = ctx.load_labware(\"opentrons_96_tiprack_300ul\", 1)\n    plate = ctx.load_labware(\"nest_96_wellplate_200ul_flat\", 2)\n    res = ctx.load_labware(\"nest_12_reservoir_15ml\", 3)\n    p300 = ctx.load_instrument(\"p300_single_gen2\", \"right\", tip_racks=[tips])\n    for col in plate.columns():\n        p300.transfer(180, res[\"A1\"], col, new_tip=\"never\")","platemap":"labware\twell\treagent\tlot\tvolume_ul\tconcentration\texpiry\nnest_12_reservoir_15ml\tA1\tmaster mix\tMM-2291\t12000\t2x\t2026-11-30","robot":"ot2","goal":"first_run","context":"First run on this instrument. One spare tip rack on the bench; the protocol does not pause.","prescan_facts":{"resources":[{"id":"tips_required","label":"12 tips required, 96 available in assigned racks"}],"flags":[{"id":"source_depletion","uid":"PD-001","severity":"blocking","area":"reagent","label":"master mix draws 17280 ul from a 12000 ul channel","detail":"12 columns x 8 channels x 180 ul against the declared starting volume.","line":12,"evidence":"nest_12_reservoir_15ml A1, declared 12000 ul"}]}}'

call estimate "$INPUT"
# {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra",
#   "markup_bps":1000,"hold_credits":2480,"min_credits":330,"sponsor_enabled":false}}
#
# estimate is FREE - no job, no charge - but it IS authenticated, so it comes
# after step 1. hold_credits is a reservation against the full output cap;
# charged_credits after settlement is normally much lower.

5. Run it, then poll

POST /run returns {job_id, status}; poll GET jobs/{job_id} until status is succeeded or failed. The report is a JSON string at data.output.output — one object, the envelope described in the output contract below. The terminal job also carries charged_credits, the real price, and truncated.

Always send an Idempotency-Key. Derive it from the input the way the web app does — a content hash plus the lane plus an attempt counter, pipette-desk:<hash>:<lane>:a<attempt>. A retried request carrying the same key returns the same job instead of billing a second run, which is what makes a retry safe after a network blip on a protocol you have already paid to review. Replaying a key with a different body is a 409 conflict, so bump the attempt suffix whenever the input actually changed — including when all that changed is task, because the lane is part of the body.

# Always send an Idempotency-Key derived from the input, with the lane in it.
# A retried request with the same key returns the SAME job instead of billing twice.
KEY="pipette-desk:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16):audit: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 until the job reaches a terminal status.
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

# The terminal job looks like this:
# {"ok":true,"data":{"job_id":"job_...","status":"succeeded",
#   "output":{"output":"{\"lane\":\"audit\",\"verdict\":\"blocked\", ...}"},
#   "charged_credits":611,"truncated":false}}

# The report is a JSON string inside the envelope, so unwrap it twice.
printf '%s' "$OUT" | python3 -c '
import sys, json
job = json.load(sys.stdin)["data"]
rep = json.loads(job["output"]["output"])
print(rep["lane"], rep["verdict"], "-", rep["headline"])
for f in rep["findings"]:
    print("  %-8s %-13s %s" % (f["severity"], f["area"], f["title"]))
print("charged", job.get("charged_credits"), "truncated", job.get("truncated"))
'

6. Or stream it

POST /run-stream is the same call over server-sent events, with Accept: text/event-stream added to the same two headers. The events are job ({job_id}, first), delta ({"text": "..."}, a chunk of the report JSON), done (status, charged_credits, truncated) and error on a failure. Two practical details: an idempotent replay of a key that already ran comes back as plain JSON rather than a stream, so check the response Content-Type before you start reading lines; and events are separated by a blank line, so split on \n\n rather than assuming one data: line per event.

For a progress display, do not parse the partial JSON — watch for key names arriving in the accumulating text. "findings" means the review is naming problems, "reconciliation" means it has reached your prescan flags, "body" means it is building the lane's own document, and "summary" means it is nearly done. Substring matching on the quoted key name is enough and it costs nothing.

# Server-sent events. Each `delta` carries a chunk of the report JSON; the final
# `done` event carries the status, charged_credits and the truncated flag.
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\":\"audit\",\"title\":\"qPCR plate setup"}
# event: delta  {"text":"\",\"verdict\":\"blocked\","}
# event: done   {"status":"succeeded","charged_credits":611,"truncated":false}
#
# An idempotent replay answers with plain JSON instead of a stream, so a robust
# client checks the content type first.

The output contract

data.output.output is a string holding one JSON object — no preamble, no code fence, no prose outside it. The web app still takes everything from the first { to the last } before parsing, and a caller should do the same: it costs one slice and it survives the small variations a model produces.

Nine keys, identical in all four lanes except body:

{
  "lane": "audit",
  "title": "short name for this review, naming the protocol",
  "verdict": "clear | clear_with_notes | hold | blocked",
  "headline": "one sentence naming the single fact that decides the verdict",
  "findings": [
    {
      "id": "F-001",
      "severity": "blocking | high | medium | low | info",
      "area": "deck | volume | tips | contamination | reagent | timing | api | documentation | inventory",
      "title": "one line",
      "detail": "what is wrong, what happens at run time, and what makes it this severity and not another",
      "evidence": "the exact text, well address or plate-map row this rests on",
      "line": 42,
      "fix": "the concrete change, with the actual value or load name where one applies"
    }
  ],
  "reconciliation": [
    { "flag_uid": "PD-001", "status": "confirmed | adjusted | set_aside | not_applicable", "note": "why" }
  ],
  "context_notes": [
    { "claim": "what the operator said", "status": "honoured | contradicted | unverifiable", "note": "what the protocol actually does about it" }
  ],
  "unassessable": [
    { "item": "the check that could not be made", "why": "what was missing" }
  ],
  "body": { },
  "summary": "3-6 sentences an operator can act on. No restating of the JSON."
}
keytypemeaning
laneenumThe lane that actually ran: audit, dryrun, runsheet or eln. Normally it echoes task; when task was absent or unrecognised it is the lane that was chosen, and summary says so in its first sentence. Read this, not your own request, before you read body.
titlestringShort name for the review, naming the protocol.
verdictenumOne of four values, below. The single field a gate should branch on.
headlinestringOne sentence naming the single fact that decides the verdict — not a summary of the findings, the one that swung it.
findingsobject[]{id, severity, area, title, detail, evidence, line, fix}. Ids are F-001, F-002, … in the order reported. May be empty, and an empty array is a real answer — then summary says so and verdict is clear. No placeholder finding is ever emitted to fill it. line is copied from prescan_facts or from the visible protocol, never estimated; null is correct when unknown.
reconciliationobject[]{flag_uid, status, note}. One entry per uid you sent in prescan_facts.flags, exactly once, no more and no fewer, and no uid you did not send. This is the contract worth asserting.
context_notesobject[]{claim, status, note}, one entry per claim in context, with status honoured, contradicted or unverifiable. Send an empty context and this is empty; send three claims and expect three entries.
unassessableobject[]{item, why} — the checks that genuinely could not be made from what was sent. An honest entry here is preferred to a confident guess, and the prescan's own unassessable items are carried forward into it rather than silently dropped.
bodyobjectThe lane's own document. Four shapes, one per lane, never blended — a merged body fails to render. Documented lane by lane below.
summarystringThree to six sentences an operator can act on, and not a restatement of the JSON. It is also where the exceptions are announced: a clipped protocol, an absent task, an estimated_duration_min that is only a lower bound.

The enums

These strings are shared verbatim with the browser's own free scanner, so the two never disagree about what a clean result is called. The renderer keys on them: an unrecognised value renders as an error rather than being coerced to something plausible, so treat them as closed sets.

verdictwhen
clearNothing above info is left. Safe to start.
clear_with_notesThe worst finding is medium or low. Start it, and read the notes.
holdThe worst finding is high. Something needs a decision before the lid closes.
blockedAt least one finding is blocking. Do not start; in the audit lane body.blocking_issues is then non-empty.

The verdict follows the findings, mechanically, and never contradicts them. That makes two cheap assertions available to any client: a blocked verdict with no blocking finding is a broken reply, and so is a clear verdict with a finding above info. Both are worth failing on rather than rendering.

severitymeaning
blockingThe run cannot succeed as written — the API raises, a well overflows, a source runs dry, no pipette is loaded.
highThe run may complete and the result would not be trustworthy, or the operator has to make a call first.
mediumReal, mitigated or bounded — worth fixing before the next run rather than this one.
lowWorth naming, not worth stopping for.
infoContext a reviewer should have. info alone still permits clear.

Severity depends on the mitigating facts, and the detail names the mitigation whenever the grade was moved because of it. A tip shortfall in a protocol that pauses for the operator is not the same finding as one that cannot be refilled. A volume over the pipette's maximum inside transfer() is split automatically and is slow, not broken; the same volume in a bare aspirate() fails at run time. If you diff two reports and a severity moved, the reason is in detail.

areacovers
deckSlots, occupancy, conflicts, trash and waste, well addresses that do not exist.
volumePipette envelopes, per-call volumes, well capacity, the liquid ledger.
tipsThe tip budget: pickups times channels against the racks the pipettes were given.
contaminationTip reuse and carry-over, new_tip semantics, missing drops.
reagentWhat is in the wells: identity, starting volume, depletion, substitutions.
timingRun duration, delays, pauses, anything time-sensitive on the bench.
apiProtocol API usage: apiLevel, load names, mounts, robot mismatches.
documentationWhat a second operator could not reproduce from what is written. The usual area for a dryrun finding, where an unobservable step is the problem.
inventoryLots, containers, remaining volumes, plate-map rows that match nothing.

Nine areas, and no synonyms: labware, liquid, pipetting and safety are not values. If you bucket findings for a dashboard, bucket on these nine.

body for task: "audit"

The evidence behind the verdict, organised the way a person checks a deck. deck_review has one row per occupied slot the protocol declares, in slot order — a slot the protocol never loads is not a row. volume_review has one row per pipette, with calls counting the liquid-handling calls made with it; when the pipette model is unknown the row's status is unassessable and its volume checks move to the top-level unassessable. liquid_review covers only positions where liquid actually moves, and starting_ul is null when no plate map declares it — which caps that row at attention, never ok, because "it fits" cannot be shown without a starting volume. blocking_issues is empty unless the verdict is blocked, and non-empty whenever it is.

"body": {
  "deck_review": [
    { "slot": "3", "occupant": "nest_12_reservoir_15ml", "status": "ok | attention | wrong", "note": "" }
  ],
  "volume_review": [
    { "pipette": "p300_single_gen2", "range_ul": "20-300", "calls": 14,
      "status": "ok | attention | wrong", "note": "" }
  ],
  "tip_budget": { "required": 96, "available": 96, "status": "ok | attention | wrong", "note": "" },
  "liquid_review": [
    {
      "position": "nest_12_reservoir_15ml A1",
      "reagent": "master mix",
      "starting_ul": 12000,
      "net_change_ul": -9600,
      "peak_ul": 12000,
      "capacity_ul": 15000,
      "status": "ok | attention | wrong | unassessable",
      "note": ""
    }
  ],
  "blocking_issues": ["the short list of things that must change before the run starts"],
  "before_you_run": ["the checks the operator does with their hands, in order"]
}

body for task: "dryrun"

What to run with water, what to watch, and what would make you stop — plus what it would take to run the same liquid handling somewhere else. Every pass_criteria is an observation, not an intention: "the tip touches the liquid surface within 2 mm of the well bottom", not "aspiration works". portability covers only platforms the protocol's own operations imply — two to four entries, and Opentrons simulate is always one of them because it costs nothing and catches load-time errors. water_run_volume_ul is the total the dry run consumes, or null when the volumes could not be totalled; it is never guessed. Findings in this lane are about verifiability, not about the protocol's correctness — that is the audit lane's job — so a step whose result cannot be observed is a documentation finding here.

"body": {
  "dry_run_steps": [
    { "n": 1, "what_to_do": "", "what_to_verify": "",
      "pass_criteria": "the observation that means it passed" }
  ],
  "calibration_checks": ["labware offsets, tip length, deck calibration - only the ones this protocol needs"],
  "abort_triggers": ["what you see that means stop the run now"],
  "portability": [
    { "platform": "Hamilton STAR via PyLabRobot", "feasible": "yes | partial | no",
      "changes": ["..."], "note": "" }
  ],
  "water_run_volume_ul": 12400
}

body for task: "runsheet"

The protocols.io-shaped document the person at the bench holds. materials covers labware, tip racks, modules and every reagent the plate map names — with the lot when the map gives one, and with spec saying the identity was not supplied when it does not — and nothing else. steps follows the protocol's own order; robot-executed and manual steps are both steps and detail says which. A loop is never collapsed into "repeat as needed" — it says how many times. duration_min may be null; estimated_duration_min is the sum of the steps that have one, and if any step is null the summary says the total is a lower bound. hazard is "" unless there is a real one stated in the input — volatile solvent, sharps, biological material. Hazards are never manufactured.

"body": {
  "protocol_title": "",
  "estimated_duration_min": 95,
  "materials": [
    { "item": "nest_12_reservoir_15ml", "spec": "12-channel reservoir, 15 mL per channel",
      "quantity": "1", "where": "deck slot 3" }
  ],
  "before_you_start": ["what must be true before the lid closes"],
  "steps": [
    { "n": 1, "action": "short imperative",
      "detail": "the volumes, positions and settings this step uses",
      "duration_min": 4, "hazard": "" }
  ],
  "after_the_run": ["sealing, storage, waste, what to photograph"]
}

body for task: "eln"

The Benchling-shaped record: an entry, the inventory that moved, the registry entities the run touched. entry_sections always has at least Purpose, Materials and lots, Procedure as executed and Result and next step, and their content is prose, not JSON. inventory_moves comes from the plate map crossed with the volumes drawn: with no plate map it is [], unassessable says so, and the verdict is capped at clear_with_notes. remaining_ul is null and status is unassessable when the starting volume is unknown — a remaining volume is never computed from a starting volume that was not supplied. status is short when the volume drawn exceeds the volume declared, which is the same fact the audit lane calls a depletion, reported at the same severity. registry_links is [] unless the input actually names an entity: a plate map naming samples is enough, a protocol that only names labware is not.

"body": {
  "entry_title": "",
  "entry_sections": [ { "heading": "Purpose", "content": "" } ],
  "inventory_moves": [
    {
      "reagent": "master mix",
      "lot": "MM-2291",
      "container": "nest_12_reservoir_15ml A1",
      "volume_used_ul": 9600,
      "remaining_ul": 2400,
      "status": "ok | short | unassessable",
      "note": ""
    }
  ],
  "registry_links": [ { "entity": "", "entity_type": "DNA | protein | cell line | sample | plate | other", "note": "" } ],
  "deviations_to_log": ["what a reviewer will want recorded, including anything the audit found"],
  "attachments_to_keep": ["the files and photographs worth attaching to the entry"]
}

A worked audit reply

Abbreviated but structurally complete — this is the reply to the first worked request above, the one whose reservoir cannot cover twelve columns:

{
  "lane": "audit",
  "title": "qPCR plate setup - full-plate distribution from one reservoir channel",
  "verdict": "blocked",
  "headline": "A1 is asked for 17,280 ul of master mix against a declared 12,000 ul, so the run goes dry in column 9.",
  "findings": [
    {
      "id": "F-001",
      "severity": "blocking",
      "area": "reagent",
      "title": "Master mix channel A1 is short by 5,280 ul",
      "detail": "12 columns x 8 channels x 180 ul is 17,280 ul drawn from a channel the plate map declares at 12,000 ul. The 8-channel pipette draws from A1 for every column, so the shortfall appears part-way through column 9 as short aspirations, not as an error.",
      "evidence": "nest_12_reservoir_15ml A1, volume_ul 12000; p300.transfer(180, res[\"A1\"], col)",
      "line": 12,
      "fix": "Fill A1 to 15,000 ul (its working capacity) and split the distribution across A1 and A2, or run 8 columns per fill with a ctx.pause() between."
    },
    {
      "id": "F-002",
      "severity": "low",
      "area": "contamination",
      "title": "new_tip=\"never\" is safe here, and only here",
      "detail": "One reagent goes from one source to 96 destinations, so a single tip carries master mix into master mix. There is no cross-contamination. It becomes a high finding the moment a second reagent joins this loop.",
      "evidence": "p300.transfer(180, res[\"A1\"], col, new_tip=\"never\")",
      "line": 12,
      "fix": "No change needed. If a second reagent is added, set new_tip=\"always\" for that loop."
    }
  ],
  "reconciliation": [
    { "flag_uid": "PD-001", "status": "confirmed",
      "note": "Confirmed and kept at low: one reagent, one destination plate. The prescan is right that it is worth naming; the consequence it does not state is that adding a second reagent to this loop makes it high." },
    { "flag_uid": "PD-002", "status": "confirmed",
      "note": "Confirmed at blocking. The arithmetic matches: 96 aspirations of 180 ul against 12,000 ul declared. It fails part-way through column 9, which is where the operator will see it." }
  ],
  "context_notes": [
    { "claim": "One spare tip rack is on the bench.", "status": "unverifiable",
      "note": "The protocol assigns one rack and needs 12 pickups, so the spare is not required. Nothing in the protocol pauses to let it be loaded." },
    { "claim": "The reservoir was filled from a 15 mL aliquot this morning.", "status": "contradicted",
      "note": "The plate map declares 12,000 ul in A1, not 15,000. If the channel really holds 15,000 ul, correct the plate map and F-001 becomes a clear." }
  ],
  "unassessable": [
    { "item": "Whether the master mix tolerates 21 minutes at room temperature",
      "why": "No temperature module is loaded and the context does not say how the reagent is held." }
  ],
  "body": {
    "deck_review": [
      { "slot": "1", "occupant": "opentrons_96_tiprack_300ul", "status": "ok", "note": "Assigned to p300 as its only rack." },
      { "slot": "2", "occupant": "nest_96_wellplate_200ul_flat", "status": "ok", "note": "Destination, 200 ul wells taking 180 ul." },
      { "slot": "3", "occupant": "nest_12_reservoir_15ml", "status": "attention", "note": "A1 is the single source for the whole plate." }
    ],
    "volume_review": [
      { "pipette": "p300_single_gen2", "range_ul": "20-300", "calls": 12, "status": "ok",
        "note": "180 ul sits inside the 20-300 ul envelope for every call." }
    ],
    "tip_budget": { "required": 12, "available": 96, "status": "ok", "note": "One pickup per column with new_tip=\"never\"." },
    "liquid_review": [
      { "position": "nest_12_reservoir_15ml A1", "reagent": "master mix", "starting_ul": 12000,
        "net_change_ul": -17280, "peak_ul": 12000, "capacity_ul": 15000, "status": "wrong",
        "note": "Goes negative during column 9." },
      { "position": "nest_96_wellplate_200ul_flat A1-H12", "reagent": null, "starting_ul": null,
        "net_change_ul": 180, "peak_ul": 180, "capacity_ul": 200, "status": "attention",
        "note": "No plate map row for the destination wells, so 180 ul is a lower bound on the final volume." }
    ],
    "blocking_issues": ["A1 holds 12,000 ul and the run needs 17,280 ul. Fix the volume or split the source before starting."],
    "before_you_run": [
      "Measure what is actually in A1 and correct the plate map.",
      "Confirm the destination plate is empty, since nothing in the input declares its starting volume.",
      "Decide whether column 9 onward will be a second fill or a second channel."
    ]
  },
  "summary": "Do not start this run. The one reservoir channel is asked for 17,280 ul and the plate map declares 12,000 ul, so it goes dry part-way through column 9 and the last three columns get short volumes that look like successful aspirations. Either fill A1 to its 15,000 ul working capacity and accept a 2,280 ul shortfall, or split the distribution across two channels, or break the loop with a pause after column 8. The tip strategy is fine as written because only one reagent is in play, and the deck and pipette envelope are both clean."
}

7. Check the reply before you trust it

The browser does not render a reply verbatim and neither should a caller. Six assertions cover everything this app can get wrong in a way that still looks plausible, and all six are cheap:

One more, lane-specific and worth having in audit: body.blocking_issues is non-empty exactly when the verdict is blocked. And if you send a context with claims in it, expect context_notes to have one entry per claim — an empty context_notes against a paragraph of operator context means the most decisive field in the input was not read.

# The report is a JSON string inside the envelope, so unwrap it twice, then check
# the invariants. Everything here prints to stdout and exits non-zero on a break.
REPORT=$(printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["output"]["output"])')

printf '%s' "$REPORT" | python3 -c '
import sys, json
r = json.load(sys.stdin)
print(r["lane"], r["verdict"], "-", r["headline"])
for f in r["findings"]:
    print("  %-8s %-13s %s" % (f["severity"], f["area"], f["title"]))
for c in r["reconciliation"]:
    print("  %-8s %-15s %s" % (c["flag_uid"], c["status"], c["note"][:60]))
'

# 1. Every prescan uid comes back exactly once, and nothing else does.
printf '%s' "$REPORT" | python3 -c '
import sys, json
seen = [c["flag_uid"] for c in json.load(sys.stdin)["reconciliation"]]
want = ["PD-001", "PD-002"]          # the uids this request sent
missing = [u for u in want if seen.count(u) != 1]
extra = [u for u in seen if u not in want]
if missing or extra:
    raise SystemExit("reconciliation drift: missing=%s extra=%s" % (missing, extra))
print("reconciliation covers every prescan flag exactly once")
'

# 2. The verdict cannot contradict the findings.
printf '%s' "$REPORT" | python3 -c '
import sys, json
r = json.load(sys.stdin)
sev = {f["severity"] for f in r["findings"]}
v = r["verdict"]
worst = ("blocked" if "blocking" in sev else "hold" if "high" in sev
         else "clear_with_notes" if sev & {"medium", "low"} else "clear")
if v != worst:
    raise SystemExit("verdict %s but the findings say %s" % (v, worst))
if v == "blocked" and not r["body"].get("blocking_issues"):
    raise SystemExit("blocked with an empty blocking_issues")
print("verdict", v, "agrees with the findings")
'

# 3. The lane is the one that was asked for, and the body matches it.
printf '%s' "$REPORT" | python3 -c '
import sys, json
KEYS = {
  "audit": {"deck_review", "volume_review", "tip_budget", "liquid_review",
            "blocking_issues", "before_you_run"},
  "dryrun": {"dry_run_steps", "calibration_checks", "abort_triggers",
             "portability", "water_run_volume_ul"},
  "runsheet": {"protocol_title", "estimated_duration_min", "materials",
               "before_you_start", "steps", "after_the_run"},
  "eln": {"entry_title", "entry_sections", "inventory_moves", "registry_links",
          "deviations_to_log", "attachments_to_keep"},
}
r = json.load(sys.stdin)
lane = r["lane"]
if lane != "audit":
    raise SystemExit("asked for audit, got " + lane)
strays = set(r["body"]) - KEYS[lane]
if strays:
    raise SystemExit("body carries keys from another lane: " + ", ".join(sorted(strays)))
print("lane", lane, "body shape is clean")
'

Truncation, retries and partial results

When the balance sits between min_credits and hold_credits, the run is not refused: it executes with a reduced output cap and comes back with truncated: true on the finished job and on the streaming done event. What you hold then is a prefix — the findings may be complete while reconciliation, body and summary are missing or cut mid-string. In this app that is worse than an error, because a prefix of an audit can read as a clean deck review with nothing after it.

Check the flag before you treat a report as complete, and treat a truncation as a retry rather than a repair. Send the same input with a concrete retry_note and the attempt suffix on the Idempotency-Key incremented, so the new body is not a replay of the old key:

"retry_note": "The previous reply was truncated after findings[]. Return the same
findings, keep reconciliation complete for all four prescan uids, and hold the
runsheet to at most twelve steps with shorter detail fields."

The same route handles a reply that fails your own checks in step 7: a missing reconciliation entry, a verdict its findings contradict, a body carrying another lane's keys. Name the defect in retry_note — it is obeyed exactly — and bump the attempt. Do not append closing braces to truncated JSON; that produces something that parses and is not what the model meant.

A last note on grounding, because it changes how you read a clean report. Every finding names the thing in the protocol, the plate map or the context that produced it, and nothing is invented — not a load name, not a lot number, not a volume, not a line number. So an empty findings array with a full unassessable array is not a pass; it is a statement that the input did not contain enough to judge. Read the two together, and read unassessable before you tell anyone the protocol is clear.