Basics
Base URL: https://api.skillsafe.ai/v1/app-api. Every response is one of two
envelopes: {"ok": true, "data": {...}} or {"ok": false, "error": {"code": "...", "message": "..."}}.
Send Authorization: Bearer YOUR_TOKEN on every call. Runs are metered against the
token's account balance; /me and /estimate are always free.
Error codes
| HTTP | error.code | Meaning |
|---|---|---|
| 400 | VALIDATION_ERROR | Missing or malformed field in the request body. |
| 401 | UNAUTHORIZED | Missing, expired, or invalid token. |
| 402 | PAYMENT_REQUIRED | Balance is below the run's min_credits. |
| 404 | NOT_FOUND | Unknown job id, or the app itself. |
| 429 | RATE_LIMITED | Too many requests — back off and retry. |
| 5xx | SERVER_ERROR | Platform-side failure — safe to retry with the same Idempotency-Key. |
Step 0 — A tiny client
One helper, reused by every step below: sends the bearer token, parses the envelope, and throws on ok: false.
# No client needed — every step below is a standalone curl command. # Just export your token once: export SKILLSAFE_TOKEN="YOUR_TOKEN"
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
def call(path, method="GET", body=None, token=None):
req = urllib.request.Request(BASE + path, method=method)
req.add_header("Authorization", "Bearer " + (token or "YOUR_TOKEN"))
data = None
if body is not None:
data = json.dumps(body).encode()
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req, data=data) as r:
out = json.loads(r.read())
if not out.get("ok"):
raise RuntimeError(out.get("error"))
return out["data"]
const BASE = "https://api.skillsafe.ai/v1/app-api";
async function call(path, { method = "GET", body, token } = {}) {
const res = await fetch(BASE + path, {
method,
headers: {
"Authorization": "Bearer " + (token || "YOUR_TOKEN"),
...(body ? { "Content-Type": "application/json" } : {})
},
body: body ? JSON.stringify(body) : undefined
});
const out = await res.json();
if (!out.ok) throw new Error(out.error && out.error.message);
return out.data;
}
package jobdesk
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
)
const Base = "https://api.skillsafe.ai/v1/app-api"
func Call(path, method string, body any, token string) (map[string]any, error) {
var buf io.Reader
if body != nil {
b, _ := json.Marshal(body)
buf = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, Base+path, buf)
req.Header.Set("Authorization", "Bearer "+token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var out map[string]any
json.NewDecoder(resp.Body).Decode(&out)
if ok, _ := out["ok"].(bool); !ok {
return nil, fmt.Errorf("%v", out["error"])
}
return out["data"].(map[string]any), nil
}
import java.net.URI;
import java.net.http.*;
import java.net.http.HttpRequest.BodyPublishers;
public class JobDeskClient {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final HttpClient http = HttpClient.newHttpClient();
static String call(String path, String method, String jsonBody, String token) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Authorization", "Bearer " + token);
if (jsonBody != null) {
b.header("Content-Type", "application/json").method(method, BodyPublishers.ofString(jsonBody));
} else {
b.method(method, BodyPublishers.noBody());
}
HttpResponse resp = http.send(b.build(), HttpResponse.BodyHandlers.ofString());
return resp.body(); // parse the {ok, data|error} envelope with your JSON library of choice
}
}
require "net/http"
require "json"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
def call(path, method: "GET", body: nil, token: "YOUR_TOKEN")
uri = URI(BASE + path)
req = Net::HTTP.const_get(method.capitalize).new(uri)
req["Authorization"] = "Bearer #{token}"
if body
req["Content-Type"] = "application/json"
req.body = body.to_json
end
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
out = JSON.parse(res.body)
raise out["error"].to_s unless out["ok"]
out["data"]
end
<?php
function jobdesk_call(string $path, string $method = "GET", ?array $body = null, string $token = "YOUR_TOKEN"): array {
$ch = curl_init("https://api.skillsafe.ai/v1/app-api" . $path);
$headers = ["Authorization: Bearer $token"];
if ($body !== null) {
$headers[] = "Content-Type: application/json";
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$out = json_decode(curl_exec($ch), true);
if (!$out["ok"]) { throw new Exception(json_encode($out["error"])); }
return $out["data"];
}
using System.Net.Http.Json;
class JobDeskClient {
static readonly HttpClient Http = new();
const string Base = "https://api.skillsafe.ai/v1/app-api";
static async Task<JsonElement> Call(string path, HttpMethod method, object? body, string token) {
var req = new HttpRequestMessage(method, Base + path);
req.Headers.Add("Authorization", "Bearer " + token);
if (body != null) req.Content = JsonContent.Create(body);
var res = await Http.SendAsync(req);
var doc = await res.Content.ReadFromJsonAsync<JsonElement>();
if (!doc.GetProperty("ok").GetBoolean()) throw new Exception(doc.GetProperty("error").ToString());
return doc.GetProperty("data");
}
}
Step 1 — Get a token
A guest token works for /me and /estimate only; runs need a personal
token from signing in at tokens.html, or scripted here via
POST /guest.
curl -s -X POST https://api.skillsafe.ai/v1/app-api/guest \
-H "Content-Type: application/json" -d '{"slug":"job-desk"}'
# {"ok":true,"data":{"token":"gst_...","subject_type":"guest"}}
import urllib.request, json
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/guest", method="POST",
data=json.dumps({"slug": "job-desk"}).encode(),
headers={"Content-Type": "application/json"})
token = json.loads(urllib.request.urlopen(req).read())["data"]["token"]
const { token } = await (await fetch("https://api.skillsafe.ai/v1/app-api/guest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "job-desk" })
})).json().then(r => r.data);
data, _ := Call("/guest", "POST", map[string]string{"slug": "job-desk"}, "")
token := data["token"].(string)
String body = call("/guest", "POST", "{\"slug\":\"job-desk\"}", "");
// parse body.data.token with your JSON library
token = call("/guest", method: "POST", body: { slug: "job-desk" }, token: "")["token"]
$data = jobdesk_call("/guest", "POST", ["slug" => "job-desk"], "");
$token = $data["token"];
var data = await Call("/guest", HttpMethod.Post, new { slug = "job-desk" }, "");
var token = data.GetProperty("token").GetString();
Step 2 — Check who you are and your balance
curl -s https://api.skillsafe.ai/v1/app-api/me \ -H "Authorization: Bearer $SKILLSAFE_TOKEN"
me = call("/me", token="YOUR_TOKEN")
print(me["credits"], me["subject_type"])
const me = await call("/me", { token: "YOUR_TOKEN" });
console.log(me.credits, me.subject_type);
me, _ := Call("/me", "GET", nil, token)
fmt.Println(me["credits"], me["subject_type"])
String body = call("/me", "GET", null, token);
me = call("/me", token: token)
puts me["credits"]
$me = jobdesk_call("/me", "GET", null, $token);
var me = await Call("/me", HttpMethod.Get, null, token);
Step 3 — Estimate the cost
POST /estimate takes the exact input shape the app sends. No charge, no job. Assert
model_alias reads gpt-terra and markup_bps reads
1000 before trusting anything else in an integration test.
Input shape (identical for /estimate, /run and /run-stream):
{
"task": "review",
"draft": "# Senior Backend Engineer\n\nYour rough job posting text here...",
"title": "Optional working title (usually the role name)",
"company_context": "Optional: company/team context",
"goal_note": "Optional: what to emphasize or fix",
"prescan_facts": {
"stats": {"words": 220, "sentences": 11, "paragraphs": 8, "headings": 4},
"flags": [{"id": "gendered:3", "label": "..."}],
"headings": [{"id": "heading:0", "label": "Senior Backend Engineer"}]
}
}
curl -s -X POST https://api.skillsafe.ai/v1/app-api/estimate \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
-d '{"task":"review","draft":"# Intro\n\nYour rough draft here."}'
# {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra","markup_bps":1000,
# "hold_credits":1400,"min_credits":160,"sponsor_enabled":false}}
est = call("/estimate", "POST", {"task": "review", "draft": "# Intro\n\nYour rough draft here."}, token)
print(est["hold_credits"], est["min_credits"])
const est = await call("/estimate", {
method: "POST", token,
body: { task: "review", draft: "# Intro\n\nYour rough draft here." }
});
est, _ := Call("/estimate", "POST", map[string]string{"task": "review", "draft": "..."}, token)
String body = call("/estimate", "POST", "{\"task\":\"review\",\"draft\":\"...\"}", token);
est = call("/estimate", method: "POST", body: { task: "review", draft: "..." }, token: token)
$est = jobdesk_call("/estimate", "POST", ["task" => "review", "draft" => "..."], $token);
var est = await Call("/estimate", HttpMethod.Post, new { task = "review", draft = "..." }, token);
Step 4 — Run the review and poll for the result
POST /run starts a job and returns immediately with a job_id; poll
GET /jobs/{id} until status is succeeded or
failed. Always send an Idempotency-Key header — a hash of the input
plus an attempt counter — so a retried request after a network blip never double-bills.
curl -s -X POST https://api.skillsafe.ai/v1/app-api/run \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: job-desk:abc123:a1" \
-d '{"task":"review","draft":"# Intro\n\nYour rough draft here."}'
# {"ok":true,"data":{"job_id":"job_...","status":"queued"}}
curl -s https://api.skillsafe.ai/v1/app-api/jobs/job_... \
-H "Authorization: Bearer $SKILLSAFE_TOKEN"
# {"ok":true,"data":{"status":"succeeded","output":{"output":"{...review JSON...}"},
# "charged_credits":210,"truncated":false}}
import time
job = call("/run", "POST", {"task": "review", "draft": draft_text}, token)
job_id = job["job_id"]
while True:
res = call(f"/jobs/{job_id}", token=token)
if res["status"] in ("succeeded", "failed"):
break
time.sleep(1)
result = json.loads(res["output"]["output"]) # the review JSON contract
const job = await call("/run", { method: "POST", token, body: { task: "review", draft } });
let res;
do {
await new Promise(r => setTimeout(r, 1000));
res = await call(`/jobs/${job.job_id}`, { token });
} while (!["succeeded", "failed"].includes(res.status));
const result = JSON.parse(res.output.output); // the review JSON contract
job, _ := Call("/run", "POST", map[string]string{"task": "review", "draft": draft}, token)
for {
res, _ := Call("/jobs/"+job["job_id"].(string), "GET", nil, token)
if s := res["status"]; s == "succeeded" || s == "failed" {
break
}
time.Sleep(time.Second)
}
String job = call("/run", "POST", "{\"task\":\"review\",\"draft\":\"...\"}", token);
// extract job_id, then poll GET /jobs/{id} until status is succeeded or failed
job = call("/run", method: "POST", body: { task: "review", draft: draft }, token: token)
loop do
res = call("/jobs/#{job['job_id']}", token: token)
break if %w[succeeded failed].include?(res["status"])
sleep 1
end
$job = jobdesk_call("/run", "POST", ["task" => "review", "draft" => $draft], $token);
do {
sleep(1);
$res = jobdesk_call("/jobs/" . $job["job_id"], "GET", null, $token);
} while (!in_array($res["status"], ["succeeded", "failed"]));
var job = await Call("/run", HttpMethod.Post, new { task = "review", draft }, token);
JsonElement res;
do {
await Task.Delay(1000);
res = await Call($"/jobs/{job.GetProperty("job_id")}", HttpMethod.Get, null, token);
} while (res.GetProperty("status").GetString() is not ("succeeded" or "failed"));
The review object — output schema
output.output in the job result is a JSON string with exactly this shape:
| Field | Type | Notes |
|---|---|---|
verdict | string | "revised" or "too_short" |
summary | string | One paragraph on the draft and the review |
issues | array | {category, severity, detail, quote}; category is one of gendered_language/missing_info/cliche/unclear_requirements/other, severity is high/medium/low |
line_edits | array | {original, revised, reason}, 6–20 entries |
revised_posting | string | The full rewritten job posting |
changelog | string[] | Plain-English bullets |
reconciliation | string[] | One entry per prescan flag family addressed |
confidence | number | 0–1 |
Step 5 — Stream the review as it is written
POST /run-stream returns a text/event-stream of raw output deltas,
terminated by a done event carrying the same job payload /jobs/{id}
would. Useful for a live progress UI; the deltas are raw partial JSON text, not
individually parseable.
curl -N -X POST https://api.skillsafe.ai/v1/app-api/run-stream \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: job-desk:abc123:a1" \
-d '{"task":"review","draft":"# Intro\n\nYour rough draft here."}'
# event: delta\ndata: {"text":"{\"verdict\""}\n\n ... event: done\ndata: {...job result...}
import sseclient # any SSE client library
resp = requests.post(BASE + "/run-stream", json={"task": "review", "draft": draft_text},
headers={"Authorization": f"Bearer {token}"}, stream=True)
for event in sseclient.SSEClient(resp):
if event.event == "delta":
print(json.loads(event.data)["text"], end="")
elif event.event == "done":
result = json.loads(json.loads(event.data)["output"]["output"])
const res = await fetch(BASE + "/run-stream", {
method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ task: "review", draft })
});
for await (const chunk of res.body) {
// parse the `event: delta` / `event: done` SSE frames from the chunk text
}
// Read the response body line by line, splitting on "event:" / "data:" SSE frames;
// the terminal "done" event's data is the same job payload as GET /jobs/{id}.
// Use an SSE-aware HTTP client (e.g. OkHttp's EventSource) against POST /run-stream;
// the terminal "done" event's data is the same job payload as GET /jobs/{id}.
# Use an SSE client gem against POST /run-stream;
# the terminal "done" event carries the same payload as GET /jobs/{id}.
// Read the curl_multi / stream response line by line, splitting on SSE "event:"/"data:" frames;
// the terminal "done" event's data matches GET /jobs/{id}.
// Read res.Content.ReadAsStreamAsync() line by line, splitting on SSE "event:"/"data:" frames;
// the terminal "done" event's data matches GET /jobs/{id}.