← PIV Desk / API
Tokens

Drive PIV Desk from your own code

A review of the measured field, not of the flow. The model reads what your browser (or your script) measured on one image pair; it never sees the images and never recomputes a number. "Sound" means the pair was read as openpiv reads it and nothing flagged at medium or high undermines the vector field - not that the flow has any physical property, and a replaced vector is interpolated, not measured.

Everything the web page does is available over HTTP. Run PIV on your image pair with the page's own piv.js and pivkit.js (openpiv 0.25.4 conventions: pyprocess.extended_search_area_piv windowed cross-correlation with a sub-pixel peak fit and a signal-to-noise ratio, pyprocess.get_coordinates, validation.sig2noise_val and optionally global_val and local_median_val, filters.replace_outliers, scaling.uniform, tools.transform_coordinates, and the skill's analyze.py statistics and vorticity), send the facts, and get back a verdict (sound, caveated, unreliable) and either a review of every metric and of the measured field or an openpiv script that reproduces the field and runs follow-up checks. The natural loop: drop the pair, review, script, change the settings, re-check. PIV Desk is derived from the agent skill @k-dense-ai/openpiv (k-dense-ai/scientific-agent-skills, skill author OpenPIV Team) and its scripts/runner.py and scripts/analyze.py.

Two lanes: the task field

taskwhat you getextra input
reviewA reading of each metric (M1.. in the order of facts.metrics: vectors, flagged vectors, median signal-to-noise, maximum and median displacement, the quarter-window rule, peak locking, border peaks, replaced vectors, mean and RMS velocity, vorticity range, particles per window, saturated pixels, for a synthetic pair the error against the true field, and the coordinate offset), the measured field in words (field: where vectors failed or were replaced, where displacements are largest), up to 4 processing changes to test (parameters), your claims judged against the facts, a methods paragraph stating exactly the processing used, and what one pair cannot show.none
scriptThe fixes (a different window, overlap or search area, another signal-to-noise threshold, adding local_median_val or global_val, windef.simple_multipass, masking, saving vectors.txt, printing the vorticity, the flagged share per region) and one complete Python script: FRAME_A and FRAME_B read with tools.imread, extended_search_area_piv with every browser setting as a literal, the same validation, an EXPECTED dict of every browser value checked with math.isclose, replace_outliers, scaling, then the fixes, all inside main().decision: the text of an earlier review run (optional)

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

Input fields

Every field is a string.

fieldrequiredmeaning
taskyesreview or script. These are the only two lanes.
factsyesA JSON-encoded string holding the browser's PIV run - see below. The page builds it with PivKit.buildInput; an API caller normally runs the free page or reproduces it. The images themselves are never sent.
titlenoA label for the analysis, up to 160 characters.
contextnoYour notes: the experiment, the flow, the camera, where dt and the calibration come from, what you want to conclude. Up to 2,500 characters.
questionnoAnswered in tldr as a bullet starting "Answer:". Up to 1,500 characters. Sent in either lane when not empty.
decisionscript onlyPlain text of an earlier review run (the page builds it with Recon.decisionText: "Verdict: ...", the headline, one line per field point, a "Try:" line per parameter change, then the next steps). Up to 5,000 characters. The page always sends it in the script lane, empty when there is no review.
retry_notenoOnly on a retry after a malformed reply.

Longer text is cut on a word by the page and marked [cut: N more characters not sent]; do the same, or keep within the limits.

The facts string

facts is a JSON string, not an object: the browser runs PIV on the pair, serialises the result with JSON.stringify and sends that text. It holds: settings (openpiv_version 0.25.4; source image files or a synthetic pair; frame_a and frame_b with file, format, width, height, bit_depth, openpiv_reads_as and grey-level statistics; window_size, overlap, search_area_size; correlation_method (the one actually used) and correlation_setting; subpixel_method, sig2noise_method, width, sig2noise_threshold; dt_s, scaling_px_per_unit, unit; validators, and global_val / local_median_val thresholds in px per frame and px/s when used; replace_outliers; drop_invalid; grid; correlation_plane; normalized_windows; runner_equivalent; for a synthetic pair synthetic); metrics (M1.., each metric, value - a number, a pair or null - and basis, sometimes fraction, p10, tke, bias_u_v, rms_after_replacement, rms_at_labels); flags (F1.. with severity high / medium / low, category, message and refs); regions (a 3 x 3 split of the grid: region, vectors, flagged, median_s2n, median_displacement_px); flagged_vectors (up to 20: row, col, x_px, y_px, u_px, v_px, s2n, why) and flagged_vectors_total; browser_verdict; expected (the exact values an openpiv reproduction must match: n_rows, n_cols, flagged_count, and u_px__rRcC, v_px__rRcC, s2n__rRcC for up to five valid vectors spread over the field) and expected_count; and clipped (what was left out for length).

A real run. The object below is what the page computes for its synthetic Lamb-Oseen vortex example (320 x 320, peak 3 px per frame, seed 11) with a 32 px window, 12 px overlap, 38 px search area, dt 0.01 s and 20 px/mm. Every vector passes, only low flags are raised, so the browser's read is sound. Some metrics and regions are cut with "...":

{
  "settings": {
    "openpiv_version": "0.25.4",
    "source": "synthetic pair generated by the page",
    "frame_a": {"file": "frame_a.png", "format": "png", "width": 320, "height": 320, "bit_depth": 8,
                "openpiv_reads_as": "2-D grey array", "grey_min": 0, "grey_max": 255,
                "grey_mean": 16.47, "grey_std": 20.22, "saturated_fraction": 0.0000293},
    "frame_b": {"file": "frame_b.png", "...": "as frame_a"},
    "window_size": 32, "overlap": 12, "search_area_size": 38,
    "correlation_method": "linear", "correlation_setting": "auto",
    "subpixel_method": "gaussian", "sig2noise_method": "peak2peak", "width": 2,
    "sig2noise_threshold": 1.05, "dt_s": 0.01, "scaling_px_per_unit": 20, "unit": "mm",
    "validators": ["sig2noise_val"],
    "replace_outliers": {"method": "localmean", "max_iter": 3, "kernel_size": 2, "tol": 0.001},
    "drop_invalid": false,
    "grid": {"rows": 11, "cols": 11, "step_px": 26, "first_label_px": [30, 30],
             "coordinate_offset_px": [11, 11], "spacing": 1.3},
    "correlation_plane": {"shape": [38, 38], "zero_padded_to": 127},
    "normalized_windows": true,
    "runner_equivalent": true,
    "synthetic": {"description": "synthetic 320 x 320, Lamb-Oseen vortex, peak 3 px/frame at r = 70 px, ...",
                  "field": "vortex", "seed": 11, "particle_density_per_px": 0.02,
                  "particle_diameter_px": 2.6, "noise_grey_levels": 3}
  },
  "metrics": [
    {"id": "M1", "metric": "vectors", "value": 121,
     "basis": "11 rows x 11 columns of 38 px search windows every 26 px"},
    {"id": "M2", "metric": "flagged_vectors", "value": 0, "basis": "sig2noise_val(s2n < 1.05); s2n 0", "fraction": 0},
    {"id": "M3", "metric": "median_s2n", "value": 4.021, "basis": "peak2peak over all 121 vectors ...", "p10": 3.076},
    {"id": "M4", "metric": "max_displacement_px", "value": 2.997,
     "basis": "largest |displacement| of a valid vector, px per frame; the quarter-window guide is 8 px"},
    "... M5 median_displacement_px 2.341, M6 quarter_rule_exceeded 0, M7 peak_locking_near_integer 0.2107,
     M8 border_peaks 0, M9 replaced_vectors 0 ...",
    {"id": "M10", "metric": "mean_velocity", "value": [-0.59864, -0.61434],
     "basis": "u_mean, v_mean in mm/s after scaling, y up (analyze.py)"},
    {"id": "M11", "metric": "rms_velocity", "value": [8.3309, 8.3922], "basis": "...", "tke": 69.916},
    {"id": "M12", "metric": "vorticity_range", "value": [-0.5193, 12.31],
     "basis": "min, max of dv/dx - du/dy in 1/s on the 1.30000 mm grid (analyze.py)"},
    "... M13 particles_per_window 18.4, M14 saturated_pixels 0.0000293 ...",
    {"id": "M15", "metric": "truth_rms_error_px", "value": 0.127, "basis": "synthetic pair: ...",
     "bias_u_v": [0.003859, -0.0007199], "rms_after_replacement": 0.127, "rms_at_labels": 0.4493},
    {"id": "M16", "metric": "coordinate_offset_px", "value": [11, 11], "basis": "..."}
  ],
  "flags": [
    {"id": "F1", "severity": "low", "category": "saturation",
     "message": "Some pixels (0%) are at full scale.", "refs": ["M14"]},
    {"id": "F2", "severity": "low", "category": "coordinate_offset",
     "message": "openpiv labels each vector 11 px right and 11 px down of its window's centre ...", "refs": ["M16"]},
    {"id": "F3", "severity": "low", "category": "single_pair_statistics",
     "message": "The mean and RMS velocities come from one image pair: ...", "refs": ["M10", "M11"]}
  ],
  "regions": [
    {"region": "top left", "vectors": 9, "flagged": 0, "median_s2n": 4.498, "median_displacement_px": 1.774},
    "... 8 more, top centre to bottom right ..."
  ],
  "flagged_vectors": [],
  "flagged_vectors_total": 0,
  "browser_verdict": "sound",
  "expected": {
    "n_rows": 11, "n_cols": 11, "flagged_count": 0,
    "u_px__r5c5": -0.688174, "v_px__r5c5": 0.509149, "s2n__r5c5": 2.588528,
    "u_px__r2c2": -1.574056, "v_px__r2c2": 1.717378, "s2n__r2c2": 3.148825,
    "...": "r2c8, r8c2 and r8c8 the same way"
  },
  "expected_count": 18,
  "clipped": []
}

The facts come from the free in-browser PIV run: the page decodes each frame the way openpiv's tools.imread would (PNG and uncompressed TIFF by its own decoder, so 16-bit frames keep their grey levels; BMP and JPEG through the canvas), correlates every window with piv.js, and builds the metrics, flags, regions and expected values with pivkit.js. Nothing is uploaded and nothing is charged. To copy the facts without writing code, run PIV on the page and press Report .json: the file carries the exact facts object under facts, next to the full vector arrays. A finished result's Download .json carries it too, under browser. Send it back as a string: json.dumps(facts), JSON.stringify(facts) or your language's equivalent. Keep the keys and values the browser produced: the reply is reconciled against them, and the script lane copies expected into its reproduction check.

Building the body

The simplest way to get a body that matches the page byte for byte is to run the page's own modules in Node 18 or later. pivkit.js needs piv.js (the correlation engine) next to it, imgio.js decodes PNG and uncompressed TIFF frames, and all three export themselves with module.exports. On the example's frames with the example's settings this reproduces the page's facts exactly, except that files read from disk are source: "image files", so the synthetic-only truth_rms_error_px metric and settings.synthetic are absent. BMP and JPEG frames need the page (a browser canvas).

// make-body.js - build the exact body the page sends, with the page's own code.
// Save https://piv-desk.skillsafe.ai/piv.js, pivkit.js and imgio.js next to this file (Node 18+), then:
//   node make-body.js frame_a.png frame_b.png review "Vortex check" "notes" "question" > body.json
// Frames are PNG (8/16-bit) or uncompressed TIFF. Settings come from settings.json if present
// (the page's "Save settings .json" file, or {"params": {...}, "unit": "mm"}); otherwise the page defaults.
const fs = require("fs");
const path = require("path");
const PIV = require("./piv.js");
const K = require("./pivkit.js");
const IO = require("./imgio.js");
const [fa, fb, lane = "review", title = "", context = "", question = "", decision = ""] = process.argv.slice(2);

async function frame(file) {
  const bytes = new Uint8Array(fs.readFileSync(file));
  const fmt = IO.sniff(bytes);
  const img = fmt === "png" ? await IO.decodePng(bytes) : fmt === "tiff" ? IO.decodeTiff(bytes) : null;
  if (!img) throw new Error(file + ": only PNG and uncompressed TIFF decode outside a browser");
  const gray = IO.toOpenpivGray(img);
  return { name: path.basename(file), format: img.format, decoder: img.decoder, w: img.w, h: img.h, depth: img.depth,
    layout: img.layout, multipage: !!img.multipage, gray, stats: IO.stats(gray, img.w, img.h, img.depth) };
}

(async () => {
  const saved = fs.existsSync("settings.json") ? JSON.parse(fs.readFileSync("settings.json", "utf8")) : {};
  const params = Object.assign({}, PIV.DEFAULTS, saved.params || {});
  const unit = saved.unit || "mm";
  const a = await frame(fa), b = await frame(fb);
  const res = PIV.runSync(a.gray, b.gray, a.h, a.w, params);
  const X = K.analyze(res, { a, b }, null, { title, unit });
  const body = K.mustBeObject(K.buildInput(X, { lane, title, context, question, decision }));
  fs.writeFileSync("vectors.txt", PIV.vectorsTxt(X.res));     // openpiv's tools.save format
  console.error("browser verdict:", X.hint, "| vectors:", X.n, "| flagged:", X.nFlag, "| flags:", X.flags.map(f => f.id + " " + f.category).join(", "));
  console.error("idempotency key: piv-desk:" + body.task + ":" + K.hashInput(body) + ":a1");
  process.stdout.write(JSON.stringify(body));
})().catch(e => { console.error(e.message); process.exit(1); });
# Or build the body in any language from a facts object you already hold, for example the
# "facts" key of the page's "Report .json" download. facts must go in as a STRING.
import json

report = json.load(open("piv-desk-vortex-check-before-the-tunnel-run-report.json"))   # the page's download
facts = report["facts"]
body = {
    "task": "review",
    "title": "Vortex check before the tunnel run",
    "context": "Synthetic check of our processing chain before the water-tunnel campaign. ...",
    "question": "Is a 32 px window fine enough for this vortex?",
    "facts": json.dumps(facts, separators=(",", ":")),
}
json.dump(body, open("body.json", "w"))

Base URL and the envelope

Every endpoint lives under https://api.skillsafe.ai/v1/app-api and every response uses the same envelope, so one helper covers the whole API:

{"ok": true, "data": {"job_id": "job_...", "status": "queued"}}
{"ok": false, "error": {"code": "payment_required", "message": "..."}}

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

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

Error codes

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

1. A tiny client

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

# Every call is the same three things: the base URL, your bearer token,
# and a JSON body. Keep the token in a shell variable.
BASE="https://api.skillsafe.ai/v1/app-api"
SLUG="piv-desk"
TOKEN="$SKILLSAFE_TOKEN"   # from https://piv-desk.skillsafe.ai/tokens.html

call() {                  # call <path> [json-body]
  if [ -n "$2" ]; then
    curl -sS -X POST "$BASE/$1" \
      -H "Authorization: Bearer $TOKEN" \
      -H "Content-Type: application/json" \
      -d "$2"
  else
    curl -sS "$BASE/$1" -H "Authorization: Bearer $TOKEN"
  fi
}

2. Get a token

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

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

3. Check the session and the balance

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

4. Price the run (free)

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

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

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

5. Run it, then poll

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

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

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

while :; do
  OUT=$(call "jobs/$JOB")
  STATUS=$(printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["status"])')
  [ "$STATUS" = "succeeded" ] && break
  [ "$STATUS" = "failed" ] && echo "$OUT" && exit 1
  sleep 2
done

# {"ok":true,"data":{"job_id":"job_...","status":"succeeded",
#   "output":{"output":"{\"lane\":\"review\",\"verdict\":\"caveated\",\"headline\":\"...\", ...}"},
#   "charged_credits":...,"truncated":false}}
printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["output"]["output"])' > reply.json

6. Or stream it

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

# Server-sent events. `delta` events carry chunks of the reply; `done` carries the
# status, charged_credits and the truncated flag. Ignore `tick` heartbeats.
curl -N -X POST "$BASE/run-stream" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -H "Accept: text/event-stream" \
  -d "$INPUT"

# event: job    {"job_id":"job_..."}
# event: delta  {"text":"{\"lane\":\"review\",\"verdict\":\"caveated\",\"headline\":\"The"}
# event: done   {"status":"succeeded","charged_credits":...,"truncated":false}

7. Parse the reply

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

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

Invariants worth asserting

Worked example: review

A real request: the synthetic Lamb-Oseen vortex from the facts section. The browser finds all 121 vectors valid (M2: 0 flagged), a largest displacement of 2.997 px per frame against the 8 px quarter-window guide, an RMS error of 0.127 px against the true field (M15), and raises three low flags (saturation, the coordinate offset, single-pair statistics), so its read is sound. The body, with facts abbreviated (send the full string from make-body.js or the page's report):

{
 "task": "review",
 "title": "Vortex check before the tunnel run",
 "context": "Synthetic check of our processing chain before the water-tunnel campaign. I want to say these settings resolve the vortex and its peak displacement of about 3 px per frame.",
 "facts": "{\"settings\":{\"openpiv_version\":\"0.25.4\",\"source\":\"synthetic pair generated by the page\",\"frame_a\":{\"file\":\"frame_a.png\",\"format\":\"png\",\"width\":320,\"height\":320,\"bit_depth\":8,\"...\":\"more\"},\"...\":\"more settings\"},\"metrics\":[{\"id\":\"M1\",\"metric\":\"vectors\",\"value\":121,\"...\":\"...\"},\"... M2 to M16 ...\"],\"flags\":[{\"id\":\"F1\",\"severity\":\"low\",\"category\":\"saturation\",\"...\":\"...\"},{\"id\":\"F2\",\"severity\":\"low\",\"category\":\"coordinate_offset\",\"...\":\"...\"},{\"id\":\"F3\",\"severity\":\"low\",\"category\":\"single_pair_statistics\",\"...\":\"...\"}],\"regions\":[...],\"flagged_vectors\":[],\"flagged_vectors_total\":0,\"browser_verdict\":\"sound\",\"expected\":{\"n_rows\":11,\"n_cols\":11,\"flagged_count\":0,\"...\":\"15 more\"},\"expected_count\":18,\"clipped\":[]}",
 "question": "Is a 32 px window fine enough for this vortex?"
}

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

{
  "lane": "review",
  "verdict": "sound",
  "headline": "The measured field of this synthetic vortex is fully valid: all 121 vectors pass sig2noise_val and none are replaced.",
  "tldr": [
    "0 of 121 vectors are flagged (M2) and 0 are replaced (M9), so every vector is a measurement.",
    "The synthetic truth check gives an RMS error of 0.127 px per frame at the window centres (M15).",
    "Answer: on this pair the 32 px window keeps every displacement well inside the 8 px quarter-window guide (M4: 2.997 px, M6: 0), but one window spans 32 px, so finer vortex structure is not resolved."
  ],
  "metrics": [
    {"id": "M1", "reading": "121 vectors on an 11 x 11 grid of 38 px search windows every 26 px."},
    {"id": "M2", "reading": "No vector falls below the 1.05 signal-to-noise threshold (0%)."},
    "... one reading for each of M3 to M16, in order ..."
  ],
  "field": [
    "No region has a flagged vector; region median displacements run from 1.774 px (top left) to 2.729 px (bottom centre).",
    "The vorticity ranges from -0.519 to 12.3 1/s (M12) on the 1.3 mm grid.",
    "..."
  ],
  "parameters": [
    {"change": "Test window_size 16 with a matching overlap and search_area_size, as a suggestion.", "why": "Displacements are far below the quarter-window guide, so a smaller window may resolve the core more finely.", "refs": ""}
  ],
  "claims": [
    {"claim": "These settings resolve the vortex and its peak displacement of about 3 px per frame.", "support": "partly", "why": "The largest measured displacement is 2.997 px per frame (M4) and the truth error is 0.127 px (M15), but the spatial resolution is one 32 px window."}
  ],
  "methods": "Vectors were computed with openpiv 0.25.4 extended_search_area_piv using a 32 px window, 12 px overlap and 38 px search area, linear correlation, a gaussian sub-pixel fit and peak2peak signal-to-noise; vectors below 1.05 were flagged with sig2noise_val and replaced by localmean replace_outliers (max_iter 3, kernel_size 2); dt 0.01 s, 20 px/mm. ...",
  "cautions": [
    "One image pair gives spatial spread, not turbulence statistics (F3).",
    "Out-of-plane motion is not measured by a single-camera pair.",
    "..."
  ],
  "next_steps": ["Repeat the synthetic check at the displacements expected in the tunnel.", "..."],
  "prescan_responses": [
    {"ref": "F1", "verdict": "confirmed", "note": "A share of 0.0000293 of the pixels is at full scale; too small to affect the field."},
    {"ref": "F2", "verdict": "confirmed", "note": "Labels sit 11 px right and down of the window centres (M16)."},
    {"ref": "F3", "verdict": "confirmed", "note": "M10 and M11 describe this one pair only."}
  ]
}

A reply must answer F1, F2 and F3 once each in prescan_responses, read M1 to M16 in order, judge the claim in context against the metrics, answer the question in a tldr bullet starting "Answer:", and may stay at sound because only low flags were raised.

Worked example: script

The same pair and facts (identical facts string) with the earlier review handed over as decision (the page fills it with Recon.decisionText of the review reply; it may be empty). No question is sent in this request. The body, with facts and decision abbreviated:

{
 "task": "script",
 "title": "Vortex check before the tunnel run",
 "context": "Synthetic check of our processing chain before the water-tunnel campaign. I want a script that reproduces this field and then tries a finer window.",
 "facts": "{\"settings\":{\"openpiv_version\":\"0.25.4\",\"...\":\"as in the review example\"},\"...\":\"...\",\"browser_verdict\":\"sound\",\"expected\":{\"n_rows\":11,\"n_cols\":11,\"flagged_count\":0,\"u_px__r5c5\":-0.688174,\"v_px__r5c5\":0.509149,\"s2n__r5c5\":2.588528,\"...\":\"12 more\"},\"expected_count\":18,\"clipped\":[]}",
 "decision": "Verdict: sound.\nThe 32 px window fully resolves this synthetic vortex - every one of 121 vectors is valid and unreplaced with a 0.127 px truth error - though its labels sit 11 px off the window centres.\n- All 121 vectors across all nine regions are unflagged and unreplaced (M2, M9), ...\n...\n- Try: Test a smaller window_size such as 16 px, with a matched search_area_size, as a suggestion to test. (...)\n...\nNext steps:\n- ..."
}

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

{
  "lane": "script",
  "verdict": "sound",
  "headline": "The script reproduces the 121-vector field from frame_a.png and frame_b.png, checks all 18 expected values, then reruns with a 16 px window.",
  "tldr": ["The reproduction check covers the grid shape, the flagged count and u, v and s2n at five vectors.", "..."],
  "fixes": [
    {"fix": "Rerun extended_search_area_piv with window_size 16, overlap 8 and search_area_size 20 and print the flagged count.", "why": "The review suggested a finer window; the flagged count shows what it costs.", "refs": ""},
    {"fix": "Add local_median_val with a 2 px per frame threshold applied in px/s and print the flagged count.", "why": "A second, spatial validator before the real tunnel pairs.", "refs": ""},
    {"fix": "Save vectors.txt with tools.save.", "why": "The same file the page exports, for comparison.", "refs": ""}
  ],
  "script": "import json\nimport math\nfrom pathlib import Path\n\nimport numpy as np\n...",
  "assumptions": ["frame_a.png and frame_b.png are the frames the page generated.", "openpiv 0.25.4 is installed.", "dt 0.01 s and 20 px/mm are the experiment's own values."],
  "checks": ["The reproduction prints no MISMATCH line.", "How the flagged count moves with the 16 px window and with local_median_val."],
  "next_steps": ["Save both frames from the page next to piv_check.py and run python piv_check.py.", "..."],
  "prescan_responses": [
    {"ref": "F1", "verdict": "confirmed", "note": "A negligible share of saturated pixels; the script does not change it."},
    {"ref": "F2", "verdict": "confirmed", "note": "The script keeps openpiv's labels from get_coordinates."},
    {"ref": "F3", "verdict": "confirmed", "note": "One pair; the script does not build an ensemble."}
  ]
}
# piv_check.py - reproduce the browser's PIV field with openpiv 0.25.4 (abbreviated reply script)
import json
import math
from pathlib import Path

import numpy as np
from openpiv import filters, pyprocess, scaling, tools, validation

FRAME_A = "frame_a.png"
FRAME_B = "frame_b.png"
DT = 0.01
SCALING = 20

EXPECTED = {
    "n_rows": 11, "n_cols": 11, "flagged_count": 0,
    "u_px__r5c5": -0.688174, "v_px__r5c5": 0.509149, "s2n__r5c5": 2.588528,
    "u_px__r2c2": -1.574056, "v_px__r2c2": 1.717378, "s2n__r2c2": 3.148825,
    "u_px__r2c8": -1.934253, "v_px__r2c8": -1.476777, "s2n__r2c8": 3.224506,
    "u_px__r8c2": 1.528143, "v_px__r8c2": 1.970747, "s2n__r8c2": 4.264483,
    "u_px__r8c8": 2.015346, "v_px__r8c8": -1.944011, "s2n__r8c8": 4.319056,
}


def check(u, v, s2n, flags):
    bad = []
    if (EXPECTED["n_rows"], EXPECTED["n_cols"]) != u.shape:
        bad.append(f"shape {u.shape}")
    if abs(int(flags.sum()) - EXPECTED["flagged_count"]) > 1:
        bad.append(f"flagged_count {int(flags.sum())}")
    for key, want in EXPECTED.items():
        if "__r" not in key:
            continue
        name, tag = key.split("__")
        r, c = (int(t) for t in tag[1:].split("c"))
        if name == "s2n":
            got, ok = float(s2n[r, c]), math.isclose(float(s2n[r, c]), want, rel_tol=1e-3, abs_tol=1e-3)
        else:
            got = float((u if name == "u_px" else v)[r, c] * DT)
            ok = math.isclose(got, want, abs_tol=2e-3)
        if not ok:
            bad.append(f"{key}: got {got!r}, expected {want!r}")
    for b in bad:
        print("MISMATCH", b)
    print("reproduction:", "OK" if not bad else f"{len(bad)} mismatch(es)")


def main():
    for name in (FRAME_A, FRAME_B):
        if not Path(name).is_file():
            raise SystemExit(f"{name} not found: save both frames from the PIV Desk page first")
    frame_a = tools.imread(FRAME_A).astype(np.int32)
    frame_b = tools.imread(FRAME_B).astype(np.int32)

    u, v, s2n = pyprocess.extended_search_area_piv(
        frame_a, frame_b, window_size=32, overlap=12, dt=DT, search_area_size=38,
        correlation_method="linear", subpixel_method="gaussian", sig2noise_method="peak2peak", width=2)
    x, y = pyprocess.get_coordinates(image_size=frame_a.shape, search_area_size=38, overlap=12)
    flags = validation.sig2noise_val(s2n, threshold=1.05)
    check(u, v, s2n, flags)

    u, v = filters.replace_outliers(u, v, flags, method="localmean", max_iter=3, kernel_size=2)
    x, y, u, v = scaling.uniform(x, y, u, v, scaling_factor=SCALING)
    x, y, u, v = tools.transform_coordinates(x, y, u, v)

    # Fix 1: a finer window (a suggestion to test).
    u16, v16, s16 = pyprocess.extended_search_area_piv(
        frame_a, frame_b, window_size=16, overlap=8, dt=DT, search_area_size=20,
        correlation_method="linear", subpixel_method="gaussian", sig2noise_method="peak2peak", width=2)
    print("16 px window: flagged", int(validation.sig2noise_val(s16, threshold=1.05).sum()), "of", u16.size)
    # Fix 2 ... Fix 3: tools.save("vectors.txt", x, y, u, v, flags)


if __name__ == "__main__":
    main()

The reply's script must put all 18 keys of facts.expected into EXPECTED with the browser's values, call extended_search_area_piv once with the browser's literal settings, and check each value with math.isclose. Save the page's frame_a.png and frame_b.png next to the script and run it with python piv_check.py.

The output contract

The model returns one JSON object as the job's output text, with no prose or code fences around it. Every key of the lane's contract is present; empty sections are []. Strings are plain sentences under 500 characters (methods under 900, script under 9000).

Both lanes

{
  "lane": "review" | "script",
  "verdict": "sound" | "caveated" | "unreliable",
  "headline": "one sentence",
  "tldr": ["2-5 bullets; one starts \"Answer:\" when a question was asked"],
  ...lane body...,
  "next_steps": ["1-5 concrete actions, most useful first"],
  "prescan_responses": [{"ref": "F1", "verdict": "confirmed|dismissed", "note": "..."}]  // every flag once
}

review

{
  "metrics": [{"id": "M1", "reading": "..."}],            // one per facts.metrics item, same order
  "field": ["1-5 strings: where vectors failed or were replaced, largest displacements, vorticity, mean velocity"],
  "parameters": [{"change": "...", "why": "...", "refs": "F2"}],   // 0-4; refs "" when no flag
  "claims": [{"claim": "...", "support": "supported|partly|not_supported", "why": "..."}],
  "methods": "one methods-section paragraph stating exactly the processing in settings",
  "cautions": ["1-4 strings on what this field cannot show"]
}

parameters choose only from: a different window_size, overlap or search_area_size; linear correlation with an extended search area; a different sig2noise_threshold on the same method; adding local_median_val or global_val (thresholds stated in px per frame, applied in px/s); windef.simple_multipass; masking with preprocess.dynamic_masking. A new value is a suggestion to test.

script

{
  "fixes": [{"fix": "...", "why": "...", "refs": "F1"}],  // 1-6; refs "" for a plain step
  "script": "import json\nimport math\n...",              // one complete Python script, under 9000 characters
  "assumptions": ["1-4 strings"],
  "checks": ["1-4 strings"]
}

The script lane's script follows a fixed order: set FRAME_A and FRAME_B to settings.frame_a.file and settings.frame_b.file and check both exist; read each with tools.imread(...).astype(np.int32); set DT to settings.dt_s and SCALING to settings.scaling_px_per_unit; call pyprocess.extended_search_area_piv(..., window_size=..., overlap=..., dt=DT, search_area_size=..., correlation_method=..., subpixel_method=..., sig2noise_method=..., width=...) with literal values, then pyprocess.get_coordinates; flag with validation.sig2noise_val, OR-ed with global_val and local_median_val exactly when settings.validators lists them; define EXPECTED and check it (n_rows/n_cols against u.shape, flagged_count within 1, u_px__rRcC as u[R, C] * DT with abs_tol=2e-3, s2n__rRcC with rel_tol=1e-3, abs_tol=1e-3); then filters.replace_outliers, np.where(flags, np.nan, ...) if drop_invalid, scaling.uniform and tools.transform_coordinates; then the fixes. The fix list may also save vectors.txt with tools.save("vectors.txt", x, y, u, v, flags), print the vorticity, or print the flagged share per 3 x 3 region. In the script lane the verdict restates the decision the script follows, under the same rules.

Truncation and partial results

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