Everything the app does is one REST surface. Submit UI source code, get back the audit in the exact tagged-markdown contract the app renders — usable from CI, a pre-commit hook, or a localization pipeline.
Base URL: https://api.skillsafe.ai/v1/app-api. Requests and responses are JSON
(UTF-8). Authentication is Authorization: Bearer <token> on every call
except POST /guest. CORS is open, so browser calls work too.
Every response is an envelope: {"ok": true, "data": ...} on success,
{"ok": false, "error": {"code", "message", "details"}} on failure.
| HTTP | error.code | Meaning |
|---|---|---|
| 401 | unauthorized | Missing, expired or revoked token. |
| 402 | payment_required | Balance below the run's minimum hold. Guests get this on /run unless sponsorship is enabled. |
| 403 | forbidden | Token belongs to a different app slug. |
| 404 | not_found | Unknown job id or endpoint. |
| 422 | invalid_input | Input failed validation (not JSON, or too large). |
| 429 | rate_limited | Slow down; retry after the Retry-After seconds. |
| 5xx | internal | Platform-side failure; safe to retry with the same Idempotency-Key. |
Billing: /guest, /me and /estimate are
free. /run and /run-stream reserve the estimate's
hold_credits up front and settle to the (usually much lower)
charged_credits when the job finishes.
Every later step is one call through a helper like this. Set
SKILLSAFE_TOKEN in your environment first — get one on the
token page or with step 1.
BASE=https://api.skillsafe.ai/v1/app-api # All later calls look like: curl -s "$BASE/me" -H "Authorization: Bearer $SKILLSAFE_TOKEN"
import os, json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = os.environ["SKILLSAFE_TOKEN"]
def api(method, path, body=None):
req = urllib.request.Request(BASE + path, method=method,
data=json.dumps(body).encode() if body is not None else None,
headers={"Authorization": "Bearer " + TOKEN,
"Content-Type": "application/json"})
with urllib.request.urlopen(req) as r:
out = json.load(r)
if not out.get("ok"):
raise RuntimeError(out["error"])
return out["data"]
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // read from your environment or secret store
async function api(method, path, body) {
const res = await fetch(BASE + path, {
method,
headers: { Authorization: "Bearer " + TOKEN,
"Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
const out = await res.json();
if (!out.ok) throw new Error(out.error.code + ": " + out.error.message);
return out.data;
}
package main
import (
"bytes"; "encoding/json"; "net/http"; "os"
)
const base = "https://api.skillsafe.ai/v1/app-api"
func api(method, path string, body any) (map[string]any, error) {
var buf bytes.Buffer
if body != nil { json.NewEncoder(&buf).Encode(body) }
req, _ := http.NewRequest(method, base+path, &buf)
req.Header.Set("Authorization", "Bearer "+os.Getenv("SKILLSAFE_TOKEN"))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
defer res.Body.Close()
var out struct{ Ok bool; Data map[string]any; Error map[string]any }
json.NewDecoder(res.Body).Decode(&out)
if !out.Ok { return nil, fmtError(out.Error) }
return out.Data, nil
}
import java.net.URI;
import java.net.http.*;
class SkillSafe {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String TOKEN = System.getenv("SKILLSAFE_TOKEN");
static final HttpClient HTTP = HttpClient.newHttpClient();
static String api(String method, String path, String jsonBody) throws Exception {
var b = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json");
b = jsonBody == null ? b.GET()
: b.method(method, HttpRequest.BodyPublishers.ofString(jsonBody));
return HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString()).body();
// parse the {ok, data|error} envelope with your JSON library
}
}
require "net/http"; require "json"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN")
def api(method, path, body = nil)
uri = URI(BASE + path)
req = Net::HTTP.const_get(method.capitalize).new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req.body = body.to_json if body
out = JSON.parse(Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }.body)
raise out["error"].to_s unless out["ok"]
out["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN");
function api(string $method, string $path, ?array $body = null) {
global $TOKEN;
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer $TOKEN",
"Content-Type: application/json"],
CURLOPT_POSTFIELDS => $body === null ? null : json_encode($body),
]);
$out = json_decode(curl_exec($ch), true);
if (!($out["ok"] ?? false)) throw new Exception(json_encode($out["error"]));
return $out["data"];
}
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
class SkillSafe {
const string Base = "https://api.skillsafe.ai/v1/app-api";
static readonly string Token =
Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN")!;
static readonly HttpClient Http = new();
static async Task<JsonElement> Api(HttpMethod method, string path, object? body = null) {
var req = new HttpRequestMessage(method, Base + path);
req.Headers.Add("Authorization", "Bearer " + Token);
if (body != null) req.Content = JsonContent.Create(body);
var doc = JsonDocument.Parse(await (await Http.SendAsync(req)).Content.ReadAsStringAsync());
if (!doc.RootElement.GetProperty("ok").GetBoolean())
throw new Exception(doc.RootElement.GetProperty("error").ToString());
return doc.RootElement.GetProperty("data");
}
}
The easiest path is the token page — it shows the token this
browser already uses (personal after SSO sign-in, guest otherwise) with one-click copy.
Scripted guest tokens come from POST /guest with the app slug; guests can call
/me and /estimate, and paid runs need a personal token (or
sponsorship, which this app leaves off).
curl -s https://api.skillsafe.ai/v1/app-api/guest \
-H "Content-Type: application/json" \
-d '{"slug":"i18n-audit"}'
# -> {"ok":true,"data":{"token":"gst_...","guest_id":"..."}}
data = api("POST", "/guest", {"slug": "i18n-audit"})
print(data["token"]) # save it as SKILLSAFE_TOKEN
const { token } = await api("POST", "/guest", { slug: "i18n-audit" });
// save it; guests can /me and /estimate
data, err := api("POST", "/guest", map[string]any{"slug": "i18n-audit"})
if err != nil { panic(err) }
token := data["token"].(string)
String body = "{\"slug\":\"i18n-audit\"}";
String resp = SkillSafe.api("POST", "/guest", body);
// parse resp -> data.token
data = api("POST", "/guest", { slug: "i18n-audit" })
puts data["token"]
$data = api("POST", "/guest", ["slug" => "i18n-audit"]);
echo $data["token"];
var data = await SkillSafe.Api(HttpMethod.Post, "/guest",
new { slug = "i18n-audit" });
var token = data.GetProperty("token").GetString();
Returns subject_type (user or guest),
subject_id and credits (1 credit = $0.0001). The app compares this
balance against the estimate's hold before enabling the run button — do the same before
submitting a run.
curl -s "$BASE/me" -H "Authorization: Bearer $SKILLSAFE_TOKEN"
me = api("GET", "/me")
print(me["subject_type"], me["credits"])
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
me, _ := api("GET", "/me", nil)
fmt.Println(me["subject_type"], me["credits"])
String me = SkillSafe.api("GET", "/me", null);
me = api("GET", "/me")
puts me["credits"]
$me = api("GET", "/me");
echo $me["credits"];
var me = await SkillSafe.Api(HttpMethod.Get, "/me");
Console.WriteLine(me.GetProperty("credits"));
The input object is the same for /estimate, /run and
/run-stream — exactly what the app submits:
| Field | Type | Meaning |
|---|---|---|
code | string, required | The UI source. Concatenate several files with // file: path marker lines. The app clips inputs over 48,000 chars by dropping the middle on line boundaries with a [... middle truncated ...] marker — do the same for huge inputs. |
framework | string | "React", "Vue", "Angular", "Svelte", "Plain JS", "HTML", "Other/mixed" or "" (the audit trusts the code over the claim). |
locales | string | Target locales, e.g. "de, fr, ar". An RTL target weights the layout checks. |
notes | string | Existing i18n setup, key-naming conventions, constraints. |
scan | object | Optional prescan counts (the app sends its in-browser pattern counts as untrusted hints). Omit it from scripts. |
retry_note | string | Only for a re-run after a malformed reply; restates the required shape. |
/estimate is free, creates no job, and returns hold_credits (the
reserve, priced at the full output cap), min_credits, model and
model_alias. Treat hold_credits as the reservation, never the
price.
curl -s "$BASE/estimate" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"code":"// file: App.jsx\nexport const T = () => <h1>Welcome back</h1>;","framework":"React","locales":"de","notes":""}'
input_obj = {
"code": open("src/App.jsx", encoding="utf-8").read(),
"framework": "React",
"locales": "de, fr, ar",
"notes": "react-i18next, keys like page.section.label",
}
est = api("POST", "/estimate", input_obj)
print(est["hold_credits"], est["model"])
const input = {
code: codeText, // your source, with // file: markers
framework: "React",
locales: "de, fr, ar",
notes: "",
};
const est = await api("POST", "/estimate", input);
console.log(est.hold_credits, est.model);
input := map[string]any{"code": code, "framework": "React",
"locales": "de", "notes": ""}
est, _ := api("POST", "/estimate", input)
fmt.Println(est["hold_credits"])
String input = "{\"code\":\"...\",\"framework\":\"React\",\"locales\":\"de\",\"notes\":\"\"}";
String est = SkillSafe.api("POST", "/estimate", input);
input = { code: File.read("src/App.jsx"), framework: "React",
locales: "de", notes: "" }
est = api("POST", "/estimate", input)
puts est["hold_credits"]
$input = ["code" => file_get_contents("src/App.jsx"),
"framework" => "React", "locales" => "de", "notes" => ""];
$est = api("POST", "/estimate", $input);
echo $est["hold_credits"];
var input = new { code = code, framework = "React",
locales = "de", notes = "" };
var est = await SkillSafe.Api(HttpMethod.Post, "/estimate", input);
Console.WriteLine(est.GetProperty("hold_credits"));
Send the same input object. Always pass an Idempotency-Key header so a network
retry of the same request can never double-bill. The app builds it as
i18n-audit-{content hash}-{per-gesture nonce}, plus a lane suffix. Two rules
follow, and both matter: keep the key stable while you are retrying one request over
a flaky connection, and make it different for anything you intend to be a new run.
A key that is merely a hash plus an attempt counter fails the second rule — the counter
restarts, the platform replays the earlier result with deduped set, and you can
never get a fresh answer for the same bytes. /run may resolve synchronously (the reply carries
output) or return a queued job — poll /jobs/{id} until
status is succeeded or failed. The result carries
charged_credits (the actual cost) and truncated (true when the
output was cut by a low balance).
The audit text is in output.output and follows the app's exact contract:
four tag lines — VERDICT: (one of Ready for localization,
Minor gaps, Needs extraction work, Not auditable),
SCORE: 0–30, CONFIDENCE: 0–100,
SUMMARY: — then six ## sections in order:
Score breakdown (six category | X/Y | found | change bullets,
maxima 8/5/5/4/4/4), Priority fixes, Extraction plan
(`key` | "source text" | where), Locale starter
(`key`: "text" — assemble these into your en.json),
RTL and formatting notes, Open questions. Empty sections contain
the single bullet - None.
curl -s "$BASE/run" \ -H "Authorization: Bearer $SKILLSAFE_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: audit-$(cksum src/App.jsx | cut -d' ' -f1)-$RUN_NONCE" \ -d @input.json # if data.status is "queued": poll curl -s "$BASE/jobs/JOB_ID" -H "Authorization: Bearer $SKILLSAFE_TOKEN"
import hashlib, time
key = "audit-" + hashlib.sha256(
json.dumps(input_obj, sort_keys=True).encode()).hexdigest()[:16] + "-$RUN_NONCE"
req = urllib.request.Request(BASE + "/run", method="POST",
data=json.dumps(input_obj).encode(),
headers={"Authorization": "Bearer " + TOKEN,
"Content-Type": "application/json",
"Idempotency-Key": key})
with urllib.request.urlopen(req) as r:
job = json.load(r)["data"]
while job.get("status") not in ("succeeded", "failed", None):
time.sleep(1)
job = api("GET", "/jobs/" + job["job_id"])
report = job["output"]["output"] # the tagged markdown
print(job["charged_credits"], "credits;", report.splitlines()[0])
const key = "audit-" + contentHash(input) + "-$RUN_NONCE"; // any stable hash
let job = await fetch(BASE + "/run", {
method: "POST",
headers: { Authorization: "Bearer " + TOKEN,
"Content-Type": "application/json",
"Idempotency-Key": key },
body: JSON.stringify(input),
}).then((r) => r.json()).then((o) => o.data);
while (job.status === "queued" || job.status === "running") {
await new Promise((r) => setTimeout(r, 1000));
job = await api("GET", "/jobs/" + job.job_id);
}
const report = job.output.output; // VERDICT: ... markdown
// add the header to the helper for this call:
req.Header.Set("Idempotency-Key", "audit-"+hash(input)+"-$RUN_NONCE")
job, _ := api("POST", "/run", input)
for job["status"] == "queued" || job["status"] == "running" {
time.Sleep(time.Second)
job, _ = api("GET", "/jobs/"+job["job_id"].(string), nil)
}
report := job["output"].(map[string]any)["output"].(string)
// send with .header("Idempotency-Key", key), then poll:
String job = SkillSafe.api("POST", "/run", input);
// while status is queued/running: SkillSafe.api("GET", "/jobs/" + id, null)
req["Idempotency-Key"] = "audit-#{Digest::SHA256.hexdigest(input.to_json)[0,16]}-$RUN_NONCE"
job = api("POST", "/run", input)
until %w[succeeded failed].include?(job["status"].to_s)
sleep 1
job = api("GET", "/jobs/#{job["job_id"]}")
end
report = job.dig("output", "output")
$key = "audit-" . substr(hash("sha256", json_encode($input)), 0, 16) . "-$RUN_NONCE";
// add "Idempotency-Key: $key" to CURLOPT_HTTPHEADER for this call
$job = api("POST", "/run", $input);
while (in_array($job["status"] ?? "", ["queued", "running"])) {
sleep(1);
$job = api("GET", "/jobs/" . $job["job_id"]);
}
$report = $job["output"]["output"];
req.Headers.Add("Idempotency-Key", $"audit-{hash}-$RUN_NONCE");
var job = await SkillSafe.Api(HttpMethod.Post, "/run", input);
// poll /jobs/{id} while status is queued/running
var report = job.GetProperty("output").GetProperty("output").GetString();
Parse defensively: strip an outer code fence if one sneaks in, and treat a
reply without the VERDICT: line as malformed — re-run once with a
retry_note restating the shape. Give that reformat request a
different Idempotency-Key (the app appends
-reformat to the same content hash and nonce). Reusing the first key here is a
trap: the platform would treat it as a duplicate and hand back the very malformed reply the
reformat was sent to replace. The reformat is a second billed run, and the app tells the user
so before it starts.
Same input and billing as /run, but the response is Server-Sent Events:
event: job (accepted), repeated event: delta
({"text": "..."} chunks), then a terminal event: done whose payload
is authoritative (output, charged_credits,
truncated) — deltas can drop the tail, so always prefer
done.output.output. event: error reports a failed job.
Idempotency-Key works here too.
curl -N -s "$BASE/run-stream" \ -H "Authorization: Bearer $SKILLSAFE_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: audit-abc123-$RUN_NONCE" \ -d @input.json
req = urllib.request.Request(BASE + "/run-stream", method="POST",
data=json.dumps(input_obj).encode(),
headers={"Authorization": "Bearer " + TOKEN,
"Content-Type": "application/json"})
with urllib.request.urlopen(req) as r:
for raw in r:
line = raw.decode().strip()
if line.startswith("data:"):
evt = json.loads(line[5:])
if "text" in evt: print(evt["text"], end="", flush=True)
const res = await fetch(BASE + "/run-stream", {
method: "POST",
headers: { Authorization: "Bearer " + TOKEN,
"Content-Type": "application/json" },
body: JSON.stringify(input),
});
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
// split on \n\n, parse "event:" and "data:" lines per SSE
}
req, _ := http.NewRequest("POST", base+"/run-stream", &buf)
req.Header.Set("Authorization", "Bearer "+os.Getenv("SKILLSAFE_TOKEN"))
req.Header.Set("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
sc := bufio.NewScanner(res.Body)
for sc.Scan() {
line := sc.Text()
if strings.HasPrefix(line, "data:") { fmt.Println(line[5:]) }
}
HttpRequest req = HttpRequest.newBuilder(URI.create(SkillSafe.BASE + "/run-stream"))
.header("Authorization", "Bearer " + SkillSafe.TOKEN)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(inputJson)).build();
SkillSafe.HTTP.send(req, HttpResponse.BodyHandlers.ofLines())
.body().filter(l -> l.startsWith("data:"))
.forEach(l -> System.out.println(l.substring(5)));
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
req = Net::HTTP::Post.new(BASE + "/run-stream")
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req.body = input.to_json
http.request(req) do |res|
res.read_body { |chunk| print chunk.scan(/data: (.*)/).join("\n") }
end
end
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer $TOKEN",
"Content-Type: application/json"],
CURLOPT_POSTFIELDS => json_encode($input),
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) {
echo $chunk; return strlen($chunk); // parse SSE lines as they arrive
},
]);
curl_exec($ch);
var req = new HttpRequestMessage(HttpMethod.Post, SkillSafe.Base + "/run-stream")
{ Content = JsonContent.Create(input) };
req.Headers.Add("Authorization", "Bearer " + token);
var res = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var sr = new StreamReader(await res.Content.ReadAsStreamAsync());
while (await sr.ReadLineAsync() is { } line)
if (line.StartsWith("data:")) Console.WriteLine(line[5..]);
The report's Locale starter bullets parse as
`key`: "value" lines — fold them into a flat JSON object and you have the
same en.json the app's download button produces.