← Slide Run Planner for PathML / API
Tokens

Drive Slide Run Planner for PathML from your own code

Slide Run Planner for PathML checks a computational-pathology plan before any tiling. In the page, JavaScript ports of three command-line tools bundled with the pathml agent skill run on your inputs: slide_manifest.py validate (pseudonymous ids, unique slides and paths, no URLs, allowed splits, no patient in two splits), plan_pipeline.py (PathML 3.0.5 tile counts and uncompressed payload) and plan_inference.py (batch memory from numbers or a JSON model card). The same tools run on your machine (python scripts/plan_pipeline.py --width=98304 --height=77824 --tile-size=512 --stride=512), and the page's "Copy commands" button gives you the exact lines for your inputs.

The metered lanes read only counts, tool reports and the page's flags: review judges what blocks a bounded pilot and returns the pilot with PathML 3.0.5 code; methods drafts a methods paragraph and a 16-item provenance record. Neither ever sees a slide, a file name, a slide id or a patient id. Research use only: nothing here supports a diagnosis.

Two lanes: the task field

tasksendyou get back
reviewfacts; context and question optionalstatus (blocked, fix_before_pilot, ready_for_pilot), a response to every flag, findings (ref, area, severity, evidence, action), a pilot_plan (scope, steps, checks), pilot_code, open_questions and assumptions.
methodsfacts; context, question and plan_review optionalstatus (draft_complete, draft_with_gaps, cannot_draft), flag responses, methods_paragraph, provenance (16 items: item, value, source = facts | context | to_fill), limitations, open_questions and assumptions.

Worked example: review

The H&E example on the page: one patient has slides in train and test, one row has no split, one path is outside the project folder, and the model's input is 256 px while the tiles are 512 px.

{
 "task": "review",
 "facts": "{\"page\":\"pathml-desk\",\"skill\":\"@k-dense-ai/pathml (scripts run in the browser: slide_manifest.py validate, plan_pipeline.py, plan_inference.py)\",\"pathml_version_modelled\":\"3.0.5\",\"assumptions\":[\"The browser cannot open the slide files: every path was checked lexically only (URL, NUL byte, ~ expansion, normalisation, escape from --root, duplicates, suffix) and assumed to exist as a regular, non-symlink file within --max-slide-gib.\",\"Tile counts and payloads are the skill's dry-run arithmetic for PathML 3.0.5; they do not open a slide, measure compression or predict transform/model workspace memory.\",\"The inference plan reads numbers and a JSON model card only; no checkpoint was opened.\"],\"manifest\":{\"script\":\"slide_manifest.py\",\"exit\":2,\"error\":\"row 7: patient_id appears in multiple splits: 'train' and 'test'\",\"page_pass\":{\"rows\":12,\"columns\":[\"slide_id\",\"patient_id\",\"path\",\"split\",\"width\",\"height\"],\"has_split_column\":true,\"rows_without_split\":1,\"slides_by_split\":{\"train\":5,\"test\":4,\"validation\":2},\"patients_by_split_single\":{\"train\":3,\"validation\":2,\"test\":3},\"patients_in_multiple_splits\":1,\"problem_rows\":2,\"issues_by_kind\":{\"patient_multi_split\":1,\"path_escapes_root\":1},\"issues\":[{\"row\":7,\"kind\":\"patient_multi_split\"},{\"row\":13,\"kind\":\"path_escapes_root\"}],\"issues_omitted\":0,\"identifier_hints\":{\"mrn_like\":1},\"identifier_hint_rows\":[11],\"path_hints\":1,\"path_hint_rows\":[10]}},\"tiling\":{\"script\":\"plan_pipeline.py\",\"exit\":0,\"argv\":[\"--width=98304\",\"--height=77824\",\"--tile-size=512\",\"--stride=512\",\"--pipeline=BoxBlur, TissueDetectionHE, StainNormalizationHE\",\"--mpp-x=0.2527\",\"--mpp-y=0.2527\"],\"report\":{\"pathml_version_modelled\":\"3.0.5\",\"input_level0_shape_hw\":[77824,98304],\"level_downsample\":1,\"planned_level_shape_hw\":[77824,98304],\"tile_size_hw\":[512,512],\"stride_ij\":[512,512],\"pad\":false,\"tiles_ij\":[152,192],\"tile_count\":29184,\"overlap_pixels_per_axis\":0,\"gap_pixels_per_axis\":0,\"pipeline\":[{\"name\":\"BoxBlur\",\"kind\":\"image\"},{\"name\":\"TissueDetectionHE\",\"kind\":\"mask\"},{\"name\":\"StainNormalizationHE\",\"kind\":\"image\"}],\"expected_mask_outputs_per_tile\":1,\"expected_label_outputs_per_tile\":0,\"expected_count_outputs_per_tile\":0,\"input_image_bytes_per_tile\":786432,\"estimated_uncompressed_payload_gib\":28.5,\"physical_tile_size_um_yx\":[129.3824,129.3824],\"within_bounds\":true,\"warnings\":[],\"note\":\"This is a dry-run estimate. It does not open a slide, import PathML, measure compression, or predict transform/model workspace memory.\"}},\"per_slide\":{\"slides_with_size\":12,\"planned\":12,\"refused\":0,\"zero_tile_slides\":0,\"total_tiles\":350556,\"total_payload_gib\":342.339844,\"min_tiles\":23520,\"max_tiles\":36960,\"capped\":false},\"inference\":{\"script\":\"plan_inference.py\",\"exit\":0,\"argv\":[\"--tile-count=29184\",\"--batch-size=64\",\"--root=<project root>\",\"--model-card=model_card.json\",\"--max-memory-mib=8192\"],\"tile_count_from\":\"tiling\",\"report\":{\"model_card_used\":true,\"model_id\":\"hovernet-pannuke-fast\",\"artifact_sha256\":null,\"checkpoint_or_model_loaded\":false,\"input_shape_chw\":[3,256,256],\"dtype\":\"float32\",\"bytes_per_element\":4,\"tile_count\":29184,\"requested_batch_size\":64,\"number_of_batches\":456,\"final_batch_size\":64,\"input_bytes_per_tile\":786432,\"output_bytes_per_tile\":1310720,\"activation_bytes_per_tile_estimate\":18874368,\"persistent_bytes_estimate\":686870912,\"planned_peak_memory_mib\":1935.051147,\"memory_budget_mib\":8192,\"within_memory_budget\":true,\"recommended_max_batch_under_estimate\":376,\"estimated_total_output_gib\":35.625,\"warnings\":[\"Estimate excludes allocator fragmentation, framework caches, graph workspace, postprocessing, stitching, and concurrent workers.\"],\"note\":\"This planner reads numbers or strict JSON metadata only. It never opens model/checkpoint artifacts or imports PathML/Torch/ONNX.\"}},\"flags\":[{\"id\":\"F1\",\"kind\":\"manifest_refused\",\"severity\":\"high\",\"area\":\"manifest\",\"text\":\"slide_manifest.py refused the manifest: row 7: patient_id appears in multiple splits: 'train' and 'test'. The page's pass over every row found: 1 path outside --root.\"},{\"id\":\"F2\",\"kind\":\"leak_patient_split\",\"severity\":\"high\",\"area\":\"split\",\"text\":\"1 patient(s) have slides in more than one split. Tiles from one patient on both sides of the split make a held-out score optimistic.\"},{\"id\":\"F3\",\"kind\":\"rows_without_split\",\"severity\":\"medium\",\"area\":\"split\",\"text\":\"1 of 12 row(s) have no split value; the tool skips them in split_counts and in the isolation check.\"},{\"id\":\"F4\",\"kind\":\"identifier_hint\",\"severity\":\"medium\",\"area\":\"deidentification\",\"text\":\"Some slide/patient ids look like real identifiers (1 mrn-like). The tool checks syntax only; confirm they are pseudonyms.\"},{\"id\":\"F5\",\"kind\":\"path_hint\",\"severity\":\"medium\",\"area\":\"deidentification\",\"text\":\"1 path(s) contain a date-, record-number- or accession-like token. Keep direct identifiers out of file names.\"},{\"id\":\"F6\",\"kind\":\"no_checksum\",\"severity\":\"medium\",\"area\":\"provenance\",\"text\":\"The model card has no artifact_sha256; verify and record the checkpoint's checksum before loading it.\"},{\"id\":\"F7\",\"kind\":\"shape_mismatch\",\"severity\":\"medium\",\"area\":\"inference\",\"text\":\"The model expects 256 x 256 inputs but the tiles are 512 x 512: resize in a documented step or retile.\"}],\"browser_status\":\"blocked\"}",
 "context": "Twelve H&E whole-slide images scanned at 40x on two scanners. We want to train a tumour-region classifier and report a held-out test score, then run nuclear segmentation with a HoVer-Net checkpoint on one 8 GB GPU.",
 "question": "Is this plan safe to start a pilot on?"
}

The reply's status is blocked: a tool refusal and a patient-split leak can only be fixed, not argued away. Load the example on the page to see a saved reply in full, free.

Worked example: methods

The CODEX example: eight regions from seven donors, split by donor, tiled at 256 px with two collapsed channels, and a locally stored Mesmer ONNX export with a checksum in its model card.

{
 "task": "methods",
 "facts": "{\"page\":\"pathml-desk\",\"skill\":\"@k-dense-ai/pathml (scripts run in the browser: slide_manifest.py validate, plan_pipeline.py, plan_inference.py)\",\"pathml_version_modelled\":\"3.0.5\",\"assumptions\":[\"The browser cannot open the slide files: every path was checked lexically only (URL, NUL byte, ~ expansion, normalisation, escape from --root, duplicates, suffix) and assumed to exist as a regular, non-symlink file within --max-slide-gib.\",\"Tile counts and payloads are the skill's dry-run arithmetic for PathML 3.0.5; they do not open a slide, measure compression or predict transform/model workspace memory.\",\"The inference plan reads numbers and a JSON model card only; no checkpoint was opened.\"],\"manifest\":{\"script\":\"slide_manifest.py\",\"exit\":0,\"report\":{\"row_count\":8,\"patient_count\":7,\"split_counts\":{\"test\":2,\"train\":4,\"validation\":2},\"suffix_counts\":{\".ome.tiff\":2,\".qptiff\":6},\"checks\":{\"local_regular_files\":true,\"no_urls\":true,\"no_symlinks\":true,\"unique_slide_ids\":true,\"unique_slide_paths\":true,\"patient_split_isolation\":true}},\"page_pass\":{\"rows\":8,\"columns\":[\"slide_id\",\"patient_id\",\"path\",\"split\"],\"has_split_column\":true,\"rows_without_split\":0,\"slides_by_split\":{\"train\":4,\"validation\":2,\"test\":2},\"patients_by_split_single\":{\"train\":3,\"validation\":2,\"test\":2},\"patients_in_multiple_splits\":0,\"problem_rows\":0,\"issues_by_kind\":{},\"issues\":[],\"issues_omitted\":0,\"identifier_hints\":{},\"identifier_hint_rows\":[],\"path_hints\":0,\"path_hint_rows\":[]}},\"tiling\":{\"script\":\"plan_pipeline.py\",\"exit\":0,\"argv\":[\"--width=24576\",\"--height=20480\",\"--tile-size=256\",\"--stride=256\",\"--channels=2\",\"--bytes-per-channel=2\",\"--pipeline=CollapseRunsCODEX, RescaleIntensity\",\"--mpp-x=0.377\",\"--mpp-y=0.377\"],\"report\":{\"pathml_version_modelled\":\"3.0.5\",\"input_level0_shape_hw\":[20480,24576],\"level_downsample\":1,\"planned_level_shape_hw\":[20480,24576],\"tile_size_hw\":[256,256],\"stride_ij\":[256,256],\"pad\":false,\"tiles_ij\":[80,96],\"tile_count\":7680,\"overlap_pixels_per_axis\":0,\"gap_pixels_per_axis\":0,\"pipeline\":[{\"name\":\"CollapseRunsCODEX\",\"kind\":\"image\"},{\"name\":\"RescaleIntensity\",\"kind\":\"image\"}],\"expected_mask_outputs_per_tile\":0,\"expected_label_outputs_per_tile\":0,\"expected_count_outputs_per_tile\":0,\"input_image_bytes_per_tile\":262144,\"estimated_uncompressed_payload_gib\":1.875,\"physical_tile_size_um_yx\":[96.512,96.512],\"within_bounds\":true,\"warnings\":[],\"note\":\"This is a dry-run estimate. It does not open a slide, import PathML, measure compression, or predict transform/model workspace memory.\"}},\"inference\":{\"script\":\"plan_inference.py\",\"exit\":0,\"argv\":[\"--tile-count=7680\",\"--batch-size=16\",\"--root=<project root>\",\"--model-card=model_card.json\",\"--max-memory-mib=6144\"],\"tile_count_from\":\"tiling\",\"report\":{\"model_card_used\":true,\"model_id\":\"mesmer-local-onnx\",\"artifact_sha256\":\"3f1c9a7be2d04c5e8a61b0f7d9e2c4a1b5f6e7d8c9a0b1c2d3e4f5a6b7c8d9e0\",\"checkpoint_or_model_loaded\":false,\"input_shape_chw\":[2,256,256],\"dtype\":\"float32\",\"bytes_per_element\":4,\"tile_count\":7680,\"requested_batch_size\":16,\"number_of_batches\":480,\"final_batch_size\":16,\"input_bytes_per_tile\":524288,\"output_bytes_per_tile\":524288,\"activation_bytes_per_tile_estimate\":8388608,\"persistent_bytes_estimate\":831306368,\"planned_peak_memory_mib\":936.795532,\"memory_budget_mib\":6144,\"within_memory_budget\":true,\"recommended_max_batch_under_estimate\":594,\"estimated_total_output_gib\":3.75,\"warnings\":[\"Estimate excludes allocator fragmentation, framework caches, graph workspace, postprocessing, stitching, and concurrent workers.\"],\"note\":\"This planner reads numbers or strict JSON metadata only. It never opens model/checkpoint artifacts or imports PathML/Torch/ONNX.\"}},\"flags\":[{\"id\":\"F1\",\"kind\":\"bioformats_backend\",\"severity\":\"low\",\"area\":\"tiling\",\"text\":\"8 slide(s) need the Bio-Formats backend (Java) rather than OpenSlide.\"},{\"id\":\"F2\",\"kind\":\"no_mask_stage\",\"severity\":\"low\",\"area\":\"tiling\",\"text\":\"No mask stage (such as TissueDetectionHE or BinaryThreshold): background tiles are processed like tissue, and no QC mask is recorded.\"}],\"browser_status\":\"pilot_ready\"}",
 "context": "Eight CODEX regions from seven donors, nuclear and membrane channels collapsed to two. Whole-cell segmentation with a locally stored Mesmer ONNX export, then per-cell quantification.",
 "question": "Keep it to one paragraph a reviewer can check."
}

Input fields

Every field is a string; facts is JSON text.

fieldtyperequiredmeaning
taskstringyes"review" or "methods".
factsstringyesThe tool reports, counts and flags, as JSON text (below).
contextstringnoThe cohort, the goal and the hardware, without patient details. Sent as written, up to about 4,000 characters.
questionstringnoA question to answer in the reply.
plan_reviewstringnoMethods lane: an earlier plan review as text (the page fills it from the review's status, findings and pilot).
retry_notestringnoOnly on a reformat retry.

The facts string

assumptions (what the browser could not check); manifest (script, exit, error with file names replaced by '<name>', report when exit is 0 with row_count, patient_count, split_counts, suffix_counts and checks, and page_pass: rows, columns, has_split_column, rows_without_split, slides_by_split, patients_by_split_single, patients_in_multiple_splits, problem_rows, issues_by_kind, issues as row numbers and kinds, identifier_hints and identifier_hint_rows, path_hints and path_hint_rows); tiling (argv, exit, error or the full plan_pipeline.py report); per_slide when the manifest has width and height (slides_with_size, planned, refused, zero_tile_slides, total_tiles, total_payload_gib, min_tiles, max_tiles, capped); inference (argv with the root replaced, tile_count_from, exit, error or the full plan_inference.py report); flags (id, kind, severity, area, text); and browser_status (blocked, fix_first, pilot_ready). Build it with the skill's own scripts, or copy "Download .json" from a page run (it includes facts_sent).

The output

One JSON object, serialised as a string at data.output.output. Keys in both lanes: task, status, headline, flag_responses (ref, stance = confirmed | explained | dismissed | needs_owner, note), open_questions, assumptions. Lane keys are listed in the table above.

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":"pathml-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="pathml-desk"
TOKEN="$SKILLSAFE_TOKEN"   # from https://pathml-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":"pathml-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://pathml-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":"pathml-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. hold_credits is a reservation, not the price. min_credits is the least balance that can start a run. What you pay is charged_credits, reported on the finished job, usually far lower. The body is the input object itself, with no {"input": ...} wrapper. /estimate does no input validation, so check the shape yourself: an object whose every value is a string, task equal to review or methods, and a facts that is a JSON string parsing to an object.

# body.json is the input object itself - no {"input": ...} wrapper. 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", "methods")
assert all(isinstance(v, str) for v in b.values())
assert isinstance(json.loads(b["facts"]), dict)
'
INPUT=$(cat body.json)

call estimate "$INPUT"
# {"ok":true,"data":{"model":"...","model_alias":"gpt-terra",
#   "markup_bps":...,"hold_credits":...,"min_credits":...,"sponsor_enabled":false}}
# hold_credits is RESERVED, not the price; charged_credits after the run is the cost.

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, pathml-desk:<lane>:<hash>:a<attempt>, so a retried request returns the same job instead of billing a second run. Use one key per distinct input: edited facts, context, plan_review or question are a new hash, and replaying an old key with a different body is a 409. Any stable digest of the body works. 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 methods
KEY="pathml-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":"{\"task\":\"review\",\"status\":\"blocked\",\"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":"{\"task\":\"review\",\"status\":\"blocked\",\"headline\":\"The"}
# event: done   {"status":"succeeded","charged_credits":...,"truncated":false}

7. Parse the reply

The reply is one JSON object serialised as a string. Parse it and check that task is the lane you asked for. Read pilot_code before running it, on de-identified local copies only.

# The reply is a JSON string inside data.output.output (saved as reply.json in step 5):
python3 -c 'import json;r=json.load(open("reply.json"));print(r["task"],r["status"],r["headline"])'
python3 -c '
import json
r = json.load(open("reply.json"))
if r["task"] == "review":
    for f in r["findings"]: print("-", f["severity"], f["area"], f["action"])
    open("pilot.py", "w").write(r["pilot_code"])   # read it before running it
else:
    for p in r["provenance"]: print(p["item"], "|", p["value"], "|", p["source"])
'

Costs

Invariants worth asserting