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
| HTTP | code | meaning |
|---|---|---|
| 400 | validation_error | Body is not a JSON object, or a field has the wrong type. |
| 401 | unauthorized | Missing, expired or guest token on a metered call. Sign in for a personal token. |
| 402 | insufficient_credits | Balance below the run's minimum. Top up at skillsafe.ai. |
| 404 | not_found | A $files id that does not exist or belongs to another session. |
| 429 | rate_limited | Slow 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
| field | type | meaning |
|---|---|---|
task | string | Always "describe". |
images | string | JSON 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). |
context | string | Optional text from the page the images sit on. Up to about 1,500 characters is useful. |
purpose | string | article, product, social, docs, slides or other. |
alt_max | number | Alt text character limit, 40 to 300. 125 is a common house limit. |
caption_style | string | general, instagram, x or linkedin. |
language | string | A language name such as English, or auto to follow the context. |
previous, note | string | To re-write ONE image: its earlier result as JSON text, and what to change. |
$files | array | The 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.