Drive Carto Desk from your own code
Everything the web app does is one HTTP API away. Base URL:
https://api.skillsafe.ai/v1/app-api, scoped to this app by the token you send.
Every response is wrapped in an envelope: success is {"ok":true,"data":{…}}, failure is
{"ok":false,"error":{"code":"…","message":"…","details":{…}}}.
Error codes you will actually meet
| Code | HTTP | What it means | What to do |
|---|---|---|---|
UNAUTHORIZED | 401 | Missing, malformed or expired token. | Mint a new one from the token panel. |
FORBIDDEN | 403 | The token belongs to another app. | Use a token minted for carto-desk. |
INSUFFICIENT_CREDITS | 402 | Balance is under min_credits. | Compare /me against /estimate before running. |
VALIDATION_ERROR | 400 | The input object is the wrong shape. | Check error.details; task and style are required. |
RATE_LIMITED | 429 | Too many requests. | Back off; do not tight-loop a poll. |
JOB_FAILED | 200 | The job reached a terminal failed status. | Read job.error; retry with the same idempotency key is safe. |
The task field comes first
This app has four lanes over one work object. task selects the lane and is the field to get right before any other: it decides the checks, the extra block and the price. An unrecognised value is answered by the closest lane, which names itself in lane — so always read lane back rather than assuming.
task | Lane | Answers | Extra block | Source skill |
|---|---|---|---|---|
audit | Audit | Does this style parse, resolve and ship? | blocking[] | @mapbox/mapbox-style-quality |
carto | Cartography | Will a reader find what they came for? | palette[] | @mapbox/mapbox-cartography |
dataviz | Data layers | Are the data layers encoding the data honestly? | recipes[] | @mapbox/mapbox-data-visualization-patterns |
perf | Render cost | What does this style cost to draw? | budget{} + wins[] | @mapbox/mapbox-web-performance-patterns |
One worked example per lane
task: "audit" — Audit
Request body:
{"task":"audit","style":"…the style.json…","usage":"dispatcher wall display","audience":"field","emphasis":"general","carryover":"","prescan_facts":{"stats":{"layer_count":15,"symbol_layers":5,"sources":3},"flags":[{"id":"SC-001","title":"Mapbox secret token embedded in the style","severity":"critical","layer":"","detail":"…"}]}}
The reply is the shared envelope plus this lane's block:
{ …envelope…, "blocking": ["Rotate the sk. token and remove it from the document", "Declare a glyphs URL or no label renders"] }
task: "carto" — Cartography
Request body:
{"task":"carto","style":"…the style.json…","usage":"driver phone app","audience":"field","emphasis":"legibility","carryover":"Previous lane: Audit. Verdict: rework…","prescan_facts":{"stats":{},"flags":[]}}
The reply is the shared envelope plus this lane's block:
{ …envelope…, "palette": [{"swatch":"#12161c","role":"background","verdict":"keep","note":"the darkest ground, and the only value the labels are read against"}] }
task: "dataviz" — Data layers
Request body:
{"task":"dataviz","style":"…the style.json…","usage":"public air-quality map","audience":"general","emphasis":"data","carryover":"","prescan_facts":{"stats":{},"flags":[]}}
The reply is the shared envelope plus this lane's block:
{ …envelope…, "recipes": [{"layer":"tract-choropleth","technique":"choropleth","expression":"[\"interpolate\",[\"linear\"],[\"get\",\"pm25_total\"],0,\"#f7fbff\",50,\"#08306b\"]","why":"a sequential ramp for a magnitude, not a diverging one"}] }
task: "perf" — Render cost
Request body:
{"task":"perf","style":"…the style.json…","usage":"mid-range Android, GL JS 3.x, map.on('load') hydrates the driver source","audience":"field","emphasis":"speed","carryover":"","prescan_facts":{"stats":{"layer_count":15,"symbol_layers":5,"sources":3},"flags":[]}}
The reply is the shared envelope plus this lane's block:
{ …envelope…, "budget": {"layer_count":15,"symbol_layers":5,"sources":3,"verdict":"tight","note":"…"}, "wins": [{"action":"Gate the label layers to z12+","impact":"high","effort":"S"}] }
The input contract
These are the exact fields the web app submits — taken from its run path, not from intent.
| Field | Type | Required | Notes |
|---|---|---|---|
task | string | yes | audit, carto, dataviz or perf. |
style | string | yes | The Mapbox GL style JSON as text. The app clips at 60 000 characters, from the middle, keeping both ends and marking the cut in-band. |
usage | string | no | What the map is for. The perf lane also reads GL JS init code pasted here. |
audience | string | no | general, analyst, field, print, unknown. |
emphasis | string | no | general, legibility, accessibility, data, speed. Orders findings; never hides them. |
carryover | string | no | A digest of a previous lane's review. The model acknowledges it and does not re-litigate it. |
prescan_facts | object | no | {stats, flags[]} from a client-side scan. Every flags[].id must come back in coverage_check. |
retry_note | string | no | Sent only on a reformat retry. A correction directive about the model's own previous reply — never user content, and never anything about the style. The web app sets it when a reply fails to parse and runs one extra attempt under a new idempotency key. |
retry_note is the only field that is not about the user's style. It exists because a
reply that is not one JSON object is unusable, and one named retry is cheaper than making a person
press the button again. If you drive this API yourself you can leave it out entirely; if you do
send it, send what was wrong with the previous reply and nothing else.
The output contract
The model replies with one JSON object as the job's output.output string. The envelope
is identical in every lane, so one parser handles all four:
{
"lane": "audit",
"title": "…",
"style_name": "…",
"verdict": "ship | fix-first | rework",
"headline": "…",
"summary": "…",
"checks": [{"name": "…", "status": "pass | warn | fail | unknown", "note": "…"}],
"findings": [{"id": "CD-001", "title": "…", "severity": "critical | high | medium | low",
"area": "spec | accessibility | palette | typography | expressions | performance | tokens | data",
"layer": "an exact layer id from the style, or \"\"",
"detail": "…", "fix": "…", "snippet": "…"}],
"coverage_check": [{"prescan_id": "SC-001", "status": "confirmed | set-aside", "note": "…"}],
"next_steps": ["…"]
}
checks carries that lane's named checks in the lane's order — ten for
audit, eight for each of the others. The app renders the canonical list and marks any
the model dropped as not reported rather than silently shortening the table, so a client
should do the same.
Step by step
1. Get a token
Every call needs Authorization: Bearer <token>. Two ways to get one:
- Your own account token — open the token panel, sign in, and copy it. This is the one that spends your credits and sees your review history.
- A guest token —
POST /guestmints an anonymous subject. Guests can call/estimateand read/app-info; running costs credits, so a guest can only run when the publisher sponsors usage, which this app does not by default.
Keep the token out of your source. Read it from an environment variable at runtime and never commit it.
TOKEN="YOUR_TOKEN"
curl -s -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
-H "Authorization: Bearer $TOKEN"
# -> {"data":{"token":"aut_...","subject_type":"guest"}}
import os, json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = os.environ["CARTO_DESK_TOKEN"] # never hard-code it
def call(path, payload=None, method=None):
data = json.dumps(payload).encode() if payload is not None else None
req = urllib.request.Request(BASE + path, data=data,
method=method or ("POST" if data else "GET"))
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r:
return json.load(r)["data"]
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = globalThis.CARTO_DESK_TOKEN; // injected at runtime, not committed
async function call(path, payload, method) {
const res = await fetch(BASE + path, {
method: method || (payload ? "POST" : "GET"),
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
body: payload ? JSON.stringify(payload) : undefined
});
const json = await res.json();
if (!res.ok) throw new Error(json.error?.code + ": " + json.error?.message);
return json.data;
}
package main
import (
"bytes"
"encoding/json"
"net/http"
"os"
)
const base = "https://api.skillsafe.ai/v1/app-api"
func call(path string, payload any) (map[string]any, error) {
var body *bytes.Reader
method := "GET"
if payload != nil {
b, _ := json.Marshal(payload)
body = bytes.NewReader(b)
method = "POST"
} else {
body = bytes.NewReader(nil)
}
req, _ := http.NewRequest(method, base+path, body)
req.Header.Set("Authorization", "Bearer "+os.Getenv("CARTO_DESK_TOKEN"))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var out struct{ Data map[string]any }
json.NewDecoder(res.Body).Decode(&out)
return out.Data, nil
}
import java.net.URI;
import java.net.http.*;
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String TOKEN = System.getenv("CARTO_DESK_TOKEN");
static String call(String path, String json) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json");
b = (json == null) ? b.GET() : b.POST(HttpRequest.BodyPublishers.ofString(json));
return HttpClient.newHttpClient()
.send(b.build(), HttpResponse.BodyHandlers.ofString()).body();
}
require "json"
require "net/http"
BASE = URI("https://api.skillsafe.ai/v1/app-api")
TOKEN = ENV.fetch("CARTO_DESK_TOKEN")
def call(path, payload = nil)
uri = URI(BASE.to_s + path)
req = payload ? Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req.body = JSON.dump(payload) if payload
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
JSON.parse(res.body)["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
$token = getenv("CARTO_DESK_TOKEN");
function call(string $path, ?array $payload = null) {
global $token;
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer $token", "Content-Type: application/json"],
]);
if ($payload !== null) {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
}
$body = json_decode(curl_exec($ch), true);
curl_close($ch);
return $body["data"] ?? $body;
}
using System.Net.Http.Json;
const string Base = "https://api.skillsafe.ai/v1/app-api";
var token = Environment.GetEnvironmentVariable("CARTO_DESK_TOKEN");
var http = new HttpClient();
http.DefaultRequestHeaders.Add("Authorization", $"Bearer {token}");
async Task<JsonElement> Call(string path, object? payload = null) {
var res = payload is null
? await http.GetAsync(Base + path)
: await http.PostAsJsonAsync(Base + path, payload);
var doc = await res.Content.ReadFromJsonAsync<JsonElement>();
return doc.GetProperty("data");
}
2. Check the session and the balance
GET /me tells you which subject the token belongs to and how many credits it
holds. Do this before a run: comparing the balance against the estimate's hold_credits
is how you avoid a 402 after submitting.
curl -s -X GET "https://api.skillsafe.ai/v1/app-api/me" \
-H "Authorization: Bearer $TOKEN"
me = call("/me")
print(me["subject_type"], me["credits"])
const me = await call("/me");
console.log(me.subject_type, me.credits);
me, _ := call("/me", nil)
fmt.Println(me["subject_type"], me["credits"])
String me = call("/me", null);
System.out.println(me);
me = call("/me")
puts "#{me['subject_type']} #{me['credits']}"
$me = call("/me");
echo $me["subject_type"], " ", $me["credits"], PHP_EOL;
var me = await Call("/me");
Console.WriteLine($"{me.GetProperty("subject_type")} {me.GetProperty("credits")}");
3. Estimate the lane — free, no job
POST /estimate costs nothing and creates no job. It returns model,
model_alias, markup_bps, hold_credits, min_credits
and sponsor_enabled.
Estimate the lane you are about to run. The hold differs per lane, because the
prompt sections and output caps differ — an estimate for audit does not price
perf. The app re-estimates on every lane switch for exactly this reason.
curl -s -X POST "https://api.skillsafe.ai/v1/app-api/estimate" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"task":"audit","style":"{\"version\":8,\"name\":\"Delivery Ops Dark\",...}","usage":"dispatcher wall display","audience":"field","emphasis":"general","carryover":"","prescan_facts":{"stats":{},"flags":[]}}'
inp = {"task": "audit", "style": style_text, "usage": "", "audience": "field",
"emphasis": "general", "carryover": "", "prescan_facts": {"stats": {}, "flags": []}}
est = call("/estimate", inp)
print(est["model"], est["model_alias"], est["hold_credits"], est["min_credits"])
const input = { task: "audit", style: styleText, usage: "", audience: "field",
emphasis: "general", carryover: "", prescan_facts: { stats: {}, flags: [] } };
const est = await call("/estimate", input);
console.log(est.model, est.hold_credits, est.min_credits);
input := map[string]any{"task": "audit", "style": styleText, "audience": "field",
"emphasis": "general", "carryover": "", "prescan_facts": map[string]any{}}
est, _ := call("/estimate", input)
fmt.Println(est["model"], est["hold_credits"])
String body = "{\"task\":\"audit\",\"style\":" + jsonQuoted(styleText)
+ ",\"usage\":\"\",\"audience\":\"field\",\"emphasis\":\"general\""
+ ",\"carryover\":\"\",\"prescan_facts\":{\"stats\":{},\"flags\":[]}}";
System.out.println(call("/estimate", body));
est = call("/estimate", { "task" => "audit", "style" => style_text,
"audience" => "field", "emphasis" => "general",
"carryover" => "", "prescan_facts" => {} })
puts est["hold_credits"]
$est = call("/estimate", ["task" => "audit", "style" => $styleText,
"audience" => "field", "emphasis" => "general",
"carryover" => "", "prescan_facts" => new stdClass()]);
echo $est["hold_credits"], PHP_EOL;
var est = await Call("/estimate", new {
task = "audit", style = styleText, audience = "field",
emphasis = "general", carryover = "", prescan_facts = new { }
});
Console.WriteLine(est.GetProperty("hold_credits"));
4. Run it, then poll the job
POST /run returns {"job_id"} immediately; poll
GET /jobs/{job_id} until status is terminal. Always send an
Idempotency-Key header: a retried request with the same key returns the original job
instead of billing a second run. Make the key a hash of the lane plus the input — two lanes
over the same style are two distinct runs and must not collide on one key.
curl -s -X POST "https://api.skillsafe.ai/v1/app-api/run" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"task":"audit","style":"{\"version\":8,\"name\":\"Delivery Ops Dark\",...}","usage":"dispatcher wall display","audience":"field","emphasis":"general","carryover":"","prescan_facts":{"stats":{},"flags":[]}}' \
-H "Idempotency-Key: carto-desk:audit:9f2a1c04:a1"
curl -s -X GET "https://api.skillsafe.ai/v1/app-api/jobs/job_123" \
-H "Authorization: Bearer $TOKEN"
import hashlib, time
key = "carto-desk:" + inp["task"] + ":" + hashlib.sha256(
(inp["task"] + inp["style"]).encode()).hexdigest()[:12] + ":a1"
req = urllib.request.Request(BASE + "/run", data=json.dumps(inp).encode(), method="POST")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key)
job = json.load(urllib.request.urlopen(req))["data"]
while True:
j = call("/jobs/" + job["job_id"])
if j["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(1.5)
review = json.loads(j["output"]["output"])
const key = `carto-desk:${input.task}:${hash(input.task + input.style)}:a1`;
const res = await fetch(`${BASE}/run`, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json",
"Idempotency-Key": key },
body: JSON.stringify(input)
});
const { data: job } = await res.json();
let j;
do {
await new Promise(r => setTimeout(r, 1500));
j = await call(`/jobs/${job.job_id}`);
} while (!["succeeded", "failed", "cancelled"].includes(j.status));
const review = JSON.parse(j.output.output);
b, _ := json.Marshal(input)
req, _ := http.NewRequest("POST", base+"/run", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+os.Getenv("CARTO_DESK_TOKEN"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "carto-desk:audit:9f2a1c04:a1")
res, _ := http.DefaultClient.Do(req)
// then poll GET /jobs/{job_id} until status is terminal
HttpRequest run = HttpRequest.newBuilder(URI.create(BASE + "/run"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", "carto-desk:audit:9f2a1c04:a1")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
// then poll GET /jobs/{job_id} until status is terminal
uri = URI(BASE.to_s + "/run")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = "carto-desk:audit:9f2a1c04:a1"
req.body = JSON.dump(input)
# then poll GET /jobs/{job_id} until status is terminal
$ch = curl_init(BASE . "/run");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($input),
CURLOPT_HTTPHEADER => [
"Authorization: Bearer $token",
"Content-Type: application/json",
"Idempotency-Key: carto-desk:audit:9f2a1c04:a1",
],
]);
$job = json_decode(curl_exec($ch), true)["data"];
// then poll GET /jobs/{job_id} until status is terminal
var msg = new HttpRequestMessage(HttpMethod.Post, Base + "/run") {
Content = JsonContent.Create(input)
};
msg.Headers.Add("Idempotency-Key", "carto-desk:audit:9f2a1c04:a1");
var job = await http.SendAsync(msg);
// then poll GET /jobs/{job_id} until status is terminal
5. Or stream it
POST /run-stream is the same call over server-sent events. Events are
job (the job id, as soon as it exists), delta (text fragments as they are
produced) and done (the terminal job, including charged_credits and
truncated). The app uses this lane so the staged progress card can advance on the
section markers as they arrive.
Send the same Idempotency-Key you would send to /run. If the stream
dies mid-flight, keep what arrived: the app closes the truncated JSON and renders whatever sections
parsed rather than throwing the run away.
curl -s -X POST "https://api.skillsafe.ai/v1/app-api/run-stream" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"task":"audit","style":"{\"version\":8,\"name\":\"Delivery Ops Dark\",...}","usage":"dispatcher wall display","audience":"field","emphasis":"general","carryover":"","prescan_facts":{"stats":{},"flags":[]}}' \
-H "Idempotency-Key: carto-desk:audit:9f2a1c04:a1" \
-N
req = urllib.request.Request(BASE + "/run-stream", data=json.dumps(inp).encode(), method="POST")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key)
buf = ""
with urllib.request.urlopen(req) as stream:
for raw in stream:
line = raw.decode().strip()
if line.startswith("data:"):
evt = json.loads(line[5:])
if evt.get("type") == "delta":
buf += evt["text"]
const res = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json",
"Idempotency-Key": key },
body: JSON.stringify(input)
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
for (;;) {
const { value, done } = await reader.read();
if (done) break;
for (const line of decoder.decode(value).split("\n")) {
if (!line.startsWith("data:")) continue;
const evt = JSON.parse(line.slice(5));
if (evt.type === "delta") buf += evt.text;
}
}
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+os.Getenv("CARTO_DESK_TOKEN"))
req.Header.Set("Idempotency-Key", "carto-desk:audit:9f2a1c04:a1")
res, _ := http.DefaultClient.Do(req)
scanner := bufio.NewScanner(res.Body)
for scanner.Scan() {
line := scanner.Text() // "data: {...}"
_ = line
}
HttpResponse<Stream<String>> res = HttpClient.newHttpClient().send(
HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Idempotency-Key", "carto-desk:audit:9f2a1c04:a1")
.POST(HttpRequest.BodyPublishers.ofString(body)).build(),
HttpResponse.BodyHandlers.ofLines());
res.body().filter(l -> l.startsWith("data:")).forEach(System.out::println);
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
req = Net::HTTP::Post.new(URI(BASE.to_s + "/run-stream"))
req["Authorization"] = "Bearer #{TOKEN}"
req["Idempotency-Key"] = "carto-desk:audit:9f2a1c04:a1"
req.body = JSON.dump(input)
http.request(req) { |res| res.read_body { |chunk| print chunk } }
end
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($input),
CURLOPT_HTTPHEADER => ["Authorization: Bearer $token",
"Idempotency-Key: carto-desk:audit:9f2a1c04:a1"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) { echo $chunk; return strlen($chunk); },
]);
curl_exec($ch);
var msg = new HttpRequestMessage(HttpMethod.Post, Base + "/run-stream") {
Content = JsonContent.Create(input)
};
msg.Headers.Add("Idempotency-Key", "carto-desk:audit:9f2a1c04:a1");
using var res = await http.SendAsync(msg, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
while (await reader.ReadLineAsync() is string line) Console.WriteLine(line);
Rate limits and good manners
- Poll no faster than once a second, and back off on
429. - Reuse one idempotency key per (lane, input, attempt). A retry with the same key returns the original job rather than billing twice.
/estimateis free — call it before every run and compare against/me.- Never put a token in client-side source or a repository. Read it from the environment.
Attribution
Carto Desk is a derived work built on @mapbox/mapbox-style-quality,
@mapbox/mapbox-cartography, @mapbox/mapbox-data-visualization-patterns
and @mapbox/mapbox-web-performance-patterns. Mapbox and Mapbox GL JS are trademarks of
Mapbox, Inc.; this app is not affiliated with or endorsed by Mapbox.