What you are driving
Base URL https://api.skillsafe.ai/v1/app-api. Every response is
ok true with a data object, or ok false with an
error object, so read data on success and
error.code on failure.
A blend is two runs. The first sends both photographs as
$files to the app's text model and gets back a render brief in marker form.
The second sends that brief, and only that brief, to the image model. There is no single
call that does both — see the two warnings below for why.
| Error code | What it means here |
|---|---|
validation_error | Usually $files on an image-generation run, or a malformed body. /estimate will not have warned you. |
not_found | A file id owned by a different subject. Re-upload under the token you are running with. |
insufficient_credits | Balance below the hold. Check /me against hold_credits first. |
rate_limited | Back off; do not tight-loop the poll. |
internal | The run failed server-side. Failed runs are not billed. |
Two platform behaviours worth knowing before you start
1. Image-generation runs reject $files. Sending file ids
alongside "$model": "gpt-image" returns 400 validation_error —
this model generates images, $files attachments are not supported on
image-generation runs. Vision input and image output live on opposite sides of the
same API and cannot be joined in one call. That is the whole reason this app has two legs.
2. /estimate validates nothing. It prices the body above
cleanly, at the full 2,652-credit hold, with the correct model binding — and prices
file ids belonging to another subject just as happily. A clean estimate is not evidence a
run will work. Check the body shape client-side; only /run knows.
Step 1 — a tiny client and a token
Get a token from the token page, or mint a guest one with
POST /guest. Guest tokens carry no balance of their own, so use a personal
token for anything that spends.
export API="https://api.skillsafe.ai/v1/app-api"
export SKILLSAFE_TOKEN="YOUR_TOKEN" # see the token page linked above
# every call below follows this shape
# curl -s "$API/..." -H "Authorization: Bearer $SKILLSAFE_TOKEN" [-d '{json}']
# a fully scripted guest token, no browser involved:
curl -s -X POST "https://api.skillsafe.ai/v1/app-api/guest" -H "Content-Type: application/json" \
-d '{"slug":"photo-blender"}' | jq -r .data.token
import os, json, time, base64, requests
API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN")
def api(method, path, body=None, **kw):
r = requests.request(method, API + path,
headers={"Authorization": f"Bearer {TOKEN}"}, json=body, **kw)
r.raise_for_status()
return r.json()["data"]
const API = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // see the token page linked above
async function api(method, path, body, extraHeaders) {
const res = await fetch(API + path, {
method,
headers: Object.assign({ Authorization: "Bearer " + TOKEN },
body ? { "Content-Type": "application/json" } : {}, extraHeaders || {}),
body: body ? JSON.stringify(body) : undefined
});
const json = await res.json();
if (!res.ok) throw new Error(json.error && json.error.message);
return json.data;
}
const API = "https://api.skillsafe.ai/v1/app-api"
var token = os.Getenv("SKILLSAFE_TOKEN") // see the token page linked above
func apiCall(method, path string, body any) (map[string]any, error) {
var buf io.Reader
if body != nil { b, _ := json.Marshal(body); buf = bytes.NewReader(b) }
req, _ := http.NewRequest(method, API+path, buf)
req.Header.Set("Authorization", "Bearer "+token)
if body != nil { req.Header.Set("Content-Type", "application/json") }
res, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
defer res.Body.Close()
var env struct{ Data map[string]any `json:"data"` }
return env.Data, json.NewDecoder(res.Body).Decode(&env)
}
static final String API = "https://api.skillsafe.ai/v1/app-api";
static final String TOKEN = System.getenv("SKILLSAFE_TOKEN"); // see the token page
static HttpRequest.Builder req(String path) {
return HttpRequest.newBuilder(URI.create(API + path))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json");
}
require "net/http"; require "json"; require "base64"; require "uri"
API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN") # see the token page linked above
def api(method, path, body = nil)
uri = URI(API + path)
klass = { "GET" => Net::HTTP::Get, "POST" => Net::HTTP::Post }[method]
req = klass.new(uri, "Authorization" => "Bearer #{TOKEN}",
"Content-Type" => "application/json")
req.body = body.to_json if body
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
JSON.parse(res.body)["data"]
end
<?php
$API = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN"); // see the token page linked above
function api($method, $path, $body = null) {
global $API, $TOKEN;
$ch = curl_init($API . $path);
$headers = ["Authorization: Bearer $TOKEN"];
if ($body !== null) {
$headers[] = "Content-Type: application/json";
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers, CURLOPT_RETURNTRANSFER => true]);
return json_decode(curl_exec($ch), true)["data"];
}
const string Api = "https://api.skillsafe.ai/v1/app-api";
static readonly HttpClient Http = new();
// see the token page linked above
Http.DefaultRequestHeaders.Authorization =
new("Bearer", Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN"));
static async Task<JsonElement> ApiCall(HttpMethod m, string path, object? body = null) {
var req = new HttpRequestMessage(m, Api + path);
if (body is not null)
req.Content = new StringContent(JsonSerializer.Serialize(body),
Encoding.UTF8, "application/json");
var res = await Http.SendAsync(req);
var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
return doc.RootElement.GetProperty("data");
}
Step 2 — who am I
/me returns exactly three fields — subject_type,
subject_id and credits. There is no email, name or id field, so
a subject_type of user is the signed-in test.
curl -s "$API/me" -H "Authorization: Bearer $SKILLSAFE_TOKEN" | jq '.data'
# -> {"subject_type":"user","subject_id":"usr_...","credits":48210}
# those three fields are the whole payload: no email, no name, no id.
me = api("GET", "/me")
print(me["subject_type"], me["credits"])
# subject_type is "user" or "guest" - that is the only signed-in test there is
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
// subject_type === "user" is the signed-in test; there is no email or name field
me, _ := apiCall("GET", "/me", nil)
fmt.Println(me["subject_type"], me["credits"])
HttpResponse<String> me = HttpClient.newHttpClient()
.send(req("/me").GET().build(), HttpResponse.BodyHandlers.ofString());
System.out.println(me.body());
me = api("GET", "/me")
puts me["subject_type"], me["credits"]
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"], "\n";
var me = await ApiCall(HttpMethod.Get, "/me");
Console.WriteLine($"{me.GetProperty("subject_type")} {me.GetProperty("credits")}");
Step 3 — upload the two photographs
Multipart to POST /files. Ten megabytes per file, at most four ids per run.
Resize first. The reader answers identically from 1280 px and the
upload is fifteen to thirty times faster; in a browser, a canvas re-encode also drops the
EXIF block, so GPS coordinates never leave the machine.
A file id belongs to the subject that uploaded it. Any other token gets
404 not_found at /run. This bites in a browser too: signing in
mints a new subject, so a photograph uploaded before sign-in is unreachable after it.
Keep the bytes and re-upload rather than asking for the file again.
# Two photographs, multipart. 10 MB per file, at most 4 per run.
# Resize before you send: the reader answers identically from 1280px and the upload is
# 15-30x faster. A canvas re-encode in a browser also drops EXIF and GPS.
FILE_IDS=()
for p in keep.jpg lend.jpg; do
ID=$(curl -s -X POST "$API/files" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-F "file=@$p;type=image/jpeg" | jq -r '.data.file.file_id')
FILE_IDS+=("$ID")
done
echo "${FILE_IDS[@]}"
# NOTE: a file id belongs to the SUBJECT that uploaded it. A token for a different
# subject gets 404 "File not found" at /run - and /estimate will price it anyway.
file_ids = []
for p in ("keep.jpg", "lend.jpg"):
with open(p, "rb") as fh:
r = requests.post(API + "/files",
headers={"Authorization": f"Bearer {TOKEN}"}, # no Content-Type
files={"file": (p, fh, "image/jpeg")})
r.raise_for_status()
file_ids.append(r.json()["data"]["file"]["file_id"])
# A file id is scoped to the subject that uploaded it: another token gets 404 at /run.
const fileIds = [];
for (const file of [keepFile, lendFile]) { // File or Blob
const fd = new FormData();
fd.append("file", file);
const res = await fetch(API + "/files", {
method: "POST",
headers: { Authorization: "Bearer " + TOKEN }, // let the browser set the boundary
body: fd
});
const json = await res.json();
fileIds.push(json.data.file.file_id);
}
// File ids are subject-scoped. Signing in mints a NEW subject, so re-upload after sign-in.
var fileIDs []string
for _, p := range []string{"keep.jpg", "lend.jpg"} {
var body bytes.Buffer
w := multipart.NewWriter(&body)
fw, _ := w.CreateFormFile("file", filepath.Base(p))
f, _ := os.Open(p)
io.Copy(fw, f)
f.Close()
w.Close()
req, _ := http.NewRequest("POST", API+"/files", &body)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", w.FormDataContentType())
res, _ := http.DefaultClient.Do(req)
var env struct {
Data struct{ File struct{ FileID string `json:"file_id"` } } `json:"data"`
}
json.NewDecoder(res.Body).Decode(&env)
res.Body.Close()
fileIDs = append(fileIDs, env.Data.File.FileID)
}
// Multipart with the JDK client: build the body by hand.
List<String> fileIds = new ArrayList<>();
for (String p : List.of("keep.jpg", "lend.jpg")) {
String boundary = "----photoblender" + System.nanoTime();
var head = ("--" + boundary + "\r\nContent-Disposition: form-data; name=\"file\";"
+ " filename=\"" + p + "\"\r\nContent-Type: image/jpeg\r\n\r\n").getBytes();
var tail = ("\r\n--" + boundary + "--\r\n").getBytes();
var bytes = Files.readAllBytes(Path.of(p));
var out = new ByteArrayOutputStream();
out.write(head); out.write(bytes); out.write(tail);
var res = HttpClient.newHttpClient().send(
HttpRequest.newBuilder(URI.create(API + "/files"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "multipart/form-data; boundary=" + boundary)
.POST(HttpRequest.BodyPublishers.ofByteArray(out.toByteArray())).build(),
HttpResponse.BodyHandlers.ofString());
fileIds.add(parseFileId(res.body()));
}
file_ids = []
["keep.jpg", "lend.jpg"].each do |p|
uri = URI(API + "/files")
req = Net::HTTP::Post.new(uri, "Authorization" => "Bearer #{TOKEN}")
form = [["file", File.open(p), { filename: p, content_type: "image/jpeg" }]]
req.set_form(form, "multipart/form-data")
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
file_ids << JSON.parse(res.body)["data"]["file"]["file_id"]
end
<?php
$fileIds = [];
foreach (["keep.jpg", "lend.jpg"] as $p) {
$ch = curl_init($API . "/files");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer $TOKEN"],
CURLOPT_POSTFIELDS => ["file" => new CURLFile($p, "image/jpeg")],
CURLOPT_RETURNTRANSFER => true,
]);
$out = json_decode(curl_exec($ch), true);
$fileIds[] = $out["data"]["file"]["file_id"];
}
var fileIds = new List<string>();
foreach (var p in new[] { "keep.jpg", "lend.jpg" }) {
using var form = new MultipartFormDataContent();
var bytes = new ByteArrayContent(await File.ReadAllBytesAsync(p));
bytes.Headers.ContentType = new("image/jpeg");
form.Add(bytes, "file", Path.GetFileName(p));
var res = await Http.PostAsync(Api + "/files", form);
var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
fileIds.Add(doc.RootElement.GetProperty("data").GetProperty("file")
.GetProperty("file_id").GetString()!);
}
Step 4 — price the read
The read body is exactly instruction plus $files. Every extra
key is concatenated into the text the model reads, so putting your own
metadata in the body changes the answer. Holds are per image and independent of prompt
length, so one estimate covers every instruction you will send.
# The read leg's body is exactly instruction + $files. Every extra key is
# concatenated into the text the model reads, so do not add your own.
jq -n --arg i "$(cat read-instruction.txt)" \
--argjson f "$(printf '%s\n' "${FILE_IDS[@]}" | jq -R . | jq -s .)" \
'{instruction: $i, "$files": $f}' > read.json
curl -s -X POST "$API/estimate" -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H "Content-Type: application/json" -d @read.json | jq '.data'
# -> {"hold_credits":1603,"min_credits":174,"model":"gpt-5.6-terra",
# "model_alias":"gpt-terra","markup_bps":1000}
# WARNING: /estimate validates NOTHING about the body. It prices a body the model
# will reject, and file ids owned by another subject, just as cleanly. Check the
# shape client-side; only /run will tell you.
read_body = {"instruction": read_instruction, "$files": file_ids}
est = api("POST", "/estimate", read_body)
print(est["hold_credits"], est["model_alias"]) # 1603 gpt-terra
# /estimate does no body validation at all - it will price a body /run rejects.
const readBody = { instruction: readInstruction, $files: fileIds };
const est = await api("POST", "/estimate", readBody);
console.log(est.hold_credits, est.model_alias); // 1603 gpt-terra
// /estimate validates nothing about the body. Check the shape yourself.
readBody := map[string]any{"instruction": readInstruction, "$files": fileIDs}
est, _ := apiCall("POST", "/estimate", readBody)
fmt.Println(est["hold_credits"], est["model_alias"])
String readBody = mapper.writeValueAsString(Map.of(
"instruction", readInstruction, "$files", fileIds));
var est = HttpClient.newHttpClient().send(
req("/estimate").POST(HttpRequest.BodyPublishers.ofString(readBody)).build(),
HttpResponse.BodyHandlers.ofString());
System.out.println(est.body());
read_body = { "instruction" => read_instruction, "$files" => file_ids }
est = api("POST", "/estimate", read_body)
puts est["hold_credits"], est["model_alias"]
<?php
$readBody = ["instruction" => $readInstruction, "\$files" => $fileIds];
$est = api("POST", "/estimate", $readBody);
echo $est["hold_credits"], " ", $est["model_alias"], "\n";
var readBody = new Dictionary<string, object> {
["instruction"] = readInstruction, ["$files"] = fileIds
};
var est = await ApiCall(HttpMethod.Post, "/estimate", readBody);
Console.WriteLine(est.GetProperty("hold_credits"));
Step 5 — run the read and poll
Pass an Idempotency-Key derived from the input, so a retry after a network
blip resumes the same job instead of paying twice. The reply arrives as
output.output in marker form — the contract is below.
JOB=$(curl -s -X POST "$API/run" -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: blend-lake-read-1" \
-d @read.json | jq -r .data.job_id)
until [ "$ST" = "succeeded" ] || [ "$ST" = "failed" ]; do
sleep 2
BODY=$(curl -s "$API/jobs/$JOB" -H "Authorization: Bearer $SKILLSAFE_TOKEN")
ST=$(echo "$BODY" | jq -r .data.status)
done
echo "$BODY" | jq -r .data.output.output
job = api("POST", "/run", read_body)
while True:
j = api("GET", "/jobs/" + job["job_id"])
if j["status"] in ("succeeded", "failed"):
break
time.sleep(2)
reply = j["output"]["output"] # the marker text, see the contract below
print(j["charged_credits"]) # settled - always far below the hold
const job = await api("POST", "/run", readBody, { "Idempotency-Key": "blend-lake-read-1" });
let j;
do {
await new Promise((r) => setTimeout(r, 2000));
j = await api("GET", "/jobs/" + job.job_id);
} while (j.status !== "succeeded" && j.status !== "failed");
const reply = j.output.output;
job, _ := apiCall("POST", "/run", readBody)
jobID := job["job_id"].(string)
var j map[string]any
for {
time.Sleep(2 * time.Second)
j, _ = apiCall("GET", "/jobs/"+jobID, nil)
if s := j["status"]; s == "succeeded" || s == "failed" { break }
}
reply := j["output"].(map[string]any)["output"].(string)
var accepted = HttpClient.newHttpClient().send(
req("/run").header("Idempotency-Key", "blend-lake-read-1")
.POST(HttpRequest.BodyPublishers.ofString(readBody)).build(),
HttpResponse.BodyHandlers.ofString());
String jobId = parseJobId(accepted.body());
String reply = pollUntilTerminal(jobId); // GET /jobs/{id} every 2s
job = api("POST", "/run", read_body)
loop do
sleep 2
@j = api("GET", "/jobs/#{job['job_id']}")
break if %w[succeeded failed].include?(@j["status"])
end
reply = @j["output"]["output"]
<?php
$job = api("POST", "/run", $readBody);
do {
sleep(2);
$j = api("GET", "/jobs/" . $job["job_id"]);
} while (!in_array($j["status"], ["succeeded", "failed"]));
$reply = $j["output"]["output"];
var job = await ApiCall(HttpMethod.Post, "/run", readBody);
var jobId = job.GetProperty("job_id").GetString();
JsonElement j;
do {
await Task.Delay(2000);
j = await ApiCall(HttpMethod.Get, $"/jobs/{jobId}");
} while (j.GetProperty("status").GetString() is not ("succeeded" or "failed"));
var reply = j.GetProperty("output").GetProperty("output").GetString();
Step 6 — render the brief
Pull the brief out of the marker block and send it with a $model of
gpt-image. No $files. The picture comes back as
output.images[0].b64; output.output is the empty string on an
image run. One run is one 1024×1024 image.
The hold is 2,652 credits. What it settles at varies several-fold picture to picture for an identical hold, so treat the hold as the plannable number and never quote a settled figure as a price.
# Pull the brief out of the marker block, then render it.
BRIEF=$(echo "$BODY" | jq -r .data.output.output \
| awk '/^BRIEF>>>/{f=1;next} /^<<<BRIEF/{f=0} f')
# The render body is exactly instruction + $model. NO $files: an image-generation
# run rejects them with 400 - and /estimate will not warn you.
jq -n --arg b "$BRIEF" '{instruction: $b, "$model": "gpt-image"}' > render.json
RJOB=$(curl -s -X POST "$API/run" -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: blend-lake-render-1" \
-d @render.json | jq -r .data.job_id)
# poll as above, then:
curl -s "$API/jobs/$RJOB" -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
| jq -r .data.output.images[0].b64 | base64 -d > blend.png
import re
brief = re.search(r"BRIEF>>>\s*(.*?)\s*<<<BRIEF", reply, re.S).group(1)
# instruction + $model, and no $files: image-generation runs reject attachments.
render_body = {"instruction": brief, "$model": "gpt-image"}
rjob = api("POST", "/run", render_body)
while True:
r = api("GET", "/jobs/" + rjob["job_id"])
if r["status"] in ("succeeded", "failed"):
break
time.sleep(2)
img = r["output"]["images"][0] # {"content_type","b64"}
open("blend.png", "wb").write(base64.b64decode(img["b64"]))
const brief = /BRIEF>>>\s*([\s\S]*?)\s*<<<BRIEF/.exec(reply)[1];
// instruction + $model only. $files here returns 400.
const renderBody = { instruction: brief, $model: "gpt-image" };
const rjob = await api("POST", "/run", renderBody,
{ "Idempotency-Key": "blend-lake-render-1" });
let r;
do {
await new Promise((x) => setTimeout(x, 2000));
r = await api("GET", "/jobs/" + rjob.job_id);
} while (r.status !== "succeeded" && r.status !== "failed");
const b64 = r.output.images[0].b64;
re := regexp.MustCompile(`(?s)BRIEF>>>\s*(.*?)\s*<<<BRIEF`)
brief := re.FindStringSubmatch(reply)[1]
renderBody := map[string]any{"instruction": brief, "$model": "gpt-image"}
rjob, _ := apiCall("POST", "/run", renderBody)
// poll as above, then:
imgs := r["output"].(map[string]any)["images"].([]any)
b64 := imgs[0].(map[string]any)["b64"].(string)
raw, _ := base64.StdEncoding.DecodeString(b64)
os.WriteFile("blend.png", raw, 0o644)
var m = Pattern.compile("BRIEF>>>\\s*(.*?)\\s*<<<BRIEF", Pattern.DOTALL)
.matcher(reply);
m.find();
String brief = m.group(1);
String renderBody = mapper.writeValueAsString(Map.of(
"instruction", brief, "$model", "gpt-image")); // no $files
String b64 = pollForImage(startRun(renderBody));
Files.write(Path.of("blend.png"), Base64.getDecoder().decode(b64));
brief = reply[/BRIEF>>>\s*(.*?)\s*<<<BRIEF/m, 1]
render_body = { "instruction" => brief, "$model" => "gpt-image" } # no $files
rjob = api("POST", "/run", render_body)
loop do
sleep 2
@r = api("GET", "/jobs/#{rjob['job_id']}")
break if %w[succeeded failed].include?(@r["status"])
end
File.binwrite("blend.png", Base64.decode64(@r["output"]["images"][0]["b64"]))
<?php
preg_match('/BRIEF>>>\s*(.*?)\s*<<<BRIEF/s', $reply, $m);
$brief = $m[1];
// instruction + $model only - $files on an image run returns 400.
$renderBody = ["instruction" => $brief, "\$model" => "gpt-image"];
$rjob = api("POST", "/run", $renderBody);
do {
sleep(2);
$r = api("GET", "/jobs/" . $rjob["job_id"]);
} while (!in_array($r["status"], ["succeeded", "failed"]));
file_put_contents("blend.png", base64_decode($r["output"]["images"][0]["b64"]));
var brief = Regex.Match(reply!, @"BRIEF>>>\s*(.*?)\s*<<<BRIEF",
RegexOptions.Singleline).Groups[1].Value;
var renderBody = new Dictionary<string, object> {
["instruction"] = brief, ["$model"] = "gpt-image" // no $files
};
var rjob = await ApiCall(HttpMethod.Post, "/run", renderBody);
// poll as above, then:
var b64 = r.GetProperty("output").GetProperty("images")[0]
.GetProperty("b64").GetString()!;
await File.WriteAllBytesAsync("blend.png", Convert.FromBase64String(b64));
Step 7 — keep the result
Store the composite through POST /files and display it with the signed URL
from GET /files/url. That URL is on api.skillsafe.ai, which
img-src allows, so it goes straight into an image tag. Do not build a
data: URI to re-fetch: connect-src blocks fetching those even
though img-src permits them as sources.
# Store the composite in your own app file space and get a signed URL back.
RID=$(curl -s -X POST "$API/files" -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-F "file=@blend.png;type=image/png" | jq -r '.data.file.file_id')
curl -s "$API/files/url?file_id=$RID" -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
| jq -r '.data.url'
# The signed URL is on api.skillsafe.ai, which img-src allows - put it straight in
# an <img src>. Do not build a data: URI; connect-src blocks fetching those.
curl -s "$API/files" -H "Authorization: Bearer $SKILLSAFE_TOKEN" | jq .data.files
with open("blend.png", "rb") as fh:
res = requests.post(API + "/files",
headers={"Authorization": f"Bearer {TOKEN}"},
files={"file": ("blend.png", fh, "image/png")})
result_id = res.json()["data"]["file"]["file_id"]
url = api("GET", "/files/url", params={"file_id": result_id})["url"]
print(url) # short-lived signed URL, safe directly in an <img src>
const out = new Blob([bytesFromBase64(b64)], { type: "image/png" });
const fd = new FormData();
fd.append("file", new File([out], "blend.png", { type: "image/png" }));
const stored = await fetch(API + "/files", {
method: "POST", headers: { Authorization: "Bearer " + TOKEN }, body: fd
}).then((r) => r.json());
const { url } = await api("GET", "/files/url?file_id=" + stored.data.file.file_id);
img.src = url; // signed URL on api.skillsafe.ai - allowed by img-src
resultID := uploadFile("blend.png", "image/png") // as in step 3
signed, _ := apiCall("GET", "/files/url?file_id="+resultID, nil)
fmt.Println(signed["url"])
String resultId = uploadFile("blend.png", "image/png"); // as in step 3
var signed = HttpClient.newHttpClient().send(
req("/files/url?file_id=" + resultId).GET().build(),
HttpResponse.BodyHandlers.ofString());
System.out.println(signed.body());
result_id = upload_file("blend.png", "image/png") # as in step 3
signed = api("GET", "/files/url?file_id=#{result_id}")
puts signed["url"]
<?php
$resultId = upload_file("blend.png", "image/png"); // as in step 3
$signed = api("GET", "/files/url?file_id=" . $resultId);
echo $signed["url"], "\n";
var resultId = await UploadFile("blend.png", "image/png"); // as in step 3
var signed = await ApiCall(HttpMethod.Get, $"/files/url?file_id={resultId}");
Console.WriteLine(signed.GetProperty("url").GetString());
The reader's output contract
The read leg returns marker sections, in this order. Parse tolerantly about shape and strictly about the safety field.
READ_A>>> one sentence naming what the KEEP photograph shows.
READ_B>>> one sentence naming what the LEND photograph shows.
PEOPLE>>> none | present
BRIEF>>>
One paragraph of art direction, 90 to 160 words.
<<<BRIEF
NOTES>>> one sentence naming what will not survive this blend.
A reply cut off mid-brief has no closing marker; take everything up to the next marker or
the end of the text rather than discarding the run you paid for.
The people field decides whether two of the six blend modes may run at all,
so an absent, garbled or unexpected value must resolve to present — the
answer that keeps the gate closed. Defaulting a safety field to the permissive value
disables it exactly when the model's output is least trustworthy.
What the app refuses, and what your client should
The hosted app enforces four rules in the browser before any credit is reserved, and enforces them twice: once on the assembled reader instruction, and again on the composed brief before the render is paid for. The second gate matters because the app writes the render prompt itself — a check on user input structurally cannot see it. If you are driving the API directly, you are the one holding that responsibility.
- No carrying one person's face, head or likeness onto another person or body.
- No picking out a specific real person — by name, role, description or relationship — and placing them in a place, act or company they were not in.
- No sexual, intimate or undressed depiction, and no making an uploaded subject more sexual than the photograph they arrived in.
- Nothing suggestive, romantic or aged-up around a subject who could read as a child, whether the age is stated or implied.
Because the renderer never receives a photograph, this pipeline structurally cannot perform a face swap. That is a property of the platform, not a promise about your code.