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
| What | What the page does |
|---|---|
| HTTP 401 | Treats 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-stream | The 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 s | The 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):
| Field | Type | Required | Meaning |
|---|---|---|---|
task | string | yes | Always "tune". |
facts | string | yes | The 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.”). |
question | string | no | Something 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_note | string | no | Sent 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):
| Key | Contents |
|---|---|
feed | name (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_floor | The browser’s verdict: ready, fix_first or hold. The reply may not be less severe. |
rule_counts | Rule ID → number of rows carrying it. |
groups | Location 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_total | How many groups the check found (may exceed what was sent). |
locations | Every distinct location the feed names, flagged or not: country, region, city, rows, up to 4 lines; most rows first, at most 60. |
locations_total | How many distinct locations there are. |
prefix_issues | Problems with the prefixes themselves: line, prefix, id, detail; one per duplicated prefix; at most 40. |
prefix_issues_total | How many prefix problems were found. |
region_options | Country → 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. |
notes | Plain 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:
task:"tune".verdict:ready,fix_firstorhold; never less severe thanfacts.verdict_floor(ready<fix_first<hold).holdwhen a prefix problem or a question blocks publication,fix_firstwhen the fixes resolve every error,readyonly when nothing but optional suggestions is left.headline: one sentence on whether the feed can be published.fixes[]: exactly one entry per group infacts.groups, same order,groupequal to the group’sid. Each hasaction(replace|keep|do_not_geolocate|ask),country,region,city(the complete location forreplace; the current values forkeepandask; all""fordo_not_geolocate, which is only for rule 3104 groups),ids(rule IDs resolved, from the group’sids),reason,confidence(high|medium|low). A region must come fromregion_options; a location may be corrected but never moved to another city, region or country;keepis never allowed for a group with an error.questions[]:{lines, ids, question}; every line infacts.prefix_issuesmust appear in some question, plus one question perask. Empty array when there is nothing to ask.publish[]: 2 to 6 short publishing steps drawn from RFC 8805 / RFC 9632 facts listed in the skill.summary: two to five sentences; answersquestionif one was sent.
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:
- It strips a leading/trailing code fence and parses from the first
{to the last}; unknown values fall back (verdict→hold,action→ask,confidence→low). - It re-runs every proposed
replacelocation through the same rule checks the feed went through, and flags a proposal that still fails a rule, moves rows to another country or region, picks a region not in the list sent, drops a city, or “replaces” with identical values. It also flagskeepon a group with an error,do_not_geolocateoutside rule 3104, cited rule IDs that did not fire, groups left unanswered, prefix-issue lines no question raises, a verdict below the floor, andreadywhile blocking questions remain. - A flagged fix is shown with its problems and is never pre-selected for applying to the feed.
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}'
import hashlib, json, os, time, urllib.parse
import requests # pip install requests
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN") # see step 2
class ApiError(Exception):
def __init__(self, status, code, message, details=None):
super().__init__(f"{status} {code}: {message}")
self.status, self.code, self.details = status, code, details
def api(method, path, body=None, headers=None):
h = {"Content-Type": "application/json"}
if TOKEN:
h["Authorization"] = "Bearer " + TOKEN
h.update(headers or {})
r = requests.request(method, BASE + path, headers=h,
data=None if body is None else json.dumps(body))
try:
env = r.json()
except ValueError:
env = {}
if not r.ok:
err = env.get("error") or {}
raise ApiError(r.status_code, err.get("code"), err.get("message") or r.reason, err.get("details"))
return env.get("data")
// tune.mjs -- Node 18+ (global fetch), run with: node tune.mjs
import { readFileSync } from "node:fs";
import { createHash } from "node:crypto";
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN"; // see step 2
async function api(method, path, body, headers = {}) {
const h = { "Content-Type": "application/json", ...headers };
if (TOKEN) h.Authorization = "Bearer " + TOKEN;
const res = await fetch(BASE + path, {
method,
headers: h,
body: body === undefined ? undefined : JSON.stringify(body),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) {
const err = new Error((json.error && json.error.message) || res.statusText);
err.status = res.status;
err.code = json.error && json.error.code;
err.details = json.error && json.error.details;
throw err;
}
return json.data;
}
package main
import (
"bufio"
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"time"
)
const base = "https://api.skillsafe.ai/v1/app-api"
var token = os.Getenv("SKILLSAFE_TOKEN") // see step 2
type apiError struct {
Status int
Code string
Message string
}
func (e *apiError) Error() string { return fmt.Sprintf("%d %s: %s", e.Status, e.Code, e.Message) }
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func newRequest(method, path string, body any, headers map[string]string) (*http.Request, error) {
var buf []byte
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return nil, err
}
buf = b
}
req, err := http.NewRequest(method, base+path, bytes.NewReader(buf))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
for k, v := range headers {
req.Header.Set(k, v)
}
return req, nil
}
func errorFrom(res *http.Response, env envelope) error {
e := &apiError{Status: res.StatusCode, Message: http.StatusText(res.StatusCode)}
if env.Error != nil {
e.Code = env.Error.Code
if env.Error.Message != "" {
e.Message = env.Error.Message
}
}
return e
}
// api sends body as JSON and decodes the envelope's data into out (out may be nil).
func api(method, path string, body any, headers map[string]string, out any) error {
req, err := newRequest(method, path, body, headers)
if err != nil {
return err
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
var env envelope
_ = json.NewDecoder(res.Body).Decode(&env)
if res.StatusCode < 200 || res.StatusCode > 299 {
return errorFrom(res, env)
}
if out != nil && len(env.Data) > 0 {
return json.Unmarshal(env.Data, out)
}
return nil
}
func main() {
// steps 2-6 go here; remove any import a step you skip no longer uses
_, _, _, _, _ = bufio.NewScanner, sha256.Sum256, hex.EncodeToString, url.PathEscape, strings.Index
_ = time.Second
}
// Geofeed.java -- Java 17+, needs com.fasterxml.jackson.core:jackson-databind on the classpath
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class Geofeed {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = System.getenv().getOrDefault("SKILLSAFE_TOKEN", "YOUR_TOKEN"); // see step 2
static final HttpClient HTTP = HttpClient.newHttpClient();
static final ObjectMapper JSON = new ObjectMapper();
static class ApiException extends Exception {
final int status;
final String code;
ApiException(int status, String code, String message) {
super(status + " " + code + ": " + message);
this.status = status;
this.code = code;
}
}
static HttpRequest.Builder request(String method, String path, Object body, Map<String, String> headers) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Content-Type", "application/json")
.method(method, body == null ? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(JSON.writeValueAsString(body)));
if (token != null && !token.isEmpty()) b.header("Authorization", "Bearer " + token);
if (headers != null) headers.forEach(b::header);
return b;
}
static ApiException errorFrom(int status, JsonNode env) {
JsonNode err = env.path("error");
return new ApiException(status, err.path("code").asText(null), err.path("message").asText("HTTP " + status));
}
static JsonNode api(String method, String path, Object body, Map<String, String> headers) throws Exception {
HttpResponse<String> res = HTTP.send(request(method, path, body, headers).build(), HttpResponse.BodyHandlers.ofString());
JsonNode env;
try { env = JSON.readTree(res.body()); } catch (Exception e) { env = JSON.createObjectNode(); }
if (env == null) env = JSON.createObjectNode();
if (res.statusCode() / 100 != 2) throw errorFrom(res.statusCode(), env);
return env.path("data");
}
public static void main(String[] args) throws Exception {
// steps 2-6 go here
}
}
require "digest"
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
$token = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN") # see step 2
class ApiError < StandardError
attr_reader :status, :code
def initialize(status, code, message)
super("#{status} #{code}: #{message}")
@status = status
@code = code
end
end
def build_request(method, path, body = nil, headers = {})
uri = URI(BASE + path)
req = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri)
req["Content-Type"] = "application/json"
req["Authorization"] = "Bearer #{$token}" if $token && !$token.empty?
headers.each { |k, v| req[k] = v }
req.body = JSON.generate(body) unless body.nil?
[uri, req]
end
def api(method, path, body = nil, headers = {})
uri, req = build_request(method, path, body, headers)
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
env = (JSON.parse(res.body) rescue {})
env = {} unless env.is_a?(Hash)
unless res.is_a?(Net::HTTPSuccess)
err = env["error"] || {}
raise ApiError.new(res.code.to_i, err["code"], err["message"] || res.message)
end
env["data"]
end
<?php
// needs the curl extension
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN"; // see step 2
class ApiError extends Exception {
public $status;
public $apiCode;
public function __construct($status, $code, $message) {
parent::__construct("$status $code: $message");
$this->status = $status;
$this->apiCode = $code;
}
}
function headers_for(array $extra = []): array {
global $TOKEN;
$h = ["Content-Type: application/json"];
if ($TOKEN) $h[] = "Authorization: Bearer $TOKEN";
foreach ($extra as $k => $v) $h[] = "$k: $v";
return $h;
}
function api(string $method, string $path, $body = null, array $headers = []) {
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => headers_for($headers),
CURLOPT_RETURNTRANSFER => true,
]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
$raw = curl_exec($ch);
if ($raw === false) throw new RuntimeException(curl_error($ch));
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
$env = json_decode($raw);
if (!is_object($env)) $env = new stdClass();
if ($status < 200 || $status > 299) {
$e = $env->error ?? new stdClass();
throw new ApiError($status, $e->code ?? null, $e->message ?? "HTTP $status");
}
return $env->data ?? null;
}
// Program.cs -- .NET 6+ console app with top-level statements; steps 2-6 go ABOVE this class
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
static class Api
{
public const string Base = "https://api.skillsafe.ai/v1/app-api";
public static string Token = Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN"; // see step 2
public static readonly HttpClient Http = new HttpClient();
public class ApiException : Exception
{
public int Status;
public string? Code;
public ApiException(int status, string? code, string message) : base($"{status} {code}: {message}")
{
Status = status;
Code = code;
}
}
public static HttpRequestMessage Request(string method, string path, object? body = null, IDictionary<string, string>? headers = null)
{
var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
if (body != null) req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
if (!string.IsNullOrEmpty(Token)) req.Headers.TryAddWithoutValidation("Authorization", "Bearer " + Token);
if (headers != null) foreach (var kv in headers) req.Headers.TryAddWithoutValidation(kv.Key, kv.Value);
return req;
}
public static ApiException ErrorFrom(HttpResponseMessage res, JsonNode? env)
{
var err = env?["error"];
return new ApiException((int)res.StatusCode, err?["code"]?.GetValue<string>(),
err?["message"]?.GetValue<string>() ?? res.ReasonPhrase ?? "error");
}
public static async Task<JsonNode?> Call(string method, string path, object? body = null, IDictionary<string, string>? headers = null)
{
using var req = Request(method, path, body, headers);
using var res = await Http.SendAsync(req);
var text = await res.Content.ReadAsStringAsync();
JsonNode? env = null;
try { env = JsonNode.Parse(text); } catch (JsonException) { }
if (!res.IsSuccessStatusCode) throw ErrorFrom(res, env);
return env?["data"];
}
}
Step 2 — get a token
Two kinds of token work here:
-
Guest.
POST /guestwith{"slug": "geofeed-desk"}and noAuthorizationheader, exactly assdk.jsguest()does. The response’sdatacarriestokenandguest_id. The page keeps the guest id so a later sign-in can take the guest wallet along. Reuse a token you already hold rather than minting a new one each time (the SDK returns the stored token without calling/guestagain). -
Personal. The tune is metered, and the page only runs it for a signed-in user
unless the estimate says
sponsor_enabled. Sign in and copy your token from /tokens.html, then export it asSKILLSAFE_TOKEN.
# 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}..."
# personal token: set SKILLSAFE_TOKEN (from /tokens.html) and skip this
if TOKEN in ("", "YOUR_TOKEN"):
TOKEN = "" # no Authorization header on /guest
guest = api("POST", "/guest", {"slug": "geofeed-desk"})
TOKEN = guest["token"]
print("guest", guest.get("guest_id"))
// personal token: paste it from /tokens.html into TOKEN and skip this
if (!TOKEN || TOKEN === "YOUR_TOKEN") {
TOKEN = ""; // no Authorization header on /guest
const guest = await api("POST", "/guest", { slug: "geofeed-desk" });
TOKEN = guest.token;
console.log("guest", guest.guest_id);
}
// personal token: set SKILLSAFE_TOKEN (from /tokens.html) and skip this
if token == "" {
var guest struct {
Token string `json:"token"`
GuestID string `json:"guest_id"`
}
if err := api("POST", "/guest", map[string]string{"slug": "geofeed-desk"}, nil, &guest); err != nil {
panic(err)
}
token = guest.Token
fmt.Println("guest", guest.GuestID)
}
// personal token: set SKILLSAFE_TOKEN (from /tokens.html) and skip this
if (token.isEmpty() || token.equals("YOUR_TOKEN")) {
token = ""; // no Authorization header on /guest
JsonNode guest = api("POST", "/guest", Map.of("slug", "geofeed-desk"), null);
token = guest.path("token").asText();
System.out.println("guest " + guest.path("guest_id").asText());
}
# personal token: set SKILLSAFE_TOKEN (from /tokens.html) and skip this
if $token.empty? || $token == "YOUR_TOKEN"
$token = "" # no Authorization header on /guest
guest = api("POST", "/guest", { slug: "geofeed-desk" })
$token = guest["token"]
puts "guest #{guest["guest_id"]}"
end
// personal token: set SKILLSAFE_TOKEN (from /tokens.html) and skip this
if ($TOKEN === "" || $TOKEN === "YOUR_TOKEN") {
$TOKEN = ""; // no Authorization header on /guest
$guest = api("POST", "/guest", ["slug" => "geofeed-desk"]);
$TOKEN = $guest->token;
echo "guest {$guest->guest_id}\n";
}
// personal token: set SKILLSAFE_TOKEN (from /tokens.html) and skip this
if (Api.Token == "" || Api.Token == "YOUR_TOKEN")
{
Api.Token = ""; // no Authorization header on /guest
var guest = await Api.Call("POST", "/guest", new { slug = "geofeed-desk" });
Api.Token = guest!["token"]!.GetValue<string>();
Console.WriteLine($"guest {guest["guest_id"]}");
}
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'
me = api("GET", "/me")
print(me["subject_type"], me.get("subject_id"), me.get("credits"))
signed_in = me["subject_type"] == "user"
const me = await api("GET", "/me");
console.log(me.subject_type, me.subject_id, me.credits);
const signedIn = me.subject_type === "user";
var me struct {
SubjectType string `json:"subject_type"`
SubjectID any `json:"subject_id"`
Credits any `json:"credits"`
}
if err := api("GET", "/me", nil, nil, &me); err != nil {
panic(err)
}
fmt.Println(me.SubjectType, me.SubjectID, me.Credits)
signedIn := me.SubjectType == "user"
_ = signedIn
JsonNode me = api("GET", "/me", null, null);
System.out.println(me.path("subject_type").asText() + " " + me.path("subject_id").asText() + " " + me.path("credits"));
boolean signedIn = me.path("subject_type").asText().equals("user");
me = api("GET", "/me")
puts [me["subject_type"], me["subject_id"], me["credits"]].join(" ")
signed_in = me["subject_type"] == "user"
$me = api("GET", "/me");
echo $me->subject_type, " ", $me->subject_id ?? "", " ", $me->credits ?? "", "\n";
$signedIn = $me->subject_type === "user";
var me = await Api.Call("GET", "/me");
Console.WriteLine($"{me?["subject_type"]} {me?["subject_id"]} {me?["credits"]}");
var signedIn = me?["subject_type"]?.GetValue<string>() == "user";
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:
| Field | How the page uses it |
|---|---|
hold_credits | The reservation for the run, shown as “reserves N credits”. A balance below it is allowed but warned: the tune may be cut short. |
min_credits | Shown as the minimum when present. A balance below it disables the run button. |
sponsor_enabled | When true, the page lets a caller who is not signed in run the tune; otherwise it asks them to sign in first. |
warnings | A 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})'
with open("facts.json", encoding="utf-8") as f:
facts = json.load(f)
inp = {
"task": "tune",
"facts": json.dumps(facts, separators=(",", ":"), ensure_ascii=False), # a STRING, like JSON.stringify
"question": "Can this go out this week?", # optional; the page caps it at 1000 chars
}
est = api("POST", "/estimate", inp)
print("hold", est.get("hold_credits"), "min", est.get("min_credits"),
"sponsored", est.get("sponsor_enabled"), "warnings", est.get("warnings") or [])
const facts = JSON.parse(readFileSync("facts.json", "utf8"));
const input = {
task: "tune",
facts: JSON.stringify(facts), // a STRING, exactly as the page sends it
question: "Can this go out this week?", // optional; the page caps it at 1000 chars
};
const est = await api("POST", "/estimate", input);
console.log("hold", est.hold_credits, "min", est.min_credits,
"sponsored", est.sponsor_enabled, "warnings", est.warnings || []);
raw, err := os.ReadFile("facts.json")
if err != nil {
panic(err)
}
var facts any
if err := json.Unmarshal(raw, &facts); err != nil {
panic(err)
}
factsJSON, _ := json.Marshal(facts) // compact; travels as a STRING
input := map[string]any{
"task": "tune",
"facts": string(factsJSON),
"question": "Can this go out this week?", // optional; the page caps it at 1000 chars
}
var est struct {
HoldCredits any `json:"hold_credits"`
MinCredits any `json:"min_credits"`
SponsorEnabled bool `json:"sponsor_enabled"`
Warnings []any `json:"warnings"`
}
if err := api("POST", "/estimate", input, nil, &est); err != nil {
panic(err)
}
fmt.Printf("hold %v min %v sponsored %v warnings %v\n", est.HoldCredits, est.MinCredits, est.SponsorEnabled, est.Warnings)
JsonNode facts = JSON.readTree(Files.readString(Path.of("facts.json")));
Map<String, Object> input = new LinkedHashMap<>();
input.put("task", "tune");
input.put("facts", JSON.writeValueAsString(facts)); // a STRING, compact
input.put("question", "Can this go out this week?"); // optional; the page caps it at 1000 chars
JsonNode est = api("POST", "/estimate", input, null);
System.out.println("hold " + est.path("hold_credits") + " min " + est.path("min_credits")
+ " sponsored " + est.path("sponsor_enabled").asBoolean(false) + " warnings " + est.path("warnings"));
facts = JSON.parse(File.read("facts.json", encoding: "UTF-8"))
input = {
task: "tune",
facts: JSON.generate(facts), # a STRING, compact
question: "Can this go out this week?" # optional; the page caps it at 1000 chars
}
est = api("POST", "/estimate", input)
puts "hold #{est["hold_credits"]} min #{est["min_credits"]} " \
"sponsored #{est["sponsor_enabled"]} warnings #{(est["warnings"] || []).inspect}"
// decode to objects (not arrays) so an empty {} stays {} when re-encoded
$facts = json_decode(file_get_contents("facts.json"));
$input = [
"task" => "tune",
"facts" => json_encode($facts, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), // a STRING
"question" => "Can this go out this week?", // optional; the page caps it at 1000 chars
];
$est = api("POST", "/estimate", $input);
echo "hold ", $est->hold_credits ?? "-", " min ", $est->min_credits ?? "-",
" sponsored ", json_encode($est->sponsor_enabled ?? false),
" warnings ", json_encode($est->warnings ?? []), "\n";
var facts = JsonNode.Parse(File.ReadAllText("facts.json"))!;
var input = new Dictionary<string, string>
{
["task"] = "tune",
["facts"] = facts.ToJsonString(), // a STRING, compact
["question"] = "Can this go out this week?", // optional; the page caps it at 1000 chars
};
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"hold {est?["hold_credits"]} min {est?["min_credits"]} " +
$"sponsored {est?["sponsor_enabled"]} warnings {est?["warnings"]?.ToJsonString() ?? "[]"}");
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:
output.output— the model’s reply as text: the JSON object from the output contract. Parse it yourself.truncated—truewhen the reply was cut short. The page then tries to close the cut-off JSON, shows what arrived as a partial result, and says the reply was cut short by the available balance, with a top-up link.charged_credits— what the run was charged. The page shows it next to the result and re-reads/mefor the new balance.error— on afailedjob.
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}'
body = json.dumps(inp)
key = "geofeed-desk:tune:" + hashlib.sha256(body.encode()).hexdigest()[:16] + ":a1"
job_id = api("POST", "/run", inp, {"Idempotency-Key": key})["job_id"]
deadline = time.time() + 180 # 1 s interval, 180 s client-side limit
while True:
job = api("GET", "/jobs/" + urllib.parse.quote(job_id, safe=""))
if job["status"] in ("succeeded", "failed"):
break
if time.time() > deadline:
raise TimeoutError("job timed out")
time.sleep(1)
if job["status"] == "failed":
raise SystemExit(f"run failed: {job.get('error')}")
print("truncated:", job.get("truncated") is True, "charged:", job.get("charged_credits"))
text = (job.get("output") or {}).get("output") or ""
reply = json.loads(text[text.index("{"): text.rindex("}") + 1])
print(reply["verdict"], "-", reply["headline"])
for fx in reply.get("fixes", []):
print(fx["group"], fx["action"], fx.get("country"), fx.get("region"), fx.get("city"))
const key = "geofeed-desk:tune:" +
createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16) + ":a1";
const { job_id } = await api("POST", "/run", input, { "Idempotency-Key": key });
let job;
const deadline = Date.now() + 180000; // 1 s interval, 180 s client-side limit
for (;;) {
job = await api("GET", "/jobs/" + encodeURIComponent(job_id));
if (job.status === "succeeded" || job.status === "failed") break;
if (Date.now() > deadline) throw new Error("job timed out");
await new Promise((r) => setTimeout(r, 1000));
}
if (job.status === "failed") throw new Error("run failed: " + JSON.stringify(job.error));
console.log("truncated:", job.truncated === true, "charged:", job.charged_credits);
const text = (job.output && job.output.output) || "";
const reply = JSON.parse(text.slice(text.indexOf("{"), text.lastIndexOf("}") + 1));
console.log(reply.verdict, "-", reply.headline);
for (const fx of reply.fixes || []) console.log(fx.group, fx.action, fx.country, fx.region, fx.city);
inputJSON, _ := json.Marshal(input)
sum := sha256.Sum256(inputJSON)
key := "geofeed-desk:tune:" + hex.EncodeToString(sum[:8]) + ":a1"
var started struct {
JobID string `json:"job_id"`
}
if err := api("POST", "/run", input, map[string]string{"Idempotency-Key": key}, &started); err != nil {
panic(err)
}
type jobT struct {
Status string `json:"status"`
Output *struct {
Output string `json:"output"`
} `json:"output"`
Truncated bool `json:"truncated"`
ChargedCredits any `json:"charged_credits"`
Error any `json:"error"`
}
var job jobT
deadline := time.Now().Add(180 * time.Second) // 1 s interval, 180 s client-side limit
for {
job = jobT{}
if err := api("GET", "/jobs/"+url.PathEscape(started.JobID), nil, nil, &job); err != nil {
panic(err)
}
if job.Status == "succeeded" || job.Status == "failed" {
break
}
if time.Now().After(deadline) {
panic("job timed out")
}
time.Sleep(time.Second)
}
if job.Status == "failed" {
panic(fmt.Sprintf("run failed: %v", job.Error))
}
fmt.Println("truncated:", job.Truncated, "charged:", job.ChargedCredits)
text := ""
if job.Output != nil {
text = job.Output.Output
}
var reply map[string]any
if a, b := strings.Index(text, "{"), strings.LastIndex(text, "}"); a >= 0 && b > a {
if err := json.Unmarshal([]byte(text[a:b+1]), &reply); err != nil {
panic(err)
}
}
fmt.Println(reply["verdict"], "-", reply["headline"])
byte[] digest = MessageDigest.getInstance("SHA-256").digest(JSON.writeValueAsBytes(input));
String key = "geofeed-desk:tune:" + HexFormat.of().formatHex(digest, 0, 8) + ":a1";
String jobId = api("POST", "/run", input, Map.of("Idempotency-Key", key)).path("job_id").asText();
JsonNode job;
long deadline = System.currentTimeMillis() + 180_000; // 1 s interval, 180 s client-side limit
while (true) {
job = api("GET", "/jobs/" + URLEncoder.encode(jobId, StandardCharsets.UTF_8), null, null);
String s = job.path("status").asText();
if (s.equals("succeeded") || s.equals("failed")) break;
if (System.currentTimeMillis() > deadline) throw new RuntimeException("job timed out");
Thread.sleep(1000);
}
if (job.path("status").asText().equals("failed")) throw new RuntimeException("run failed: " + job.path("error"));
System.out.println("truncated: " + job.path("truncated").asBoolean(false) + ", charged: " + job.path("charged_credits"));
String text = job.path("output").path("output").asText("");
JsonNode reply = JSON.readTree(text.substring(text.indexOf('{'), text.lastIndexOf('}') + 1));
System.out.println(reply.path("verdict").asText() + " - " + reply.path("headline").asText());
for (JsonNode fx : reply.path("fixes")) {
System.out.println(fx.path("group").asText() + " " + fx.path("action").asText() + " " + fx.path("city").asText());
}
key = "geofeed-desk:tune:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1"
job_id = api("POST", "/run", input, { "Idempotency-Key" => key })["job_id"]
deadline = Time.now + 180 # 1 s interval, 180 s client-side limit
job = nil
loop do
job = api("GET", "/jobs/#{URI.encode_www_form_component(job_id)}")
break if %w[succeeded failed].include?(job["status"])
raise "job timed out" if Time.now > deadline
sleep 1
end
raise "run failed: #{job["error"].inspect}" if job["status"] == "failed"
puts "truncated: #{job["truncated"] == true}, charged: #{job["charged_credits"]}"
text = (job["output"] || {})["output"].to_s
reply = JSON.parse(text[text.index("{")..text.rindex("}")])
puts "#{reply["verdict"]} - #{reply["headline"]}"
(reply["fixes"] || []).each { |fx| puts [fx["group"], fx["action"], fx["country"], fx["region"], fx["city"]].join(" ") }
$key = "geofeed-desk:tune:" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$jobId = api("POST", "/run", $input, ["Idempotency-Key" => $key])->job_id;
$deadline = time() + 180; // 1 s interval, 180 s client-side limit
while (true) {
$job = api("GET", "/jobs/" . rawurlencode($jobId));
if (in_array($job->status, ["succeeded", "failed"], true)) break;
if (time() > $deadline) throw new RuntimeException("job timed out");
sleep(1);
}
if ($job->status === "failed") throw new RuntimeException("run failed: " . json_encode($job->error ?? null));
echo "truncated: ", json_encode(($job->truncated ?? false) === true), ", charged: ", $job->charged_credits ?? "-", "\n";
$text = $job->output->output ?? "";
$reply = json_decode(substr($text, strpos($text, "{"), strrpos($text, "}") - strpos($text, "{") + 1));
echo $reply->verdict, " - ", $reply->headline, "\n";
foreach ($reply->fixes ?? [] as $fx) echo "$fx->group $fx->action $fx->country $fx->region $fx->city\n";
var digest = System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(input)));
var key = "geofeed-desk:tune:" + Convert.ToHexString(digest, 0, 8).ToLowerInvariant() + ":a1";
var started = await Api.Call("POST", "/run", input, new Dictionary<string, string> { ["Idempotency-Key"] = key });
var jobId = started!["job_id"]!.GetValue<string>();
JsonNode job;
var deadline = DateTime.UtcNow.AddSeconds(180); // 1 s interval, 180 s client-side limit
while (true)
{
job = (await Api.Call("GET", "/jobs/" + Uri.EscapeDataString(jobId)))!;
var s = job["status"]?.GetValue<string>();
if (s == "succeeded" || s == "failed") break;
if (DateTime.UtcNow > deadline) throw new TimeoutException("job timed out");
await Task.Delay(1000);
}
if (job["status"]?.GetValue<string>() == "failed") throw new Exception($"run failed: {job["error"]?.ToJsonString()}");
var truncated = job["truncated"] is JsonValue t && t.TryGetValue(out bool tb) && tb;
Console.WriteLine($"truncated: {truncated}, charged: {job["charged_credits"]}");
var text = job["output"]?["output"]?.GetValue<string>() ?? "";
var reply = JsonNode.Parse(text[text.IndexOf('{')..(text.LastIndexOf('}') + 1)])!;
Console.WriteLine($"{reply["verdict"]} - {reply["headline"]}");
foreach (var fx in reply["fixes"]?.AsArray() ?? new JsonArray())
Console.WriteLine($"{fx?["group"]} {fx?["action"]} {fx?["country"]} {fx?["region"]} {fx?["city"]}");
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:
| Event | Data | What the SDK does |
|---|---|---|
delta | {text} | Passes text to onDelta. |
job | job info | Passes it to onJob (the page uses it to advance its progress display). |
done | the final payload: {job_id, status, charged_credits, output} | Keeps it as the result. |
pending | a payload | Treated 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 .
h = {"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN, "Idempotency-Key": key}
done, failure, streamed = None, None, ""
with requests.post(BASE + "/run-stream", headers=h, data=json.dumps(inp), stream=True) as r:
if "text/event-stream" not in r.headers.get("content-type", ""):
try:
env = r.json()
except ValueError:
env = {}
if not r.ok:
err = env.get("error") or {}
raise ApiError(r.status_code, err.get("code"), err.get("message") or r.reason)
done = env.get("data")
else:
event, data = "message", ""
for line in r.iter_lines(decode_unicode=True):
if line == "": # a blank line ends a frame
if data:
try:
payload = json.loads(data)
except ValueError:
payload = None
if event == "delta" and payload is not None:
streamed += payload.get("text") or ""
elif event == "job":
print("job", payload)
elif event in ("done", "pending"):
done = payload
elif event == "error":
failure = payload
event, data = "message", ""
elif line.startswith("event:"):
event = line[6:].strip()
elif line.startswith("data:"):
data += line[5:].strip()
if failure:
raise SystemExit(f"{failure.get('code')}: {failure.get('message')} (job {failure.get('job_id')})")
done = done or {}
full = (done.get("output") or {}).get("output") or streamed
print("truncated:", done.get("truncated") is True, "charged:", done.get("charged_credits"))
print(json.loads(full[full.index("{"): full.rindex("}") + 1])["headline"])
const res = await fetch(BASE + "/run-stream", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer " + TOKEN, "Idempotency-Key": key },
body: JSON.stringify(input),
});
let done = null, failure = null, streamed = "";
if (!(res.headers.get("content-type") || "").includes("text/event-stream")) {
const json = await res.json().catch(() => ({}));
if (!res.ok) throw new Error((json.error && json.error.message) || res.statusText);
done = json.data;
} else {
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
for (;;) {
const chunk = await reader.read();
if (chunk.done) break;
buffer += decoder.decode(chunk.value, { stream: true });
let idx;
while ((idx = buffer.indexOf("\n\n")) >= 0) {
const frame = buffer.slice(0, idx);
buffer = buffer.slice(idx + 2);
let event = "message", dataStr = "";
for (const line of frame.split("\n")) {
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) dataStr += line.slice(5).trim();
}
if (!dataStr) continue;
let data;
try { data = JSON.parse(dataStr); } catch { continue; }
if (event === "delta") streamed += data.text || "";
else if (event === "job") console.log("job", data);
else if (event === "done" || event === "pending") done = data;
else if (event === "error") failure = data;
}
}
}
if (failure) throw new Error(`${failure.code}: ${failure.message} (job ${failure.job_id})`);
const full = (done && done.output && done.output.output) || streamed;
console.log("truncated:", !!(done && done.truncated), "charged:", done && done.charged_credits);
console.log(JSON.parse(full.slice(full.indexOf("{"), full.lastIndexOf("}") + 1)).headline);
sreq, err := newRequest("POST", "/run-stream", input, map[string]string{"Idempotency-Key": key})
if err != nil {
panic(err)
}
sres, err := http.DefaultClient.Do(sreq)
if err != nil {
panic(err)
}
defer sres.Body.Close()
var done, failure map[string]any
var streamed strings.Builder
if !strings.Contains(sres.Header.Get("Content-Type"), "text/event-stream") {
var env envelope
_ = json.NewDecoder(sres.Body).Decode(&env)
if sres.StatusCode < 200 || sres.StatusCode > 299 {
panic(errorFrom(sres, env))
}
_ = json.Unmarshal(env.Data, &done)
} else {
sc := bufio.NewScanner(sres.Body)
sc.Buffer(make([]byte, 64*1024), 16*1024*1024) // the done frame can be long
event, data := "message", ""
for sc.Scan() {
line := sc.Text()
switch {
case line == "": // a blank line ends a frame
if data != "" {
var p map[string]any
if json.Unmarshal([]byte(data), &p) == nil {
switch event {
case "delta":
if t, ok := p["text"].(string); ok {
streamed.WriteString(t)
}
case "job":
fmt.Println("job", p)
case "done", "pending":
done = p
case "error":
failure = p
}
}
}
event, data = "message", ""
case strings.HasPrefix(line, "event:"):
event = strings.TrimSpace(line[6:])
case strings.HasPrefix(line, "data:"):
data += strings.TrimSpace(line[5:])
}
}
}
if failure != nil {
panic(fmt.Sprintf("%v: %v (job %v)", failure["code"], failure["message"], failure["job_id"]))
}
full := streamed.String()
if out, ok := done["output"].(map[string]any); ok {
if s, ok := out["output"].(string); ok && s != "" {
full = s
}
}
fmt.Println("truncated:", done["truncated"] == true, "charged:", done["charged_credits"])
fmt.Println(full)
HttpRequest sreq = request("POST", "/run-stream", input, Map.of("Idempotency-Key", key)).build();
HttpResponse<Stream<String>> sres = HTTP.send(sreq, HttpResponse.BodyHandlers.ofLines());
JsonNode done = null, failure = null;
StringBuilder streamed = new StringBuilder();
if (!sres.headers().firstValue("content-type").orElse("").contains("text/event-stream")) {
JsonNode env = JSON.readTree(sres.body().collect(Collectors.joining("\n")));
if (env == null) env = JSON.createObjectNode();
if (sres.statusCode() / 100 != 2) throw errorFrom(sres.statusCode(), env);
done = env.path("data");
} else {
String event = "message";
StringBuilder data = new StringBuilder();
Iterator<String> it = sres.body().iterator();
while (it.hasNext()) {
String line = it.next();
if (line.isEmpty()) { // a blank line ends a frame
if (data.length() > 0) {
JsonNode p = null;
try { p = JSON.readTree(data.toString()); } catch (Exception ignored) { }
if (p != null) {
switch (event) {
case "delta": streamed.append(p.path("text").asText("")); break;
case "job": System.out.println("job " + p); break;
case "done": case "pending": done = p; break;
case "error": failure = p; break;
default: break;
}
}
}
event = "message";
data.setLength(0);
} else if (line.startsWith("event:")) event = line.substring(6).trim();
else if (line.startsWith("data:")) data.append(line.substring(5).trim());
}
}
if (failure != null) {
throw new RuntimeException(failure.path("code").asText() + ": " + failure.path("message").asText()
+ " (job " + failure.path("job_id").asText() + ")");
}
String full = done == null ? "" : done.path("output").path("output").asText("");
if (full.isEmpty()) full = streamed.toString();
System.out.println("truncated: " + (done != null && done.path("truncated").asBoolean(false))
+ ", charged: " + (done == null ? "-" : done.path("charged_credits").toString()));
System.out.println(full);
uri, req = build_request("POST", "/run-stream", input, { "Idempotency-Key" => key })
done = failure = nil
streamed = +""
Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 300) do |http|
http.request(req) do |res|
unless res["content-type"].to_s.include?("text/event-stream")
env = (JSON.parse(res.body) rescue {})
unless res.is_a?(Net::HTTPSuccess)
raise ApiError.new(res.code.to_i, env.dig("error", "code"), env.dig("error", "message") || res.message)
end
done = env["data"]
next
end
buf = +""
res.read_body do |chunk|
buf << chunk
while (i = buf.index("\n\n"))
frame = buf.slice!(0, i + 2)
event = "message"
data = +""
frame.split("\n").each do |line|
if line.start_with?("event:") then event = line[6..].strip
elsif line.start_with?("data:") then data << line[5..].strip
end
end
next if data.empty?
payload = (JSON.parse(data) rescue nil)
next if payload.nil?
case event
when "delta" then streamed << payload["text"].to_s
when "job" then puts "job #{payload.inspect}"
when "done", "pending" then done = payload
when "error" then failure = payload
end
end
end
end
end
raise "#{failure["code"]}: #{failure["message"]} (job #{failure["job_id"]})" if failure
done ||= {}
full = (done["output"] || {})["output"].to_s
full = streamed if full.empty?
puts "truncated: #{done["truncated"] == true}, charged: #{done["charged_credits"]}"
puts full
$done = null; $failure = null; $streamed = ""; $buf = ""; $ctype = "";
$handle = function (string $event, string $data) use (&$done, &$failure, &$streamed) {
$p = json_decode($data);
if ($p === null) return; // not JSON: skipped, as sdk.js does
switch ($event) {
case "delta": $streamed .= $p->text ?? ""; break;
case "job": echo "job ", json_encode($p), "\n"; break;
case "done": case "pending": $done = $p; break;
case "error": $failure = $p; break;
}
};
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => headers_for(["Idempotency-Key" => $key]),
CURLOPT_POSTFIELDS => json_encode($input, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$ctype) {
if (stripos($line, "content-type:") === 0) $ctype = strtolower(trim(substr($line, 13)));
return strlen($line);
},
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$buf, &$ctype, $handle) {
$buf .= $chunk;
if (strpos($ctype, "text/event-stream") === false) return strlen($chunk); // plain JSON: parse at the end
while (($i = strpos($buf, "\n\n")) !== false) {
$frame = substr($buf, 0, $i);
$buf = substr($buf, $i + 2);
$event = "message"; $data = "";
foreach (explode("\n", $frame) as $line) {
if (strpos($line, "event:") === 0) $event = trim(substr($line, 6));
elseif (strpos($line, "data:") === 0) $data .= trim(substr($line, 5));
}
if ($data !== "") $handle($event, $data);
}
return strlen($chunk);
},
]);
if (curl_exec($ch) === false) throw new RuntimeException(curl_error($ch));
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if (strpos($ctype, "text/event-stream") === false) {
$env = json_decode($buf) ?? new stdClass();
if ($status < 200 || $status > 299) {
throw new ApiError($status, $env->error->code ?? null, $env->error->message ?? "HTTP $status");
}
$done = $env->data ?? null;
}
if ($failure) throw new RuntimeException(($failure->code ?? "") . ": " . ($failure->message ?? "") . " (job " . ($failure->job_id ?? "") . ")");
$full = $done->output->output ?? "";
if ($full === "") $full = $streamed;
echo "truncated: ", json_encode(($done->truncated ?? false) === true), ", charged: ", $done->charged_credits ?? "-", "\n";
echo $full, "\n";
using var sreq = Api.Request("POST", "/run-stream", input, new Dictionary<string, string> { ["Idempotency-Key"] = key });
using var sres = await Api.Http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
JsonNode? done = null, failure = null;
var streamed = new StringBuilder();
if (sres.Content.Headers.ContentType?.MediaType != "text/event-stream")
{
JsonNode? env = null;
try { env = JsonNode.Parse(await sres.Content.ReadAsStringAsync()); } catch (JsonException) { }
if (!sres.IsSuccessStatusCode) throw Api.ErrorFrom(sres, env);
done = env?["data"];
}
else
{
using var reader = new StreamReader(await sres.Content.ReadAsStreamAsync());
var ev = "message";
var data = new StringBuilder();
string? line;
while ((line = await reader.ReadLineAsync()) != null)
{
if (line.Length == 0) // a blank line ends a frame
{
if (data.Length > 0)
{
JsonNode? p = null;
try { p = JsonNode.Parse(data.ToString()); } catch (JsonException) { }
if (p != null)
{
switch (ev)
{
case "delta": streamed.Append(p["text"]?.GetValue<string>() ?? ""); break;
case "job": Console.WriteLine($"job {p.ToJsonString()}"); break;
case "done": case "pending": done = p; break;
case "error": failure = p; break;
}
}
}
ev = "message";
data.Clear();
}
else if (line.StartsWith("event:")) ev = line[6..].Trim();
else if (line.StartsWith("data:")) data.Append(line[5..].Trim());
}
}
if (failure != null) throw new Exception($"{failure["code"]}: {failure["message"]} (job {failure["job_id"]})");
var full = done?["output"]?["output"]?.GetValue<string>();
if (string.IsNullOrEmpty(full)) full = streamed.ToString();
var cut = done?["truncated"] is JsonValue tv && tv.TryGetValue(out bool cutShort) && cutShort;
Console.WriteLine($"truncated: {cut}, charged: {done?["charged_credits"]}");
Console.WriteLine(full);
What the page does that you now do
- Runs the whole RFC 8805 check in the browser and builds
factsfrom it (geokit.js); you build the same object and stringify it. - Prices every input with
/estimateand refuses to run belowmin_credits. - Sends each attempt with an input-derived Idempotency-Key, and retries once with
retry_notewhen a reply does not parse. - Closes a cut-off JSON reply and shows what arrived when
truncatedis true or the stream ends early. - Re-checks every proposed location with its own rules before offering to apply it (
recon.js).
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.