← Geofeed Desk / API
Tokens

Drive Geofeed Desk from your own code

The page checks an RFC 8805 geofeed in your browser for free. Its one paid step, the tune, is available over HTTP: you send one JSON object with task: "tune" and a facts string that describes the check’s findings, and the model replies with one JSON object that says what each flagged location should become, what you must answer before publishing, and how to publish.

Base URL: https://api.skillsafe.ai/v1/app-api. Every call sends Content-Type: application/json and, once you have a token, Authorization: Bearer <token>.

The response envelope

Responses are JSON of the form {"ok": ..., "data": ..., "error": {...}}. The page’s SDK (sdk.js) decides success from the HTTP status, not from ok: on a 2xx it returns data; otherwise it raises an error whose message is error.message (or the HTTP status text when the body carries none), with status, code (error.code) and details (error.details) attached. A body that is not JSON is treated as {}.

Errors the page handles by name

WhatWhat the page does
HTTP 401Treats the session as expired: forgets the signed-in user and says “Your session expired - sign in again.”
HTTP 402“Not enough credits for this run.” with a top-up link. The page also tries never to submit into a 402: it disables the run button when the balance from /me is below the estimate’s min_credits, and warns when it is below hold_credits that the tune may be cut short.
event: error on /run-streamThe SDK raises an error carrying the event’s message, code and job_id.
Job status: "failed"Polling stops; the job’s error field says why.
Polling past 180 sThe SDK’s own client-side limit (waitForJob, 1 s interval, 180 000 ms default): it throws “job timed out”. The job itself is not cancelled.

Every other failure arrives the same way: a non-2xx status with error.code and error.message in the body. Branch on error.code, show error.message.

The task field: one lane, tune

The app has exactly one lane. task is always the string "tune". (The skill’s instructions tell the model to do the tune even if task is missing or different, and to answer with "task": "tune"; send it anyway.)

Input fields

This is exactly what the page sends (geokit.js buildInput, app.js run):

FieldTypeRequiredMeaning
taskstringyesAlways "tune".
factsstringyesThe facts object below, JSON-encoded into a string. The page builds it with JSON.stringify(facts); you must do the same. Sending an object here is not what the page does and not what the model is told to expect (“a JSON string. Parse it.”).
questionstringnoSomething you want answered; the model answers it in summary. The page trims it, sends it only when non-empty, and cuts it to 1000 characters.
retry_notestringnoSent only by the page’s automatic reformat retry: when a reply does not parse as JSON (even after the page tries to close a cut-off object), the page runs once more with a note saying the previous reply was not the single JSON object required. A normal call never sends it.

The top-level body must be a JSON object; the page refuses to send anything else (mustBeObject). Nothing else is sent: no feed text, no rows. The page runs the whole check in the browser and sends only the findings that need judgement.

The facts object

What buildFacts produces from the browser’s check (key order as built):

KeyContents
feedname (up to 120 chars), counts entries, ipv4, ipv6, invalid, errors, warnings, suggestions, ok; accuracy {city, region, country, do_not_geolocate}; booleans header_row, signature_block, held_space_given; nested_prefixes; line_endings; publish_url (up to 300 chars, may be empty).
verdict_floorThe browser’s verdict: ready, fix_first or hold. The reply may not be less severe.
rule_countsRule ID → number of rows carrying it.
groupsLocation groups that need judgement (rows sharing country, region and city), most severe first, at most 80. Each: id (G1, G2, …), country, region, city as written, country_name, region_name, small_territory, ids, details (rule ID → finding), rows, up to 6 sample lines, up to 3 sample prefixes.
groups_totalHow many groups the check found (may exceed what was sent).
locationsEvery distinct location the feed names, flagged or not: country, region, city, rows, up to 4 lines; most rows first, at most 60.
locations_totalHow many distinct locations there are.
prefix_issuesProblems with the prefixes themselves: line, prefix, id, detail; one per duplicated prefix; at most 40.
prefix_issues_totalHow many prefix problems were found.
region_optionsCountry → list of [code, name] ISO 3166-2 pairs for the countries involved (at most 1200 pairs in all). The model may only choose a region from these.
notesPlain sentences about what was cut before sending (groups, prefix problems, locations, region lists over the caps; a file over the 2 000 000-character read limit).

Worked example: a two-line feed

The feed (the fifth, postal-code field is empty on both lines):

192.0.2.0/24,DE,,FRA,
2001:db8:100::/48,DE,DE-BE,Berlin,

With the name example-net and the publish URL https://example.net/geofeed.csv, the page’s check flags line 1’s city FRA as a code (1402) with no region (3303), and both prefixes as documentation space (1103), which is why the floor is hold. Line 2’s location is fine, so it appears in locations but not in groups. This is the facts object buildFacts produces for it; save it as facts.json to run the samples below:

{
  "feed": {
    "name": "example-net",
    "entries": 2,
    "ipv4": 1,
    "ipv6": 1,
    "invalid": 0,
    "errors": 2,
    "warnings": 0,
    "suggestions": 0,
    "ok": 0,
    "accuracy": { "city": 0, "region": 0, "country": 0, "do_not_geolocate": 0 },
    "header_row": false,
    "signature_block": false,
    "held_space_given": false,
    "nested_prefixes": 0,
    "line_endings": "LF",
    "publish_url": "https://example.net/geofeed.csv"
  },
  "verdict_floor": "hold",
  "rule_counts": { "1103": 2, "1402": 1, "3102": 1, "3303": 1 },
  "groups": [
    {
      "id": "G1",
      "country": "DE",
      "region": "",
      "city": "FRA",
      "country_name": "Germany",
      "region_name": "",
      "small_territory": false,
      "ids": ["1402", "3303"],
      "details": {
        "1402": "\"FRA\" reads as an airport, city or region code",
        "3303": "city \"FRA\" has no region"
      },
      "rows": 1,
      "lines": [1],
      "prefixes": ["192.0.2.0/24"]
    }
  ],
  "groups_total": 1,
  "locations": [
    { "country": "DE", "region": "", "city": "FRA", "rows": 1, "lines": [1] },
    { "country": "DE", "region": "DE-BE", "city": "Berlin", "rows": 1, "lines": [2] }
  ],
  "locations_total": 2,
  "prefix_issues": [
    { "line": 1, "prefix": "192.0.2.0/24", "id": "1103", "detail": "192.0.2.0/24 Documentation (TEST-NET-1) [RFC5737]" },
    { "line": 2, "prefix": "2001:db8:100::/48", "id": "1103", "detail": "2001:db8::/32 Documentation [RFC3849]" }
  ],
  "prefix_issues_total": 2,
  "region_options": {
    "DE": [
      ["DE-BB", "Brandenburg"], ["DE-BE", "Berlin"], ["DE-BW", "Baden-Württemberg"], ["DE-BY", "Bayern"],
      ["DE-HB", "Bremen"], ["DE-HE", "Hessen"], ["DE-HH", "Hamburg"], ["DE-MV", "Mecklenburg-Vorpommern"],
      ["DE-NI", "Niedersachsen"], ["DE-NW", "Nordrhein-Westfalen"], ["DE-RP", "Rheinland-Pfalz"], ["DE-SH", "Schleswig-Holstein"],
      ["DE-SL", "Saarland"], ["DE-SN", "Sachsen"], ["DE-ST", "Sachsen-Anhalt"], ["DE-TH", "Thüringen"]
    ]
  },
  "notes": []
}

The full request body, with facts stringified (this is byte-for-byte what buildInput(facts, "Can this go out this week?") returns):

{"task":"tune","facts":"{\"feed\":{\"name\":\"example-net\",\"entries\":2,\"ipv4\":1,\"ipv6\":1,\"invalid\":0,\"errors\":2,\"warnings\":0,\"suggestions\":0,\"ok\":0,\"accuracy\":{\"city\":0,\"region\":0,\"country\":0,\"do_not_geolocate\":0},\"header_row\":false,\"signature_block\":false,\"held_space_given\":false,\"nested_prefixes\":0,\"line_endings\":\"LF\",\"publish_url\":\"https://example.net/geofeed.csv\"},\"verdict_floor\":\"hold\",\"rule_counts\":{\"1103\":2,\"1402\":1,\"3102\":1,\"3303\":1},\"groups\":[{\"id\":\"G1\",\"country\":\"DE\",\"region\":\"\",\"city\":\"FRA\",\"country_name\":\"Germany\",\"region_name\":\"\",\"small_territory\":false,\"ids\":[\"1402\",\"3303\"],\"details\":{\"1402\":\"\\\"FRA\\\" reads as an airport, city or region code\",\"3303\":\"city \\\"FRA\\\" has no region\"},\"rows\":1,\"lines\":[1],\"prefixes\":[\"192.0.2.0/24\"]}],\"groups_total\":1,\"locations\":[{\"country\":\"DE\",\"region\":\"\",\"city\":\"FRA\",\"rows\":1,\"lines\":[1]},{\"country\":\"DE\",\"region\":\"DE-BE\",\"city\":\"Berlin\",\"rows\":1,\"lines\":[2]}],\"locations_total\":2,\"prefix_issues\":[{\"line\":1,\"prefix\":\"192.0.2.0/24\",\"id\":\"1103\",\"detail\":\"192.0.2.0/24 Documentation (TEST-NET-1) [RFC5737]\"},{\"line\":2,\"prefix\":\"2001:db8:100::/48\",\"id\":\"1103\",\"detail\":\"2001:db8::/32 Documentation [RFC3849]\"}],\"prefix_issues_total\":2,\"region_options\":{\"DE\":[[\"DE-BB\",\"Brandenburg\"],[\"DE-BE\",\"Berlin\"],[\"DE-BW\",\"Baden-Württemberg\"],[\"DE-BY\",\"Bayern\"],[\"DE-HB\",\"Bremen\"],[\"DE-HE\",\"Hessen\"],[\"DE-HH\",\"Hamburg\"],[\"DE-MV\",\"Mecklenburg-Vorpommern\"],[\"DE-NI\",\"Niedersachsen\"],[\"DE-NW\",\"Nordrhein-Westfalen\"],[\"DE-RP\",\"Rheinland-Pfalz\"],[\"DE-SH\",\"Schleswig-Holstein\"],[\"DE-SL\",\"Saarland\"],[\"DE-SN\",\"Sachsen\"],[\"DE-ST\",\"Sachsen-Anhalt\"],[\"DE-TH\",\"Thüringen\"]]},\"notes\":[]}","question":"Can this go out this week?"}

A reply that satisfies the contract below for these facts (and passes the page’s own re-check with no disagreements):

{
  "task": "tune",
  "verdict": "hold",
  "headline": "Hold: both prefixes are documentation space and cannot appear in a public feed.",
  "fixes": [
    {
      "group": "G1",
      "action": "replace",
      "country": "DE",
      "region": "DE-HE",
      "city": "Frankfurt am Main",
      "ids": ["1402", "3303"],
      "reason": "FRA is the airport code for Frankfurt am Main, which sits in Hessen (DE-HE); spelling the city out resolves 1402 and adding the region resolves 3303.",
      "confidence": "medium"
    }
  ],
  "questions": [
    {
      "lines": [1, 2],
      "ids": ["1103"],
      "question": "192.0.2.0/24 and 2001:db8:100::/48 are documentation ranges (RFC 5737, RFC 3849) and must be removed from a public feed. Which public prefixes were meant? Until they are replaced, nothing in this file can be published."
    }
  ],
  "publish": [
    "Replace both documentation prefixes with the address space you actually announce, then re-run the check.",
    "Serve the file over HTTPS at https://example.net/geofeed.csv.",
    "Reference that URL from each covering inetnum:/inet6num: object with a geofeed: attribute, or remarks: Geofeed <url> where the registry lacks the attribute."
  ],
  "summary": "Both rows use documentation address space, so the feed must hold until real prefixes are in place. The only location fix is line 1: FRA becomes Frankfurt am Main in DE-HE. Line 2 (Berlin, DE-BE) is already correct."
}

The output contract

The model is instructed (SKILL.md) to reply with one JSON object and nothing else — no prose, no code fences:

Every text field is plain text (no Markdown, no HTML).

The page re-checks the reply; the API does not

What comes back from the API is the model’s raw text in output.output. The page does more with it before showing anything (recon.js), and a direct API caller gets none of this:

If you call the API directly, do your own validation before you rewrite a feed with the reply.

Step 1 — a tiny client helper

One function per language that does what sdk.js does for every call: JSON in, bearer token if you have one, data out on a 2xx, an error carrying status, error.code and error.message otherwise. The later steps assume this helper is in the same file (for Go, Java and C#, the later snippets go inside main / the top-level statements).

BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="${SKILLSAFE_TOKEN:-YOUR_TOKEN}"   # see step 2

# api METHOD PATH [extra curl args...] -- prints the whole envelope; pipe to jq
api() {
  method="$1"; path="$2"; shift 2
  curl -sS -X "$method" "$BASE$path" \
    -H "Content-Type: application/json" \
    -H "Authorization: Bearer $TOKEN" "$@"
}
# success: jq '.data'      failure: jq '.error | {code, message}'

Step 2 — get a token

Two kinds of token work here:

# personal token: export SKILLSAFE_TOKEN=... (from /tokens.html) and skip this
# guest token: no Authorization header on this one call
TOKEN=$(curl -sS -X POST "$BASE/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"geofeed-desk"}' | jq -r '.data.token')
echo "guest token: ${TOKEN:0:12}..."

Step 3 — GET /me

Who the token belongs to and what it can spend. sdk.js documents the shape as {subject_type, subject_id, credits, profile?}. The page treats the caller as signed in only when subject_type is "user", shows credits as the balance (dividing by 10 000 for the dollar figure), and compares it with the estimate in step 4 before it lets you run.

api GET /me | jq '.data // .error'

Step 4 — POST /estimate

Price the tune before you run it. The body is the same input object you will send to /run (task, facts as a string, optional question); no job is created and nothing is charged. From the response the page reads four fields:

FieldHow the page uses it
hold_creditsThe reservation for the run, shown as “reserves N credits”. A balance below it is allowed but warned: the tune may be cut short.
min_creditsShown as the minimum when present. A balance below it disables the run button.
sponsor_enabledWhen true, the page lets a caller who is not signed in run the tune; otherwise it asks them to sign in first.
warningsA list; when non-empty the page shows it as “input warnings: …” (joined with “; ”).

The response also carries model, model_alias, markup_bps and input_checked; the page does not read them. The page re-prices whenever the input changes and ignores a reply that arrives for an input that is no longer current.

What we observed on 2026-09-28, calling /estimate directly with the three example inputs: every call returned model gpt-5.6-terra, model_alias gpt-terra and input_checked: true with no warnings. With a guest token markup_bps was 1000 and hold_credits was 1,262 to 1,356 (min_credits 120 to 213); the reservation grows with the size of facts. These numbers move with the model's rates, so read them from the call rather than hard-coding them. /estimate does check the body against the app's declared input fields: an empty body {} came back with the warnings missing required field 'task' and missing required field 'facts', and a body wrapped as {"input": {...}} also warned unknown field 'input'. Those are warnings, not refusals: the call still returned a hold, so check warnings before you run.

# facts.json = the facts object from the worked example; jq -c turns it into ONE string
BODY=$(jq -n --arg facts "$(jq -c . facts.json)" --arg q "Can this go out this week?" \
  '{task: "tune", facts: $facts, question: $q}')

api POST /estimate -d "$BODY" \
  | jq '.error // (.data | {hold_credits, min_credits, sponsor_enabled, warnings, model, model_alias, markup_bps})'

Step 5 — POST /run, then poll the job

Send the same input to /run with an Idempotency-Key header. Its data carries a job_id. Poll GET /jobs/{job_id} the way sdk.js waitForJob does: every second until status is succeeded or failed, giving up on the client side after 180 seconds. On the finished job, read:

Idempotency key. Resending a request with the same key must not start a second paid run, so derive the key from the input, not from the clock. The page’s own convention is geofeed-desk:tune:<hash>:a<attempt>, where <hash> is a 32-bit djb2 hash of JSON.stringify(input) in hex, a dash, and the string’s length in hex, and <attempt> is 1 for a normal run and 2 for the automatic reformat retry (which also adds retry_note). For the worked example with its question that is geofeed-desk:tune:69bd2c6c-77c:a1. That format is the page’s convention, not a requirement: any stable, unique string does. The samples below use a SHA-256 prefix.

The page itself uses /run-stream (step 6), not /run; both take the same body and header.

KEY="geofeed-desk:tune:$(printf '%s' "$BODY" | shasum -a 256 | cut -c1-16):a1"

JOB=$(api POST /run -H "Idempotency-Key: $KEY" -d "$BODY" | jq -r '.data.job_id')

for i in $(seq 180); do                       # 1 s interval, 180 s client-side limit
  J=$(api GET "/jobs/$JOB")
  S=$(printf '%s' "$J" | jq -r '.data.status')
  if [ "$S" = "succeeded" ] || [ "$S" = "failed" ]; then break; fi
  sleep 1
done

printf '%s' "$J" | jq '.data | {status, truncated, charged_credits, error}'
printf '%s' "$J" | jq -r '.data.output.output' | jq '{verdict, headline, fixes, questions}'

Step 6 — POST /run-stream (Server-Sent Events)

The alternative to step 5, and what the page uses: same body, same Idempotency-Key header, one request. Run one or the other for a given input, not both. The response is text/event-stream. sdk.js splits it into frames on a blank line, takes the event name from an event: line (default message) and concatenates the data: lines, skips frames with no data or with data that is not JSON, and handles these events:

EventDataWhat the SDK does
delta{text}Passes text to onDelta.
jobjob infoPasses it to onJob (the page uses it to advance its progress display).
donethe final payload: {job_id, status, charged_credits, output}Keeps it as the result.
pendinga payloadTreated exactly like done: kept as the result. The SDK does not poll after it; if it carries a job_id and no output, you can poll that job as in step 5.
error{message, code, job_id}After the stream ends, raises an error with those fields.

If the response is not text/event-stream, it is an ordinary JSON envelope: the SDK returns its data (its comment says this is what an idempotent replay returns) or raises the error on a non-2xx status. Handle both shapes.

From the final payload the page reads output.output (falling back to the concatenated delta text), truncated and charged_credits, the same fields as a polled job. A comment in app.js notes that browsers receive progress ticks, not deltas, from this endpoint, which is why the page relies on the final payload rather than on the deltas. If the stream fails part-way, the page tries to recover a partial reply from whatever delta text arrived.

# -N turns off buffering so frames print as they arrive
api POST /run-stream -N -H "Idempotency-Key: $KEY" -d "$BODY"

# frames look like:
#   event: job
#   data: {...}
#
#   event: done
#   data: {"job_id":"...","status":"...","charged_credits":...,"output":{"output":"{\"task\":\"tune\",...}"}}

# keep only the final reply text:
api POST /run-stream -N -H "Idempotency-Key: $KEY" -d "$BODY" \
  | awk '/^event:/{ev=$2} /^data:/ && ev=="done"{sub(/^data: */,""); print}' \
  | jq -r '.output.output' | jq .

What the page does that you now do

Or skip all of this and use Geofeed Desk in the browser: the check, the mechanical fixes, the exports and the registry lines are free.