Photo Blender — API

Two photographs in, one picture out — from your own code.

Back to the app

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 codeWhat it means here
validation_errorUsually $files on an image-generation run, or a malformed body. /estimate will not have warned you.
not_foundA file id owned by a different subject. Re-upload under the token you are running with.
insufficient_creditsBalance below the hold. Check /me against hold_credits first.
rate_limitedBack off; do not tight-loop the poll.
internalThe 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_errorthis 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.

  1. No carrying one person's face, head or likeness onto another person or body.
  2. 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.
  3. No sexual, intimate or undressed depiction, and no making an uploaded subject more sexual than the photograph they arrived in.
  4. 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.