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
| task | what you get | extra input |
|---|---|---|
review | A 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 |
script | The 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.
| field | required | meaning |
|---|---|---|
task | yes | review or script. These are the only two lanes. |
facts | yes | A 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. |
title | no | A label for the analysis, up to 160 characters. |
context | no | Your notes: the experiment, the flow, the camera, where dt and the calibration come from, what you want to conclude. Up to 2,500 characters. |
question | no | Answered in tldr as a bullet starting "Answer:". Up to 1,500 characters. Sent in either lane when not empty. |
decision | script only | Plain 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_note | no | Only 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
| status | code | what to do |
|---|---|---|
| 400 | validation_error | A field is missing or the wrong type. Every field is a string: facts must be a JSON-encoded string, not an object. |
| 401 | unauthorized | The token is missing, malformed or expired. Get a new one from the token page. |
| 402 | payment_required | The balance is below min_credits. Call /estimate first and top up. |
| 403 | forbidden | The 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. |
| 404 | not_found | Unknown job id, or the app slug does not exist. |
| 409 | conflict | The same Idempotency-Key was replayed with a different body. Change the key or send the original input. |
| 429 | rate_limited | Too many requests. Back off and retry; do not tight-loop. |
| 5xx | internal | A 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
}
import json, os, urllib.error, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "piv-desk"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://piv-desk.skillsafe.ai/tokens.html
def call(path, body=None):
"""Returns the unwrapped `data`, or raises with the API error code."""
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"]
import { readFileSync } from "node:fs";
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "piv-desk";
// Paste the token from https://piv-desk.skillsafe.ai/tokens.html into a file named "token",
// or replace the fallback with it.
let TOKEN = "YOUR_TOKEN";
try { TOKEN = readFileSync("token", "utf8").trim(); } catch {}
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"
slug = "piv-desk"
)
var token = os.Getenv("SKILLSAFE_TOKEN") // from https://piv-desk.skillsafe.ai/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"`
}
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 PivDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String SLUG = "piv-desk";
static final String TOKEN = System.getenv().getOrDefault("SKILLSAFE_TOKEN", "YOUR_TOKEN");
static final HttpClient HTTP = HttpClient.newHttpClient();
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();
}
}
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "piv-desk"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://piv-desk.skillsafe.ai/tokens.html
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";
const SLUG = "piv-desk";
define("TOKEN", getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN"); // from /tokens.html
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.Json;
static class PivDesk
{
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Slug = "piv-desk";
static readonly string Token =
Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN";
static readonly HttpClient Http = new();
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");
}
}
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"}}
# Open https://piv-desk.skillsafe.ai/tokens.html and press "Copy token",
# or mint a guest token here. A guest token can call /me and /estimate but
# cannot start a metered run.
import json, urllib.request
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/guest", data=b'{"slug": "piv-desk"}', method="POST")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r:
TOKEN = json.load(r)["data"]["token"]
// Open https://piv-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
const res = await fetch("https://api.skillsafe.ai/v1/app-api/guest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "piv-desk" }),
});
const TOKEN = (await res.json()).data.token;
// Open https://piv-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
guestReq, _ := http.NewRequest(http.MethodPost,
"https://api.skillsafe.ai/v1/app-api/guest", bytes.NewReader([]byte(`{"slug":"piv-desk"}`)))
guestReq.Header.Set("Content-Type", "application/json")
guestRes, err := http.DefaultClient.Do(guestReq)
if err != nil {
panic(err)
}
defer guestRes.Body.Close()
var guest struct {
Data struct {
Token string `json:"token"`
} `json:"data"`
}
_ = json.NewDecoder(guestRes.Body).Decode(&guest)
fmt.Println(guest.Data.Token)
// Open https://piv-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
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\":\"piv-desk\"}"))
.build();
HttpResponse<String> guest = http.send(guestReq, HttpResponse.BodyHandlers.ofString());
System.out.println(guest.body()); // {"ok":true,"data":{"token":"...","subject_type":"guest"}}
# Open https://piv-desk.skillsafe.ai/tokens.html and press "Copy token",
# or mint a guest token here. A guest token can call /me and /estimate but
# cannot start a metered run.
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: "piv-desk" })
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
TOKEN = JSON.parse(res.body)["data"]["token"]
<?php
// Open https://piv-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
$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" => "piv-desk"]));
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json"]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$guest = json_decode(curl_exec($ch), true);
curl_close($ch);
echo $guest["data"]["token"];
// Open https://piv-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
using var http = new HttpClient();
var guestReq = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/guest");
guestReq.Content = new StringContent("{\"slug\":\"piv-desk\"}", Encoding.UTF8, "application/json");
var guestRes = await http.SendAsync(guestReq);
var guest = await guestRes.Content.ReadFromJsonAsync<JsonElement>();
Console.WriteLine(guest.GetProperty("data").GetProperty("token").GetString());
3. Check the session and the balance
call me
# {"ok":true,"data":{"subject_type":"user","username":"you","credits":51234}}
me = call("me")
print(me["subject_type"], me.get("credits"))
const me = await call("me");
console.log(me.subject_type, me.credits);
raw, err := call("me", nil)
if err != nil {
panic(err)
}
var me struct {
SubjectType string `json:"subject_type"`
Credits int `json:"credits"`
}
_ = json.Unmarshal(raw, &me)
fmt.Println(me.SubjectType, me.Credits)
System.out.println(call("me", null));
// {"ok":true,"data":{"subject_type":"user","username":"you","credits":51234}}
me = call("me")
puts "#{me['subject_type']} #{me['credits']}"
<?php
$me = call("me");
echo $me["subject_type"], " ", $me["credits"], PHP_EOL;
var me = await PivDesk.Call("me");
Console.WriteLine(me.GetProperty("subject_type").GetString());
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.
INPUT = json.load(open("body.json")) # built by make-body.js above, or by hand
assert isinstance(INPUT, dict) and INPUT.get("task") in ("review", "script")
assert all(isinstance(v, str) for v in INPUT.values())
assert all(INPUT.get(k, "").strip() for k in ("facts",))
assert isinstance(json.loads(INPUT["facts"]), dict) # facts is a JSON STRING
est = call("estimate", INPUT)
print(est["model_alias"], est["markup_bps"], est["hold_credits"], est.get("warnings"))
me = call("me")
if me.get("credits", 0) < est["min_credits"]:
raise SystemExit("top up first: balance is below min_credits")
const INPUT = JSON.parse(readFileSync("body.json", "utf8")); // built by make-body.js above
if (!INPUT || typeof INPUT !== "object" || !["review", "script"].includes(INPUT.task)) throw new Error("task must be review or script");
for (const [k, v] of Object.entries(INPUT)) if (typeof v !== "string") throw new Error(k + " must be a string");
for (const k of ["facts"]) if (!(INPUT[k] || "").trim()) throw new Error(k + " is required");
JSON.parse(INPUT.facts); // throws unless facts is a JSON string
const est = await call("estimate", INPUT);
console.log(est.model_alias, est.markup_bps, est.hold_credits, est.warnings);
const me = await call("me");
if ((me.credits ?? 0) < est.min_credits) throw new Error("top up first");
raw, _ := os.ReadFile("body.json") // built by make-body.js above
var input map[string]string // every field is a string, facts included
if err := json.Unmarshal(raw, &input); err != nil {
panic("body.json must be an object of strings: " + err.Error())
}
if input["task"] != "review" && input["task"] != "script" {
panic("task must be review or script")
}
for _, k := range []string{"facts"} {
if strings.TrimSpace(input[k]) == "" {
panic(k + " is required")
}
}
var facts map[string]any
if err := json.Unmarshal([]byte(input["facts"]), &facts); err != nil {
panic("facts must be a JSON string holding an object")
}
est, err := call("estimate", input)
if err != nil {
panic(err)
}
fmt.Println(string(est)) // model_alias gpt-terra, markup_bps 1000, hold_credits, min_credits
String input = Files.readString(Path.of("body.json")); // built by make-body.js above
if (!input.matches("(?s)\\s*\\{.*\"task\"\\s*:\\s*\"(review|script)\".*\\}\\s*"))
throw new IllegalStateException("body.json must be an object with task review or script");
String lane = input.replaceAll("(?s).*\"task\"\\s*:\\s*\"(review|script)\".*", "$1");
for (String k : new String[] {"facts"})
if (!input.contains("\"" + k + "\"")) throw new IllegalStateException(k + " is required");
String est = call("estimate", input);
System.out.println(est); // model_alias gpt-terra, markup_bps 1000, hold_credits, min_credits
INPUT = JSON.parse(File.read("body.json")) # built by make-body.js above
raise "task must be review or script" unless %w[review script].include?(INPUT["task"])
INPUT.each { |k, v| raise "#{k} must be a string" unless v.is_a?(String) }
%w[facts].each { |k| raise "#{k} is required" if INPUT[k].to_s.strip.empty? }
raise "facts must hold an object" unless JSON.parse(INPUT["facts"]).is_a?(Hash)
est = call("estimate", INPUT)
puts est["model_alias"], est["markup_bps"], est["hold_credits"]
<?php
$input = json_decode(file_get_contents("body.json"), true); // built by make-body.js above
if (!is_array($input) || !in_array($input["task"] ?? "", ["review", "script"], true)) { throw new Exception("task must be review or script"); }
foreach ($input as $k => $v) { if (!is_string($v)) { throw new Exception("$k must be a string"); } }
foreach (["facts"] as $k) { if (trim($input[$k] ?? "") === "") { throw new Exception("$k is required"); } }
if (!is_array(json_decode($input["facts"], true))) { throw new Exception("facts must be a JSON string"); }
$est = call("estimate", $input);
echo $est["model_alias"], " ", $est["markup_bps"], " ", $est["hold_credits"], PHP_EOL;
var input = File.ReadAllText("body.json"); // built by make-body.js above
using var doc = JsonDocument.Parse(input);
var root = doc.RootElement;
var lane = root.GetProperty("task").GetString();
if (lane != "review" && lane != "script") throw new Exception("task must be review or script");
foreach (var p in root.EnumerateObject())
if (p.Value.ValueKind != JsonValueKind.String) throw new Exception($"{p.Name} must be a string");
JsonDocument.Parse(root.GetProperty("facts").GetString()!); // facts is a JSON string
var est = await PivDesk.Call("estimate", root);
Console.WriteLine(est); // model_alias gpt-terra, markup_bps 1000, hold_credits, min_credits
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
import hashlib, time
digest = hashlib.sha256(json.dumps(INPUT, sort_keys=True).encode()).hexdigest()[:16]
key = f"piv-desk:{INPUT['task']}:{digest}:a1"
req = urllib.request.Request(f"{BASE}/run", 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)
with urllib.request.urlopen(req) as r:
job_id = json.load(r)["data"]["job_id"]
while True:
job = call(f"jobs/{job_id}")
if job["status"] in ("succeeded", "failed"):
break
time.sleep(2)
if job["status"] == "failed":
raise RuntimeError(job.get("error"))
text = job["output"]["output"] # the reply, as a string
print("charged", job.get("charged_credits"), "truncated", job.get("truncated"))
import { createHash } from "node:crypto";
const digest = createHash("sha256").update(JSON.stringify(INPUT)).digest("hex").slice(0, 16);
const key = `piv-desk:${INPUT.task}:${digest}:a1`;
const started = await fetch(`${BASE}/run`, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", "Idempotency-Key": key },
body: JSON.stringify(INPUT),
}).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, 2000));
job = await call(`jobs/${job.job_id}`);
}
if (job.status === "failed") throw new Error(JSON.stringify(job.error));
const text = job.output.output; // the reply, as a string
console.log(job.charged_credits, job.truncated);
body, _ := json.Marshal(input)
sum := sha256.Sum256(body)
key := fmt.Sprintf("piv-desk:%s:%x:a1", input["task"], sum[:8])
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)
}
var started struct {
Data struct {
JobID string `json:"job_id"`
} `json:"data"`
}
_ = json.NewDecoder(res.Body).Decode(&started)
res.Body.Close()
var jobOutput string
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"`
Charged int `json:"charged_credits"`
Truncated bool `json:"truncated"`
}
_ = json.Unmarshal(raw, &job)
if job.Status == "succeeded" {
jobOutput = job.Output.Output
fmt.Println(job.Charged, job.Truncated)
break
}
if job.Status == "failed" {
panic(string(raw))
}
time.Sleep(2 * time.Second)
}
String key = "piv-desk:" + lane + ":" + sha256Hex(input).substring(0, 16) + ":a1";
HttpRequest run = 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(run, HttpResponse.BodyHandlers.ofString()).body();
String jobId = started.replaceAll(".*\"job_id\":\"([^\"]+)\".*", "$1");
while (true) {
String job = call("jobs/" + jobId, null);
if (job.contains("\"status\":\"succeeded\"")) { System.out.println(job); break; }
if (job.contains("\"status\":\"failed\"")) throw new RuntimeException(job);
Thread.sleep(2000);
}
// Parse data.output.output (a string holding the reply JSON) with your JSON library.
// sha256Hex: HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(input.getBytes(UTF_8)))
require "digest"
key = "piv-desk:#{INPUT['task']}:#{Digest::SHA256.hexdigest(JSON.generate(INPUT))[0, 16]}: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)
job = JSON.parse(Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }.body)["data"]
until %w[succeeded failed].include?(job["status"])
sleep 2
job = call("jobs/#{job['job_id']}")
end
raise job.inspect if job["status"] == "failed"
text = job["output"]["output"] # the reply, as a string
puts job["charged_credits"], job["truncated"]
<?php
$key = "piv-desk:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$ch = curl_init(BASE . "/run");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($input),
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . TOKEN, "Content-Type: application/json", "Idempotency-Key: " . $key],
CURLOPT_RETURNTRANSFER => true,
]);
$job = json_decode(curl_exec($ch), true)["data"];
curl_close($ch);
while (!in_array($job["status"], ["succeeded", "failed"], true)) {
sleep(2);
$job = call("jobs/" . $job["job_id"]);
}
if ($job["status"] === "failed") { throw new RuntimeException(json_encode($job)); }
$text = $job["output"]["output"]; // the reply, as a string
echo $job["charged_credits"], PHP_EOL;
using System.Security.Cryptography;
var json = input; // the body.json text from step 4
var key = $"piv-desk:{lane}:" + Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(json)))[..16].ToLower() + ":a1";
var req = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run");
req.Headers.Add("Authorization", $"Bearer {Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN"}");
req.Headers.Add("Idempotency-Key", key);
req.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
var started = await (await new HttpClient().SendAsync(req)).Content.ReadFromJsonAsync<JsonElement>();
var jobId = started.GetProperty("data").GetProperty("job_id").GetString();
JsonElement job;
while (true)
{
job = await PivDesk.Call($"jobs/{jobId}");
var status = job.GetProperty("status").GetString();
if (status == "succeeded") break;
if (status == "failed") throw new Exception(job.ToString());
await Task.Delay(2000);
}
var output = job.GetProperty("output").GetProperty("output").GetString()!; // the reply, as a string
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}
req = urllib.request.Request(f"{BASE}/run-stream", data=json.dumps(INPUT).encode(), method="POST")
for h, v in (("Authorization", f"Bearer {TOKEN}"), ("Content-Type", "application/json"),
("Idempotency-Key", key), ("Accept", "text/event-stream")):
req.add_header(h, v)
raw, done, event = "", {}, None
with urllib.request.urlopen(req) as stream:
for line in stream:
line = line.decode().rstrip("\n")
if line.startswith("event: "):
event = line[7:]
elif line.startswith("data: ") and event == "delta":
raw += json.loads(line[6:]).get("text", "")
elif line.startswith("data: ") and event == "done":
done = json.loads(line[6:])
text = (done.get("output") or {}).get("output") or raw
print(done.get("status"), done.get("charged_credits"), done.get("truncated"))
const res = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", "Idempotency-Key": key, Accept: "text/event-stream" },
body: JSON.stringify(INPUT),
});
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "", raw = "", event = null, done = null;
for (;;) {
const { value, done: end } = await reader.read();
if (end) break;
buf += dec.decode(value, { stream: true });
let i;
while ((i = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, i); buf = buf.slice(i + 1);
if (line.startsWith("event: ")) event = line.slice(7);
else if (line.startsWith("data: ") && event === "delta") raw += JSON.parse(line.slice(6)).text || "";
else if (line.startsWith("data: ") && event === "done") done = JSON.parse(line.slice(6));
}
}
const streamed = done?.output?.output || raw; // browsers may get only ticks + done
console.log(done, streamed.length);
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, err = http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
var raw strings.Builder
event := ""
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 1<<20), 1<<20)
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event: "):
event = line[7:]
case strings.HasPrefix(line, "data: ") && event == "delta":
var d struct{ Text string `json:"text"` }
_ = json.Unmarshal([]byte(line[6:]), &d)
raw.WriteString(d.Text)
case strings.HasPrefix(line, "data: ") && event == "done":
fmt.Println("done:", line[6:])
}
}
HttpRequest 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();
HTTP.send(stream, HttpResponse.BodyHandlers.ofLines()).body().forEach(line -> {
// "event: delta" lines are followed by "data: {\"text\":...}"; "event: done" by the status.
if (line.startsWith("data: ")) System.out.println(line.substring(6));
});
uri = URI("#{BASE}/run-stream")
req = Net::HTTP::Post.new(uri)
{ "Authorization" => "Bearer #{TOKEN}", "Content-Type" => "application/json",
"Idempotency-Key" => key, "Accept" => "text/event-stream" }.each { |k, v| req[k] = v }
req.body = JSON.generate(INPUT)
raw, event = +"", nil
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |h|
h.request(req) do |res|
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" then raw << JSON.parse(line[6..])["text"].to_s
elsif line.start_with?("data: ") && event == "done" then puts line[6..]
end
end
end
end
end
<?php
$raw = ""; $event = null;
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($input),
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . TOKEN, "Content-Type: application/json", "Idempotency-Key: " . $key, "Accept: text/event-stream"],
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;
}
return strlen($chunk);
},
]);
curl_exec($ch);
curl_close($ch);
var sreq = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run-stream");
sreq.Headers.Add("Authorization", $"Bearer {Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN"}");
sreq.Headers.Add("Idempotency-Key", key);
sreq.Headers.Add("Accept", "text/event-stream");
sreq.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
using var sres = await new HttpClient().SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var sr = new StreamReader(await sres.Content.ReadAsStreamAsync());
var raw = new System.Text.StringBuilder(); string? ev = null, line;
while ((line = await sr.ReadLineAsync()) != null)
{
if (line.StartsWith("event: ")) ev = line[7..];
else if (line.StartsWith("data: ") && ev == "delta") raw.Append(JsonSerializer.Deserialize<JsonElement>(line[6..]).GetProperty("text").GetString());
else if (line.StartsWith("data: ") && ev == "done") Console.WriteLine(line[6..]);
}
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"])'
reply = json.loads(job["output"]["output"])
assert reply["lane"] == INPUT["task"], "the model answered as another lane"
print(reply["verdict"], reply["headline"])
for m in reply.get("metrics", []): # review lane
print(m["id"], m["reading"])
open("piv_check.py", "w").write(reply.get("script", "")) # script lane
const reply = JSON.parse(job.output.output);
if (reply.lane !== INPUT.task) throw new Error("the model answered as another lane");
console.log(reply.verdict, reply.headline);
for (const m of reply.metrics || []) console.log(m.id, m.reading); // review lane
if (reply.script) require("fs").writeFileSync("piv_check.py", reply.script); // script lane
var reply struct {
Lane, Verdict, Headline, Script string
Metrics []struct{ Id, Reading string }
}
if err := json.Unmarshal([]byte(job.Output.Output), &reply); err != nil { panic(err) }
fmt.Println(reply.Verdict, reply.Headline)
// With any JSON library (Jackson shown): the reply is a string that holds a JSON object.
JsonNode reply = new ObjectMapper().readTree(outputString);
System.out.println(reply.get("verdict").asText() + " " + reply.get("headline").asText());
reply = JSON.parse(job["output"]["output"])
raise "the model answered as another lane" unless reply["lane"] == INPUT["task"]
puts [reply["verdict"], reply["headline"]].join(" ")
$reply = json_decode($job["output"]["output"], true);
if ($reply["lane"] !== $input["task"]) { throw new Exception("the model answered as another lane"); }
echo $reply["verdict"], " ", $reply["headline"], "\n";
var reply = JsonSerializer.Deserialize<JsonElement>(outputString);
Console.WriteLine($"{reply.GetProperty("verdict")} {reply.GetProperty("headline")}");
Invariants worth asserting
- The verdict is never looser than
facts.browser_verdict(unreliable < caveated < sound) unless the medium or high flags that set it were dismissed. Low flags never set the verdict on their own. - Every flag id appears exactly once in
prescan_responses, and no answer names a flag that was never raised. Arefsinparametersorfixesnames a browser flag (F2), never a metric id, or is"". - In the review lane there is one reading per metric id (
M1.. in the order offacts.metrics),fieldis not empty, andmethodsstates the window size, overlap, search area and signal-to-noise threshold the browser used, the correlation method actually used and openpiv 0.25.4. - Every number in the prose exists in
factsor your notes (after rounding to 3 significant figures; afractionmay be written as a percentage). Only a recommended new setting inparametersorfixesis exempt. Pixel displacements (px per frame) are never called velocities; velocities areunit/s, vorticity 1/s. - The prose speaks of the measured field ("the measured field shows"), not of the flow's physics: no Reynolds numbers, turbulence intensity, flow rates or forces unless your notes state them. A synthetic pair is never presented as an experiment.
- In the script lane
FRAME_AandFRAME_Baresettings.frame_a.fileandsettings.frame_b.file, the frames are read withtools.imread(...).astype(np.int32), oneextended_search_area_pivcall carries every browser setting as a literal (anddt),get_coordinates,sig2noise_val(plus every other validator insettings.validators) andreplace_outliersare called,EXPECTEDcarries every key offacts.expectedwith its value and is checked withmath.isclose, imports come only fromnumpy,openpiv(tools,pyprocess,validation,filters,scaling,windef,preprocess),math,jsonandpathlib, nothing touches pickle, the network orplt.show(), and the work sits under a__main__guard. - The page's
recon.jschecks all of this; you can run it in Node the same way aspivkit.js(Recon.reconcile(Recon.normalize(Recon.parseResult(text), task), {input: body})). For a synthetic pair the page's frame_a.png + frame_b.png button saves the exact frames the script expects; for your own pair, keep the file names you dropped.
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.