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
| Code | HTTP | What it means |
|---|---|---|
UNAUTHORIZED | 401 | No token, a malformed token, an expired guest token, or a token minted for a different app. |
INSUFFICIENT_CREDITS | 402 | The balance is below min_credits for this run. /estimate is free, so a client that estimates first never sees this. |
VALIDATION_ERROR | 400 | The 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_FOUND | 404 | No such app, job or record. A job_id from another subject reads as 404, not 403. |
RATE_LIMITED | 429 | Too many requests. Back off; never tight-loop a poll. |
INTERNAL | 500 | A 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.
task | What it answers | Sections it returns in body |
|---|---|---|
align | Can 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 |
validate | Can 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 |
report | Is 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.
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
/estimate, /run and /run-stream. It is never nested under input, payload or body.| Field | Type | Required | What it is |
|---|---|---|---|
task | string | yes | The lane: align, validate or report. |
layers | string | yes | The 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. |
matrix | string | yes | The 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. |
methods | string | no | The 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. |
product | string | no | "", categorical, change or continuous. A hint only: it fills a silence in methods and never overrides what the prose says. |
design | string | no | "", 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. |
claim | string | no | The claim the accuracy figure is meant to support, in one sentence. Every claim comes back in context_notes[] marked honoured, contradicted or unverifiable. |
context | string | no | Anything the three blocks do not say: how the data was collected, what the deadline is, which reviewer asked for what. |
prescan_facts | object | no | The 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.
id | area | The question |
|---|---|---|
crs_declared | crs | Every layer declares a coordinate reference system |
crs_consistent | crs | The layers share one coordinate reference system |
crs_unit_plausible | crs | Each pixel size is plausible in its own CRS's units |
grid_alignment | grid | Rasters on one CRS sit on a common pixel grid |
resolution_ratio | resampling | No layer is upsampled onto a finer grid than it has |
extent_coverage | coverage | Every layer covers the mapped area |
nodata_declared | nodata | Every raster declares its nodata value |
reference_layer_matches_matrix | consistency | The reference layer's feature count matches the matrix total |
matrix_consistency | consistency | The confusion matrix agrees with itself |
orientation_declared | consistency | The matrix declares which axis is the map |
sampling_design_stated | design | The validation sampling design is stated |
design_supports_inference | design | The design supports the inference the numbers are used for |
spatial_validation | leakage | Validation folds are spatially separated |
block_vs_autocorrelation | leakage | The spatial block is at least as large as the autocorrelation range |
unit_independence | leakage | Training and validation units are independent of each other |
sample_allocation | allocation | Every class carries enough reference units |
oa_recomputed | metrics | The reported overall accuracy matches the matrix |
kappa_recomputed | metrics | The reported kappa matches the matrix |
beats_no_information | metrics | Overall accuracy beats the no-information rate |
area_weighted_gap | estimator | The accuracy is area-weighted where the design requires it |
interval_reported | reporting | The accuracy is reported with an interval |
metric_fits_product | reporting | The 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:
lineis an integer ornull.nullmeans the location is not known — a claim in prose that has no line, a matrix-wide inconsistency. It is never0, and a null line is simply not printed rather than rendered as "line 0".reconciliation[]carries exactly one entry perprescan_facts.flags[].uid, one for one. A missingflag_uidis a parse failure, not a silent pass; an invented one is dropped.adjustedmeans the lane agrees there is a problem but re-scoped it,set_asidemeans it is real but not material to this claim,not_applicablemeans the check does not apply to this product at all, andnotedis for aninfoflag that needs no action.- An unrecognised
verdictfalls back todefensible_with_changes; an unrecognisedseverityfalls back tomedium. The verdict may never sit above the floor thatprescan_facts.verdict_floorsets for the worst severity present. unassessable[]is the lane's own list, distinct fromprescan_facts.not_assessable: the checks the free read could not run, restated as what the study would have to say for the question to be answerable.- A reply with neither
findingsnorsummaryis treated as a parse failure and triggers exactly one reformat retry, under an idempotency key derived from the same input with a new attempt suffix.
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:
task | Section names in body |
|---|---|
align | crs_review · grid_review · coverage_review · resampling_plan · nodata_plan · ready_to_extract |
validate | design_review · leakage_review · allocation_review · independence_review · redesign_plan |
report | estimator_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".
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # paste it from /tokens.html
def call(path, body=None, method=None, extra_headers=None):
data = None if body is None else json.dumps(body).encode()
req = urllib.request.Request(BASE + path, data=data,
method=method or ("POST" if data else "GET"))
req.add_header("Content-Type", "application/json")
req.add_header("Authorization", "Bearer " + TOKEN)
for k, v in (extra_headers or {}).items():
req.add_header(k, v)
with urllib.request.urlopen(req) as r:
env = json.loads(r.read().decode())
if "error" in env: # no "ok" flag in this API
raise RuntimeError(env["error"]["code"] + ": " + env["error"].get("message", ""))
return env["data"]
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN"; // paste it from /tokens.html
async function call(path, body, method, extraHeaders) {
const res = await fetch(BASE + path, {
method: method || (body ? "POST" : "GET"),
headers: Object.assign({
"Content-Type": "application/json",
"Authorization": "Bearer " + TOKEN
}, extraHeaders || {}),
body: body ? JSON.stringify(body) : undefined
});
const env = await res.json();
if (env.error) throw new Error(env.error.code + ": " + (env.error.message || ""));
return env.data; // success carries data, failure carries error
}
package main
import (
"bytes"
"encoding/json"
"errors"
"io"
"net/http"
"os"
)
const base = "https://api.skillsafe.ai/v1/app-api"
// Read the token from the environment rather than pasting it into source.
var token = os.Getenv("SKILLSAFE_TOKEN")
type envelope struct {
Data json.RawMessage `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func call(path string, body any, method string, extra map[string]string) (json.RawMessage, error) {
var rdr io.Reader
if body != nil {
b, _ := json.Marshal(body)
rdr = bytes.NewReader(b)
if method == "" {
method = "POST"
}
} else if method == "" {
method = "GET"
}
req, _ := http.NewRequest(method, base+path, rdr)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
for k, v := range extra {
req.Header.Set(k, v)
}
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.Error != nil {
return nil, errors.New(env.Error.Code + ": " + env.Error.Message)
}
return env.Data, nil
}
import java.net.URI;
import java.net.http.*;
class GeoDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = System.getenv("SKILLSAFE_TOKEN");
static final HttpClient HTTP = HttpClient.newHttpClient();
static String call(String path, String jsonBody, String method, String idemKey)
throws Exception {
HttpRequest.BodyPublisher pub = jsonBody == null
? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(jsonBody);
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token)
.method(method != null ? method : (jsonBody != null ? "POST" : "GET"), pub);
if (idemKey != null) b.header("Idempotency-Key", idemKey);
HttpResponse<String> res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
// res.body() is {"data": ...} or {"error": {...}} -- check for "error" first.
return res.body();
}
}
require "json"
require "net/http"
BASE = URI("https://api.skillsafe.ai/v1/app-api")
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN")
def call(path, body = nil, method = nil, extra = {})
uri = URI(BASE.to_s + path)
verb = method || (body ? "POST" : "GET")
klass = verb == "POST" ? Net::HTTP::Post : Net::HTTP::Get
req = klass.new(uri)
req["Content-Type"] = "application/json"
req["Authorization"] = "Bearer #{TOKEN}"
extra.each { |k, v| req[k] = v }
req.body = JSON.dump(body) if body
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
env = JSON.parse(res.body)
raise "#{env['error']['code']}: #{env['error']['message']}" if env.key?("error")
env["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // or getenv("SKILLSAFE_TOKEN")
function call(string $path, ?array $body = null, ?string $method = null, array $extra = []) {
$ch = curl_init(BASE . $path);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, array_merge([
"Content-Type: application/json",
"Authorization: Bearer " . TOKEN,
], $extra));
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method ?? ($body !== null ? "POST" : "GET"));
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
$env = json_decode(curl_exec($ch), true);
curl_close($ch);
if (isset($env["error"])) {
throw new RuntimeException($env["error"]["code"] . ": " . ($env["error"]["message"] ?? ""));
}
return $env["data"];
}
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
static class GeoDesk
{
const string Base = "https://api.skillsafe.ai/v1/app-api";
static string Token = Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN";
static readonly HttpClient Http = new HttpClient();
static async Task<JsonElement> Call(string path, object body = null,
string method = null, string idemKey = null)
{
var verb = new HttpMethod(method ?? (body != null ? "POST" : "GET"));
var req = new HttpRequestMessage(verb, Base + path);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
if (idemKey != null) req.Headers.Add("Idempotency-Key", idemKey);
if (body != null)
req.Content = new StringContent(JsonSerializer.Serialize(body),
Encoding.UTF8, "application/json");
var res = await Http.SendAsync(req);
var env = JsonDocument.Parse(await res.Content.ReadAsStringAsync()).RootElement;
if (env.TryGetProperty("error", out var err))
throw new Exception(err.GetProperty("code").GetString());
return env.GetProperty("data");
}
}
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.
def guest_token():
req = urllib.request.Request(
BASE + "/guest",
data=json.dumps({"slug": "geo-desk"}).encode(), # the slug goes in the BODY
method="POST")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r: # answers 201
return json.loads(r.read().decode())["data"]["token"]
TOKEN = guest_token() # good for /me and /estimate; a run needs a personal token
async function guestToken() {
const res = await fetch(BASE + "/guest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "geo-desk" }) // the slug goes in the BODY
});
const env = await res.json(); // 201
return env.data.token; // also: env.data.guest_id, expires_at
}
TOKEN = await guestToken();
func guestToken() (string, error) {
b, _ := json.Marshal(map[string]string{"slug": "geo-desk"})
res, err := http.Post(base+"/guest", "application/json", bytes.NewReader(b))
if err != nil {
return "", err
}
defer res.Body.Close() // 201 Created
var env struct {
Data struct {
Token string `json:"token"`
GuestID string `json:"guest_id"`
ExpiresAt string `json:"expires_at"`
} `json:"data"`
}
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return "", err
}
return env.Data.Token, nil
}
// POST /guest with {"slug":"geo-desk"} -- the slug travels in the body, not a header.
// Answers 201 with {"data":{"token":"aut_...","guest_id":...,"expires_at":...}}.
static String guestToken() throws Exception {
HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + "/guest"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"geo-desk\"}"))
.build();
String body = HTTP.send(req, HttpResponse.BodyHandlers.ofString()).body();
// pull data.token out with your JSON library of choice
return body;
}
def guest_token
uri = URI(BASE.to_s + "/guest")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req.body = JSON.dump({ slug: "geo-desk" }) # the slug goes in the body
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
JSON.parse(res.body)["data"]["token"] # 201
end
<?php
function guest_token(): string {
$ch = curl_init(BASE . "/guest");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json"]);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(["slug" => "geo-desk"]));
$env = json_decode(curl_exec($ch), true); // 201
curl_close($ch);
return $env["data"]["token"];
}
static async Task<string> GuestToken()
{
var body = new StringContent("{\"slug\":\"geo-desk\"}", Encoding.UTF8, "application/json");
var res = await Http.PostAsync(Base + "/guest", body); // 201 Created
var env = JsonDocument.Parse(await res.Content.ReadAsStringAsync()).RootElement;
return env.GetProperty("data").GetProperty("token").GetString();
}
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.
me = call("/me")
# {"subject_type": "user" | "guest", "subject_id": "...", "credits": 41500}
signed_in = me["subject_type"] == "user" # NOT me.get("is_guest") -- no such field
print(me["credits"], "credits")
const me = await call("/me");
// { subject_type: "user" | "guest", subject_id, credits }
const signedIn = me.subject_type === "user"; // branch on subject_type
console.log(me.credits, "credits");
raw, err := call("/me", nil, "", nil)
if err != nil {
panic(err)
}
var me struct {
SubjectType string `json:"subject_type"`
SubjectID string `json:"subject_id"`
Credits int64 `json:"credits"`
}
json.Unmarshal(raw, &me)
signedIn := me.SubjectType == "user" // there is no IsGuest to read
_ = signedIn
String me = GeoDesk.call("/me", null, "GET", null);
// data.subject_type is "user" or "guest"; data.credits is the balance.
// There is no is_guest field -- branch on subject_type.
System.out.println(me);
me = call("/me")
signed_in = me["subject_type"] == "user" # branch on subject_type
puts "#{me['credits']} credits"
<?php
$me = call("/me");
$signedIn = $me["subject_type"] === "user"; // branch on subject_type
echo $me["credits"], " credits\n";
var me = await GeoDesk.Call("/me");
var signedIn = me.GetProperty("subject_type").GetString() == "user";
Console.WriteLine($"{me.GetProperty("credits").GetInt64()} credits");
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 lane — report 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.
body = {
"task": "align", # price each lane on its own
"layers": open("layers.txt").read(),
"matrix": open("matrix.csv").read(),
"methods": open("methods.txt").read(),
"product": "categorical",
"design": "",
"claim": "The map is 90.6% accurate across the study area.",
"context": "",
"prescan_facts": prescan_facts, # see "The input contract" above
}
est = call("/estimate", body) # free, no job, but authenticated
assert est["model_alias"] == "gpt-terra" and est["markup_bps"] == 1000
print(est["hold_credits"], "held;", est["min_credits"], "minimum")
const body = {
task: "align", // price each lane on its own
layers: layersText,
matrix: matrixText,
methods: methodsText,
product: "categorical",
design: "",
claim: "The map is 90.6% accurate across the study area.",
context: "",
prescan_facts: prescanFacts // see "The input contract" above
};
const est = await call("/estimate", body); // free, no job, but authenticated
if (est.model_alias !== "gpt-terra" || est.markup_bps !== 1000) {
throw new Error("wired to the wrong model: " + est.model_alias);
}
console.log(est.hold_credits, "held,", est.min_credits, "minimum");
body := map[string]any{
"task": "align", // price each lane on its own
"layers": layersText,
"matrix": matrixText,
"methods": methodsText,
"product": "categorical",
"design": "",
"claim": "The map is 90.6% accurate across the study area.",
"context": "",
"prescan_facts": prescanFacts,
}
raw, err := call("/estimate", body, "", nil) // free, no job
if err != nil {
panic(err)
}
var est struct {
Model string `json:"model"`
ModelAlias string `json:"model_alias"`
MarkupBps int `json:"markup_bps"`
HoldCredits int64 `json:"hold_credits"`
MinCredits int64 `json:"min_credits"`
}
json.Unmarshal(raw, &est)
if est.ModelAlias != "gpt-terra" || est.MarkupBps != 1000 {
panic("wired to the wrong model: " + est.ModelAlias)
}
// The body is the input object itself. Never wrap it in an "input" key.
String body = """
{"task":"align","layers":"layer: lc_2024_classified\\nkind: raster\\n...",
"matrix":"orientation: rows = map\\nmap_class, forest, cropland, ...",
"methods":"Validation used 480 points, 120 per class...",
"product":"categorical","design":"","claim":"The map is 90.6% accurate.",
"context":"","prescan_facts":{"readable":true,"verdict":"needs_rework"}}
""";
String est = GeoDesk.call("/estimate", body, "POST", null); // free, no job
// assert data.model_alias == "gpt-terra" and data.markup_bps == 1000
System.out.println(est);
body = {
task: "align", # price each lane on its own
layers: File.read("layers.txt"),
matrix: File.read("matrix.csv"),
methods: File.read("methods.txt"),
product: "categorical",
design: "",
claim: "The map is 90.6% accurate across the study area.",
context: "",
prescan_facts: prescan_facts
}
est = call("/estimate", body) # free, no job, but authenticated
raise "wrong model" unless est["model_alias"] == "gpt-terra" && est["markup_bps"] == 1000
puts "#{est['hold_credits']} held, #{est['min_credits']} minimum"
<?php
$body = [
"task" => "align", // price each lane on its own
"layers" => file_get_contents("layers.txt"),
"matrix" => file_get_contents("matrix.csv"),
"methods" => file_get_contents("methods.txt"),
"product" => "categorical",
"design" => "",
"claim" => "The map is 90.6% accurate across the study area.",
"context" => "",
"prescan_facts" => $prescanFacts,
];
$est = call("/estimate", $body); // free, no job, but authenticated
if ($est["model_alias"] !== "gpt-terra" || $est["markup_bps"] !== 1000) {
throw new RuntimeException("wired to the wrong model");
}
echo $est["hold_credits"], " held\n";
var body = new {
task = "align", // price each lane on its own
layers = layersText,
matrix = matrixText,
methods = methodsText,
product = "categorical",
design = "",
claim = "The map is 90.6% accurate across the study area.",
context = "",
prescan_facts = prescanFacts
};
var est = await GeoDesk.Call("/estimate", body); // free, no job
if (est.GetProperty("model_alias").GetString() != "gpt-terra"
|| est.GetProperty("markup_bps").GetInt32() != 1000)
throw new Exception("wired to the wrong model");
Console.WriteLine(est.GetProperty("hold_credits").GetInt64());
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
import time
key = "geo-desk:align:" + input_hash + ":a1" # the LANE is inside the key
job_id = call("/run", body, extra_headers={"Idempotency-Key": key})["job_id"]
while True:
job = call("/jobs/" + job_id)
if job["status"] in ("succeeded", "failed"):
break
time.sleep(1.5) # fixed interval, never a tight loop
if job["status"] == "failed":
raise RuntimeError(job.get("error") or "the job failed")
result = json.loads(job["output"]["output"]) # the output contract above
print(result["lane"], result["verdict"], "-", result["headline"])
print("charged", job["charged_credits"], "credits; truncated:", job.get("truncated"))
for section, sec in result["body"].items():
print(section, "->", sec["statement"])
const key = `geo-desk:align:${inputHash}:a1`; // the LANE is inside the key
const { job_id } = await call("/run", body, "POST", { "Idempotency-Key": key });
let job;
do {
await new Promise((r) => setTimeout(r, 1500));
job = await call("/jobs/" + job_id);
} while (job.status !== "succeeded" && job.status !== "failed");
if (job.status === "failed") throw new Error(job.error || "the job failed");
const result = JSON.parse(job.output.output);
console.log(result.lane, result.verdict, result.headline);
console.log("charged", job.charged_credits, "truncated", job.truncated);
for (const [name, sec] of Object.entries(result.body)) {
console.log(name, "->", sec.statement, sec.points.length + " points");
}
key := "geo-desk:align:" + inputHash + ":a1" // the LANE is inside the key
raw, err := call("/run", body, "POST", map[string]string{"Idempotency-Key": key})
if err != nil {
panic(err)
}
var started struct{ JobID string `json:"job_id"` }
json.Unmarshal(raw, &started)
for {
time.Sleep(1500 * time.Millisecond)
raw, err = call("/jobs/"+started.JobID, nil, "", nil)
if err != nil {
panic(err)
}
var job struct {
Status string `json:"status"`
ChargedCredits int64 `json:"charged_credits"`
Truncated bool `json:"truncated"`
Output struct{ Output string `json:"output"` } `json:"output"`
}
json.Unmarshal(raw, &job)
if job.Status == "succeeded" || job.Status == "failed" {
// job.Output.Output is the JSON string described in the output contract
break
}
}
// POST /run with the Idempotency-Key header, then poll GET /jobs/{id}.
// The key must include the lane: two lanes over one study are two distinct runs.
String key = "geo-desk:align:" + inputHash + ":a1";
String started = GeoDesk.call("/run", body, "POST", key);
String jobId = /* started -> data.job_id */ parseJobId(started);
String status = "queued";
String job = null;
while (!status.equals("succeeded") && !status.equals("failed")) {
Thread.sleep(1500);
job = GeoDesk.call("/jobs/" + jobId, null, "GET", null);
status = parseStatus(job);
}
// data.output.output is the JSON string; data.charged_credits is the real cost.
key = "geo-desk:align:#{input_hash}:a1" # the LANE is inside the key
job_id = call("/run", body, "POST", { "Idempotency-Key" => key })["job_id"]
job = nil
loop do
sleep 1.5
job = call("/jobs/#{job_id}")
break if %w[succeeded failed].include?(job["status"])
end
raise "the job failed" if job["status"] == "failed"
result = JSON.parse(job["output"]["output"])
puts "#{result['lane']} #{result['verdict']} - #{result['headline']}"
puts "charged #{job['charged_credits']}, truncated #{job['truncated']}"
<?php
$key = "geo-desk:align:" . $inputHash . ":a1"; // the LANE is inside the key
$jobId = call("/run", $body, "POST", ["Idempotency-Key: " . $key])["job_id"];
do {
sleep(2);
$job = call("/jobs/" . $jobId);
} while (!in_array($job["status"], ["succeeded", "failed"], true));
if ($job["status"] === "failed") { throw new RuntimeException("the job failed"); }
$result = json_decode($job["output"]["output"], true);
echo $result["lane"], " ", $result["verdict"], " - charged ",
$job["charged_credits"], "\n";
var key = $"geo-desk:align:{inputHash}:a1"; // the LANE is inside the key
var started = await GeoDesk.Call("/run", body, "POST", key);
var jobId = started.GetProperty("job_id").GetString();
JsonElement job;
string status;
do {
await Task.Delay(1500);
job = await GeoDesk.Call("/jobs/" + jobId);
status = job.GetProperty("status").GetString();
} while (status is not ("succeeded" or "failed"));
if (status == "failed") throw new Exception("the job failed");
var result = JsonDocument.Parse(
job.GetProperty("output").GetProperty("output").GetString()).RootElement;
Console.WriteLine(result.GetProperty("verdict").GetString());
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.
req = urllib.request.Request(BASE + "/run-stream",
data=json.dumps(body).encode(), method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Idempotency-Key", "geo-desk:report:" + input_hash + ":a1")
raw, done, event = "", None, None
with urllib.request.urlopen(req) as r:
for line in r:
line = line.decode().rstrip("\n")
if line.startswith("event: "):
event = line[7:]
elif line.startswith("data: "):
payload = json.loads(line[6:])
if event == "delta":
raw += payload["text"] # fragments split mid-key; just append
elif event == "done":
done = payload
result = json.loads(raw)
print(result["verdict"], "-", result["body"]["estimator_review"]["statement"])
print("charged", done["charged_credits"])
const res = await fetch(BASE + "/run-stream", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer " + TOKEN,
"Idempotency-Key": `geo-desk:report:${inputHash}:a1`
},
body: JSON.stringify(body)
});
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "", raw = "", event = null, done = null;
for (;;) {
const { value, done: fin } = await reader.read();
if (fin) break;
buf += dec.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop();
for (const line of lines) {
if (line.startsWith("event: ")) event = line.slice(7);
else if (line.startsWith("data: ")) {
const p = JSON.parse(line.slice(6));
if (event === "delta") raw += p.text;
else if (event === "done") done = p;
}
}
}
const result = JSON.parse(raw);
console.log(result.verdict, "charged", done.charged_credits);
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(mustJSON(body)))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", "geo-desk:report:"+inputHash+":a1")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 1<<20), 1<<20) // a delta frame can be long
var raw strings.Builder
event := ""
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event: "):
event = strings.TrimPrefix(line, "event: ")
case strings.HasPrefix(line, "data: ") && event == "delta":
var p struct{ Text string `json:"text"` }
json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &p)
raw.WriteString(p.Text)
}
}
// raw.String() is the whole JSON object -- parse it once, here.
// POST /run-stream and read the response as lines. "event: delta" frames carry
// {"text": "..."} fragments to concatenate; "event: done" carries the settlement.
HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token)
.header("Idempotency-Key", "geo-desk:report:" + inputHash + ":a1")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<java.util.stream.Stream<String>> res =
HTTP.send(req, HttpResponse.BodyHandlers.ofLines());
StringBuilder raw = new StringBuilder();
final String[] event = {""};
res.body().forEach(line -> {
if (line.startsWith("event: ")) event[0] = line.substring(7);
else if (line.startsWith("data: ") && event[0].equals("delta"))
raw.append(textFieldOf(line.substring(6))); // your JSON library
});
uri = URI(BASE.to_s + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req["Authorization"] = "Bearer #{TOKEN}"
req["Idempotency-Key"] = "geo-desk:report:#{input_hash}:a1"
req.body = JSON.dump(body)
raw = ""
event = nil
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
line = line.chomp
if line.start_with?("event: ")
event = line[7..]
elsif line.start_with?("data: ") && event == "delta"
raw << JSON.parse(line[6..])["text"]
end
end
end
end
end
result = JSON.parse(raw)
<?php
$ch = curl_init(BASE . "/run-stream");
$raw = "";
$event = null;
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Content-Type: application/json",
"Authorization: Bearer " . TOKEN,
"Idempotency-Key: geo-desk:report:" . $inputHash . ":a1",
]);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
curl_setopt($ch, CURLOPT_WRITEFUNCTION,
function ($ch, $chunk) use (&$raw, &$event) {
foreach (explode("\n", $chunk) as $line) {
if (str_starts_with($line, "event: ")) {
$event = substr($line, 7);
} elseif (str_starts_with($line, "data: ") && $event === "delta") {
$raw .= json_decode(substr($line, 6), true)["text"];
}
}
return strlen($chunk);
});
curl_exec($ch);
curl_close($ch);
$result = json_decode($raw, true);
var req = new HttpRequestMessage(HttpMethod.Post, Base + "/run-stream");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
req.Headers.Add("Idempotency-Key", $"geo-desk:report:{inputHash}:a1");
req.Content = new StringContent(JsonSerializer.Serialize(body),
Encoding.UTF8, "application/json");
var res = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
var raw = new StringBuilder();
string ev = null, line;
while ((line = await reader.ReadLineAsync()) != null)
{
if (line.StartsWith("event: ")) ev = line[7..];
else if (line.StartsWith("data: ") && ev == "delta")
raw.Append(JsonDocument.Parse(line[6..]).RootElement
.GetProperty("text").GetString());
}
var result = JsonDocument.Parse(raw.ToString());
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
LAYERS = """\
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
"""
MATRIX = """\
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
"""
METHODS = """\
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.
"""
align = {
"task": "align",
"layers": LAYERS,
"matrix": MATRIX,
"methods": METHODS,
"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": prescan_facts,
}
job_id = call("/run", align,
extra_headers={"Idempotency-Key": "geo-desk:align:" + input_hash + ":a1"})["job_id"]
# ... poll, then:
out = json.loads(job["output"]["output"])
print(out["body"]["resampling_plan"]["statement"])
for p in out["body"]["nodata_plan"]["points"]:
print(" -", p)
const LAYERS = `\
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
`;
const MATRIX = `\
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
`;
const METHODS = `\
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.
`;
const align = {
task: "align",
layers: LAYERS,
matrix: MATRIX,
methods: METHODS,
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: prescanFacts
};
const { job_id } = await call("/run", align, "POST",
{ "Idempotency-Key": `geo-desk:align:${inputHash}:a1` });
// ... poll, then:
console.log(out.body.resampling_plan.statement);
out.body.nodata_plan.points.forEach((p) => console.log(" -", p));
const layers = `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
`
const matrix = `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
`
const methods = `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.
`
align := map[string]any{
"task": "align",
"layers": layers,
"matrix": matrix,
"methods": methods,
"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": prescanFacts,
}
raw, err := call("/run", align, "POST",
map[string]string{"Idempotency-Key": "geo-desk:align:" + inputHash + ":a1"})
static final String LAYERS = """
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
""";
static final String MATRIX = """
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
""";
static final String METHODS = """
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.
""";
// Build the JSON with a real library; the field order does not matter.
String align = jsonOf(
"task", "align",
"layers", LAYERS,
"matrix", MATRIX,
"methods", METHODS,
"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", prescanFacts);
String started = GeoDesk.call("/run", align, "POST",
"geo-desk:align:" + inputHash + ":a1");
LAYERS = <<~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
MATRIX = <<~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
METHODS = <<~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
align = {
task: "align",
layers: LAYERS,
matrix: MATRIX,
methods: METHODS,
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: prescan_facts
}
job_id = call("/run", align, "POST",
{ "Idempotency-Key" => "geo-desk:align:#{input_hash}:a1" })["job_id"]
<?php
$layers = <<<'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;
$matrix = <<<'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;
$methods = <<<'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;
$align = [
"task" => "align",
"layers" => $layers,
"matrix" => $matrix,
"methods" => $methods,
"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" => $prescanFacts,
];
$jobId = call("/run", $align, "POST",
["Idempotency-Key: geo-desk:align:" . $inputHash . ":a1"])["job_id"];
const string Layers = """
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
""";
const string Matrix = """
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
""";
const string Methods = """
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.
""";
var align = new {
task = "align",
layers = Layers,
matrix = Matrix,
methods = Methods,
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 = prescanFacts
};
var started = await GeoDesk.Call("/run", align, "POST",
$"geo-desk:align:{inputHash}:a1");
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
validate = dict(align,
task="validate",
design="stratified_random", # fills a silence only; the prose wins
claim="The map is 90.6% accurate across the study area.",
context="Training and validation points were collected on the same field days.")
job_id = call("/run", validate, extra_headers={
"Idempotency-Key": "geo-desk:validate:" + input_hash + ":a1"})["job_id"]
# ... poll, then:
out = json.loads(job["output"]["output"])
print(out["body"]["leakage_review"]["statement"])
print(out["body"]["redesign_plan"]["points"])
for r in out["reconciliation"]:
print(r["flag_uid"], r["status"], r["note"])
const validate = {
...align,
task: "validate",
design: "stratified_random", // fills a silence only; the prose wins
claim: "The map is 90.6% accurate across the study area.",
context: "Training and validation points were collected on the same field days."
};
const { job_id } = await call("/run", validate, "POST",
{ "Idempotency-Key": `geo-desk:validate:${inputHash}:a1` });
// ... poll, then:
console.log(out.body.leakage_review.statement);
console.log(out.body.allocation_review.points.join("\n"));
validate := map[string]any{}
for k, v := range align {
validate[k] = v
}
validate["task"] = "validate"
validate["design"] = "stratified_random" // fills a silence only; the prose wins
validate["claim"] = "The map is 90.6% accurate across the study area."
validate["context"] = "Training and validation points were collected on the same field days."
raw, err := call("/run", validate, "POST",
map[string]string{"Idempotency-Key": "geo-desk:validate:" + inputHash + ":a1"})
// Same three texts, new routing fields.
String validate = jsonOf(
"task", "validate",
"layers", LAYERS,
"matrix", MATRIX,
"methods", METHODS,
"product", "categorical",
"design", "stratified_random", // fills a silence only; the prose wins
"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", prescanFacts);
String started = GeoDesk.call("/run", validate, "POST",
"geo-desk:validate:" + inputHash + ":a1");
validate = align.merge(
task: "validate",
design: "stratified_random", # fills a silence only; the prose wins
claim: "The map is 90.6% accurate across the study area.",
context: "Training and validation points were collected on the same field days."
)
job_id = call("/run", validate, "POST",
{ "Idempotency-Key" => "geo-desk:validate:#{input_hash}:a1" })["job_id"]
<?php
$validate = array_merge($align, [
"task" => "validate",
"design" => "stratified_random", // fills a silence only; the prose wins
"claim" => "The map is 90.6% accurate across the study area.",
"context" => "Training and validation points were collected on the same field days.",
]);
$jobId = call("/run", $validate, "POST",
["Idempotency-Key: geo-desk:validate:" . $inputHash . ":a1"])["job_id"];
var validate = new {
task = "validate",
layers = Layers,
matrix = Matrix,
methods = Methods,
product = "categorical",
design = "stratified_random", // fills a silence only; the prose wins
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 = prescanFacts
};
var started = await GeoDesk.Call("/run", validate, "POST",
$"geo-desk:validate:{inputHash}:a1");
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.
report = dict(align,
task="report",
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.")
job_id = call("/run", report, extra_headers={
"Idempotency-Key": "geo-desk:report:" + input_hash + ":a1"})["job_id"]
# ... poll, then:
out = json.loads(job["output"]["output"])
print(out["body"]["accuracy_statement"]["statement"])
print(out["body"]["area_statement"]["points"])
print(out["body"]["drafted_methods"]["statement"]) # paste-ready paragraph
for c in out["context_notes"]:
print(c["status"], "-", c["claim"]) # honoured | contradicted | unverifiable
const report = {
...align,
task: "report",
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."
};
const { job_id } = await call("/run", report, "POST",
{ "Idempotency-Key": `geo-desk:report:${inputHash}:a1` });
// ... poll, then:
console.log(out.body.estimator_review.statement);
console.log(out.body.drafted_methods.statement);
out.context_notes.forEach((c) => console.log(c.status, c.claim));
report := map[string]any{}
for k, v := range align {
report[k] = v
}
report["task"] = "report"
report["design"] = "stratified_random"
report["claim"] = "Forest covers 41,820 ha of the study area, mapped at 90.6% overall accuracy."
report["context"] = "The figure goes into a national reporting annex."
raw, err := call("/run", report, "POST",
map[string]string{"Idempotency-Key": "geo-desk:report:" + inputHash + ":a1"})
String report = jsonOf(
"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", prescanFacts);
String started = GeoDesk.call("/run", report, "POST",
"geo-desk:report:" + inputHash + ":a1");
// body.drafted_methods.statement is the paragraph to paste into the manuscript.
report = align.merge(
task: "report",
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."
)
job_id = call("/run", report, "POST",
{ "Idempotency-Key" => "geo-desk:report:#{input_hash}:a1" })["job_id"]
<?php
$report = array_merge($align, [
"task" => "report",
"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.",
]);
$jobId = call("/run", $report, "POST",
["Idempotency-Key: geo-desk:report:" . $inputHash . ":a1"])["job_id"];
var report = new {
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 = prescanFacts
};
var started = await GeoDesk.Call("/run", report, "POST",
$"geo-desk:report:{inputHash}:a1");
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.
import hashlib
def idem_key(body, attempt=1):
material = "\x1f".join([
body["task"], # the LANE is part of the content
body["layers"], body["matrix"], body.get("methods", ""),
body.get("product", ""), body.get("design", ""), body.get("claim", ""),
])
digest = hashlib.sha256(material.encode("utf-8")).hexdigest()[:10]
return "geo-desk:%s:%s:a%d" % (body["task"], digest, attempt)
key = idem_key(align) # geo-desk:align:9f31c7a4e0:a1
job = call("/run", align, extra_headers={"Idempotency-Key": key})
# One reformat retry, same input, bumped attempt -- cannot double-bill the first one.
retry = call("/run", align, extra_headers={"Idempotency-Key": idem_key(align, 2)})
async function idemKey(body, attempt = 1) {
const material = [
body.task, // the LANE is part of the content
body.layers, body.matrix, body.methods || "",
body.product || "", body.design || "", body.claim || ""
].join("\u001f");
const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(material));
const hex = [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
return `geo-desk:${body.task}:${hex.slice(0, 10)}:a${attempt}`;
}
const key = await idemKey(align); // geo-desk:align:9f31c7a4e0:a1
await call("/run", align, "POST", { "Idempotency-Key": key });
func idemKey(task, layers, matrix, methods, product, design, claim string, attempt int) string {
material := strings.Join([]string{
task, // the LANE is part of the content
layers, matrix, methods, product, design, claim,
}, "\x1f")
sum := sha256.Sum256([]byte(material))
return fmt.Sprintf("geo-desk:%s:%s:a%d", task, hex.EncodeToString(sum[:])[:10], attempt)
}
key := idemKey("align", layers, matrix, methods, "categorical", "", claim, 1)
raw, err := call("/run", align, "POST", map[string]string{"Idempotency-Key": key})
static String idemKey(String task, String layers, String matrix, String methods,
String product, String design, String claim, int attempt)
throws Exception {
String material = String.join("\u001f",
task, // the LANE is part of the content
layers, matrix, methods, product, design, claim);
byte[] d = java.security.MessageDigest.getInstance("SHA-256")
.digest(material.getBytes("UTF-8"));
StringBuilder hex = new StringBuilder();
for (byte b : d) hex.append(String.format("%02x", b));
return "geo-desk:" + task + ":" + hex.substring(0, 10) + ":a" + attempt;
}
require "digest"
def idem_key(body, attempt = 1)
material = [
body[:task], # the LANE is part of the content
body[:layers], body[:matrix], body[:methods].to_s,
body[:product].to_s, body[:design].to_s, body[:claim].to_s
].join("\x1f")
digest = Digest::SHA256.hexdigest(material)[0, 10]
"geo-desk:#{body[:task]}:#{digest}:a#{attempt}"
end
key = idem_key(align) # geo-desk:align:9f31c7a4e0:a1
call("/run", align, "POST", { "Idempotency-Key" => key })
<?php
function idem_key(array $body, int $attempt = 1): string {
$material = implode("\x1f", [
$body["task"], // the LANE is part of the content
$body["layers"], $body["matrix"], $body["methods"] ?? "",
$body["product"] ?? "", $body["design"] ?? "", $body["claim"] ?? "",
]);
$digest = substr(hash("sha256", $material), 0, 10);
return "geo-desk:" . $body["task"] . ":" . $digest . ":a" . $attempt;
}
$key = idem_key($align); // geo-desk:align:9f31c7a4e0:a1
call("/run", $align, "POST", ["Idempotency-Key: " . $key]);
static string IdemKey(string task, string layers, string matrix, string methods,
string product, string design, string claim, int attempt = 1)
{
var material = string.Join("\u001f",
task, // the LANE is part of the content
layers, matrix, methods, product, design, claim);
var bytes = System.Security.Cryptography.SHA256.HashData(
Encoding.UTF8.GetBytes(material));
var hex = Convert.ToHexString(bytes).ToLowerInvariant()[..10];
return $"geo-desk:{task}:{hex}:a{attempt}";
}
var key = IdemKey("align", Layers, Matrix, Methods, "categorical", "", claim);
var started = await GeoDesk.Call("/run", align, "POST", key);
5. Rate limits and costs
POST /guest,GET /meandPOST /estimateare free./estimatecreates no job and can be called as often as you like within the rate limit.POST /runandPOST /run-streamare metered. You are chargedcharged_creditsfrom the finished job, not thehold_creditsreservation — the hold is released and the real cost is usually well below it.hold_creditsis per lane. On the worked study above it runs roughly 2,870 foralign, 3,110 forvalidateand 3,640 forreport, which writes the longest output. Estimate the lane you are about to run.- The model is
gpt-5.6-terrabehind the aliasgpt-terra, atmarkup_bps: 1000. If/estimatereports anything else, stop and check which app the token was minted for. - If the balance sits between
min_creditsandhold_creditsthe run still executes, under a reduced output cap, and comes back withtruncated: true. Surface that — a clipped set of sections presented as complete is exactly the failure this app exists to catch. - Poll
GET /jobs/{job_id}on a fixed interval of 1.5 to 2 seconds. Back off on a429. Never tight-loop. - Guest tokens expire. A
401mid-session on a token that worked five minutes ago meansexpires_atpassed — mint another, or sign in for a personal token.
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.