Driving Graph Desk from your own code
Everything the web app does is available over HTTP. Paste an edge list, pick a task, and get back one JSON object. The deterministic graph algorithms the browser runs for free - degree, components, cut-vertices, bridges, centrality - are not run server-side, so if you drive the API directly you should send your own prescan facts: that is what the model is held accountable to.
Base URL and headers
https://api.skillsafe.ai/v1/app-api
One header on every request:
Authorization: Bearer <token>— get one from the token page, no developer console needed.
The token is app-scoped, so the slug is not a header. There is no X-App-Slug header — a token minted for this app addresses this app and nothing else. The slug appears in exactly one place: the body of POST /guest, which is how you get a token in the first place.
curl -sS -X POST https://api.skillsafe.ai/v1/app-api/guest \
-H "Content-Type: application/json" \
-d '{"slug": "graph-desk"}'
# {"ok":true,"data":{"token":"aut_...","guest_id":"gst_...","expires_at":"..."}}
A guest token is enough for /me and /estimate. Running either lane is metered and needs a personal token, which comes from signing in on the token page.
The body of /estimate, /run and /run-stream is the input object itself, not wrapped in an input key. Its fields are listed under step 4 below.
The response envelope
Every response has the same two shapes. Branch on error.code, never on the message text — messages are for humans and will change.
// success
{"ok": true, "data": { ... }}
// failure
{"ok": false, "error": {"code": "VALIDATION_ERROR",
"message": "human-readable",
"details": { ... }}}
Error codes
| code | HTTP | What it means and what to do |
|---|---|---|
UNAUTHORIZED | 401 | No token, a malformed token, or a token for a different app. Mint a new one from the token page. |
FORBIDDEN | 403 | A guest token on a metered lane. Sign in for a personal token, or ask the publisher to enable sponsorship. |
NOT_FOUND | 404 | The job id does not exist, or the token belongs to a different app. |
VALIDATION_ERROR | 400 | The input failed validation. error.details names the offending field - usually task set to something outside structure/brief. |
PAYMENT_REQUIRED | 402 | The balance is below min_credits. Never let a user reach this: compare hold_credits against /me first. |
RATE_LIMITED | 429 | Too many requests. Back off and retry with a growing delay; the app-api budget is shared across your whole account. |
INTERNAL | 500 | A platform fault. Retry once with the same Idempotency-Key so you are not billed twice. |
1. A tiny client helper
Two headers on every call: the bearer token and, where the call takes a body, the content type. Success is always {"ok": true, "data": {...}}; a failure carries error.code, so branch on the code and not on the message text.
# Every call needs two things: the app slug and a bearer token.
# Keep the token in a shell variable so it never lands in your history.
SLUG="graph-desk"
TOKEN="YOUR_TOKEN" # from https://graph-desk.skillsafe.ai/tokens.html
BASE="https://api.skillsafe.ai/v1/app-api"
# A tiny helper: $1 is the path, $2 is the JSON body (optional).
ssapp() {
if [ -n "$2" ]; then
curl -sS -X POST "$BASE/$1" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "$2"
else
curl -sS "$BASE/$1" \
-H "Authorization: Bearer $TOKEN"
fi
}
import json
import urllib.request
SLUG = "graph-desk"
TOKEN = "YOUR_TOKEN" # from https://graph-desk.skillsafe.ai/tokens.html
BASE = "https://api.skillsafe.ai/v1/app-api"
class AppError(Exception):
"""Carries the platform's error code so callers can branch on it."""
def __init__(self, code, message, details=None):
super().__init__(f"{code}: {message}")
self.code, self.message, self.details = code, message, details
def call(path, body=None):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(f"{BASE}/{path}", data=data, method="POST" if data else "GET")
req.add_header("Authorization", f"Bearer {TOKEN}")
if data:
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req) as r:
payload = json.load(r)
except urllib.error.HTTPError as e:
payload = json.load(e)
err = payload.get("error") or {}
raise AppError(err.get("code", "unknown"), err.get("message", str(e)), err.get("details"))
# Success is always {"ok": true, "data": {...}}.
return payload["data"]
const SLUG = "graph-desk";
const TOKEN = "YOUR_TOKEN"; // from https://graph-desk.skillsafe.ai/tokens.html
const BASE = "https://api.skillsafe.ai/v1/app-api";
class AppError extends Error {
constructor(code, message, details) {
super(`${code}: ${message}`);
this.code = code;
this.details = details;
}
}
async function call(path, body) {
const res = await fetch(`${BASE}/${path}`, {
method: body ? "POST" : "GET",
headers: {
"Authorization": `Bearer ${TOKEN}`,
...(body ? { "Content-Type": "application/json" } : {})
},
body: body ? JSON.stringify(body) : undefined
});
const payload = await res.json();
if (!res.ok) {
const e = payload.error || {};
throw new AppError(e.code || "unknown", e.message || res.statusText, e.details);
}
return payload.data;
}
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
)
const (
slug = "graph-desk"
token = "YOUR_TOKEN" // from https://graph-desk.skillsafe.ai/tokens.html
base = "https://api.skillsafe.ai/v1/app-api"
)
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
Details json.RawMessage `json:"details"`
} `json:"error"`
}
func call(path string, body any) (json.RawMessage, error) {
method := http.MethodGet
var rdr io.Reader
if body != nil {
method = http.MethodPost
b, err := json.Marshal(body)
if err != nil {
return nil, err
}
rdr = bytes.NewReader(b)
}
req, err := http.NewRequest(method, base+"/"+path, rdr)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var env envelope
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return nil, err
}
if env.Error != nil {
return nil, fmt.Errorf("%s: %s", env.Error.Code, env.Error.Message)
}
return env.Data, nil
}
import java.net.URI;
import java.net.http.*;
import java.time.Duration;
public final class GraphDesk {
static final String SLUG = "graph-desk";
static final String TOKEN = "YOUR_TOKEN"; // from https://graph-desk.skillsafe.ai/tokens.html
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final HttpClient CLIENT = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
/** Returns the raw JSON body. Use your JSON library of choice to read it. */
static String call(String path, String jsonBody) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + "/" + path))
.header("Authorization", "Bearer " + TOKEN);
if (jsonBody == null) {
b.GET();
} else {
b.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
}
HttpResponse res = CLIENT.send(b.build(), HttpResponse.BodyHandlers.ofString());
if (res.statusCode() >= 400) {
throw new IllegalStateException("HTTP " + res.statusCode() + ": " + res.body());
}
return res.body();
}
}
require "json"
require "net/http"
require "uri"
SLUG = "graph-desk"
TOKEN = "YOUR_TOKEN" # from https://graph-desk.skillsafe.ai/tokens.html
BASE = "https://api.skillsafe.ai/v1/app-api"
class AppError < StandardError
attr_reader :code, :details
def initialize(code, message, details = nil)
super("#{code}: #{message}")
@code = code
@details = details
end
end
def call(path, body = nil)
uri = URI("#{BASE}/#{path}")
req = body ? Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
if body
req["Content-Type"] = "application/json"
req.body = JSON.generate(body)
end
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
payload = JSON.parse(res.body)
unless res.is_a?(Net::HTTPSuccess)
e = payload["error"] || {}
raise AppError.new(e["code"] || "unknown", e["message"] || res.message, e["details"])
end
payload["data"]
end
<?php
const SLUG = "graph-desk";
const TOKEN = "YOUR_TOKEN"; // from https://graph-desk.skillsafe.ai/tokens.html
const BASE = "https://api.skillsafe.ai/v1/app-api";
class AppError extends Exception {
public string $errorCode;
public $details;
public function __construct(string $code, string $message, $details = null) {
parent::__construct("$code: $message");
$this->errorCode = $code;
$this->details = $details;
}
}
function call(string $path, ?array $body = null) {
$headers = ["Authorization: Bearer " . TOKEN, ];
$opts = ["http" => ["method" => $body === null ? "GET" : "POST",
"ignore_errors" => true]];
if ($body !== null) {
$headers[] = "Content-Type: application/json";
$opts["http"]["content"] = json_encode($body);
}
$opts["http"]["header"] = implode("\r\n", $headers);
$raw = file_get_contents(BASE . "/" . $path, false, stream_context_create($opts));
$payload = json_decode($raw, true);
if (isset($payload["error"])) {
$e = $payload["error"];
throw new AppError($e["code"] ?? "unknown", $e["message"] ?? "request failed",
$e["details"] ?? null);
}
return $payload["data"];
}
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
public static class GraphDesk
{
const string Slug = "graph-desk";
const string Token = "YOUR_TOKEN"; // from https://graph-desk.skillsafe.ai/tokens.html
const string Base = "https://api.skillsafe.ai/v1/app-api";
static readonly HttpClient Client = new HttpClient();
public static async Task Call(string path, object body = null)
{
var req = new HttpRequestMessage(body == null ? HttpMethod.Get : HttpMethod.Post,
$"{Base}/{path}");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
if (body != null)
{
req.Content = new StringContent(JsonSerializer.Serialize(body),
Encoding.UTF8, "application/json");
}
var res = await Client.SendAsync(req);
var text = await res.Content.ReadAsStringAsync();
var payload = JsonDocument.Parse(text).RootElement;
if (payload.TryGetProperty("error", out var err))
{
throw new InvalidOperationException(
$"{err.GetProperty("code").GetString()}: {err.GetProperty("message").GetString()}");
}
return payload.GetProperty("data");
}
}
2. Who am I, and can I afford it
GET /me is free. subject_type is user for a personal token and guest for an anonymous one. Only a personal token can run either lane, and credits is the balance you compare the hold against.
ssapp me
# {"ok":true,"data":{"subject_type":"user","username":"you","credits":184250,
# "app":{"slug":"graph-desk","model":"gpt-5.6-terra","markup_bps":1000}}}
me = call("me")
print(me["subject_type"], me["credits"], "credits")
const me = await call("me");
console.log(me.subject_type, me.credits, "credits");
raw, err := call("me", nil)
if err != nil {
panic(err)
}
var me struct {
SubjectType string `json:"subject_type"`
Credits int `json:"credits"`
}
_ = json.Unmarshal(raw, &me)
fmt.Println(me.SubjectType, me.Credits, "credits")
String me = GraphDesk.call("me", null);
System.out.println(me);
me = call("me")
puts "#{me["subject_type"]} #{me["credits"]} credits"
$me = call("me");
echo $me["subject_type"], " ", $me["credits"], " credits\n";
var me = await GraphDesk.Call("me");
Console.WriteLine($"{me.GetProperty("subject_type").GetString()} " +
$"{me.GetProperty("credits").GetInt32()} credits");
3. The input fields
The same object goes to /estimate, /run and /run-stream. task selects the lane and comes first.
| field | type | required | what it is |
|---|---|---|---|
task | string | yes | "structure" or "brief". |
edge_list | string | yes | One relationship per line: A -> B, A <- B, A <-> B, A,B, tab/semicolon/pipe-separated, or A - B (spaces required around the bare dash). An optional trailing weight: A -> B : 3.5. A bare line with just a name declares an isolated node. |
ambiguous_is_directed | boolean | yes | How to read a comma/tab/semicolon/pipe/dash pair that carries no arrow. Explicit arrows always win regardless of this flag. |
purpose | string | no | One of org-chart, dependency-graph, supply-chain, social-network, citation-network, other. Only read by the brief task; it governs language, never arithmetic. |
prescan | object | yes | The exact structural facts the app's free browser engine computed - counts, degree, components, directed facts, cycle, articulation points, bridges, clustering, closeness, betweenness, distance, and a flags array. Driving the API directly means computing and sending this yourself; it is what the model is held to. See graphlib.js in the bundle for the exact shape, or run the app once and read the network tab. |
prior_structure | object | no | brief task only. The structure task's own prior output on the same graph - verdict, key_nodes, and a trimmed findings list - so the two lanes read as one sitting. |
clip_note | string | no | Set when the app clipped the pasted edge list to fit the run; tells the model how much was cut. |
4. Price it before you run it
POST /estimate is free and creates no job. It returns the model binding and hold_credits - the amount reserved, which is almost always more than the settled charge because the hold prices the full output cap. The hold differs per lane, so re-estimate whenever you change task.
# The prescan fields shown here are trimmed for readability - send the real ones your engine computed.
read -r -d '' INPUT <<'JSON'
{
"task": "structure",
"edge_list": "Alice -> Bob\nAlice -> Carol\nBob -> Dana",
"ambiguous_is_directed": true,
"purpose": "org-chart",
"prescan": {"ok": true, "counts": {"nodes": 4, "edges_simple": 3}, "directed": true,
"degree": {"min": 1, "max": 2, "mean": 1.5, "median": 1.5, "top": [{"node": "Alice", "value": 2}]},
"components": {"count": 1, "largest_size": 4, "largest_fraction": 100, "sizes": [4]},
"articulation": {"count": 1, "nodes": ["Alice"]}, "bridges": {"count": 3, "edges": []},
"cycle": {"cyclomatic_number": 0, "has_cycle": false}, "flags": []}
}
JSON
ssapp estimate "$INPUT"
# {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra","markup_bps":1000,
# "hold_credits":1890,"min_credits":310,"sponsor_enabled":false}}
est = call("estimate", {
"task": "structure",
"edge_list": "Alice -> Bob\nAlice -> Carol\nBob -> Dana",
"ambiguous_is_directed": True,
"purpose": "org-chart",
"prescan": {"ok": True, "counts": {"nodes": 4, "edges_simple": 3}, "flags": []},
})
print(est["hold_credits"], "credits reserved")
const est = await call("estimate", {
task: "structure",
edge_list: "Alice -> Bob\nAlice -> Carol\nBob -> Dana",
ambiguous_is_directed: true,
purpose: "org-chart",
prescan: { ok: true, counts: { nodes: 4, edges_simple: 3 }, flags: [] }
});
console.log(est.hold_credits, "credits reserved");
raw, err := call("estimate", map[string]any{
"task": "structure",
"edge_list": "Alice -> Bob\nAlice -> Carol\nBob -> Dana",
"ambiguous_is_directed": true,
"purpose": "org-chart",
"prescan": map[string]any{"ok": true, "flags": []any{}},
})
if err != nil {
panic(err)
}
fmt.Println(string(raw))
String body = "{\"task\":\"structure\",\"edge_list\":\"Alice -> Bob\\nAlice -> Carol\\nBob -> Dana\"," +
"\"ambiguous_is_directed\":true,\"purpose\":\"org-chart\",\"prescan\":{\"ok\":true,\"flags\":[]}}";
String est = GraphDesk.call("estimate", body);
System.out.println(est);
est = call("estimate", {
task: "structure",
edge_list: "Alice -> Bob\nAlice -> Carol\nBob -> Dana",
ambiguous_is_directed: true,
purpose: "org-chart",
prescan: { ok: true, flags: [] }
})
puts "#{est["hold_credits"]} credits reserved"
$est = call("estimate", [
"task" => "structure",
"edge_list" => "Alice -> Bob\nAlice -> Carol\nBob -> Dana",
"ambiguous_is_directed" => true,
"purpose" => "org-chart",
"prescan" => ["ok" => true, "flags" => []]
]);
echo $est["hold_credits"], " credits reserved\n";
var est = await GraphDesk.Call("estimate", new {
task = "structure",
edge_list = "Alice -> Bob\nAlice -> Carol\nBob -> Dana",
ambiguous_is_directed = true,
purpose = "org-chart",
prescan = new { ok = true, flags = Array.Empty
5. Run it and poll, or stream it
POST /run returns a job_id immediately; poll GET /jobs/{id} until status is terminal. POST /run-stream is Server-Sent Events - deltas arrive as they are generated, which is what the web app uses for the staged progress card. Both need an Idempotency-Key header: reuse the same key on a retry of the same logical run so a network blip or a malformed first reply can never double-bill.
JOB=$(curl -sS -X POST "$BASE/run" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: graph-desk:structure:$(echo -n "$INPUT" | shasum | cut -c1-16):a1" \
-d "$INPUT" | python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["job_id"])')
until curl -sS "$BASE/jobs/$JOB" -H "Authorization: Bearer $TOKEN" \
| tee /tmp/job.json | python3 -c 'import json,sys;d=json.load(sys.stdin)["data"];exit(0 if d["status"] in ("succeeded","failed") else 1)'
do sleep 1; done
cat /tmp/job.json
# Streaming instead:
curl -sS -N -X POST "$BASE/run-stream" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: graph-desk:structure:$(echo -n "$INPUT" | shasum | cut -c1-16):a1" \
-d "$INPUT"
# text/event-stream: a sequence of "data: {...}" lines, terminated by a final job event.
import hashlib, time
def idem_key(task, input_obj, attempt=1):
h = hashlib.sha256(json.dumps([task, input_obj.get("edge_list"), input_obj.get("purpose")]).encode()).hexdigest()[:16]
return f"graph-desk:{task}:{h}:a{attempt}"
def run_and_wait(input_obj):
req = urllib.request.Request(f"{BASE}/run", data=json.dumps(input_obj).encode(), method="POST")
req.add_header("Authorization", f"Bearer {TOKEN}")
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", idem_key(input_obj["task"], input_obj))
with urllib.request.urlopen(req) as r:
job_id = json.load(r)["data"]["job_id"]
while True:
job = call(f"jobs/{job_id}")
if job["status"] in ("succeeded", "failed"):
return job
time.sleep(1)
async function idemKey(task, input) {
const enc = new TextEncoder().encode(JSON.stringify([task, input.edge_list, input.purpose]));
const digest = await crypto.subtle.digest("SHA-256", enc);
const hex = Array.from(new Uint8Array(digest)).map(b => b.toString(16).padStart(2, "0")).join("").slice(0, 16);
return `graph-desk:${task}:${hex}:a1`;
}
async function runStream(input, onDelta) {
const key = await idemKey(input.task, input);
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 = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
// Parse "data: {...}\n\n" frames from buf and call onDelta per delta event.
}
}
// Poll variant: POST /run, then GET /jobs/{id} until status is terminal.
raw, err := call("run", input) // set the Idempotency-Key header inside call() for this request
// ... unmarshal raw to get job_id, then loop GET("jobs/"+jobID) with a short sleep.
// Poll variant: POST run, read job_id, then GET jobs/{id} in a loop with Thread.sleep(1000)
// until status is "succeeded" or "failed". Add the Idempotency-Key header alongside Authorization.
def idem_key(task, input)
h = Digest::SHA256.hexdigest([task, input[:edge_list], input[:purpose]].to_json)[0, 16]
"graph-desk:#{task}:#{h}:a1"
end
# POST to "run" with that header, read job_id, then GET "jobs/#{job_id}" until status is terminal.
// Poll variant: POST to "run" with an Idempotency-Key header derived the same way,
// read job_id from the response, then GET "jobs/{$jobId}" in a loop until status is terminal.
// Poll variant: POST to "run" with an Idempotency-Key header, read job_id,
// then GET "jobs/{jobId}" in a loop with Task.Delay(1000) until status is terminal.
6. Worked example: the structure task
Output body shape for task: "structure":
{
"task": "structure", "task_inferred": false, "title": "...", "verdict": "fragile",
"summary": "...", "assumptions": [], "open_questions": [],
"findings": [{"id": "GD-001", "severity": "high", "node": "Priya Chen", "title": "...", "why": "...", "fix": "..."}],
"reconciliation": [], "next_lane": {"lane": "brief", "reason": "..."},
"body": {
"overview": "...",
"key_nodes": [{"node": "Priya Chen", "role": "cut-vertex", "metric_name": "articulation", "metric_value": "yes", "why": "..."}],
"components_note": "..."
}
}
role is one of hub, cut-vertex, bridge-endpoint, isolated, source, sink, central, other. Every node field anywhere in the reply is checked back against your prescan and edge_list - a name that does not appear in either is rendered but marked ungrounded.
7. Worked example: the brief task
Output body shape for task: "brief":
{
"task": "brief", "task_inferred": false, "title": "...", "verdict": "fragile",
"summary": "...", "assumptions": [], "open_questions": [],
"findings": [], "reconciliation": [], "next_lane": {"lane": "", "reason": ""},
"body": {
"purpose": "org-chart",
"executive_summary": "...",
"priority_actions": [{"rank": 1, "action": "Document Priya Chen's role and identify a backup", "node": "Priya Chen", "rationale": "..."}],
"node_recommendations": [{"node": "Priya Chen", "recommendation": "...", "based_on": "betweenness 0.6667, articulation point"}],
"longer_term": ["..."]
}
}
next_lane is always {"lane": "", "reason": ""} from this task - there is no third lane. Send the structure task's own prior output as prior_structure in the input to carry its verdict and key nodes into the brief, exactly as the web app's handoff button does.