← Geo Desk /API
Your token

The Geo Desk API

Geo Desk takes one finished earth-observation or land-cover mapping study and decides whether its accuracy claim is defensible. Everything the page does, a script can do. Base URL: https://api.skillsafe.ai/v1/app-api.

You paste three blocks — a layer manifest, a validation confusion matrix, and the methods and results prose. A free read runs entirely in the browser first: 22 named checks over coordinate reference systems and their units, pixel-grid alignment, resampling ratios, extent coverage, nodata, matrix self-consistency, sampling design, spatial validation and autocorrelation leakage, per-class sample allocation, the recomputed overall accuracy and kappa, the no-information rate, the area-weighted Olofsson estimator, its confidence interval, and whether the reported metrics fit the kind of product at all. Then three metered lanes work the study.

Geo Desk is a derived work crediting two SkillSafe skills: @k-dense-ai/geopandas (coordinate reference systems, geometry, areas, overlays) and @k-dense-ai/geomaster (raster grids, resampling, spatial statistics, validation design). It is not a republication of either.

The envelope

Every response is one of two shapes. Success carries data; failure carries error. There is no ok flag to read — test for the presence of error.

{"data": { ... }}
{"error": {"code": "VALIDATION_ERROR", "message": "matrix: no readable class labels"}}

A job that runs to a terminal failed state is still a data response: the failure is in data.status, not in error. Only transport-level and billing-level problems produce an error object.

Error codes

CodeHTTPWhat it means
UNAUTHORIZED401No token, a malformed token, an expired guest token, or a token minted for a different app.
INSUFFICIENT_CREDITS402The balance is below min_credits for this run. /estimate is free, so a client that estimates first never sees this.
VALIDATION_ERROR400The body is not the shape the endpoint wants: a missing task, an empty layers or matrix, or a body wrapped in an input key.
NOT_FOUND404No such app, job or record. A job_id from another subject reads as 404, not 403.
RATE_LIMITED429Too many requests. Back off; never tight-loop a poll.
INTERNAL500A platform fault. Retry with the same Idempotency-Key — the key is what stops a retry double-billing.

1. The task field comes first

Geo Desk is one app with one system prompt and three lanes. Every request must carry a task. It is the router: it selects the lane, it decides which section names come back in body, and it is part of the idempotency key, because two lanes over one study are two distinct runs that must never collide.

taskWhat it answersSections it returns in body
alignCan the numbers even be extracted from these layers? Coordinate reference systems and their units, pixel-grid alignment, extent coverage, what has to be resampled onto what, and where nodata will silently become a class.crs_review, grid_review, coverage_review, resampling_plan, nodata_plan, ready_to_extract
validateCan this validation design carry the claim? The sampling design, spatial autocorrelation leakage between training and validation units, per-class allocation, unit independence, and what a redesign would cost.design_review, leakage_review, allocation_review, independence_review, redesign_plan
reportIs the number that is reported the right number, reported the right way? The area-weighted Olofsson estimator against the plain sample estimate, the interval, the no-information rate, whether the metric fits the product, and a methods paragraph you can paste.estimator_review, accuracy_statement, area_statement, metric_fit, limitations, drafted_methods

An absent or unrecognised task does not error: the model picks the closest lane and names the lane it chose in lane and in headline. Do not rely on that — send the field.

One worked body per lane

All three bodies below carry the same study, because that is the whole point: one set of three pasted blocks, three questions. Only task and the optional hints change.

The input object IS the POST body. Do not wrap it in an input key. A wrapped body answers 200 with an entirely plausible price hold, and then the model never sees a single field: the run bills against a payload the prompt cannot read. Nothing about the response tells you this happened. Send the flat object.

task: "align" — is a 10 m UTM classification and a degree-gridded SRTM DEM the same map?

{
  "task": "align",
  "layers": "layer: lc_2024_classified\nkind: raster\ncrs: EPSG:32633\npixel_size: 10\nextent: 499980, 7190200, 609780, 7300000\nbands: 1\ndtype: uint8\nnodata: 255\nrole: target\n\n[... four more layer blocks, in full in step 7 ...]",
  "matrix": "orientation: rows = map\nmap_class, forest, cropland, water, built, area_ha\nforest,      101,       12,     3,     4,   41820\ncropland,      9,      106,     2,     3,   26310\n[... two more class rows ...]",
  "methods": "The 2024 land-cover map was classified from a Sentinel-2 summer composite and an\nSRTM-derived slope layer with a random forest at 10 m.\nValidation used 480 points, 120 per class, interpreted from 0.5 m aerial imagery.\nOverall accuracy is 90.6% and kappa is 0.875.\n[... two more sentences ...]",
  "product": "categorical",
  "design": "",
  "claim": "The layer stack is ready for a per-pixel accuracy extraction.",
  "context": "The DEM is the only layer I did not reproject myself.",
  "prescan_facts": <the object the free read computes -- see below>
}

Returns the six align sections. Here it lands on the DEM: EPSG:4326 with a pixel size of 0.000277778 is about 31 m north-south and about 13 m east-west at 65 degrees north, so a single nearest-neighbour warp to the 10 m UTM grid upsamples it three-fold in one axis and nine-fold in the other, and nodata: -32768 on an int16 band becomes a real slope value the moment it is resampled with anything but nearest neighbour.

task: "validate" — can 120 points per class carry the claim?

{
  "task": "validate",
  "layers": "layer: lc_2024_classified\nkind: raster\ncrs: EPSG:32633\npixel_size: 10\nextent: 499980, 7190200, 609780, 7300000\nbands: 1\ndtype: uint8\nnodata: 255\nrole: target\n\n[... four more layer blocks, in full in step 7 ...]",
  "matrix": "orientation: rows = map\nmap_class, forest, cropland, water, built, area_ha\nforest,      101,       12,     3,     4,   41820\ncropland,      9,      106,     2,     3,   26310\n[... two more class rows ...]",
  "methods": "The 2024 land-cover map was classified from a Sentinel-2 summer composite and an\nSRTM-derived slope layer with a random forest at 10 m.\nValidation used 480 points, 120 per class, interpreted from 0.5 m aerial imagery.\nOverall accuracy is 90.6% and kappa is 0.875.\n[... two more sentences ...]",
  "product": "categorical",
  "design": "stratified_random",
  "claim": "The map is 90.6% accurate across the study area.",
  "context": "The training and validation points were collected on the same field days.",
  "prescan_facts": <the object the free read computes -- see below>
}

Returns the five validate sections. The design hint only fills a silence: the prose already says "480 points, 120 per class", so the lane reads the design from the prose and uses the hint for nothing. The finding it cannot avoid is the last sentence of the methods — training and validation points drawn from one campaign with no enforced separation is leakage, and no amount of sample size fixes it.

task: "report" — is 90.6% the number to print?

{
  "task": "report",
  "layers": "layer: lc_2024_classified\nkind: raster\ncrs: EPSG:32633\npixel_size: 10\nextent: 499980, 7190200, 609780, 7300000\nbands: 1\ndtype: uint8\nnodata: 255\nrole: target\n\n[... four more layer blocks, in full in step 7 ...]",
  "matrix": "orientation: rows = map\nmap_class, forest, cropland, water, built, area_ha\nforest,      101,       12,     3,     4,   41820\ncropland,      9,      106,     2,     3,   26310\n[... two more class rows ...]",
  "methods": "The 2024 land-cover map was classified from a Sentinel-2 summer composite and an\nSRTM-derived slope layer with a random forest at 10 m.\nValidation used 480 points, 120 per class, interpreted from 0.5 m aerial imagery.\nOverall accuracy is 90.6% and kappa is 0.875.\n[... two more sentences ...]",
  "product": "categorical",
  "design": "stratified_random",
  "claim": "Forest covers 41,820 ha of the study area, mapped at 90.6% overall accuracy.",
  "context": "The figure goes into a national reporting annex.",
  "prescan_facts": <the object the free read computes -- see below>
}

Returns the six report sections. The arithmetic is not arguable: 435 of 480 correct is 90.6%, kappa is 0.875, the no-information rate is 25.6%. But the strata are equal-allocated across classes whose mapped areas run from 5,230 ha to 41,820 ha, so the area-weighted overall accuracy is 87.5% with a 95% interval of plus or minus 3.8 points, and the forest area estimated from the reference data is 37,456 ha, not the 41,820 ha counted off the map. drafted_methods comes back as a paragraph that says all of that in the register of a methods section.

2. The input contract

Again, because it is the one mistake that costs money: the object below is the POST body of /estimate, /run and /run-stream. It is never nested under input, payload or body.
FieldTypeRequiredWhat it is
taskstringyesThe lane: align, validate or report.
layersstringyesThe layer manifest, as key: value blocks separated by a blank line (one block per layer) or as a delimited table with a header row. Recognised keys: layer/name, kind, crs/epsg, pixel_size/resolution, extent/bbox (or xmin/ymin/xmax/ymax columns), bands, dtype, nodata, geometry, features, size, and role — one of reference, predictor, samples, aoi, mask, target.
matrixstringyesThe validation confusion matrix. Either a labelled grid — a header row of reference classes, one row per map class, with a map\ref corner cell or an orientation: rows = map line to declare the axes — or one map,reference,count triple per line. An extra area_ha, pixels or weight column, or a trailing areas (ha): block, supplies the per-class mapped areas that make the area-weighted estimator computable at all. total rows and columns are recognised and set aside rather than counted as a class.
methodsstringnoThe methods and results prose. Everything the checks know about the sampling design, the reported metrics, the block size and the autocorrelation range is read out of here. With this empty, a third of the checks come back not_assessable.
productstringno"", categorical, change or continuous. A hint only: it fills a silence in methods and never overrides what the prose says.
designstringno"", simple_random, stratified_random, systematic, cluster, purposive or existing. Same rule: it fills a silence only. If the prose names a design, the prose wins.
claimstringnoThe claim the accuracy figure is meant to support, in one sentence. Every claim comes back in context_notes[] marked honoured, contradicted or unverifiable.
contextstringnoAnything the three blocks do not say: how the data was collected, what the deadline is, which reviewer asked for what.
prescan_factsobjectnoThe free read's own output, passed straight back in. This is what holds the model to arithmetic it cannot argue with.

prescan_facts

The browser computes this before any request is made, and the prompt instructs the lane to reconcile against it rather than recompute it. Every flag in it must come back answered in reconciliation[], one entry per uid, no more and no fewer. If you are driving the API yourself you can build it however you like — but the prompt will hold the reply to whatever you put there, so either put real numbers in it or send {"readable": false, "why": "..."} and let the lane say plainly that it had no arithmetic to work from.

{
  "readable": true,
  "verdict": "needs_rework",
  "verdict_driver": "high",
  "verdict_floor": [
    {"severity": "blocking", "verdict": "not_defensible"},
    {"severity": "high",     "verdict": "needs_rework"}
  ],
  "severity_counts": {"blocking": 0, "high": 2, "medium": 3, "low": 1, "info": 0},
  "flag_count": 6,
  "flags": [
    {"uid": "F-01", "check": "crs_consistent", "severity": "medium", "area": "crs",
     "title": "The layers do not share one coordinate reference system",
     "detail": "Four layers are in EPSG:32633; dem_srtm_void_filled is in EPSG:4326.",
     "evidence": "crs: EPSG:4326", "line": 23},
    {"uid": "F-02", "check": "unit_independence", "severity": "high", "area": "leakage",
     "title": "Training and validation units are not independent",
     "detail": "The methods state that both came from one field campaign with no enforced separation.",
     "evidence": "no minimum separation was enforced", "line": null},
    {"uid": "F-03", "check": "area_weighted_gap", "severity": "high", "area": "estimator",
     "title": "The reported accuracy is not area-weighted",
     "detail": "Equal allocation over classes of 41,820 ha to 5,230 ha. Unweighted 90.6%, area-weighted 87.5%.",
     "evidence": "Overall accuracy is 90.6%", "line": 4}
  ],
  "checks_total": 22,
  "checks_ran": 19,
  "checks": [
    {"id": "crs_declared", "area": "crs", "question": "Every layer declares a coordinate reference system",
     "state": "pass", "detail": "All 5 layers declare a CRS.", "line": null}
  ],
  "not_assessable": [
    {"item": "block_vs_autocorrelation",
     "why": "The methods state neither a block size nor an autocorrelation range."}
  ],
  "numbers": {
    "layer_count": 5, "raster_count": 3, "vector_count": 2,
    "crs_codes": ["EPSG:32633", "EPSG:4326"],
    "analysis_layer": "lc_2024_classified", "analysis_pixel_metres": 10,
    "classes": 4, "reference_units": 480, "correct_units": 435,
    "overall_accuracy": 0.90625, "overall_accuracy_ci95": 0.0261,
    "kappa": 0.875, "no_information_rate": 0.25625,
    "majority_reference_class": "cropland",
    "overall_accuracy_area_weighted": 0.87461,
    "overall_accuracy_area_weighted_ci95": 0.0381,
    "area_weighting_shift_pp": -3.16,
    "total_mapped_area_m2": 830000000,
    "reported_overall_accuracy": 0.906, "reported_kappa": 0.875,
    "autocorrelation_range_m": null, "block_size_m": null, "buffer_m": null
  },
  "class_table": [
    {"class": "forest", "map_units": 120, "reference_units": 115, "correct": 101,
     "users_accuracy": 0.84167, "producers_accuracy": 0.87826,
     "producers_accuracy_area_weighted": 0.93958,
     "mapped_area_m2": 418200000, "mapped_area_as_pasted": 41820,
     "estimated_area_m2": 374562400, "estimated_area_se_m2": 15430000}
  ],
  "layers": [
    {"name": "dem_srtm_void_filled", "kind": "raster", "crs": "EPSG:4326",
     "crs_kind": "geographic", "pixel": 0.000277778, "pixel_y": 0.000277778,
     "bands": 1, "nodata_stated": true, "nodata": -32768,
     "features": null, "role": "predictor", "line": 23}
  ],
  "layer_sample": {"sampled": false, "total": 5, "sent": 5, "how": "every layer"},
  "methods_read": {
    "product": "categorical", "design": "stratified_random",
    "metrics": {"overall_accuracy": {"stated": true, "value": 0.906},
                "kappa": {"stated": true, "value": 0.875}},
    "interval_stated": false, "areas_from": "pixel counts"
  }
}

The fields worth understanding before you build your own: verdict_floor is the ladder that maps the worst severity present to the lowest verdict the lane is allowed to return, so a single blocking flag makes not_defensible the ceiling as well as the floor. flag_count is taken from the array itself, so it can never disagree with the list the model has to answer. checks_ran is checks_total minus the not_assessable ones — and not_assessable is not a pass; the prompt requires the lane to say what was missing rather than treat a silence as clean. layer_sample matters on a large manifest: above the cap the layers sent are a golden-ratio draw plus the first and last layer and at least one layer of every distinct CRS, kind and role, never a fixed stride, because a fixed stride resonates with a periodic manifest and can return one position out of every group.

The 22 checks the free read runs

Each is pass, attention, fail or not_assessable, and each carries the area that findings use.

idareaThe question
crs_declaredcrsEvery layer declares a coordinate reference system
crs_consistentcrsThe layers share one coordinate reference system
crs_unit_plausiblecrsEach pixel size is plausible in its own CRS's units
grid_alignmentgridRasters on one CRS sit on a common pixel grid
resolution_ratioresamplingNo layer is upsampled onto a finer grid than it has
extent_coveragecoverageEvery layer covers the mapped area
nodata_declarednodataEvery raster declares its nodata value
reference_layer_matches_matrixconsistencyThe reference layer's feature count matches the matrix total
matrix_consistencyconsistencyThe confusion matrix agrees with itself
orientation_declaredconsistencyThe matrix declares which axis is the map
sampling_design_stateddesignThe validation sampling design is stated
design_supports_inferencedesignThe design supports the inference the numbers are used for
spatial_validationleakageValidation folds are spatially separated
block_vs_autocorrelationleakageThe spatial block is at least as large as the autocorrelation range
unit_independenceleakageTraining and validation units are independent of each other
sample_allocationallocationEvery class carries enough reference units
oa_recomputedmetricsThe reported overall accuracy matches the matrix
kappa_recomputedmetricsThe reported kappa matches the matrix
beats_no_informationmetricsOverall accuracy beats the no-information rate
area_weighted_gapestimatorThe accuracy is area-weighted where the design requires it
interval_reportedreportingThe accuracy is reported with an interval
metric_fits_productreportingThe reported metrics fit the kind of product

3. The output contract

The model returns one JSON object and nothing else — no prose above it, no code fence around it, no trailing commentary. This is the shape the page's own normaliser parses, so it is the shape to code against.

{
  "lane": "align | validate | report",
  "title": "string",
  "verdict": "defensible | defensible_with_changes | needs_rework | not_defensible",
  "headline": "string",
  "summary": "string",
  "body": { "...": "the lane's named sections -- see below" },
  "findings": [
    {
      "id": "F-001",
      "severity": "blocking | high | medium | low | info",
      "area": "crs | grid | coverage | resampling | nodata | consistency | design | leakage | allocation | estimator | metrics | reporting",
      "title": "string",
      "detail": "string",
      "evidence": "string",
      "line": 23,
      "fix": "string"
    }
  ],
  "reconciliation": [
    {
      "flag_uid": "F-01",
      "status": "confirmed | adjusted | set_aside | not_applicable | noted",
      "note": "string"
    }
  ],
  "context_notes": [
    {
      "claim": "string",
      "status": "honoured | contradicted | unverifiable",
      "note": "string"
    }
  ],
  "unassessable": [
    {
      "item": "string",
      "why": "string"
    }
  ]
}

Rules that hold across every lane:

body is a set of named sections

Every section, in every lane, is the same two-field shape. Nothing else is allowed inside body.

"body": {
  "crs_review": {
    "statement": "Four of the five layers are in EPSG:32633; the DEM is in EPSG:4326.",
    "points": [
      "A pixel size of 0.000277778 in EPSG:4326 is degrees, not metres.",
      "At 65 degrees north that is about 31 m north-south and about 13 m east-west."
    ]
  }
}

The section names are fixed per lane and are exactly these, in this order:

taskSection names in body
aligncrs_review · grid_review · coverage_review · resampling_plan · nodata_plan · ready_to_extract
validatedesign_review · leakage_review · allocation_review · independence_review · redesign_plan
reportestimator_review · accuracy_statement · area_statement · metric_fit · limitations · drafted_methods

A section whose question the study does not answer still appears, with a statement that says so and the matching item in unassessable[]. Sections are never dropped, and never renamed — if you get crs back instead of crs_review, you sent no task and the model routed itself.

4. Numbered steps

Pick a language once. The choice applies to every block on the page and is remembered in this browser.

Step 1 — a tiny client

Two headers on every call: Content-Type: application/json and Authorization: Bearer <token>. That is the whole authentication story. There is no X-App-Slug header — the slug appears exactly once, in the body of POST /guest — and sending one changes nothing.

# There is no helper in cURL; every call below repeats the two headers.
BASE=https://api.skillsafe.ai/v1/app-api
TOKEN=YOUR_TOKEN          # paste it from https://geo-desk.skillsafe.ai/tokens.html

# Success is {"data": ...}; failure is {"error": {"code": ..., "message": ...}}.
# There is no "ok" flag -- test for the presence of "error".

Step 2 — mint a guest token

POST /guest answers 201 with {token, guest_id, expires_at}. The slug travels in the body and nowhere else: there is no X-App-Slug header. A guest token is enough for /me and /estimate; a metered run needs a personal token, which you can copy from the token page after signing in, without opening a developer console.

# A GUEST token is enough for /me and /estimate. The slug travels in the BODY --
# there is no X-App-Slug header, and a bogus one would simply be ignored.
curl -s -X POST https://api.skillsafe.ai/v1/app-api/guest \
  -H "Content-Type: application/json" \
  -d '{"slug":"geo-desk"}'
# -> 201 {"data":{"token":"aut_...","guest_id":"gst_...","expires_at":"2026-08-21T09:14:02Z"}}
#
# Running a lane is metered, so it needs a PERSONAL token: sign in at
# https://geo-desk.skillsafe.ai/tokens.html and copy it from there.

Step 3 — GET /me

Returns exactly {subject_type, subject_id, credits}. There is no user_id and no is_guest field anywhere in this API — branch on subject_type, which is user or guest.

curl -s https://api.skillsafe.ai/v1/app-api/me \
  -H "Authorization: Bearer $TOKEN"
# -> {"data":{"subject_type":"guest","subject_id":"gst_...","credits":0}}
# -> {"data":{"subject_type":"user","subject_id":"usr_...","credits":41500}}
#
# No user_id, no is_guest. Branch on subject_type.

Step 4 — POST /estimate (free)

Free, creates no job — but authenticated, so it has to come after the token is minted; call it first and you get a 401. It returns model, model_alias, markup_bps, hold_credits, min_credits and sponsor_enabled. Assert that model_alias is gpt-terra and markup_bps is 1000: that is the authoritative proof you are wired to the right model at the right markup. hold_credits is a reservation, not a price, and it differs per lanereport holds more than align because it writes a methods paragraph — so price each lane separately rather than reusing one estimate for all three.

# FREE and creates no job, but AUTHENTICATED: mint the token first.
# body.json is the input object ITSELF. Never {"input": {...}}.
curl -s -X POST https://api.skillsafe.ai/v1/app-api/estimate \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d @body.json
# -> {"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra","markup_bps":1000,
#             "hold_credits":2870,"min_credits":240,"sponsor_enabled":false}}
#
# Each lane prices separately -- align 2870, validate 3110, report 3640 on this study.

Step 5 — POST /run, then poll GET /jobs/{job_id}

Metered. /run answers with a job_id; poll the job until status is terminal (succeeded or failed). data.output.output is the JSON string the model produced, data.charged_credits is what you actually paid — usually far below hold_credits — and data.truncated is true when a thin balance forced a reduced output cap. Poll on a fixed 1.5–2 s interval and back off on a 429; never tight-loop.

# METERED. The Idempotency-Key is what stops a retry double-billing.
curl -s -X POST https://api.skillsafe.ai/v1/app-api/run \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: geo-desk:align:9f31c7a4e0:a1" \
  -d @body.json
# -> {"data":{"job_id":"job_7Qd2..."}}

# then poll until terminal
curl -s "https://api.skillsafe.ai/v1/app-api/jobs/job_7Qd2..." \
  -H "Authorization: Bearer $TOKEN"
# -> data.status          queued | running | succeeded | failed
#    data.output.output   the JSON string the model produced
#    data.charged_credits the ACTUAL cost, usually well under hold_credits
#    data.truncated       true when a thin balance forced a reduced output cap

Step 6 — POST /run-stream (metered, SSE)

The same body and the same key rules, delivered as Server-Sent Events. Three event types: job carries the job_id, each delta carries a text fragment of the JSON as it is produced, and done carries status, charged_credits and truncated. Concatenate every delta.text in arrival order and parse the result once the stream closes — the fragments split at arbitrary points, often mid-key, so do not try to parse each one.

# METERED, Server-Sent Events. Same Idempotency-Key rules as /run.
curl -N -X POST https://api.skillsafe.ai/v1/app-api/run-stream \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: geo-desk:report:9f31c7a4e0:a1" \
  -d @body.json
# event: job    data: {"job_id":"job_7Qd2..."}
# event: delta  data: {"text":"{\"lane\":\"report\","}
# event: delta  data: {"text":"\"verdict\":\"needs_rework\","}
# event: delta  data: {"text":"\"headline\":\"90.6% is the sample estimate"}
# event: done   data: {"status":"succeeded","charged_credits":1980,"truncated":false}
#
# Accumulate every delta's text; parse once, at the end.

Step 7 — one worked example per lane

The same study, three times. The first block carries the three pasted texts in full; the two after it reuse them and change only the routing fields, because that is exactly how you would drive it.

7a. task: "align"

Asks whether the layers can be made into one grid at all. On this study it answers on the DEM: a pixel_size of 0.000277778 under EPSG:4326 is degrees, so crs_review and grid_review both fire, resampling_plan works out the warp to the 10 m UTM grid, and nodata_plan points out that -32768 survives a bilinear resample as a slope of minus 32,768.

# The three pasted blocks live in three files, verbatim.
cat > layers.txt <<'TXT'
layer: lc_2024_classified
kind: raster
crs: EPSG:32633
pixel_size: 10
extent: 499980, 7190200, 609780, 7300000
bands: 1
dtype: uint8
nodata: 255
role: target

layer: s2_summer_composite
kind: raster
crs: EPSG:32633
pixel_size: 10
extent: 499980, 7190200, 609780, 7300000
bands: 10
dtype: uint16
nodata: 0
role: predictor

layer: dem_srtm_void_filled
kind: raster
crs: EPSG:4326
pixel_size: 0.000277778
extent: 12.0, 64.8, 14.4, 65.9
bands: 1
dtype: int16
nodata: -32768
role: predictor

layer: validation_points
kind: vector
crs: EPSG:32633
geometry: point
features: 480
role: samples

layer: study_area
kind: vector
crs: EPSG:32633
geometry: polygon
features: 1
role: aoi
TXT

cat > matrix.csv <<'TXT'
orientation: rows = map
map_class, forest, cropland, water, built, area_ha
forest,      101,       12,     3,     4,   41820
cropland,      9,      106,     2,     3,   26310
water,         2,        1,   115,     2,    9640
built,         3,        4,     0,   113,    5230
TXT

cat > methods.txt <<'TXT'
The 2024 land-cover map was classified from a Sentinel-2 summer composite and an
SRTM-derived slope layer with a random forest at 10 m.
Validation used 480 points, 120 per class, interpreted from 0.5 m aerial imagery.
Overall accuracy is 90.6% and kappa is 0.875.
Class areas were read from the pixel counts of the classified map.
Training points came from the same field campaign; no minimum separation was
enforced between training and validation locations.
TXT

# Build the body from them. The object is the body: there is no "input" wrapper.
jq -n --rawfile layers layers.txt \
      --rawfile matrix matrix.csv \
      --rawfile methods methods.txt \
      --slurpfile facts prescan.json \
  '{task: "align", layers: $layers, matrix: $matrix, methods: $methods,
    product: "categorical", design: "", context: "",
    claim: "The layer stack is ready for a per-pixel accuracy extraction.",
    prescan_facts: $facts[0]}' > align.json

curl -s -X POST https://api.skillsafe.ai/v1/app-api/run \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: geo-desk:align:9f31c7a4e0:a1" \
  -d @align.json

7b. task: "validate"

Same three blocks, new question: can this design carry the claim? Note that design is set here and still changes nothing — the prose already says "480 points, 120 per class", and a hint only ever fills a silence. What comes back is design_review on the stratification, leakage_review on the shared field campaign, allocation_review on 120 points against a class of 5,230 ha and a class of 41,820 ha, independence_review on what "no minimum separation" costs, and redesign_plan with the sample sizes a defensible version would need.

# Reuse the same three files; only the routing fields change.
jq -n --rawfile layers layers.txt \
      --rawfile matrix matrix.csv \
      --rawfile methods methods.txt \
      --slurpfile facts prescan.json \
  '{task: "validate", layers: $layers, matrix: $matrix, methods: $methods,
    product: "categorical", design: "stratified_random",
    claim: "The map is 90.6% accurate across the study area.",
    context: "Training and validation points were collected on the same field days.",
    prescan_facts: $facts[0]}' > validate.json

# A DIFFERENT lane, so a DIFFERENT idempotency key over the same study.
curl -s -X POST https://api.skillsafe.ai/v1/app-api/run \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: geo-desk:validate:9f31c7a4e0:a1" \
  -d @validate.json

7c. task: "report"

The lane that decides which number gets printed. estimator_review sets the plain sample estimate against the area-weighted one (90.6% against 87.5%), accuracy_statement writes the sentence with its interval (plus or minus 3.8 points at 95%), area_statement replaces the pixel-counted 41,820 ha of forest with the 37,456 ha the reference data estimates, metric_fit deals with kappa being reported at all for a categorical product, limitations carries what the design cannot support, and drafted_methods is a paste-ready paragraph.

jq -n --rawfile layers layers.txt \
      --rawfile matrix matrix.csv \
      --rawfile methods methods.txt \
      --slurpfile facts prescan.json \
  '{task: "report", layers: $layers, matrix: $matrix, methods: $methods,
    product: "categorical", design: "stratified_random",
    claim: "Forest covers 41,820 ha of the study area, mapped at 90.6% overall accuracy.",
    context: "The figure goes into a national reporting annex.",
    prescan_facts: $facts[0]}' > report.json

curl -s -X POST https://api.skillsafe.ai/v1/app-api/run-stream \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: geo-desk:report:9f31c7a4e0:a1" \
  -d @report.json
# report holds the most credits of the three lanes: it writes drafted_methods.

Step 8 — the Idempotency-Key

Send one on every metered call. The key is a content hash of the input, and the lane is part of the content: align, validate and report over one study are three distinct runs, and a key that hashes only the three text blocks would collide them — the second lane would come back with the first lane's answer, correctly billed once, entirely wrong.

The shape used here is geo-desk:{lane}:{hash}:{attempt}. The attempt suffix is what makes the automatic reformat retry safe: when a reply does not parse, the client retries under a key derived from the same input with the attempt bumped, so the retry is idempotent against itself while still being a new run against the platform. Retrying a 500 is the opposite case — reuse the key exactly, unchanged, and the platform returns the original run instead of billing a second one.

# Hash the lane together with the three blocks. Order matters, so keep it fixed.
HASH=$( { printf 'align\n'; cat layers.txt matrix.csv methods.txt; } \
        | shasum -a 256 | cut -c1-10 )
KEY="geo-desk:align:$HASH:a1"

curl -s -X POST https://api.skillsafe.ai/v1/app-api/run \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d @align.json

# Retrying a 500: send the SAME key, unchanged -- you get the original run back.
# Retrying an unparseable reply: bump the attempt, "...:a2", same hash.

5. Rate limits and costs

Provenance

Geo Desk is a derived work crediting @k-dense-ai/geopandas for coordinate reference systems, geometry, areas and overlays, and @k-dense-ai/geomaster for raster grids, resampling, spatial statistics and validation design. Both are credited on every lane they inform. It is not a republication of either skill.