← Alt Text Writer/API
Your token

Alt Text Writer API

The same run the page makes, from your own code. You upload up to six images per run; the model returns, for each, the kind of image, alt text within your limit, a longer description, a social caption, hashtags, the text it can read in the image, and flags to act on. The length and wording checks the page shows are done in the browser by /alt.js; run the same rules on your side.

Base URL https://api.skillsafe.ai/v1/app-api. Every response is {"ok":true,"data":…} or {"ok":false,"error":{"code","message"}}. Runs are paid per run with SkillSafe credits from the signed-in account; /me and /estimate are free.

Errors

HTTPcodemeaning
400validation_errorBody is not a JSON object, or a field has the wrong type.
401unauthorizedMissing, expired or guest token on a metered call. Sign in for a personal token.
402insufficient_creditsBalance below the run's minimum. Top up at skillsafe.ai.
404not_foundA $files id that does not exist or belongs to another session.
429rate_limitedSlow down and retry with the same Idempotency-Key.

1. Get a token

Open the token page, sign in, and copy your token. Guest tokens can call /me and /estimate but not run.

2. Check the session: GET /me

curl -s https://api.skillsafe.ai/v1/app-api/me \
  -H "Authorization: Bearer YOUR_TOKEN"
import requests
H = {"Authorization": "Bearer YOUR_TOKEN"}
print(requests.get("https://api.skillsafe.ai/v1/app-api/me", headers=H).json()["data"])
const H = { Authorization: "Bearer YOUR_TOKEN" };
const me = await (await fetch("https://api.skillsafe.ai/v1/app-api/me", { headers: H })).json();
console.log(me.data.credits);
req, _ := http.NewRequest("GET", "https://api.skillsafe.ai/v1/app-api/me", nil)
req.Header.Set("Authorization", "Bearer YOUR_TOKEN")
res, _ := http.DefaultClient.Do(req)
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
var req = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/me"))
  .header("Authorization", "Bearer YOUR_TOKEN").build();
var res = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString());
System.out.println(res.body());
require "net/http"; require "json"
uri = URI("https://api.skillsafe.ai/v1/app-api/me")
req = Net::HTTP::Get.new(uri); req["Authorization"] = "Bearer YOUR_TOKEN"
res = Net::HTTP.start(uri.host, 443, use_ssl: true) { |h| h.request(req) }
puts JSON.parse(res.body)["data"]
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/me");
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true,
  CURLOPT_HTTPHEADER => ["Authorization: Bearer YOUR_TOKEN"]]);
echo curl_exec($ch);
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("Authorization", "Bearer YOUR_TOKEN");
Console.WriteLine(await http.GetStringAsync("https://api.skillsafe.ai/v1/app-api/me"));

3. Upload each image: POST /files

Multipart, field file. Shrink first: about 1280 px on the long edge is plenty, even for small text. File ids belong to the token that uploaded them.

curl -s -X POST https://api.skillsafe.ai/v1/app-api/files \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -F "file=@hero.jpg;type=image/jpeg"
# -> {"ok":true,"data":{"file":{"file_id":"fil_...", ...}}}
with open("hero.jpg", "rb") as f:
    r = requests.post("https://api.skillsafe.ai/v1/app-api/files", headers=H, files={"file": ("hero.jpg", f, "image/jpeg")})
file_id = r.json()["data"]["file"]["file_id"]
const fd = new FormData();
fd.append("file", imageBlob, "hero.jpg");
const up = await (await fetch("https://api.skillsafe.ai/v1/app-api/files", { method: "POST", headers: H, body: fd })).json();
const fileId = up.data.file.file_id;
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
part, _ := w.CreateFormFile("file", "hero.jpg")
img, _ := os.ReadFile("hero.jpg")
part.Write(img)
w.Close()
req, _ := http.NewRequest("POST", "https://api.skillsafe.ai/v1/app-api/files", &buf)
req.Header.Set("Authorization", "Bearer YOUR_TOKEN")
req.Header.Set("Content-Type", w.FormDataContentType())
res, _ := http.DefaultClient.Do(req)
// Any multipart client works; field name "file".
// e.g. with OkHttp:
RequestBody body = new MultipartBody.Builder().setType(MultipartBody.FORM)
  .addFormDataPart("file", "hero.jpg", RequestBody.create(imageBytes, MediaType.get("image/jpeg"))).build();
Request req = new Request.Builder().url("https://api.skillsafe.ai/v1/app-api/files")
  .header("Authorization", "Bearer YOUR_TOKEN").post(body).build();
uri = URI("https://api.skillsafe.ai/v1/app-api/files")
req = Net::HTTP::Post.new(uri); req["Authorization"] = "Bearer YOUR_TOKEN"
req.set_form([["file", File.open("hero.jpg"), { content_type: "image/jpeg" }]], "multipart/form-data")
res = Net::HTTP.start(uri.host, 443, use_ssl: true) { |h| h.request(req) }
file_id = JSON.parse(res.body)["data"]["file"]["file_id"]
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/files");
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true,
  CURLOPT_HTTPHEADER => ["Authorization: Bearer YOUR_TOKEN"],
  CURLOPT_POSTFIELDS => ["file" => new CURLFile("hero.jpg", "image/jpeg")]]);
$fileId = json_decode(curl_exec($ch), true)["data"]["file"]["file_id"];
var form = new MultipartFormDataContent();
var img = new ByteArrayContent(imageBytes);
img.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("image/jpeg");
form.Add(img, "file", "hero.jpg");
var up = await http.PostAsync("https://api.skillsafe.ai/v1/app-api/files", form);

4. Input fields

fieldtypemeaning
taskstringAlways "describe".
imagesstringJSON text: one {"n","name","role","note"} per attachment, in attachment order. role is auto, decorative or functional (a link or button; say where it goes in note).
contextstringOptional text from the page the images sit on. Up to about 1,500 characters is useful.
purposestringarticle, product, social, docs, slides or other.
alt_maxnumberAlt text character limit, 40 to 300. 125 is a common house limit.
caption_stylestringgeneral, instagram, x or linkedin.
languagestringA language name such as English, or auto to follow the context.
previous, notestringTo re-write ONE image: its earlier result as JSON text, and what to change.
$filesarrayThe file ids from step 3, at most six, in the same order as images.

5. Price it: POST /estimate (free)

hold_credits is the most the run can reserve; you are charged charged_credits, usually far less. 10,000 credits = $1.

curl -s -X POST https://api.skillsafe.ai/v1/app-api/estimate \
  -H "Authorization: Bearer YOUR_TOKEN" -H "Content-Type: application/json"  \
  -d '{"task":"describe","images":"[{\"n\":1,\"name\":\"hero.jpg\",\"role\":\"auto\",\"note\":\"\"}]","context":"Our spring menu is here.","purpose":"article","alt_max":125,"caption_style":"general","language":"auto","previous":"","note":"","$files":["FILE_ID"]}'
images = json.dumps([{"n": 1, "name": "hero.jpg", "role": "auto", "note": ""}])
body = {"task": "describe", "images": images, "context": "Our spring menu is here.", "purpose": "article",
        "alt_max": 125, "caption_style": "general", "language": "auto", "$files": [file_id]}
r = requests.post("https://api.skillsafe.ai/v1/app-api/estimate", headers={**H}, json=body)
print(r.json()["data"])
const images = JSON.stringify([{ n: 1, name: "hero.jpg", role: "auto", note: "" }]);
const body = { task: "describe", images, context: "Our spring menu is here.", purpose: "article",
  alt_max: 125, caption_style: "general", language: "auto", $files: [fileId] };
const r = await fetch("https://api.skillsafe.ai/v1/app-api/estimate", { method: "POST",
  headers: { ...H, "Content-Type": "application/json" }, body: JSON.stringify(body) });
console.log((await r.json()).data);
body := []byte(`{"task":"describe","images":"[{\"n\":1,\"name\":\"hero.jpg\",\"role\":\"auto\",\"note\":\"\"}]","context":"Our spring menu is here.","purpose":"article","alt_max":125,"caption_style":"general","language":"auto","previous":"","note":"","$files":["FILE_ID"]}`)
req, _ := http.NewRequest("POST", "https://api.skillsafe.ai/v1/app-api/estimate", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer YOUR_TOKEN")
req.Header.Set("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
var req = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/estimate"))
  .header("Authorization", "Bearer YOUR_TOKEN").header("Content-Type", "application/json")
  .POST(HttpRequest.BodyPublishers.ofString(json)).build();
var res = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString());
uri = URI("https://api.skillsafe.ai/v1/app-api/estimate")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json")
req["Authorization"] = "Bearer YOUR_TOKEN"
images = [{ n: 1, name: "hero.jpg", role: "auto", note: "" }].to_json
req.body = { task: "describe", images: images, context: "Our spring menu is here.", purpose: "article",
  alt_max: 125, caption_style: "general", language: "auto", "$files" => [file_id] }.to_json
res = Net::HTTP.start(uri.host, 443, use_ssl: true) { |h| h.request(req) }
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/estimate");
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true,
  CURLOPT_HTTPHEADER => ["Authorization: Bearer YOUR_TOKEN", "Content-Type: application/json"],
  CURLOPT_POSTFIELDS => json_encode(["task" => "describe", "images" => json_encode([["n" => 1, "name" => "hero.jpg", "role" => "auto", "note" => ""]]),
    "context" => "Our spring menu is here.", "purpose" => "article", "alt_max" => 125, "caption_style" => "general",
    "language" => "auto", "$files" => [$fileId]])]);
echo curl_exec($ch);
var json = JsonSerializer.Serialize(new Dictionary<string, object> {
  ["task"] = "describe", ["images"] = "[{\"n\":1,\"name\":\"hero.jpg\",\"role\":\"auto\",\"note\":\"\"}]",
  ["context"] = "Our spring menu is here.", ["purpose"] = "article", ["alt_max"] = 125,
  ["caption_style"] = "general", ["language"] = "auto", ["$files"] = new[] { fileId } });
var msg = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/estimate") { Content = new StringContent(json, Encoding.UTF8, "application/json") };
var res = await http.SendAsync(msg);

6. Run: POST /run, then poll GET /jobs/{id}

Send an Idempotency-Key: a retry with the same key never bills twice.

curl -s -X POST https://api.skillsafe.ai/v1/app-api/run \
  -H "Authorization: Bearer YOUR_TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: alt-2026-10-31-001" \
  -d '{"task":"describe","images":"[{\"n\":1,\"name\":\"hero.jpg\",\"role\":\"auto\",\"note\":\"\"}]","context":"Our spring menu is here.","purpose":"article","alt_max":125,"caption_style":"general","language":"auto","previous":"","note":"","$files":["FILE_ID"]}'
images = json.dumps([{"n": 1, "name": "hero.jpg", "role": "auto", "note": ""}])
body = {"task": "describe", "images": images, "context": "Our spring menu is here.", "purpose": "article",
        "alt_max": 125, "caption_style": "general", "language": "auto", "$files": [file_id]}
r = requests.post("https://api.skillsafe.ai/v1/app-api/run", headers={**H, 'Idempotency-Key': 'alt-2026-10-31-001'}, json=body)
print(r.json()["data"])
const images = JSON.stringify([{ n: 1, name: "hero.jpg", role: "auto", note: "" }]);
const body = { task: "describe", images, context: "Our spring menu is here.", purpose: "article",
  alt_max: 125, caption_style: "general", language: "auto", $files: [fileId] };
const r = await fetch("https://api.skillsafe.ai/v1/app-api/run", { method: "POST",
  headers: { ...H, "Content-Type": "application/json", "Idempotency-Key": "alt-2026-10-31-001" }, body: JSON.stringify(body) });
console.log((await r.json()).data);
body := []byte(`{"task":"describe","images":"[{\"n\":1,\"name\":\"hero.jpg\",\"role\":\"auto\",\"note\":\"\"}]","context":"Our spring menu is here.","purpose":"article","alt_max":125,"caption_style":"general","language":"auto","previous":"","note":"","$files":["FILE_ID"]}`)
req, _ := http.NewRequest("POST", "https://api.skillsafe.ai/v1/app-api/run", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer YOUR_TOKEN")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "alt-2026-10-31-001")
res, _ := http.DefaultClient.Do(req)
var req = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/run"))
  .header("Authorization", "Bearer YOUR_TOKEN").header("Content-Type", "application/json")
  .header("Idempotency-Key", "alt-2026-10-31-001")
  .POST(HttpRequest.BodyPublishers.ofString(json)).build();
var res = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString());
uri = URI("https://api.skillsafe.ai/v1/app-api/run")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json")
req["Authorization"] = "Bearer YOUR_TOKEN"
req["Idempotency-Key"] = "alt-2026-10-31-001"
images = [{ n: 1, name: "hero.jpg", role: "auto", note: "" }].to_json
req.body = { task: "describe", images: images, context: "Our spring menu is here.", purpose: "article",
  alt_max: 125, caption_style: "general", language: "auto", "$files" => [file_id] }.to_json
res = Net::HTTP.start(uri.host, 443, use_ssl: true) { |h| h.request(req) }
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/run");
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true,
  CURLOPT_HTTPHEADER => ["Authorization: Bearer YOUR_TOKEN", "Content-Type: application/json", "Idempotency-Key: alt-2026-10-31-001"],
  CURLOPT_POSTFIELDS => json_encode(["task" => "describe", "images" => json_encode([["n" => 1, "name" => "hero.jpg", "role" => "auto", "note" => ""]]),
    "context" => "Our spring menu is here.", "purpose" => "article", "alt_max" => 125, "caption_style" => "general",
    "language" => "auto", "$files" => [$fileId]])]);
echo curl_exec($ch);
var json = JsonSerializer.Serialize(new Dictionary<string, object> {
  ["task"] = "describe", ["images"] = "[{\"n\":1,\"name\":\"hero.jpg\",\"role\":\"auto\",\"note\":\"\"}]",
  ["context"] = "Our spring menu is here.", ["purpose"] = "article", ["alt_max"] = 125,
  ["caption_style"] = "general", ["language"] = "auto", ["$files"] = new[] { fileId } });
var msg = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run") { Content = new StringContent(json, Encoding.UTF8, "application/json") };
msg.Headers.Add("Idempotency-Key", "alt-2026-10-31-001");
var res = await http.SendAsync(msg);
curl -s https://api.skillsafe.ai/v1/app-api/jobs/JOB_ID -H "Authorization: Bearer YOUR_TOKEN"
# repeat until data.status is "succeeded" or "failed"; the reply text is data.output.output
import time, json
while True:
    job = requests.get(f"https://api.skillsafe.ai/v1/app-api/jobs/{job_id}", headers=H).json()["data"]
    if job["status"] in ("succeeded", "failed"): break
    time.sleep(1.5)
result = json.loads(job["output"]["output"])
let job;
do {
  await new Promise(r => setTimeout(r, 1500));
  job = (await (await fetch(`https://api.skillsafe.ai/v1/app-api/jobs/${jobId}`, { headers: H })).json()).data;
} while (job.status !== "succeeded" && job.status !== "failed");
const result = JSON.parse(job.output.output);
for {
  req, _ := http.NewRequest("GET", "https://api.skillsafe.ai/v1/app-api/jobs/"+jobID, nil)
  req.Header.Set("Authorization", "Bearer YOUR_TOKEN")
  // decode data.status; stop on succeeded or failed
  time.Sleep(1500 * time.Millisecond)
}
// GET https://api.skillsafe.ai/v1/app-api/jobs/{job_id} every 1.5 s until data.status is succeeded or failed
loop do
  job = JSON.parse(Net::HTTP.get(URI("https://api.skillsafe.ai/v1/app-api/jobs/#{job_id}"), { "Authorization" => "Bearer YOUR_TOKEN" }))["data"]
  break if %w[succeeded failed].include?(job["status"])
  sleep 1.5
end
do { usleep(1500000);
  $ch = curl_init("https://api.skillsafe.ai/v1/app-api/jobs/$jobId");
  curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ["Authorization: Bearer YOUR_TOKEN"]]);
  $job = json_decode(curl_exec($ch), true)["data"];
} while (!in_array($job["status"], ["succeeded", "failed"]));
string status;
do { await Task.Delay(1500);
  var j = JsonDocument.Parse(await http.GetStringAsync($"https://api.skillsafe.ai/v1/app-api/jobs/{jobId}"));
  status = j.RootElement.GetProperty("data").GetProperty("status").GetString();
} while (status != "succeeded" && status != "failed");

7. Or stream: POST /run-stream

curl -N -X POST https://api.skillsafe.ai/v1/app-api/run-stream \
  -H "Authorization: Bearer YOUR_TOKEN" -H "Content-Type: application/json" \
  -H "Accept: text/event-stream" -d '{"task":"describe","images":"[{\"n\":1,\"name\":\"hero.jpg\",\"role\":\"auto\",\"note\":\"\"}]","context":"Our spring menu is here.","purpose":"article","alt_max":125,"caption_style":"general","language":"auto","previous":"","note":"","$files":["FILE_ID"]}'
# events: job, delta (text), tick (seconds), done (the whole job), error
with requests.post("https://api.skillsafe.ai/v1/app-api/run-stream", headers={**H, "Accept": "text/event-stream"}, json=body, stream=True) as r:
    for line in r.iter_lines(decode_unicode=True):
        if line.startswith("event:") or line.startswith("data:"):
            print(line)
const res = await fetch("https://api.skillsafe.ai/v1/app-api/run-stream", { method: "POST",
  headers: { ...H, "Content-Type": "application/json", Accept: "text/event-stream" }, body: JSON.stringify(body) });
const reader = res.body.getReader(); const dec = new TextDecoder();
for (;;) { const { done, value } = await reader.read(); if (done) break; console.log(dec.decode(value)); }

Output contract

output.output is one JSON object:

{"images": [
  {"n": 1,
   "kind": "informative | decorative | functional | complex | text",
   "alt": "Golden retriever leaping to catch a red frisbee on a beach at sunset.",
   "description": "Two to five plain sentences; the data for a chart.",
   "caption": "A post-ready caption for caption_style.",
   "hashtags": ["BeachDay"],
   "text_in_image": "Every legible word, verbatim.",
   "flags": ["Add a data table near this chart."]}
]}

alt is "" for decorative images: write alt="", never leave the attribute out. A complex image's description belongs in a <figcaption> or next to the image. Review before publishing.