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:
| call | auth | cost | what it does |
|---|---|---|---|
POST /guest | none | free | Mints a guest token for one app. Answers 201 with {token, guest_id, expires_at}. |
GET /me | token | free | Returns {subject_type, subject_id, credits} and nothing else. |
POST /estimate | token | free | Prices an input. Creates no job and charges nothing — but it is authenticated, so it has to come after the token. |
POST /run | token | metered | Starts a review. Returns {job_id, status}. |
GET /jobs/{job_id} | token | free | Polls one job. The terminal job carries output.output, charged_credits and truncated. |
POST /run-stream | token | metered | The same run as server-sent events: job, delta, done, and error on a failure. |
Error codes
| code | status | what to do |
|---|---|---|
unauthorized | 401 | The token is missing, malformed or past its expires_at. Mint another with POST /guest, or copy a personal one from the token page. |
payment_required | 402 | The balance is below min_credits. Call /estimate first — it is free — and top up before you run. |
forbidden | 403 | The 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_found | 404 | Unknown job_id, or the app slug in the /guest body does not exist. Check for a typo in slug. |
conflict | 409 | The same Idempotency-Key was replayed with a different body. Bump the attempt suffix, or resend the original input. |
validation_error | 422 | The 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_limited | 429 | Too many requests. Back off and retry with the same key; do not tight-loop. |
internal | 5xx | A 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.
task | the question it answers | what body carries |
|---|---|---|
audit | Is this protocol safe to start right now? | deck_review[], volume_review[], tip_budget, liquid_review[], blocking_issues[], before_you_run[] |
dryrun | What 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 |
runsheet | What 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[] |
eln | What 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
# One protocol, four lanes. Only `task` changes between them.
PROTOCOL = (
"from opentrons import protocol_api\n\n"
'metadata = {"protocolName": "qPCR plate setup", "apiLevel": "2.15"}\n\n'
"def 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'
)
BASE_INPUT = {
"protocol": PROTOCOL,
"robot": "ot2",
"goal": "first_run",
"context": "One spare tip rack on the bench; the protocol does not pause.",
# Empty prescan facts are legitimate. Anything you DO send in flags[] must
# come back exactly once in reconciliation[].
"prescan_facts": {"resources": [], "flags": []},
}
GOALS = {"audit": "first_run", "dryrun": "validation", "runsheet": "handoff", "eln": "repeat_run"}
for lane in ("audit", "dryrun", "runsheet", "eln"):
body = dict(BASE_INPUT, task=lane, goal=GOALS[lane])
est = call("estimate", body) # free, and it does not create a job
print(lane, "hold", est["hold_credits"], "min", est["min_credits"])
job = run_and_wait(body, attempt=1) # step 5 defines run_and_wait
report = json.loads(job["output"]["output"])
print(lane, report["lane"], report["verdict"], "-", report["headline"])
// One protocol, four lanes. Only `task` changes between them.
const 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")',
].join("\n");
const BASE_INPUT = {
protocol: PROTOCOL,
robot: "ot2",
context: "One spare tip rack on the bench; the protocol does not pause.",
// Empty prescan facts are legitimate; whatever you send in flags[] must come
// back exactly once in reconciliation[].
prescan_facts: { resources: [], flags: [] },
};
const GOALS = { audit: "first_run", dryrun: "validation", runsheet: "handoff", eln: "repeat_run" };
for (const task of ["audit", "dryrun", "runsheet", "eln"]) {
const body = { ...BASE_INPUT, task, goal: GOALS[task] };
const est = await call("estimate", body); // free, creates no job
console.log(task, "hold", est.hold_credits, "min", est.min_credits);
const job = await runAndWait(body, 1); // step 5 defines runAndWait
const report = JSON.parse(job.output.output);
console.log(task, report.lane, report.verdict, "-", report.headline);
}
// One protocol, four lanes. Only "task" changes between them.
const protocolSrc = `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")`
goals := map[string]string{
"audit": "first_run", "dryrun": "validation",
"runsheet": "handoff", "eln": "repeat_run",
}
for _, lane := range []string{"audit", "dryrun", "runsheet", "eln"} {
input := map[string]any{
"task": lane,
"protocol": protocolSrc,
"robot": "ot2",
"goal": goals[lane],
"context": "One spare tip rack on the bench; the protocol does not pause.",
// Empty prescan facts are legitimate.
"prescan_facts": map[string]any{"resources": []any{}, "flags": []any{}},
}
raw, err := call("estimate", input) // free, creates no job
if err != nil {
panic(err)
}
fmt.Println(lane, string(raw))
// Then POST /run with an Idempotency-Key that includes the lane, poll
// jobs/{job_id}, and parse data.output.output as this lane's report.
}
// One protocol, four lanes. Only "task" changes between them; give each lane its
// own Idempotency-Key, because the lane is part of the body.
String 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")
""";
var goals = java.util.Map.of(
"audit", "first_run", "dryrun", "validation",
"runsheet", "handoff", "eln", "repeat_run");
for (String lane : new String[] { "audit", "dryrun", "runsheet", "eln" }) {
String input = """
{"task":"%s","protocol":%s,"robot":"ot2","goal":"%s",
"context":"One spare tip rack on the bench; the protocol does not pause.",
"prescan_facts":{"resources":[],"flags":[]}}
""".formatted(lane, jsonString(protocol), goals.get(lane));
System.out.println(call("estimate", input)); // free, creates no job
// Then POST /run with "Idempotency-Key: pipette-desk:<hash>:" + lane + ":a1",
// poll jobs/{job_id}, and read data.output.output as this lane's report.
}
# One protocol, four lanes. Only `task` changes between them.
protocol = <<~PY
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")
PY
goals = { "audit" => "first_run", "dryrun" => "validation",
"runsheet" => "handoff", "eln" => "repeat_run" }
["audit", "dryrun", "runsheet", "eln"].each do |lane|
input = {
"task" => lane,
"protocol" => protocol,
"robot" => "ot2",
"goal" => goals[lane],
"context" => "One spare tip rack on the bench; the protocol does not pause.",
"prescan_facts" => { "resources" => [], "flags" => [] }
}
est = call("estimate", input) # free, creates no job
puts "#{lane} hold=#{est['hold_credits']} min=#{est['min_credits']}"
# Then POST /run with a per-lane Idempotency-Key and poll jobs/{job_id}.
end
<?php
// One protocol, four lanes. Only "task" changes between them.
$protocol = <<<'PY'
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")
PY;
$goals = ["audit" => "first_run", "dryrun" => "validation",
"runsheet" => "handoff", "eln" => "repeat_run"];
foreach (["audit", "dryrun", "runsheet", "eln"] as $lane) {
$input = [
"task" => $lane,
"protocol" => $protocol,
"robot" => "ot2",
"goal" => $goals[$lane],
"context" => "One spare tip rack on the bench; the protocol does not pause.",
"prescan_facts" => ["resources" => [], "flags" => []],
];
$est = call("estimate", $input); // free, creates no job
echo $lane, " hold=", $est["hold_credits"], " min=", $est["min_credits"], PHP_EOL;
// Then POST /run with a per-lane Idempotency-Key and poll jobs/{job_id}.
}
// One protocol, four lanes. Only "task" changes between them.
const string 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")
""";
var goals = new Dictionary<string, string>
{
["audit"] = "first_run", ["dryrun"] = "validation",
["runsheet"] = "handoff", ["eln"] = "repeat_run",
};
foreach (var lane in new[] { "audit", "dryrun", "runsheet", "eln" })
{
var input = new
{
task = lane,
protocol = Protocol,
robot = "ot2",
goal = goals[lane],
context = "One spare tip rack on the bench; the protocol does not pause.",
prescan_facts = new { resources = Array.Empty<object>(), flags = Array.Empty<object>() },
};
var est = await PipetteDesk.Call("estimate", input); // free, creates no job
Console.WriteLine($"{lane} hold={est.GetProperty("hold_credits").GetInt32()}");
// Then POST /run with a per-lane Idempotency-Key and poll jobs/{job_id}.
}
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.
| field | type | meaning |
|---|---|---|
task | enum, required | Which lane to run: audit, dryrun, runsheet or eln. Always present in a well-formed request. See the lanes. |
protocol | string | The 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. |
platemap | string, optional | A 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". |
robot | enum | What 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. |
goal | enum | What 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. |
context | string | Free 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_facts | object | {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_note | string, optional | Send 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:
status | means |
|---|---|
confirmed | The reviewer agrees, at the same or a higher severity. A useful confirmed adds the consequence the flag itself does not state. |
adjusted | Real, 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_aside | Not a problem here, with the reason that makes it harmless. A set_aside with no reason is worse than no entry at all. |
not_applicable | The 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:
id | severity | area | fires when |
|---|---|---|---|
no_pipette | blocking | api | load_instrument was never called, so nothing in the protocol can move liquid. |
mount_conflict | blocking | deck | Two pipettes are loaded on the same mount. |
deck_slot_conflict | blocking | deck | Two pieces of labware, or labware and a module, claim one slot. |
ot2_trash_slot_used | blocking | deck | An OT-2 protocol loads labware into the fixed trash slot. |
well_out_of_range | blocking | deck | A well address does not exist in that labware's format. The Protocol API raises before any liquid moves. |
volume_above_max | blocking or low | volume | A 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_more | blocking | volume | A 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_more | blocking | reagent | A source gives out more than the plate map declares it holds. |
no_tips_available | blocking | tips | The tip budget exceeds what the assigned racks hold, with no refill pause. |
missing_api_level | high | api | No apiLevel in metadata, so the run's semantics depend on the app version. |
robot_pipette_mismatch | high | api | The declared robot and the pipette load names disagree — a Flex pipette on a stated OT-2, or the reverse. |
flex_missing_trash | high | deck | A Flex protocol never loads a trash bin or waste chute. |
tip_reuse_across_reagents | high | contamination | One tip carries more than one reagent — real cross-contamination, not a style note. |
volume_below_min | high or medium | volume | A volume is below the pipette's minimum; medium when it is within about 10 per cent of the floor. |
zero_volume_transfer | medium | volume | A liquid-handling call moves zero microlitres. |
unresolved_location | medium | volume | A destination depends on values this reader cannot evaluate, so that liquid is left out of the accounting rather than guessed at. |
uncertain_loop | medium | volume | A loop's iteration count is not statically known, so the multiplier is a lower bound. |
tip_reuse_unverified | medium | contamination | new_tip is left at a value whose carry-over cannot be judged from the source alone. |
no_drop_tip | medium | contamination | Tips are picked up and never dropped. |
unknown_labware | medium | deck | A load name this reader has no geometry for, so its capacity checks are unassessable. |
unknown_pipette | medium | api | A pipette load name outside the known envelope table; its volume checks go to unassessable. |
platemap_missing_reagent | medium | reagent | The protocol draws from a position the plate map does not name. |
long_runtime | medium or low | timing | The estimate crosses a working-day threshold. |
tip_reuse_single_reagent | low | contamination | new_tip="never" with one reagent — worth naming, and not cross-contamination. |
unused_labware | low | deck | Labware is loaded and never touched, so a slot is occupied for nothing. |
platemap_unmatched | low | inventory | A plate-map row matches no labware in the protocol. |
platemap_parse_warnings | low | inventory | Rows the plate-map parser could not fully read — an unparseable volume, an unknown well address. |
no_platemap | info | reagent | No 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.
# Open https://pipette-desk.skillsafe.ai/tokens.html and press "Copy token", or
# mint a guest token here. The slug goes in the BODY of /guest and nowhere else.
import json, urllib.request
GUEST_BODY = json.dumps({"slug": "pipette-desk"}).encode()
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/guest", data=GUEST_BODY, method="POST")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r:
status = r.status # 201
guest = json.load(r)["data"]
TOKEN = guest["token"]
print(status, guest["guest_id"], "expires", guest["expires_at"])
# Keep guest_id: it is what lets a later sign-in migrate this wallet. Keep
# expires_at too - a long-lived worker re-mints instead of caching forever.
# A guest token can call /me and /estimate; a metered review cannot.
// Open https://pipette-desk.skillsafe.ai/tokens.html and press "Copy token", or
// mint a guest token here. The slug goes in the BODY of /guest and nowhere else.
const res = await fetch("https://api.skillsafe.ai/v1/app-api/guest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "pipette-desk" }),
});
console.log(res.status); // 201
const guest = (await res.json()).data;
const TOKEN = guest.token;
console.log(guest.guest_id, "expires", guest.expires_at);
// Keep guest_id - a later sign-in migrates the guest wallet with it. A guest
// token can call /me and /estimate but not a metered run.
// Open https://pipette-desk.skillsafe.ai/tokens.html and press "Copy token", or
// mint a guest token here. The slug goes in the BODY of /guest and nowhere else.
guestBody := []byte(`{"slug":"pipette-desk"}`)
guestReq, _ := http.NewRequest(http.MethodPost,
"https://api.skillsafe.ai/v1/app-api/guest", bytes.NewReader(guestBody))
guestReq.Header.Set("Content-Type", "application/json")
guestRes, err := http.DefaultClient.Do(guestReq)
if err != nil {
panic(err)
}
defer guestRes.Body.Close()
fmt.Println(guestRes.StatusCode) // 201
var guest struct {
Data struct {
Token string `json:"token"`
GuestID string `json:"guest_id"`
ExpiresAt string `json:"expires_at"`
} `json:"data"`
}
_ = json.NewDecoder(guestRes.Body).Decode(&guest)
fmt.Println(guest.Data.GuestID, "expires", guest.Data.ExpiresAt)
// A guest token covers /me and /estimate; a metered review needs a personal one.
// Open https://pipette-desk.skillsafe.ai/tokens.html and press "Copy token", or
// mint a guest token here. The slug goes in the BODY of /guest and nowhere else.
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\":\"pipette-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_...",
// "expires_at":"2026-08-27T09:14:02Z"}}
//
// Keep guest_id (a later sign-in migrates the wallet with it) and expires_at
// (re-mint rather than cache forever). A guest token cannot start a metered run.
# Open https://pipette-desk.skillsafe.ai/tokens.html and press "Copy token", or
# mint a guest token here. The slug goes in the BODY of /guest and nowhere else.
require "json"
require "net/http"
require "uri"
uri = URI("https://api.skillsafe.ai/v1/app-api/guest")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req.body = JSON.generate({ "slug" => "pipette-desk" })
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
puts res.code # "201"
guest = JSON.parse(res.body)["data"]
TOKEN = guest["token"]
puts "#{guest['guest_id']} expires #{guest['expires_at']}"
# A guest token covers /me and /estimate; a metered review needs a personal one.
<?php
// Open https://pipette-desk.skillsafe.ai/tokens.html and press "Copy token", or
// mint a guest token here. The slug goes in the BODY of /guest and nowhere else.
$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" => "pipette-desk"]));
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json"]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$guest = json_decode(curl_exec($ch), true)["data"];
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); // 201
curl_close($ch);
echo $status, " ", $guest["guest_id"], " expires ", $guest["expires_at"], PHP_EOL;
define("TOKEN", $guest["token"]);
// A guest token covers /me and /estimate; a metered review needs a personal one.
// Open https://pipette-desk.skillsafe.ai/tokens.html and press "Copy token", or
// mint a guest token here. The slug goes in the BODY of /guest and nowhere else.
using var http = new HttpClient();
var guestReq = new HttpRequestMessage(HttpMethod.Post,
"https://api.skillsafe.ai/v1/app-api/guest")
{
Content = new StringContent("{\"slug\":\"pipette-desk\"}",
Encoding.UTF8, "application/json"),
};
var guestRes = await http.SendAsync(guestReq);
Console.WriteLine((int)guestRes.StatusCode); // 201
var guest = (await guestRes.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("data");
var token = guest.GetProperty("token").GetString();
Console.WriteLine($"{guest.GetProperty("guest_id").GetString()} expires " +
$"{guest.GetProperty("expires_at").GetString()}");
// A guest token covers /me and /estimate; a metered review needs a personal one.
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.
import json, os, urllib.error, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from /tokens.html
def call(path, body=None):
"""Returns the unwrapped `data`, or raises with the API error code.
Two headers only: Content-Type and Authorization. There is no X-App-Slug -
the token is already bound to pipette-desk.
"""
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")
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')}")
return payload["data"]
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // from https://pipette-desk.skillsafe.ai/tokens.html
// Two headers only: Content-Type and Authorization. There is no X-App-Slug.
async function call(path, body) {
const res = await fetch(`${BASE}/${path}`, {
method: body ? "POST" : "GET",
headers: {
Authorization: `Bearer ${TOKEN}`,
...(body ? { "Content-Type": "application/json" } : {}),
},
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"
var token = os.Getenv("SKILLSAFE_TOKEN") // from /tokens.html
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
// Two headers only: Content-Type and Authorization. There is no X-App-Slug.
func call(path string, body any) (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")
}
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.*;
public class PipetteDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String TOKEN = System.getenv().getOrDefault("SKILLSAFE_TOKEN", "YOUR_TOKEN");
static final HttpClient HTTP = HttpClient.newHttpClient();
// Two headers only: Content-Type and Authorization. There is no X-App-Slug.
static String call(String path, String jsonBody) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + "/" + path))
.header("Authorization", "Bearer " + TOKEN);
if (jsonBody != null) {
b.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
} else {
b.GET();
}
HttpResponse<String> res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
// The envelope is always {"ok":true,"data":...} or {"ok":false,"error":...}.
return res.body();
}
// Small helper for embedding a protocol in a hand-built JSON body.
static String jsonString(String s) {
StringBuilder out = new StringBuilder("\"");
for (char c : s.toCharArray()) {
switch (c) {
case '"' -> out.append("\\\"");
case '\\' -> out.append("\\\\");
case '\n' -> out.append("\\n");
case '\t' -> out.append("\\t");
default -> out.append(c);
}
}
return out.append('"').toString();
}
}
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from /tokens.html
# Two headers only: Content-Type and Authorization. There is no X-App-Slug.
def call(path, body = nil)
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
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
const BASE = "https://api.skillsafe.ai/v1/app-api";
define("TOKEN", getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN"); // from /tokens.html
// Two headers only: Content-Type and Authorization. There is no X-App-Slug.
function call(string $path, ?array $body = null) {
$ch = curl_init(BASE . "/" . $path);
$headers = ["Authorization: Bearer " . TOKEN];
if ($body !== null) {
$headers[] = "Content-Type: application/json";
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
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;
static class PipetteDesk
{
const string Base = "https://api.skillsafe.ai/v1/app-api";
static readonly string Token =
Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN";
static readonly HttpClient Http = new();
// Two headers only: Content-Type and Authorization. There is no X-App-Slug.
public static async Task<JsonElement> Call(string path, object? body = null)
{
var req = new HttpRequestMessage(
body is null ? HttpMethod.Get : HttpMethod.Post, $"{Base}/{path}");
req.Headers.Add("Authorization", $"Bearer {Token}");
if (body is not null) req.Content = JsonContent.Create(body);
var res = await Http.SendAsync(req);
var payload = await res.Content.ReadFromJsonAsync<JsonElement>();
if (!payload.GetProperty("ok").GetBoolean())
{
var e = payload.GetProperty("error");
throw new Exception($"{e.GetProperty("code")}: {e.GetProperty("message")}");
}
return payload.GetProperty("data");
}
}
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")
'
me = call("me")
# Exactly three fields: subject_type, subject_id, credits.
kind = me["subject_type"] # "guest" or "user"
print(kind, me["subject_id"], me["credits"], "credits")
# Branch on subject_type. There is no is_guest flag - a truthiness test on one
# is False for everybody, which reads as "signed in" for a guest token.
if kind == "guest":
print("guest token: /me and /estimate only; a review needs a personal token")
# me.get("user_id") is always None. The identifier is subject_id.
const me = await call("me");
// Exactly three fields: subject_type, subject_id, credits.
console.log(me.subject_type, me.subject_id, me.credits);
// Branch on subject_type. me.is_guest is undefined, so `if (me.is_guest)` is
// false for a guest too - which is exactly backwards.
if (me.subject_type === "guest") {
console.log("guest token: /me and /estimate only; a review needs a personal token");
}
// me.user_id is undefined. The identifier is subject_id for both kinds.
raw, err := call("me", nil)
if err != nil {
panic(err)
}
// Exactly three fields. No UserID, no IsGuest - adding them to this struct just
// gives you a zero value that looks like an answer.
var me struct {
SubjectType string `json:"subject_type"` // "guest" or "user"
SubjectID string `json:"subject_id"`
Credits int `json:"credits"`
}
_ = json.Unmarshal(raw, &me)
fmt.Println(me.SubjectType, me.SubjectID, me.Credits)
if me.SubjectType == "guest" {
fmt.Println("guest token: /me and /estimate only; a review needs a personal token")
}
String meJson = call("me", null);
System.out.println(meJson);
// {"ok":true,"data":{"subject_type":"user","subject_id":"usr_...","credits":51234}}
//
// Three fields, and only three: subject_type ("guest" or "user"), subject_id and
// credits. There is no user_id and no is_guest, so branch on subject_type:
//
// boolean guest = "guest".equals(subjectType);
//
// A guest token can call /me and /estimate. A metered review needs a personal
// token from https://pipette-desk.skillsafe.ai/tokens.html.
System.out.println(meJson.contains("\"subject_type\":\"guest\"")
? "guest token: no metered run"
: "personal token: metered runs allowed");
me = call("me")
# Exactly three fields: subject_type, subject_id, credits.
puts "#{me['subject_type']} #{me['subject_id']} #{me['credits']} credits"
# Branch on subject_type. me["is_guest"] is nil for everybody, and me["user_id"]
# does not exist - the identifier is subject_id.
if me["subject_type"] == "guest"
puts "guest token: /me and /estimate only; a review needs a personal token"
end
<?php
$me = call("me");
// Exactly three fields: subject_type, subject_id, credits.
echo $me["subject_type"], " ", $me["subject_id"], " ", $me["credits"], PHP_EOL;
// Branch on subject_type. $me["is_guest"] is not set, so an isset() test is
// false for a guest as well, and there is no user_id at all.
if ($me["subject_type"] === "guest") {
echo "guest token: /me and /estimate only; a review needs a personal token", PHP_EOL;
}
var me = await PipetteDesk.Call("me");
// Exactly three fields: subject_type, subject_id, credits.
var kind = me.GetProperty("subject_type").GetString(); // "guest" or "user"
Console.WriteLine($"{kind} {me.GetProperty("subject_id").GetString()} " +
$"{me.GetProperty("credits").GetInt32()}");
// Branch on subject_type. TryGetProperty("is_guest", ...) is always false, and
// there is no user_id - subject_id is the identifier for both kinds.
if (kind == "guest")
Console.WriteLine("guest token: /me and /estimate only; a review needs a personal token");
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:
| field | meaning |
|---|---|
model | The exact model the run will bind to. |
model_alias | The stable alias that binding came from — what to log, since the concrete model moves under it. |
markup_bps | The app's markup in basis points; 1000 is ten per cent. |
hold_credits | What gets reserved when the run starts. Priced against the full output cap, so it is an upper bound, not the price. |
min_credits | The balance you must clear for the run to start at all. Compare this against credits from /me. |
sponsor_enabled | Whether 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.
INPUT = {
"task": "audit",
"protocol": PROTOCOL, # the whole protocol source, as a string
"platemap": (
"labware\twell\treagent\tlot\tvolume_ul\tconcentration\texpiry\n"
"nest_12_reservoir_15ml\tA1\tmaster mix\tMM-2291\t12000\t2x\t2026-11-30\n"
),
"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 volume.",
"line": 12, "evidence": "nest_12_reservoir_15ml A1, declared 12000 ul",
}
],
},
}
# This is the whole body. It is NOT {"input": INPUT} - that shape returns 200 and
# the model never sees a field of it.
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"])
me = call("me")
if not est["sponsor_enabled"] and me["credits"] < est["min_credits"]:
raise SystemExit(f"balance {me['credits']} is below min_credits {est['min_credits']}")
const INPUT = {
task: "audit",
protocol: PROTOCOL, // the whole protocol source, as a string
platemap:
"labware\twell\treagent\tlot\tvolume_ul\tconcentration\texpiry\n" +
"nest_12_reservoir_15ml\tA1\tmaster mix\tMM-2291\t12000\t2x\t2026-11-30\n",
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 volume.",
line: 12, evidence: "nest_12_reservoir_15ml A1, declared 12000 ul",
},
],
},
};
// This is the whole body - not { input: INPUT }, which returns 200 while the
// model sees nothing.
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, "sponsored", est.sponsor_enabled);
const me = await call("me");
if (!est.sponsor_enabled && me.credits < est.min_credits) {
throw new Error(`balance ${me.credits} is below min_credits ${est.min_credits}`);
}
input := map[string]any{
"task": "audit",
"protocol": protocolSrc,
"platemap": "labware\twell\treagent\tlot\tvolume_ul\tconcentration\texpiry\n" +
"nest_12_reservoir_15ml\tA1\tmaster mix\tMM-2291\t12000\t2x\t2026-11-30\n",
"robot": "ot2",
"goal": "first_run",
"context": "First run on this instrument. One spare tip rack on the bench.",
"prescan_facts": map[string]any{
"resources": []any{
map[string]any{"id": "tips_required", "label": "12 tips required, 96 available"},
},
"flags": []any{
map[string]any{
"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.", "line": 12,
"evidence": "nest_12_reservoir_15ml A1, declared 12000 ul",
},
},
},
}
// The body is `input` itself, not map[string]any{"input": input}.
raw, err := call("estimate", input)
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.HoldCredits, est.MinCredits, est.SponsorEnabled)
// Free: no job is created and nothing is charged. The hold is a reservation.
String input = """
{
"task": "audit",
"protocol": %s,
"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 run on this instrument. One spare tip rack on the bench.",
"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 volume.",
"line": 12, "evidence": "nest_12_reservoir_15ml A1, declared 12000 ul" }
]
}
}
""".formatted(jsonString(protocol));
// This whole object IS the body. Do not nest it under an "input" key.
System.out.println(call("estimate", input));
// data carries model, model_alias, markup_bps, hold_credits, min_credits and
// sponsor_enabled. Free - no job, no charge - but authenticated, so it comes
// after step 1. hold_credits is a reservation against the full output cap.
input = {
"task" => "audit",
"protocol" => protocol,
"platemap" => "labware\twell\treagent\tlot\tvolume_ul\tconcentration\texpiry\n" \
"nest_12_reservoir_15ml\tA1\tmaster mix\tMM-2291\t12000\t2x\t2026-11-30\n",
"robot" => "ot2",
"goal" => "first_run",
"context" => "First run on this instrument. One spare tip rack on the bench.",
"prescan_facts" => {
"resources" => [{ "id" => "tips_required", "label" => "12 tips required, 96 available" }],
"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 volume.",
"line" => 12, "evidence" => "nest_12_reservoir_15ml A1, declared 12000 ul"
}]
}
}
# The body is `input` itself, never { "input" => input }.
est = call("estimate", input)
puts "#{est['model']} hold=#{est['hold_credits']} min=#{est['min_credits']}"
me = call("me")
abort "balance #{me['credits']} below min_credits #{est['min_credits']}" if
!est["sponsor_enabled"] && me["credits"] < est["min_credits"]
<?php
$input = [
"task" => "audit",
"protocol" => $protocol,
"platemap" => "labware\twell\treagent\tlot\tvolume_ul\tconcentration\texpiry\n"
. "nest_12_reservoir_15ml\tA1\tmaster mix\tMM-2291\t12000\t2x\t2026-11-30\n",
"robot" => "ot2",
"goal" => "first_run",
"context" => "First run on this instrument. One spare tip rack on the bench.",
"prescan_facts" => [
"resources" => [["id" => "tips_required", "label" => "12 tips required, 96 available"]],
"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 volume.",
"line" => 12, "evidence" => "nest_12_reservoir_15ml A1, declared 12000 ul",
]],
],
];
// The body is $input itself, never ["input" => $input].
$est = call("estimate", $input);
echo $est["model"], " hold=", $est["hold_credits"], " min=", $est["min_credits"], PHP_EOL;
$me = call("me");
if (!$est["sponsor_enabled"] && $me["credits"] < $est["min_credits"]) {
echo "balance ", $me["credits"], " is below min_credits ", $est["min_credits"], PHP_EOL;
exit(1);
}
var input = new
{
task = "audit",
protocol = Protocol,
platemap = "labware\twell\treagent\tlot\tvolume_ul\tconcentration\texpiry\n" +
"nest_12_reservoir_15ml\tA1\tmaster mix\tMM-2291\t12000\t2x\t2026-11-30\n",
robot = "ot2",
goal = "first_run",
context = "First run on this instrument. One spare tip rack on the bench.",
prescan_facts = new
{
resources = new[]
{
new { id = "tips_required", label = "12 tips required, 96 available" },
},
flags = new[]
{
new
{
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 volume.",
line = 12, evidence = "nest_12_reservoir_15ml A1, declared 12000 ul",
},
},
},
};
// The body is `input` itself, never new { input }.
var est = await PipetteDesk.Call("estimate", input);
Console.WriteLine(est.GetProperty("hold_credits").GetInt32());
Console.WriteLine(est.GetProperty("min_credits").GetInt32());
Console.WriteLine(est.GetProperty("sponsor_enabled").GetBoolean());
// Free - no job, no charge - but authenticated, so it comes after step 1.
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"))
'
import hashlib, time
def run_and_wait(body, attempt=1, interval=2.0, timeout=300.0):
"""POST /run with an idempotency key, then poll jobs/{job_id} to a terminal state."""
payload = json.dumps(body, sort_keys=True).encode()
digest = hashlib.sha256(payload).hexdigest()[:16]
# The lane is part of the body, so it belongs in the key: one key for two
# lanes is a 409 conflict.
key = f"pipette-desk:{digest}:{body.get('task', 'audit')}:a{attempt}"
req = urllib.request.Request(f"{BASE}/run", data=json.dumps(body).encode(), method="POST")
req.add_header("Authorization", f"Bearer {TOKEN}")
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key)
with urllib.request.urlopen(req) as r:
job_id = json.load(r)["data"]["job_id"]
deadline = time.monotonic() + timeout
while True:
job = call(f"jobs/{job_id}")
if job["status"] in ("succeeded", "failed"):
return job
if time.monotonic() > deadline:
raise TimeoutError(f"job {job_id} still {job['status']}")
time.sleep(interval)
job = run_and_wait(INPUT, attempt=1)
if job["status"] == "failed":
raise RuntimeError(job.get("error"))
report = json.loads(job["output"]["output"])
print(report["lane"], report["verdict"], "-", report["headline"])
for f in report["findings"]:
print(f" {f['severity']:8} {f['area']:13} {f['title']}")
print("charged", job.get("charged_credits"), "truncated", job.get("truncated"))
import { createHash } from "node:crypto";
async function runAndWait(body, attempt = 1, intervalMs = 2000) {
const digest = createHash("sha256").update(JSON.stringify(body)).digest("hex").slice(0, 16);
// The lane is part of the body, so it belongs in the key.
const key = `pipette-desk:${digest}:${body.task ?? "audit"}:a${attempt}`;
const started = await fetch(`${BASE}/run`, {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": key,
},
body: JSON.stringify(body),
}).then((r) => r.json());
if (!started.ok) throw new Error(`${started.error.code}: ${started.error.message}`);
let job = started.data;
while (job.status !== "succeeded" && job.status !== "failed") {
await new Promise((r) => setTimeout(r, intervalMs));
job = await call(`jobs/${job.job_id}`);
}
return job;
}
const job = await runAndWait(INPUT, 1);
if (job.status === "failed") throw new Error(JSON.stringify(job.error));
const report = JSON.parse(job.output.output);
console.log(report.lane, report.verdict, "-", report.headline);
for (const f of report.findings) console.log(" ", f.severity, f.area, f.title);
console.log("charged", job.charged_credits, "truncated", job.truncated);
// Always send an Idempotency-Key derived from the input, with the lane in it.
body, _ := json.Marshal(input)
sum := sha256.Sum256(body)
key := fmt.Sprintf("pipette-desk:%x:%s:a1", sum[:8], input["task"])
req, _ := http.NewRequest(http.MethodPost, base+"/run", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
var started struct {
Data struct {
JobID string `json:"job_id"`
} `json:"data"`
}
_ = json.NewDecoder(res.Body).Decode(&started)
for {
raw, err := call("jobs/"+started.Data.JobID, nil)
if err != nil {
panic(err)
}
var job struct {
Status string `json:"status"`
Output struct {
Output string `json:"output"`
} `json:"output"`
ChargedCredits int `json:"charged_credits"`
Truncated bool `json:"truncated"`
}
_ = json.Unmarshal(raw, &job)
if job.Status == "succeeded" {
fmt.Println(job.Output.Output) // the report JSON, as a string
fmt.Println(job.ChargedCredits, job.Truncated)
break
}
if job.Status == "failed" {
panic("run failed")
}
time.Sleep(2 * time.Second)
}
// 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.
var digest = java.security.MessageDigest.getInstance("SHA-256")
.digest(input.getBytes(java.nio.charset.StandardCharsets.UTF_8));
var key = "pipette-desk:" + java.util.HexFormat.of().formatHex(digest).substring(0, 16)
+ ":audit:a1";
var start = HttpRequest.newBuilder(URI.create(BASE + "/run"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString(input))
.build();
String started = HTTP.send(start, HttpResponse.BodyHandlers.ofString()).body();
System.out.println(started); // {"ok":true,"data":{"job_id":"job_...","status":"queued"}}
// Then poll GET jobs/{job_id} every two seconds until status is "succeeded" or
// "failed". The report JSON is the string at data.output.output, and the terminal
// job also carries charged_credits and truncated.
String jobId = /* data.job_id from `started` */ "job_...";
String job = call("jobs/" + jobId, null);
System.out.println(job);
require "digest"
# Always send an Idempotency-Key derived from the input, with the lane in it.
digest = Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]
key = "pipette-desk:#{digest}:#{input['task']}:a1"
uri = URI("#{BASE}/run")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = key
req.body = JSON.generate(input)
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
job_id = JSON.parse(res.body)["data"]["job_id"]
loop do
job = call("jobs/#{job_id}")
if job["status"] == "succeeded"
report = JSON.parse(job["output"]["output"])
puts "#{report['lane']} #{report['verdict']} - #{report['headline']}"
report["findings"].each { |f| puts format(" %-8s %-13s %s", f["severity"], f["area"], f["title"]) }
puts "charged=#{job['charged_credits']} truncated=#{job['truncated']}"
break
end
raise "run failed" if job["status"] == "failed"
sleep 2
end
<?php
// Always send an Idempotency-Key derived from the input, with the lane in it.
$digest = substr(hash("sha256", json_encode($input)), 0, 16);
$key = "pipette-desk:{$digest}:{$input['task']}:a1";
$ch = curl_init(BASE . "/run");
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,
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$jobId = json_decode(curl_exec($ch), true)["data"]["job_id"];
curl_close($ch);
while (true) {
$job = call("jobs/" . $jobId);
if ($job["status"] === "succeeded") {
$report = json_decode($job["output"]["output"], true);
echo $report["lane"], " ", $report["verdict"], " - ", $report["headline"], PHP_EOL;
foreach ($report["findings"] as $f) {
printf(" %-8s %-13s %s\n", $f["severity"], $f["area"], $f["title"]);
}
echo "charged=", $job["charged_credits"],
" truncated=", var_export($job["truncated"], true), PHP_EOL;
break;
}
if ($job["status"] === "failed") { throw new RuntimeException("run failed"); }
sleep(2);
}
using System.Security.Cryptography;
// Always send an Idempotency-Key derived from the input, with the lane in it.
var json = JsonSerializer.Serialize(input);
var digest = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(json)))[..16]
.ToLowerInvariant();
var key = $"pipette-desk:{digest}:audit:a1";
var run = new HttpRequestMessage(HttpMethod.Post,
"https://api.skillsafe.ai/v1/app-api/run");
run.Headers.Add("Authorization", "Bearer YOUR_TOKEN");
run.Headers.Add("Idempotency-Key", key);
run.Content = JsonContent.Create(input);
// POST it, read data.job_id, then poll GET jobs/{job_id} every two seconds until
// status is "succeeded" or "failed":
//
// var job = await PipetteDesk.Call($"jobs/{jobId}");
// var report = JsonSerializer.Deserialize<JsonElement>(
// job.GetProperty("output").GetProperty("output").GetString()!);
// Console.WriteLine(report.GetProperty("verdict").GetString());
//
// The terminal job also carries charged_credits and 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.
# Server-sent events: the report 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 protocol"
with urllib.request.urlopen(req) as stream:
if "text/event-stream" not in stream.headers.get("Content-Type", ""):
# An idempotent replay answers with plain JSON, not a stream.
done = json.load(stream)["data"]
raw = done.get("output", {}).get("output", "")
else:
for line in stream:
line = line.decode().rstrip("\n")
if line.startswith("event: "):
event = line[7:]
elif line.startswith("data: ") and event == "delta":
raw += json.loads(line[6:]).get("text", "")
# The arrival of a key name is the progress signal the web app uses.
if '"summary"' in raw:
stage = "writing the summary"
elif '"body"' in raw:
stage = "building the lane document"
elif '"reconciliation"' in raw:
stage = "reconciling the prescan flags"
elif '"findings"' in raw:
stage = "naming the findings"
elif line.startswith("data: ") and event == "done":
done = json.loads(line[6:])
elif line.startswith("data: ") and event == "error":
raise RuntimeError(line[6:])
report = json.loads(raw[raw.index("{"):raw.rindex("}") + 1])
print(stage, report["verdict"], len(report["findings"]), "findings",
done.get("charged_credits"))
// Server-sent events: the report arrives in chunks, so a UI can show progress.
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),
});
// An idempotent replay answers with plain JSON instead of a stream.
if (!(res.headers.get("content-type") || "").includes("text/event-stream")) {
const replay = (await res.json()).data;
console.log("replay", replay.status, replay.charged_credits);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let raw = "";
let done = {};
let stage = "reading the protocol";
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
buffer += decoder.decode(chunk.value, { stream: true });
// Events are separated by a blank line, not by a single newline.
let idx;
while ((idx = buffer.indexOf("\n\n")) >= 0) {
const frame = buffer.slice(0, idx);
buffer = buffer.slice(idx + 2);
let event = "message";
let data = "";
for (const line of frame.split("\n")) {
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) data += line.slice(5).trim();
}
if (!data) continue;
const payload = JSON.parse(data);
if (event === "delta") {
raw += payload.text ?? "";
if (raw.includes('"summary"')) stage = "writing the summary";
else if (raw.includes('"body"')) stage = "building the lane document";
else if (raw.includes('"reconciliation"')) stage = "reconciling the prescan flags";
else if (raw.includes('"findings"')) stage = "naming the findings";
} else if (event === "done") done = payload;
else if (event === "error") throw new Error(payload.message ?? "job failed");
}
}
const report = JSON.parse(raw.slice(raw.indexOf("{"), raw.lastIndexOf("}") + 1));
console.log(stage, report.verdict, report.findings.length, "findings", done.charged_credits);
// Server-sent events: the report arrives in chunks, so a UI can show progress.
req, _ = http.NewRequest(http.MethodPost, base+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
req.Header.Set("Accept", "text/event-stream")
res, _ = http.DefaultClient.Do(req)
defer res.Body.Close()
// An idempotent replay answers with plain JSON instead of a stream.
if !strings.Contains(res.Header.Get("Content-Type"), "text/event-stream") {
all, _ := io.ReadAll(res.Body)
fmt.Println("replay:", string(all))
return
}
var raw string
var event string
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event: "):
event = strings.TrimPrefix(line, "event: ")
case strings.HasPrefix(line, "data: ") && event == "delta":
var d struct {
Text string `json:"text"`
}
_ = json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &d)
raw += d.Text
// The arrival of "findings", "reconciliation", "body" or "summary"
// advances the progress stage.
case strings.HasPrefix(line, "data: ") && event == "done":
fmt.Println(strings.TrimPrefix(line, "data: ")) // status, charged_credits, truncated
case strings.HasPrefix(line, "data: ") && event == "error":
panic(strings.TrimPrefix(line, "data: "))
}
}
fmt.Println(raw)
// Server-sent events: the report arrives in chunks, so a UI can show progress.
var stream = HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.header("Accept", "text/event-stream")
.POST(HttpRequest.BodyPublishers.ofString(input))
.build();
StringBuilder raw = new StringBuilder();
String[] event = { null };
HTTP.send(stream, HttpResponse.BodyHandlers.ofLines()).body().forEach(line -> {
if (line.startsWith("event: ")) event[0] = line.substring(7);
else if (line.startsWith("data: ") && "delta".equals(event[0])) {
// Each data line is {"text":"..."} - decode it and append .text.
raw.append(line.substring(6));
}
});
System.out.println(raw);
// Watch the accumulating text for "findings", "reconciliation", "body" and
// "summary" to advance a progress display. The final `done` event carries
// status, charged_credits and truncated; an `error` event carries code and
// message. An idempotent replay answers with plain JSON, not a stream, so check
// the response content type before reading lines.
# Server-sent events: the report arrives in chunks, so a UI can show progress.
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
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
# An idempotent replay answers with plain JSON instead of a stream.
unless res["content-type"].to_s.include?("text/event-stream")
puts JSON.parse(res.body)["data"]["status"]
next
end
res.read_body do |chunk|
chunk.each_line do |line|
line = line.chomp
if line.start_with?("event: ") then event = line[7..]
elsif line.start_with?("data: ") && event == "delta"
raw << (JSON.parse(line[6..])["text"] || "")
# "findings", "reconciliation", "body", "summary" advance the stage.
elsif line.start_with?("data: ") && event == "error"
raise line[6..]
end
end
end
end
end
report = JSON.parse(raw[raw.index("{")..raw.rindex("}")])
puts "#{report['verdict']} #{report['findings'].length} findings"
<?php
// Server-sent events: the report arrives in chunks, so a UI can show progress.
$raw = "";
$event = null;
$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) {
foreach (explode("\n", $chunk) as $line) {
if (str_starts_with($line, "event: ")) {
$event = substr($line, 7);
} elseif (str_starts_with($line, "data: ") && $event === "delta") {
$raw .= json_decode(substr($line, 6), true)["text"] ?? "";
} elseif (str_starts_with($line, "data: ") && $event === "done") {
echo substr($line, 6), PHP_EOL; // status, charged_credits, truncated
}
}
return strlen($chunk);
});
curl_exec($ch);
curl_close($ch);
$report = json_decode(substr($raw, strpos($raw, "{")), true);
echo $report["lane"], " ", $report["verdict"], PHP_EOL;
// Server-sent events: the report arrives in chunks, so a UI can show progress.
var stream = new HttpRequestMessage(HttpMethod.Post,
"https://api.skillsafe.ai/v1/app-api/run-stream");
stream.Headers.Add("Authorization", "Bearer YOUR_TOKEN");
stream.Headers.Add("Idempotency-Key", key);
stream.Headers.Add("Accept", "text/event-stream");
stream.Content = JsonContent.Create(input);
using var res = await Http.SendAsync(stream, HttpCompletionOption.ResponseHeadersRead);
// An idempotent replay answers with plain JSON instead of a stream.
if (res.Content.Headers.ContentType?.MediaType != "text/event-stream")
{
Console.WriteLine(await res.Content.ReadAsStringAsync());
return;
}
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
var raw = new StringBuilder();
string? evt = null;
while (await reader.ReadLineAsync() is { } line)
{
if (line.StartsWith("event: ")) evt = line[7..];
else if (line.StartsWith("data: ") && evt == "delta")
{
var d = JsonSerializer.Deserialize<JsonElement>(line[6..]);
if (d.TryGetProperty("text", out var t)) raw.Append(t.GetString());
// Watch raw for "findings", "reconciliation", "body" and "summary".
}
else if (line.StartsWith("data: ") && evt == "done") Console.WriteLine(line[6..]);
}
Console.WriteLine(raw.ToString());
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."
}
| key | type | meaning |
|---|---|---|
lane | enum | The 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. |
title | string | Short name for the review, naming the protocol. |
verdict | enum | One of four values, below. The single field a gate should branch on. |
headline | string | One sentence naming the single fact that decides the verdict — not a summary of the findings, the one that swung it. |
findings | object[] | {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. |
reconciliation | object[] | {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_notes | object[] | {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. |
unassessable | object[] | {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. |
body | object | The lane's own document. Four shapes, one per lane, never blended — a merged body fails to render. Documented lane by lane below. |
summary | string | Three 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.
verdict | when |
|---|---|
clear | Nothing above info is left. Safe to start. |
clear_with_notes | The worst finding is medium or low. Start it, and read the notes. |
hold | The worst finding is high. Something needs a decision before the lid closes. |
blocked | At 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.
severity | meaning |
|---|---|
blocking | The run cannot succeed as written — the API raises, a well overflows, a source runs dry, no pipette is loaded. |
high | The run may complete and the result would not be trustworthy, or the operator has to make a call first. |
medium | Real, mitigated or bounded — worth fixing before the next run rather than this one. |
low | Worth naming, not worth stopping for. |
info | Context 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.
area | covers |
|---|---|
deck | Slots, occupancy, conflicts, trash and waste, well addresses that do not exist. |
volume | Pipette envelopes, per-call volumes, well capacity, the liquid ledger. |
tips | The tip budget: pickups times channels against the racks the pipettes were given. |
contamination | Tip reuse and carry-over, new_tip semantics, missing drops. |
reagent | What is in the wells: identity, starting volume, depletion, substitutions. |
timing | Run duration, delays, pauses, anything time-sensitive on the bench. |
api | Protocol API usage: apiLevel, load names, mounts, robot mismatches. |
documentation | What a second operator could not reproduce from what is written. The usual area for a dryrun finding, where an unobservable step is the problem. |
inventory | Lots, 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:
- Reconciliation covers the prescan exactly. Every
uidyou sent appears once inreconciliation; nouidyou did not send appears at all. This is the one that catches a fluent report which quietly dropped your blocking fact. - The verdict matches the worst severity.
blockedneeds ablockingfinding,holdahighone, andclearneeds nothing aboveinfo. A verdict its own findings contradict is a broken reply, not a judgement call. - The lane is the one you asked for. Read
lane, not your own request, and readbodyaccording to it. A different lane meanstaskdid not arrive — which is what a wrappedinputkey looks like from the outside. - The body keys belong to that lane. Four shapes, never blended. A body carrying both
deck_reviewandstepsis malformed even though it parses. - The enums are in range.
verdict,severityandareaare closed sets; an unrecognised value renders as an error rather than being coerced. truncatedis false. A truncated reply is a prefix, not a report. Retry with aretry_noteand a bumped attempt suffix; do not repair it.
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")
'
VERDICTS = ("clear", "clear_with_notes", "hold", "blocked")
SEVERITIES = ("blocking", "high", "medium", "low", "info")
AREAS = ("deck", "volume", "tips", "contamination", "reagent", "timing",
"api", "documentation", "inventory")
BODY_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"},
}
def check(report, sent_input, job):
# 0. A truncated reply is a prefix, not a report.
if job.get("truncated"):
raise ValueError("truncated: retry with a retry_note, do not repair")
# 1. The lane that ran is the lane you asked for.
lane = report["lane"]
if lane != sent_input["task"]:
raise ValueError(f"asked for {sent_input['task']}, got {lane} - did task arrive?")
# 2. Reconciliation covers the prescan exactly: every uid once, nothing extra.
sent = [f["uid"] for f in sent_input["prescan_facts"]["flags"]]
seen = [c["flag_uid"] for c in report["reconciliation"]]
missing = [u for u in sent if seen.count(u) != 1]
extra = [u for u in seen if u not in sent]
if missing or extra:
raise ValueError(f"reconciliation drift: missing={missing} extra={extra}")
# 3. The verdict follows the findings, mechanically.
sev = {f["severity"] for f in report["findings"]}
worst = ("blocked" if "blocking" in sev else "hold" if "high" in sev
else "clear_with_notes" if sev & {"medium", "low"} else "clear")
if report["verdict"] != worst:
raise ValueError(f"verdict {report['verdict']} but findings say {worst}")
# 4. The enums are in range - the renderer keys on these strings.
assert report["verdict"] in VERDICTS
for f in report["findings"]:
if f["severity"] not in SEVERITIES or f["area"] not in AREAS:
raise ValueError(f"{f['id']}: unknown severity/area {f['severity']}/{f['area']}")
# 5. The body belongs to this lane and to no other.
strays = set(report["body"]) - BODY_KEYS[lane]
if strays:
raise ValueError(f"body carries {sorted(strays)}, which are not {lane} keys")
# 6. Lane-specific: blocked implies a non-empty blocking_issues, and only then.
if lane == "audit":
blocking = bool(report["body"].get("blocking_issues"))
if blocking != (report["verdict"] == "blocked"):
raise ValueError("blocking_issues and verdict disagree")
# 7. Claims in context come back one for one.
if sent_input.get("context") and not report["context_notes"]:
print("warning: context was sent but no context_notes came back")
return report
report = check(json.loads(job["output"]["output"]), INPUT, job)
print(report["verdict"], "-", report["headline"])
for c in report["reconciliation"]:
print(f" {c['flag_uid']} {c['status']:15} {c['note']}")
for item in report["unassessable"]:
print(f" unassessable: {item['item']} ({item['why']})")
const VERDICTS = ["clear", "clear_with_notes", "hold", "blocked"];
const SEVERITIES = ["blocking", "high", "medium", "low", "info"];
const AREAS = ["deck", "volume", "tips", "contamination", "reagent", "timing",
"api", "documentation", "inventory"];
const BODY_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"],
};
function check(report, sentInput, job) {
// 0. A truncated reply is a prefix, not a report.
if (job.truncated) throw new Error("truncated: retry with a retry_note");
// 1. The lane that ran is the lane you asked for.
if (report.lane !== sentInput.task) {
throw new Error(`asked for ${sentInput.task}, got ${report.lane} - did task arrive?`);
}
// 2. Reconciliation covers the prescan exactly.
const sent = sentInput.prescan_facts.flags.map((f) => f.uid);
const seen = report.reconciliation.map((c) => c.flag_uid);
const missing = sent.filter((u) => seen.filter((s) => s === u).length !== 1);
const extra = seen.filter((u) => !sent.includes(u));
if (missing.length || extra.length) {
throw new Error(`reconciliation drift: missing=${missing} extra=${extra}`);
}
// 3. The verdict follows the findings.
const sev = new Set(report.findings.map((f) => f.severity));
const worst = sev.has("blocking") ? "blocked"
: sev.has("high") ? "hold"
: sev.has("medium") || sev.has("low") ? "clear_with_notes"
: "clear";
if (report.verdict !== worst) {
throw new Error(`verdict ${report.verdict} but findings say ${worst}`);
}
// 4. The enums are in range.
if (!VERDICTS.includes(report.verdict)) throw new Error("unknown verdict");
for (const f of report.findings) {
if (!SEVERITIES.includes(f.severity) || !AREAS.includes(f.area)) {
throw new Error(`${f.id}: unknown severity/area`);
}
}
// 5. The body belongs to this lane and no other.
const strays = Object.keys(report.body).filter((k) => !BODY_KEYS[report.lane].includes(k));
if (strays.length) throw new Error(`body carries non-${report.lane} keys: ${strays}`);
// 6. audit only: blocked implies a non-empty blocking_issues, and only then.
if (report.lane === "audit") {
const hasBlocking = (report.body.blocking_issues ?? []).length > 0;
if (hasBlocking !== (report.verdict === "blocked")) {
throw new Error("blocking_issues and verdict disagree");
}
}
return report;
}
const report = check(JSON.parse(job.output.output), INPUT, job);
console.log(report.verdict, "-", report.headline);
for (const c of report.reconciliation) console.log(" ", c.flag_uid, c.status, c.note);
type finding struct {
ID string `json:"id"`
Severity string `json:"severity"`
Area string `json:"area"`
Title string `json:"title"`
Detail string `json:"detail"`
Evidence string `json:"evidence"`
Line *int `json:"line"`
Fix string `json:"fix"`
}
type recon struct {
FlagUID string `json:"flag_uid"`
Status string `json:"status"`
Note string `json:"note"`
}
type report struct {
Lane string `json:"lane"`
Title string `json:"title"`
Verdict string `json:"verdict"`
Headline string `json:"headline"`
Findings []finding `json:"findings"`
Reconciliation []recon `json:"reconciliation"`
ContextNotes []struct {
Claim string `json:"claim"`
Status string `json:"status"`
Note string `json:"note"`
} `json:"context_notes"`
Unassessable []struct {
Item string `json:"item"`
Why string `json:"why"`
} `json:"unassessable"`
Body map[string]json.RawMessage `json:"body"`
Summary string `json:"summary"`
}
var r report
if err := json.Unmarshal([]byte(job.Output.Output), &r); err != nil {
panic(err)
}
// 1. Every prescan uid comes back exactly once, and nothing else does.
count := map[string]int{}
for _, c := range r.Reconciliation {
count[c.FlagUID]++
}
for _, uid := range []string{"PD-001", "PD-002"} { // the uids this request sent
if count[uid] != 1 {
panic("unreconciled prescan flag: " + uid)
}
}
// 2. The verdict follows the findings.
worst := "clear"
for _, f := range r.Findings {
switch f.Severity {
case "blocking":
worst = "blocked"
case "high":
if worst != "blocked" {
worst = "hold"
}
case "medium", "low":
if worst == "clear" {
worst = "clear_with_notes"
}
}
}
if r.Verdict != worst {
panic(fmt.Sprintf("verdict %s but findings say %s", r.Verdict, worst))
}
// 3. The body belongs to this lane.
laneKeys := map[string][]string{
"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"},
}
allowed := map[string]bool{}
for _, k := range laneKeys[r.Lane] {
allowed[k] = true
}
for k := range r.Body {
if !allowed[k] {
panic("body carries a key from another lane: " + k)
}
}
fmt.Println(r.Lane, r.Verdict, "-", r.Headline, len(r.Findings), "findings")
// The report JSON is the string at data.output.output. Parse it, then check the
// invariants before trusting it:
//
// 1. truncated is false. A truncated reply is a prefix, not a report - retry
// with a retry_note and a bumped attempt suffix, never repair the JSON.
// 2. lane equals the task you sent. A different lane means task did not
// arrive, which is what a wrapped "input" key looks like from outside.
// 3. every uid you sent in prescan_facts.flags appears exactly once in
// reconciliation[].flag_uid, and no uid you did not send appears there.
// 4. the verdict follows the findings: blocked needs a blocking finding, hold
// needs a high one, clear needs nothing above info.
// 5. verdict, findings[].severity and findings[].area are inside the closed
// sets - the renderer keys on these strings and coerces nothing.
// 6. body carries only this lane's keys. The four shapes never blend.
// 7. audit only: body.blocking_issues is non-empty exactly when the verdict
// is blocked.
//
// A useful eighth: if you sent a context paragraph, context_notes should have
// one entry per claim in it. An empty context_notes means the most decisive
// field in the input went unread.
String reportJson = /* data.output.output */ call("jobs/" + jobId, null);
System.out.println(reportJson);
var verdicts = java.util.Set.of("clear", "clear_with_notes", "hold", "blocked");
var severities = java.util.Set.of("blocking", "high", "medium", "low", "info");
var areas = java.util.Set.of("deck", "volume", "tips", "contamination", "reagent",
"timing", "api", "documentation", "inventory");
System.out.println(verdicts.size() + " verdicts, " + severities.size()
+ " severities, " + areas.size() + " areas - all closed sets");
VERDICTS = ["clear", "clear_with_notes", "hold", "blocked"].freeze
SEVERITIES = ["blocking", "high", "medium", "low", "info"].freeze
AREAS = ["deck", "volume", "tips", "contamination", "reagent", "timing",
"api", "documentation", "inventory"].freeze
BODY_KEYS = {
"audit" => %w[deck_review volume_review tip_budget liquid_review blocking_issues before_you_run],
"dryrun" => %w[dry_run_steps calibration_checks abort_triggers portability water_run_volume_ul],
"runsheet" => %w[protocol_title estimated_duration_min materials before_you_start steps after_the_run],
"eln" => %w[entry_title entry_sections inventory_moves registry_links deviations_to_log attachments_to_keep]
}.freeze
report = JSON.parse(job["output"]["output"])
# 0. A truncated reply is a prefix, not a report.
raise "truncated: retry with a retry_note" if job["truncated"]
# 1. The lane that ran is the lane you asked for.
raise "asked for #{input['task']}, got #{report['lane']}" if report["lane"] != input["task"]
# 2. Reconciliation covers the prescan exactly.
sent = input["prescan_facts"]["flags"].map { |f| f["uid"] }
seen = report["reconciliation"].map { |c| c["flag_uid"] }
missing = sent.reject { |u| seen.count(u) == 1 }
extra = seen - sent
raise "reconciliation drift: #{missing} / #{extra}" unless missing.empty? && extra.empty?
# 3. The verdict follows the findings.
sev = report["findings"].map { |f| f["severity"] }
worst = if sev.include?("blocking") then "blocked"
elsif sev.include?("high") then "hold"
elsif sev.any? { |s| ["medium", "low"].include?(s) } then "clear_with_notes"
else "clear"
end
raise "verdict #{report['verdict']} but findings say #{worst}" if report["verdict"] != worst
# 4. The enums are in range.
raise "unknown verdict" unless VERDICTS.include?(report["verdict"])
report["findings"].each do |f|
raise "#{f['id']}: unknown severity/area" unless
SEVERITIES.include?(f["severity"]) && AREAS.include?(f["area"])
end
# 5. The body belongs to this lane.
strays = report["body"].keys - BODY_KEYS[report["lane"]]
raise "body carries #{strays.join(', ')}" unless strays.empty?
puts "#{report['verdict']} - #{report['headline']}"
report["reconciliation"].each { |c| puts " #{c['flag_uid']} #{c['status']} #{c['note']}" }
<?php
const VERDICTS = ["clear", "clear_with_notes", "hold", "blocked"];
const SEVERITIES = ["blocking", "high", "medium", "low", "info"];
const AREAS = ["deck", "volume", "tips", "contamination", "reagent", "timing",
"api", "documentation", "inventory"];
const BODY_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"],
];
$report = json_decode($job["output"]["output"], true);
// 0. A truncated reply is a prefix, not a report.
if (!empty($job["truncated"])) { throw new RuntimeException("truncated: retry with a retry_note"); }
// 1. The lane that ran is the lane you asked for.
if ($report["lane"] !== $input["task"]) {
throw new RuntimeException("asked for {$input['task']}, got {$report['lane']}");
}
// 2. Reconciliation covers the prescan exactly.
$sent = array_column($input["prescan_facts"]["flags"], "uid");
$seen = array_column($report["reconciliation"], "flag_uid");
$counts = array_count_values($seen);
foreach ($sent as $uid) {
if (($counts[$uid] ?? 0) !== 1) {
throw new RuntimeException("unreconciled prescan flag: " . $uid);
}
}
foreach (array_diff($seen, $sent) as $uid) {
throw new RuntimeException("reconciliation invented a uid: " . $uid);
}
// 3. The verdict follows the findings.
$sev = array_column($report["findings"], "severity");
$worst = in_array("blocking", $sev, true) ? "blocked"
: (in_array("high", $sev, true) ? "hold"
: ((array_intersect(["medium", "low"], $sev)) ? "clear_with_notes" : "clear"));
if ($report["verdict"] !== $worst) {
throw new RuntimeException("verdict {$report['verdict']} but findings say {$worst}");
}
// 4. The body belongs to this lane.
$strays = array_diff(array_keys($report["body"]), BODY_KEYS[$report["lane"]]);
if ($strays) { throw new RuntimeException("body carries " . implode(", ", $strays)); }
echo $report["verdict"], " - ", $report["headline"], PHP_EOL;
foreach ($report["reconciliation"] as $c) {
echo " ", $c["flag_uid"], " ", $c["status"], " ", $c["note"], PHP_EOL;
}
var report = JsonSerializer.Deserialize<JsonElement>(
job.GetProperty("output").GetProperty("output").GetString()!);
// 0. A truncated reply is a prefix, not a report.
if (job.TryGetProperty("truncated", out var tr) && tr.GetBoolean())
throw new Exception("truncated: retry with a retry_note");
// 1. The lane that ran is the lane you asked for.
var lane = report.GetProperty("lane").GetString();
if (lane != "audit") throw new Exception($"asked for audit, got {lane}");
// 2. Reconciliation covers the prescan exactly: every uid once, nothing extra.
var sent = new[] { "PD-001", "PD-002" };
var seen = report.GetProperty("reconciliation").EnumerateArray()
.Select(c => c.GetProperty("flag_uid").GetString()).ToList();
foreach (var uid in sent)
if (seen.Count(s => s == uid) != 1)
throw new Exception($"unreconciled prescan flag: {uid}");
foreach (var uid in seen.Where(s => !sent.Contains(s)))
throw new Exception($"reconciliation invented a uid: {uid}");
// 3. The verdict follows the findings.
var sev = report.GetProperty("findings").EnumerateArray()
.Select(f => f.GetProperty("severity").GetString()).ToHashSet();
var worst = sev.Contains("blocking") ? "blocked"
: sev.Contains("high") ? "hold"
: sev.Contains("medium") || sev.Contains("low") ? "clear_with_notes"
: "clear";
var verdict = report.GetProperty("verdict").GetString();
if (verdict != worst) throw new Exception($"verdict {verdict} but findings say {worst}");
// 4. The body belongs to this lane and no other.
var laneKeys = new Dictionary<string, string[]>
{
["audit"] = new[] { "deck_review", "volume_review", "tip_budget", "liquid_review",
"blocking_issues", "before_you_run" },
["dryrun"] = new[] { "dry_run_steps", "calibration_checks", "abort_triggers",
"portability", "water_run_volume_ul" },
["runsheet"] = new[] { "protocol_title", "estimated_duration_min", "materials",
"before_you_start", "steps", "after_the_run" },
["eln"] = new[] { "entry_title", "entry_sections", "inventory_moves",
"registry_links", "deviations_to_log", "attachments_to_keep" },
};
foreach (var k in report.GetProperty("body").EnumerateObject())
if (!laneKeys[lane!].Contains(k.Name))
throw new Exception($"body carries a key from another lane: {k.Name}");
Console.WriteLine($"{verdict} - {report.GetProperty("headline").GetString()}");
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.