Job Desk — API & developer tutorial

Everything the app itself calls, so you can script it.

API tokens Open the app

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

HTTPerror.codeMeaning
400VALIDATION_ERRORMissing or malformed field in the request body.
401UNAUTHORIZEDMissing, expired, or invalid token.
402PAYMENT_REQUIREDBalance is below the run's min_credits.
404NOT_FOUNDUnknown job id, or the app itself.
429RATE_LIMITEDToo many requests — back off and retry.
5xxSERVER_ERRORPlatform-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:

FieldTypeNotes
verdictstring"revised" or "too_short"
summarystringOne paragraph on the draft and the review
issuesarray{category, severity, detail, quote}; category is one of gendered_language/missing_info/cliche/unclear_requirements/other, severity is high/medium/low
line_editsarray{original, revised, reason}, 6–20 entries
revised_postingstringThe full rewritten job posting
changelogstring[]Plain-English bullets
reconciliationstring[]One entry per prescan flag family addressed
confidencenumber0–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}.