Driving Photo Edit Brief over HTTP
Everything the page does, you can do directly. Base URL:
https://api.skillsafe.ai/v1/app-api. Every response is
{"ok":true,"data":{…}} or {"ok":false,"error":{…}}.
/apps/{slug}/ segment anywhere. The endpoints are /guest,
/me, /files, /estimate, /run,
/run-stream and /jobs/{id}, and the slug is bound to your token when you
mint it. Getting this wrong returns 404 not_found.
POST /run with
$model: "gpt-image" plus $files returns
400 validation_error. There is no image-in / image-out call on this platform, which is
the whole reason this app is shaped the way it is: a text model reads your photograph and writes a
description, and the image model paints from that description alone. Note also that
/estimate happily approves the body that /run rejects — a clean estimate
is not evidence a run works.
The three lanes
Two text lanes routed on a task field, plus one image lane selected by a
$model override. Every lane also takes guide, whose value is the full
text of /guide.js — the writing instructions live in a served asset rather
than the system prompt, because on an image run the system prompt is joined to the input and
anything instruction-shaped in it gets painted into the picture as words.
| Lane | Selected by | Model | Attachments | Returns |
|---|---|---|---|---|
brief | "task": "brief" | gpt-terra → gpt-5.6-terra | the photograph, via $files | one JSON object of spec lines |
render | "$model": "gpt-image" | gpt-image | none — refused | job.output.images[0].b64 |
check | "task": "check" | gpt-terra → gpt-5.6-terra | both pictures, original first | one JSON report object |
The language field
Both text lanes take language — the language the model must WRITE in, spelled out as
a name rather than a code: "Japanese", "Simplified Chinese",
"Spanish". A companion language_code carries the short form
(ja, zh) for your own bookkeeping; the model acts on the name.
language field that the prompt never mentions is tokens you pay for and
the model ignores, and the output comes back in whatever language it felt like. The guide at
/guide.js has an explicit section acting on it, and names the four things
that do NOT change with it — the JSON keys, the use_case slug, the quoted half of
each invariant translation, and any words destined to be lettered into the picture.
Accepted names, matching the app's own picker: English,
Simplified Chinese, Japanese, Korean,
Spanish, Portuguese, French, German,
Russian, Indonesian, Vietnamese. Omit the field and you
get English.
1. A tiny client
Error handling on the envelope, once, so the rest of the steps stay readable.
# The token goes in an environment variable so it never lands in your shell history.
# Get one from https://photo-edit-brief.skillsafe.ai/tokens.html
export TOKEN="YOUR_TOKEN"
export BASE="https://api.skillsafe.ai/v1/app-api"
# Every response is {"ok":true,"data":{...}} or {"ok":false,"error":{...}}.
# jq -e .ok exits non-zero on an error envelope, which is what you want in a script.
import json, time, requests
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from /tokens.html
session = requests.Session()
session.headers["Authorization"] = f"Bearer {TOKEN}"
def call(method, path, body=None, files=None):
r = session.request(method, BASE + path, json=body, files=files, timeout=120)
payload = r.json()
if not payload.get("ok"):
err = payload.get("error", {})
raise RuntimeError(f"{err.get('code')}: {err.get('message')}")
return payload["data"]
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // from /tokens.html
async function call(method, path, body, extraHeaders) {
const headers = Object.assign({ Authorization: "Bearer " + TOKEN }, extraHeaders || {});
if (body && !(body instanceof FormData)) headers["Content-Type"] = "application/json";
const res = await fetch(BASE + path, {
method,
headers,
body: body ? (body instanceof FormData ? body : JSON.stringify(body)) : undefined,
});
const payload = await res.json();
if (!payload.ok) throw new Error(payload.error.code + ": " + payload.error.message);
return payload.data;
}
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
)
const base = "https://api.skillsafe.ai/v1/app-api"
const token = "YOUR_TOKEN" // from /tokens.html
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func call(method, path string, body []byte) (json.RawMessage, error) {
req, _ := http.NewRequest(method, base+path, bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
raw, _ := io.ReadAll(res.Body)
var e envelope
json.Unmarshal(raw, &e)
if !e.OK {
return nil, fmt.Errorf("%s: %s", e.Error.Code, e.Error.Message)
}
return e.Data, nil
}
import java.net.URI;
import java.net.http.*;
import com.fasterxml.jackson.databind.*;
public class Client {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String TOKEN = "YOUR_TOKEN"; // from /tokens.html
static final HttpClient http = HttpClient.newHttpClient();
static final ObjectMapper mapper = new ObjectMapper();
static JsonNode call(String method, String path, String body) throws Exception {
HttpRequest.BodyPublisher pub = body == null
? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(body);
HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.method(method, pub)
.build();
HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString());
JsonNode payload = mapper.readTree(res.body());
if (!payload.path("ok").asBoolean()) {
JsonNode e = payload.path("error");
throw new RuntimeException(e.path("code").asText() + ": " + e.path("message").asText());
}
return payload.path("data");
}
}
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from /tokens.html
def call(method, path, body = nil)
uri = URI(BASE + path)
klass = method == "POST" ? Net::HTTP::Post : Net::HTTP::Get
req = klass.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req.body = JSON.dump(body) if body
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
payload = JSON.parse(res.body)
raise "#{payload['error']['code']}: #{payload['error']['message']}" unless payload["ok"]
payload["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // from /tokens.html
function call(string $method, string $path, $body = null) {
$ch = curl_init(BASE . $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,
]);
$payload = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($payload["ok"])) {
throw new RuntimeException($payload["error"]["code"] . ": " . $payload["error"]["message"]);
}
return $payload["data"];
}
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Token = "YOUR_TOKEN"; // from /tokens.html
var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", Token);
async Task<JsonNode> Call(string method, string path, object? body = null)
{
var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
if (body is not null)
req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
var res = await http.SendAsync(req);
var payload = JsonNode.Parse(await res.Content.ReadAsStringAsync())!;
if (payload["ok"]?.GetValue<bool>() != true)
throw new Exception($"{payload["error"]!["code"]}: {payload["error"]!["message"]}");
return payload["data"]!;
}
2. Get a token
A guest token reads the app and prices a run. The model runs need a signed-in account, because each one spends credits — /tokens.html hands you a personal one without opening DevTools.
# A guest token is enough to read the app and price a run.
curl -s -X POST "$BASE/guest" \
-H "Content-Type: application/json" \
-d '{"slug":"photo-edit-brief"}'
# -> {"ok":true,"data":{"token":"...","guest_id":"..."}}
# The model runs need a signed-in account. Get a personal token from
# https://photo-edit-brief.skillsafe.ai/tokens.html
# A guest token is enough to read the app and price a run; the model runs need a
# signed-in account. Grab a personal token from /tokens.html instead.
guest = requests.post(f"{BASE}/guest", json={"slug": "photo-edit-brief"}).json()["data"]
TOKEN = guest["token"]
session.headers["Authorization"] = f"Bearer {TOKEN}"
// A guest token is enough to read the app and price a run; the model runs need a
// signed-in account. Grab a personal token from /tokens.html instead.
const res = await fetch(BASE + "/guest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "photo-edit-brief" }),
});
const guest = (await res.json()).data; // { token, guest_id }
body, _ := json.Marshal(map[string]any{"slug": "photo-edit-brief"})
data, err := call("POST", "/guest", body)
var guest struct {
Token string `json:"token"`
GuestID string `json:"guest_id"`
}
json.Unmarshal(data, &guest)
JsonNode guest = call("POST", "/guest", "{\"slug\":\"photo-edit-brief\"}");
String token = guest.path("token").asText();
guest = call("POST", "/guest", { "slug" => "photo-edit-brief" })
token = guest["token"]
$guest = call("POST", "/guest", ["slug" => "photo-edit-brief"]);
$token = $guest["token"];
var guest = await Call("POST", "/guest", new { slug = "photo-edit-brief" });
var token = guest["token"]!.GetValue<string>();
3. Who am I, and can I afford it
GET /me returns exactly three fields: subject_type
("user" or "guest"), subject_id and credits.
Signed-in is subject_type === "user" — there is no email and no
name to test.
curl -s "$BASE/me" -H "Authorization: Bearer $TOKEN" | jq .data
# -> {"subject_type":"user","subject_id":"acc_...","credits":10008454}
me = call("GET", "/me")
signed_in = me["subject_type"] == "user"
print(signed_in, me["credits"])
const me = await call("GET", "/me");
const signedIn = me.subject_type === "user";
data, _ := call("GET", "/me", nil)
var me struct {
SubjectType string `json:"subject_type"`
Credits int `json:"credits"`
}
json.Unmarshal(data, &me)
JsonNode me = call("GET", "/me", null);
boolean signedIn = "user".equals(me.path("subject_type").asText());
me = call("GET", "/me")
signed_in = me["subject_type"] == "user"
$me = call("GET", "/me");
$signedIn = $me["subject_type"] === "user";
var me = await Call("GET", "/me");
var signedIn = me["subject_type"]!.GetValue<string>() == "user";
4. Upload the photograph
POST /files is multipart and returns a file_id. Two things worth knowing
before you build against it: file ids are scoped to the subject that minted them,
so an id created as a guest returns 404 the moment you sign in; and
/estimate both prices attachments (about +35 credits each) and
validates that they exist, so a body that omits them under-quotes the run and one
carrying a stale id fails to price at all.
curl -s -X POST "$BASE/files" \
-H "Authorization: Bearer $TOKEN" \
-F "file=@front-of-house.jpg"
# -> {"ok":true,"data":{"file":{"file_id":"fil_...", ...}}}
with open("front-of-house.jpg", "rb") as fh:
up = call("POST", "/files", files={"file": fh})
photo_id = up["file"]["file_id"]
const fd = new FormData();
fd.append("file", fileFromInput); // a File or Blob
const up = await call("POST", "/files", fd);
const photoId = up.file.file_id;
// multipart, so this one does not use call()
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
fw, _ := w.CreateFormFile("file", "front-of-house.jpg")
f, _ := os.Open("front-of-house.jpg")
io.Copy(fw, f)
w.Close()
req, _ := http.NewRequest("POST", base+"/files", &buf)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", w.FormDataContentType())
res, _ := http.DefaultClient.Do(req)
// java.net.http has no multipart builder; assemble the body yourself
String boundary = "----peb" + System.currentTimeMillis();
byte[] image = Files.readAllBytes(Path.of("front-of-house.jpg"));
var out = new ByteArrayOutputStream();
out.write(("--" + boundary + "\r\n").getBytes());
out.write("Content-Disposition: form-data; name=\"file\"; filename=\"front-of-house.jpg\"\r\n".getBytes());
out.write("Content-Type: image/jpeg\r\n\r\n".getBytes());
out.write(image);
out.write(("\r\n--" + boundary + "--\r\n").getBytes());
HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + "/files"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "multipart/form-data; boundary=" + boundary)
.POST(HttpRequest.BodyPublishers.ofByteArray(out.toByteArray()))
.build();
require "net/http/post/multipart"
uri = URI(BASE + "/files")
File.open("front-of-house.jpg") do |fh|
req = Net::HTTP::Post::Multipart.new(uri.path,
"file" => UploadIO.new(fh, "image/jpeg", "front-of-house.jpg"))
req["Authorization"] = "Bearer #{TOKEN}"
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
photo_id = JSON.parse(res.body)["data"]["file"]["file_id"]
end
$ch = curl_init(BASE . "/files");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . TOKEN],
CURLOPT_POSTFIELDS => ["file" => new CURLFile("front-of-house.jpg", "image/jpeg")],
CURLOPT_RETURNTRANSFER => true,
]);
$up = json_decode(curl_exec($ch), true);
$photoId = $up["data"]["file"]["file_id"];
using var form = new MultipartFormDataContent();
var bytes = await File.ReadAllBytesAsync("front-of-house.jpg");
var part = new ByteArrayContent(bytes);
part.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg");
form.Add(part, "file", "front-of-house.jpg");
var res = await http.PostAsync(Base + "/files", form);
var up = JsonNode.Parse(await res.Content.ReadAsStringAsync())!;
var photoId = up["data"]!["file"]!["file_id"]!.GetValue<string>();
5. Price it — free, and it creates no job
POST /estimate takes the same body as /run and charges nothing. Assert
three things on the response: model is gpt-5.6-terra,
model_alias is gpt-terra, and markup_bps is
1000. Present hold_credits as reserved, never as the price —
the hold prices the full output cap and the actual charged_credits is usually far
lower.
curl -s -X POST "https://api.skillsafe.ai/v1/app-api/estimate" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"task": "brief", "guide": "\u2026the full text of /guide.js\u2026", "language": "Japanese", "language_code": "ja", "request": "Make this a winter evening with snow falling. Keep the house exactly as it is.", "use_case": "lighting-weather", "use_case_label": "Lighting & weather", "verdict": "survives", "verdict_why": "The whole edit is atmosphere, and atmosphere is what words carry best.", "carries": "Camera angle and lens feel\nComposition and framing\nScene geometry and layout", "lost": "The exact identity of any specific person\nSmall incidental detail nobody described", "keeps": "Keep the house exactly as it is", "$files": ["fil_your_photo_id"]}'
r = session.post("https://api.skillsafe.ai/v1/app-api/estimate", json={
"task": "brief",
"guide": "\u2026the full text of /guide.js\u2026",
"language": "Japanese",
"language_code": "ja",
"language": "Japanese",
"language_code": "ja",
"language": "Japanese", "language_code": "ja", "request": "Make this a winter evening with snow falling. Keep the house exactly as it is.",
"use_case": "lighting-weather",
"use_case_label": "Lighting & weather",
"verdict": "survives",
"verdict_why": "The whole edit is atmosphere, and atmosphere is what words carry best.",
"carries": "Camera angle and lens feel\nComposition and framing\nScene geometry and layout",
"lost": "The exact identity of any specific person\nSmall incidental detail nobody described",
"keeps": "Keep the house exactly as it is",
"$files": [
"fil_your_photo_id"
]
})
data = r.json()["data"]
const data = await call("POST", "/estimate", {
"task": "brief",
"guide": "\u2026the full text of /guide.js\u2026",
"language": "Japanese",
"language_code": "ja",
"language": "Japanese",
"language_code": "ja",
"language": "Japanese", "language_code": "ja", "request": "Make this a winter evening with snow falling. Keep the house exactly as it is.",
"use_case": "lighting-weather",
"use_case_label": "Lighting & weather",
"verdict": "survives",
"verdict_why": "The whole edit is atmosphere, and atmosphere is what words carry best.",
"carries": "Camera angle and lens feel\nComposition and framing\nScene geometry and layout",
"lost": "The exact identity of any specific person\nSmall incidental detail nobody described",
"keeps": "Keep the house exactly as it is",
"$files": [
"fil_your_photo_id"
]
});
body, _ := json.Marshal(map[string]any{
"task": "brief",
"guide": "\u2026the full text of /guide.js\u2026",
"language": "Japanese",
"language_code": "ja",
"language": "Japanese",
"language_code": "ja",
"language": "Japanese", "language_code": "ja", "request": "Make this a winter evening with snow falling. Keep the house exactly as it is.",
"use_case": "lighting-weather",
"use_case_label": "Lighting & weather",
"verdict": "survives",
"verdict_why": "The whole edit is atmosphere, and atmosphere is what words carry best.",
"carries": "Camera angle and lens feel\nComposition and framing\nScene geometry and layout",
"lost": "The exact identity of any specific person\nSmall incidental detail nobody described",
"keeps": "Keep the house exactly as it is",
"$files": []any{"fil_your_photo_id"},
})
data, err := call("POST", "/estimate", body)
String body = """
{
"task": "brief",
"guide": "\u2026the full text of /guide.js\u2026",
"language": "Japanese",
"language_code": "ja",
"language": "Japanese",
"language_code": "ja",
"language": "Japanese", "language_code": "ja", "request": "Make this a winter evening with snow falling. Keep the house exactly as it is.",
"use_case": "lighting-weather",
"use_case_label": "Lighting & weather",
"verdict": "survives",
"verdict_why": "The whole edit is atmosphere, and atmosphere is what words carry best.",
"carries": "Camera angle and lens feel\nComposition and framing\nScene geometry and layout",
"lost": "The exact identity of any specific person\nSmall incidental detail nobody described",
"keeps": "Keep the house exactly as it is",
"$files": [
"fil_your_photo_id"
]
}
""";
JsonNode data = call("POST", "/estimate", body);
data = call("POST", "/estimate", {
"task" => "brief",
"guide" => "\u2026the full text of /guide.js\u2026",
"language" => "Japanese",
"language_code" => "ja",
"request" => "Make this a winter evening with snow falling. Keep the house exactly as it is.",
"use_case" => "lighting-weather",
"use_case_label" => "Lighting & weather",
"verdict" => "survives",
"verdict_why" => "The whole edit is atmosphere, and atmosphere is what words carry best.",
"carries" => "Camera angle and lens feel\nComposition and framing\nScene geometry and layout",
"lost" => "The exact identity of any specific person\nSmall incidental detail nobody described",
"keeps" => "Keep the house exactly as it is",
"$files" => ["fil_your_photo_id"],
})
$data = call("POST", "/estimate", [
"task" => "brief",
"guide" => "\u2026the full text of /guide.js\u2026",
"language" => "Japanese",
"language_code" => "ja",
"request" => "Make this a winter evening with snow falling. Keep the house exactly as it is.",
"use_case" => "lighting-weather",
"use_case_label" => "Lighting & weather",
"verdict" => "survives",
"verdict_why" => "The whole edit is atmosphere, and atmosphere is what words carry best.",
"carries" => "Camera angle and lens feel\nComposition and framing\nScene geometry and layout",
"lost" => "The exact identity of any specific person\nSmall incidental detail nobody described",
"keeps" => "Keep the house exactly as it is",
"$files" => ["fil_your_photo_id"],
]);
var body = new {
task = "brief",
guide = "\u2026the full text of /guide.js\u2026",
language = "Japanese",
language_code = "ja",
request = "Make this a winter evening with snow falling. Keep the house exactly as it is.",
use_case = "lighting-weather",
use_case_label = "Lighting & weather",
verdict = "survives",
verdict_why = "The whole edit is atmosphere, and atmosphere is what words carry best.",
carries = "Camera angle and lens feel\nComposition and framing\nScene geometry and layout",
lost = "The exact identity of any specific person\nSmall incidental detail nobody described",
keeps = "Keep the house exactly as it is",
["$files"] = new object[] { "fil_your_photo_id" },
};
var data = await Call("POST", "/estimate", body);
6. Lane one — write the brief
A text run with the photograph attached. Pass an Idempotency-Key header on every run,
derived from a hash of the body plus an attempt counter: without one, a network retry can bill
twice.
JOB=$(curl -s -X POST "$BASE/run" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: peb:brief:$(date +%s)" \
-d @brief-body.json | jq -r .data.job_id)
# Poll until terminal. succeeded | failed | cancelled
while :; do
JOB_JSON=$(curl -s "$BASE/jobs/$JOB" -H "Authorization: Bearer $TOKEN")
STATUS=$(echo "$JOB_JSON" | jq -r .data.status)
[ "$STATUS" = "running" ] || [ "$STATUS" = "queued" ] || break
sleep 1.5
done
echo "$JOB_JSON" | jq -r .data.output.output | jq .
started = call("POST", "/run", BRIEF_BODY) # add an Idempotency-Key header in production
job_id = started["job_id"]
while True:
job = call("GET", f"/jobs/{job_id}")["job"]
if job["status"] not in ("queued", "running"):
break
time.sleep(1.5)
if job["status"] != "succeeded":
raise RuntimeError(job.get("error") or "the run failed")
brief = json.loads(job["output"]["output"])
print(brief["spec"]["subject"])
print("charged:", job["charged_credits"], "credits")
const started = await call("POST", "/run", briefBody, {
"Idempotency-Key": "peb:brief:" + hashOf(briefBody) + ":1",
});
let job;
for (;;) {
job = (await call("GET", "/jobs/" + started.job_id)).job;
if (job.status !== "queued" && job.status !== "running") break;
await new Promise((r) => setTimeout(r, 1500));
}
if (job.status !== "succeeded") throw new Error(job.error || "the run failed");
const brief = JSON.parse(job.output.output);
console.log(brief.spec.subject, job.charged_credits);
data, err := call("POST", "/run", briefBody)
var started struct{ JobID string `json:"job_id"` }
json.Unmarshal(data, &started)
var job struct {
Status string `json:"status"`
Charged int `json:"charged_credits"`
Output struct{ Output string `json:"output"` } `json:"output"`
}
for {
d, _ := call("GET", "/jobs/"+started.JobID, nil)
var wrap struct{ Job json.RawMessage `json:"job"` }
json.Unmarshal(d, &wrap)
json.Unmarshal(wrap.Job, &job)
if job.Status != "queued" && job.Status != "running" {
break
}
time.Sleep(1500 * time.Millisecond)
}
JsonNode started = call("POST", "/run", briefBody);
String jobId = started.path("job_id").asText();
JsonNode job;
while (true) {
job = call("GET", "/jobs/" + jobId, null).path("job");
String status = job.path("status").asText();
if (!status.equals("queued") && !status.equals("running")) break;
Thread.sleep(1500);
}
if (!job.path("status").asText().equals("succeeded")) {
throw new RuntimeException("the run failed");
}
JsonNode brief = mapper.readTree(job.path("output").path("output").asText());
started = call("POST", "/run", brief_body)
job_id = started["job_id"]
loop do
@job = call("GET", "/jobs/#{job_id}")["job"]
break unless %w[queued running].include?(@job["status"])
sleep 1.5
end
raise "the run failed" unless @job["status"] == "succeeded"
brief = JSON.parse(@job["output"]["output"])
puts brief["spec"]["subject"]
$started = call("POST", "/run", $briefBody);
$jobId = $started["job_id"];
do {
$job = call("GET", "/jobs/$jobId")["job"];
if (!in_array($job["status"], ["queued", "running"], true)) break;
usleep(1_500_000);
} while (true);
if ($job["status"] !== "succeeded") throw new RuntimeException("the run failed");
$brief = json_decode($job["output"]["output"], true);
var started = await Call("POST", "/run", briefBody);
var jobId = started["job_id"]!.GetValue<string>();
JsonNode job;
while (true)
{
job = (await Call("GET", $"/jobs/{jobId}"))["job"]!;
var status = job["status"]!.GetValue<string>();
if (status != "queued" && status != "running") break;
await Task.Delay(1500);
}
if (job["status"]!.GetValue<string>() != "succeeded") throw new Exception("the run failed");
var brief = JsonNode.Parse(job["output"]!["output"]!.GetValue<string>())!;
What comes back
One JSON object on job.output.output, as a string you parse yourself:
{
"title": "three to six words",
"seen": "what the writer actually saw in your photograph",
"spec": { …the thirteen labelled lines… },
"translated": [ { "kept": "keep the background", "as": "the description it became" } ],
"unsupported": [ "what this brief cannot deliver" ],
"why": "one sentence"
}
Or, if the request is refused: {"refused": true, "reason": "…"}.
7. Lane two — render it
Exactly two keys. Every additional key is concatenated into the text the image
model sees and painted as literal words, so this body must carry nothing but the prompt and the
model override. Output arrives at job.output.images[0].b64;
job.output.output is the empty string.
curl -s -X POST "https://api.skillsafe.ai/v1/app-api/run" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"instruction": "Use case: lighting-weather\nPrimary request: \u2026\nSubject: \u2026", "$model": "gpt-image"}'
r = session.post("https://api.skillsafe.ai/v1/app-api/run", json={
"instruction": "Use case: lighting-weather\nPrimary request: \u2026\nSubject: \u2026",
"$model": "gpt-image"
})
data = r.json()["data"]
const data = await call("POST", "/run", {
"instruction": "Use case: lighting-weather\nPrimary request: \u2026\nSubject: \u2026",
"$model": "gpt-image"
});
body, _ := json.Marshal(map[string]any{
"instruction": "Use case: lighting-weather\nPrimary request: \u2026\nSubject: \u2026",
"$model": "gpt-image",
})
data, err := call("POST", "/run", body)
String body = """
{
"instruction": "Use case: lighting-weather\nPrimary request: \u2026\nSubject: \u2026",
"$model": "gpt-image"
}
""";
JsonNode data = call("POST", "/run", body);
data = call("POST", "/run", {
"instruction" => "Use case: lighting-weather\nPrimary request: \u2026\nSubject: \u2026",
"$model" => "gpt-image",
})
$data = call("POST", "/run", [
"instruction" => "Use case: lighting-weather\nPrimary request: \u2026\nSubject: \u2026",
"$model" => "gpt-image",
]);
var body = new {
instruction = "Use case: lighting-weather\nPrimary request: \u2026\nSubject: \u2026",
["$model"] = "gpt-image",
};
var data = await Call("POST", "/run", body);
8. Lane three — check what survived
A text run with both pictures attached, original first. Returns
verdict (close / partial / off),
checks (each held / drifted / lost),
summary, and next_change — one targeted change, because iterating with a
single change is what converges.
curl -s -X POST "https://api.skillsafe.ai/v1/app-api/run" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"task": "check", "guide": "\u2026the full text of /guide.js\u2026", "language": "Japanese", "language_code": "ja", "request": "Make this a winter evening with snow falling. Keep the house exactly as it is.", "brief": "Use case: lighting-weather\nPrimary request: \u2026\nSubject: \u2026", "promises": "Camera angle and lens feel\nComposition and framing\nKeep the house exactly as it is", "$files": ["fil_original_id", "fil_render_id"]}'
r = session.post("https://api.skillsafe.ai/v1/app-api/run", json={
"task": "check",
"guide": "\u2026the full text of /guide.js\u2026",
"language": "Japanese",
"language_code": "ja",
"language": "Japanese",
"language_code": "ja",
"language": "Japanese", "language_code": "ja", "request": "Make this a winter evening with snow falling. Keep the house exactly as it is.",
"brief": "Use case: lighting-weather\nPrimary request: \u2026\nSubject: \u2026",
"promises": "Camera angle and lens feel\nComposition and framing\nKeep the house exactly as it is",
"$files": [
"fil_original_id",
"fil_render_id"
]
})
data = r.json()["data"]
const data = await call("POST", "/run", {
"task": "check",
"guide": "\u2026the full text of /guide.js\u2026",
"language": "Japanese",
"language_code": "ja",
"language": "Japanese",
"language_code": "ja",
"language": "Japanese", "language_code": "ja", "request": "Make this a winter evening with snow falling. Keep the house exactly as it is.",
"brief": "Use case: lighting-weather\nPrimary request: \u2026\nSubject: \u2026",
"promises": "Camera angle and lens feel\nComposition and framing\nKeep the house exactly as it is",
"$files": [
"fil_original_id",
"fil_render_id"
]
});
body, _ := json.Marshal(map[string]any{
"task": "check",
"guide": "\u2026the full text of /guide.js\u2026",
"language": "Japanese",
"language_code": "ja",
"language": "Japanese",
"language_code": "ja",
"language": "Japanese", "language_code": "ja", "request": "Make this a winter evening with snow falling. Keep the house exactly as it is.",
"brief": "Use case: lighting-weather\nPrimary request: \u2026\nSubject: \u2026",
"promises": "Camera angle and lens feel\nComposition and framing\nKeep the house exactly as it is",
"$files": []any{"fil_original_id", "fil_render_id"},
})
data, err := call("POST", "/run", body)
String body = """
{
"task": "check",
"guide": "\u2026the full text of /guide.js\u2026",
"language": "Japanese",
"language_code": "ja",
"language": "Japanese",
"language_code": "ja",
"language": "Japanese", "language_code": "ja", "request": "Make this a winter evening with snow falling. Keep the house exactly as it is.",
"brief": "Use case: lighting-weather\nPrimary request: \u2026\nSubject: \u2026",
"promises": "Camera angle and lens feel\nComposition and framing\nKeep the house exactly as it is",
"$files": [
"fil_original_id",
"fil_render_id"
]
}
""";
JsonNode data = call("POST", "/run", body);
data = call("POST", "/run", {
"task" => "check",
"guide" => "\u2026the full text of /guide.js\u2026",
"language" => "Japanese",
"language_code" => "ja",
"request" => "Make this a winter evening with snow falling. Keep the house exactly as it is.",
"brief" => "Use case: lighting-weather\nPrimary request: \u2026\nSubject: \u2026",
"promises" => "Camera angle and lens feel\nComposition and framing\nKeep the house exactly as it is",
"$files" => ["fil_original_id", "fil_render_id"],
})
$data = call("POST", "/run", [
"task" => "check",
"guide" => "\u2026the full text of /guide.js\u2026",
"language" => "Japanese",
"language_code" => "ja",
"request" => "Make this a winter evening with snow falling. Keep the house exactly as it is.",
"brief" => "Use case: lighting-weather\nPrimary request: \u2026\nSubject: \u2026",
"promises" => "Camera angle and lens feel\nComposition and framing\nKeep the house exactly as it is",
"$files" => ["fil_original_id", "fil_render_id"],
]);
var body = new {
task = "check",
guide = "\u2026the full text of /guide.js\u2026",
language = "Japanese",
language_code = "ja",
request = "Make this a winter evening with snow falling. Keep the house exactly as it is.",
brief = "Use case: lighting-weather\nPrimary request: \u2026\nSubject: \u2026",
promises = "Camera angle and lens feel\nComposition and framing\nKeep the house exactly as it is",
["$files"] = new object[] { "fil_original_id", "fil_render_id" },
};
var data = await Call("POST", "/run", body);
9. Streaming
POST /run-stream returns Server-Sent Events for the two text lanes. Read the SSE
frames yourself as below; note that in a browser the SDK's onDelta callback receives
keep-alive ticks rather than text deltas, so a progress bar built on it will never move.
curl -sN -X POST "$BASE/run-stream" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: peb:brief:stream:1" \
-d @brief-body.json
with session.post(f"{BASE}/run-stream", json=BRIEF_BODY, stream=True) as r:
for line in r.iter_lines(decode_unicode=True):
if not line or not line.startswith("data:"):
continue
event = json.loads(line[5:].strip())
if event.get("type") == "delta":
print(event.get("text", ""), end="", flush=True)
elif event.get("type") == "done":
break
const res = await fetch(BASE + "/run-stream", {
method: "POST",
headers: {
Authorization: "Bearer " + TOKEN,
"Content-Type": "application/json",
"Idempotency-Key": "peb:brief:stream:1",
},
body: JSON.stringify(briefBody),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const chunks = buffer.split("\n\n");
buffer = chunks.pop();
for (const chunk of chunks) {
const line = chunk.split("\n").find((l) => l.startsWith("data:"));
if (!line) continue;
const event = JSON.parse(line.slice(5).trim());
if (event.type === "delta") process.stdout.write(event.text || "");
}
}
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(briefBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
scanner := bufio.NewScanner(res.Body)
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "data:") {
continue
}
var event struct {
Type string `json:"type"`
Text string `json:"text"`
}
json.Unmarshal([]byte(strings.TrimSpace(line[5:])), &event)
if event.Type == "delta" {
fmt.Print(event.Text)
}
}
HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(briefBody))
.build();
http.send(req, HttpResponse.BodyHandlers.ofLines())
.body()
.filter(l -> l.startsWith("data:"))
.forEach(l -> {
try {
JsonNode event = mapper.readTree(l.substring(5).trim());
if ("delta".equals(event.path("type").asText())) {
System.out.print(event.path("text").asText());
}
} catch (Exception ignored) { }
});
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req.body = JSON.dump(brief_body)
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |h|
h.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
next unless line.start_with?("data:")
event = JSON.parse(line[5..].strip)
print event["text"] if event["type"] == "delta"
end
end
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($briefBody),
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) {
foreach (explode("\n", $chunk) as $line) {
if (!str_starts_with($line, "data:")) continue;
$event = json_decode(trim(substr($line, 5)), true);
if (($event["type"] ?? "") === "delta") echo $event["text"] ?? "";
}
return strlen($chunk);
},
]);
curl_exec($ch);
var req = new HttpRequestMessage(HttpMethod.Post, Base + "/run-stream")
{
Content = new StringContent(JsonSerializer.Serialize(briefBody), Encoding.UTF8, "application/json"),
};
req.Headers.Add("Idempotency-Key", "peb:brief:stream:1");
using var res = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
while (await reader.ReadLineAsync() is { } line)
{
if (!line.StartsWith("data:")) continue;
var ev = JsonNode.Parse(line[5..].Trim())!;
if (ev["type"]?.GetValue<string>() == "delta") Console.Write(ev["text"]?.GetValue<string>());
}
Error codes
| Code | HTTP | What it means |
|---|---|---|
not_found | 404 | Wrong route. The app-API endpoints have no /apps/{slug}/ segment — the slug is bound to the token at /guest. |
validation_error | 400 | The body was rejected. On an image run this is usually $files: attachments are not supported there at all. |
unauthorized | 401 | No token, or a stale one. Mint a fresh one; guest tokens expire. |
insufficient_credits | 402 | The balance is below min_credits. Call /estimate first and check against /me. |
rate_limited | 429 | Back off and retry. Do not tight-loop a poll. |
internal | 500 | A platform-side failure. Retry once with the SAME idempotency key so a completed job is not re-billed. |
The use-case taxonomy
Sixteen exact slugs from the imagegen skill by @openai. The eight Edit slugs carry a survival verdict; the
invariant column is the clause the source skill defines that edit by, and is exactly
what a renderer with no pixels cannot keep by negation.
| Slug | Edit | Survives a re-render? | The invariant it is defined by |
|---|---|---|---|
lighting-weather | Lighting & weather | survives | preserve subject identity, geometry, camera angle, and composition; change only lighting, atmosphere, and weather |
sketch-to-render | Sketch to render | survives | preserve layout, proportions, and perspective; choose realistic materials and lighting; do not add new elements or text |
style-transfer | Style transfer | survives | preserve palette, texture, and brushwork; no extra elements |
text-localization | Text & localization | degrades | change only the text; preserve layout, typography, spacing, and hierarchy; no extra words; do not alter logos or imagery |
precise-object-edit | Object add / remove / replace | degrades | preserve camera angle, room lighting, floor shadows, and surrounding objects; keep all other aspects unchanged |
compositing | Compositing | degrades | match lighting, perspective, and scale; keep the base framing unchanged |
background-extraction | Cutout / transparent background | impossible | crisp silhouette; no halos or fringing; preserve label text exactly; no restyling |
identity-preserve | Identity-preserving edit | impossible | preserve face, body shape, pose, hair, expression, and identity; match lighting and shadows |
The eight Generate slugs have no original to preserve, so the re-render gap does not apply.
| Slug | Kind |
|---|---|
photorealistic-natural | Photorealistic / natural |
product-mockup | Product mockup |
ui-mockup | UI mockup |
infographic-diagram | Infographic / diagram |
logo-brand | Logo / brand mark |
illustration-story | Illustration / story |
stylized-concept | Stylized concept |
historical-scene | Historical scene |
The prompt schema
Thirteen labelled lines, joined as Label: value in this order and sent to the image
model. The source skill's schema has a fourteenth, Input images: — it is omitted here
because it names an attachment the renderer cannot open, which makes it a pointer by definition.
| Key | Label | What goes in it | |
|---|---|---|---|
use_case | Use case | required | One of the sixteen taxonomy slugs. |
asset_type | Asset type | optional | Where the picture will be used. |
primary_request | Primary request | required | The change, in one sentence. |
scene | Scene/backdrop | optional | The environment, described as if new. |
subject | Subject | required | The main subject, concretely. |
style | Style/medium | required | Photo, illustration, 3D - and the register. |
composition | Composition/framing | required | Camera height, distance, what sits where. |
lighting | Lighting/mood | required | Direction, hardness, colour of the light. |
palette | Color palette | optional | The actual colours and their relationship. |
materials | Materials/textures | optional | Real surfaces and how they catch light. |
text | Text (verbatim) | optional | Every string quoted letter-for-letter. |
constraints | Constraints | required | What the picture must do. Positive, not preserve-clauses. |
avoid | Avoid | optional | Negative constraints - things that must not appear. |
The app's own linter blocks a render on four things, each of which would otherwise waste a paid run: an unfilled bracketed placeholder, a phrase addressed to a chat assistant, a phrase pointing at the attachment, and any surviving preserve-clause. It warns above about 1800 tokens.