Driving the lab from your own code
Two model runs sit behind this app and both are reachable over HTTP. The
compose lane writes a six-block image prompt from a plain request; the
render lane turns a finished prompt into a picture with gpt-image-2.
Everything else the app does — matching a template, checking a prompt's structure, the 541
example prompts — happens in the browser and is not an API call at all.
Base URL and envelope
Every endpoint lives under https://api.skillsafe.ai/v1/app-api and answers with the same envelope.
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "INSUFFICIENT_CREDITS", "message": "..." } }
Errors you will actually meet
| code | what happened | what to do |
|---|---|---|
UNAUTHORIZED | No token, or it expired. | Mint a new one — step 2. |
INSUFFICIENT_CREDITS | Balance below the hold. | Call /estimate first and compare against /me. |
VALIDATION_ERROR | The body was not the input object. | Post the fields at the top level — there is no input wrapper. |
RATE_LIMITED | Too many calls. | Back off; do not tight-loop. |
NOT_FOUND | Unknown job id. | Job ids come from /run and are per-account. |
{"input": {...}} returns 200 and runs anyway — with every one of your
fields hidden from the model, including task. The run looks like it worked and the
answer is generic. Post the fields flat.
The lanes
task selects the lane on a text run. There is exactly one text lane today; the
render lane is selected by the $model override instead and takes no task.
| lane | selected by | model | returns |
|---|---|---|---|
describe | "task": "describe" | gpt-terra → gpt-5.6-terra | one JSON object: the product card and one brief per shot |
render | "$model": "gpt-image" | gpt-image-2 | job.output.images[0].b64 |
describe — input fields
| field | type | what it is |
|---|---|---|
task | string | always "describe" |
$files | string[] | 1-4 file ids from POST /files — the product photos the reader looks at |
guide | string | the reading guide, served verbatim at /describe-prompt.js |
language | string | the language to write the card and briefs in, by name |
product_name, brand_colour | string | what the seller typed; may be empty |
category, category_treatment, category_avoid | string | the category's conventions from shots.js |
marketplace, marketplace_rules | string | the marketplace and its packshot rules |
shots_wanted | string | comma-separated shot ids: packshot, lifestyle, detail, scale, alternate, flatlay, packaging, hero |
shot_specs | string | one line per shot: job, camera, light, shadow, background, fill, ratio |
photo_count | string | how many photos are attached |
Reference pictures — the compose lane only
POST /run with
"$model": "gpt-image" plus file ids returns
400 validation_error: "This model generates images — $files attachments are not supported
on image-generation runs". There is no image-in/image-out call. Worse,
/estimate approves that exact body first, so a clean estimate proves nothing here.
So attachments go on the compose run, which is a text model and reads them fine. The writer looks at your picture and describes it into the prompt blocks; the renderer then paints from words alone. Upload first:
curl -s -X POST "https://api.skillsafe.ai/v1/app-api/files" -H "Authorization: Bearer $TOKEN" -F "file=@reference.png" -F "name=reference.png"
# -> {"ok":true,"data":{"file":{"file_id":"udf_...","content_type":"image/png"}}}
then pass the ids as $files on the compose body. File ids are
subject-scoped — an id minted with a guest token 404s once you sign in, so upload
against the subject that will run.
Every value is a scalar. The template fields are joined strings, not arrays — the transport
takes scalars only, and an array arrives at the model as [object Object].
describe — output contract
One JSON object, no prose around it. This is exactly what parseCompose in
render.js reads, and the app assembles blocks into the final
prompt in the order below — the model never writes the assembled string.
{
"card": {
"name": "Stoneware pour-over mug",
"type": "coffee mug",
"shape": "tall cylinder, slight taper, wide C handle",
"size_guess": "350 ml, about 11 cm tall - judged against the hand in photo 2",
"materials": "glazed stoneware",
"colours": [
"#E9E2D6",
"#3B3A36"
],
"label_text": "KESTREL",
"marks": "small stamped bird mark near the base",
"features": "speckled cream glaze, unglazed raw clay ring at the foot",
"must_keep": [
"the word KESTREL on the front",
"cream speckled glaze with a raw clay foot",
"one wide C-shaped handle"
]
},
"shots": {
"packshot": "...",
"lifestyle": "...",
"detail": "...",
"scale": "...",
"alternate": "..."
},
"why": "Check the stamped word KESTREL is spelled exactly in every render - it is the fact most likely to drift.",
"warnings": [
"the base of the mug is never shown, so the alternate view describes it conservatively"
]
}
A refusal comes back as {"refused": true, "reason": "..."} instead.
render — input and output
Exactly two keys. Every extra key is joined into the text the renderer sees and painted into the picture as literal words, so nothing else belongs here.
{
"instruction": "<<one shot brief, as edited in the lab>>",
"$model": "gpt-image"
}
The payload is job.output.images[0] — {content_type, b64}. On an image
run job.output.output is the empty string, and reading it is the first mistake a
text-lane habit produces.
1 · A tiny client
Unwraps the envelope and raises on the error shape. Everything below assumes it.
# Every call needs a token and returns {"ok":true,"data":{...}} or {"ok":false,"error":{...}}.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="YOUR_TOKEN" # from https://product-shot-lab.skillsafe.ai/tokens.html
# unwrap the envelope with jq
call() { curl -s -X "$1" "$BASE$2" -H "Authorization: Bearer $TOKEN" \
${3:+-H "Content-Type: application/json"} ${3:+-d "$3"} | jq '.data // .error'; }
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from https://product-shot-lab.skillsafe.ai/tokens.html
def call(method, path, body=None):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(BASE + path, data=data, method=method)
req.add_header("Authorization", "Bearer " + TOKEN)
if data:
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r:
payload = json.load(r)
if not payload.get("ok"):
raise RuntimeError(payload.get("error"))
return payload["data"]
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // from https://product-shot-lab.skillsafe.ai/tokens.html
async function call(method, path, body) {
const res = await fetch(BASE + path, {
method,
headers: {
Authorization: `Bearer ${TOKEN}`,
...(body ? { "Content-Type": "application/json" } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const payload = await res.json();
if (!payload.ok) throw new Error(JSON.stringify(payload.error));
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 https://product-shot-lab.skillsafe.ai/tokens.html
func call(method, path string, body any) (map[string]any, error) {
var r io.Reader
if body != nil {
b, _ := json.Marshal(body)
r = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, base+path, r)
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 payload struct {
OK bool `json:"ok"`
Data map[string]any `json:"data"`
Error map[string]any `json:"error"`
}
json.NewDecoder(res.Body).Decode(&payload)
if !payload.OK { return nil, fmt.Errorf("%v", payload.Error) }
return payload.Data, nil
}
import java.net.URI;
import java.net.http.*;
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String TOKEN = "YOUR_TOKEN"; // from https://product-shot-lab.skillsafe.ai/tokens.html
static String call(String method, String path, String body) throws Exception {
var b = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Authorization", "Bearer " + TOKEN);
if (body == null) {
b = b.method(method, HttpRequest.BodyPublishers.noBody());
} else {
b = b.header("Content-Type", "application/json")
.method(method, HttpRequest.BodyPublishers.ofString(body));
}
var res = HttpClient.newHttpClient().send(b.build(), HttpResponse.BodyHandlers.ofString());
return res.body(); // {"ok":true,"data":{...}} or {"ok":false,"error":{...}}
}
require "json"
require "net/http"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from https://product-shot-lab.skillsafe.ai/tokens.html
def call(method, path, body = nil)
uri = URI(BASE + path)
klass = Net::HTTP.const_get(method.capitalize)
req = klass.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
if body
req["Content-Type"] = "application/json"
req.body = JSON.dump(body)
end
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
payload = JSON.parse(res.body)
raise payload["error"].to_s unless payload["ok"]
payload["data"]
end
<?php
$BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = "YOUR_TOKEN"; // from https://product-shot-lab.skillsafe.ai/tokens.html
function call($method, $path, $body = null) {
global $BASE, $TOKEN;
$headers = ["Authorization: Bearer $TOKEN"];
if ($body !== null) $headers[] = "Content-Type: application/json";
$ch = curl_init($BASE . $path);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => $body === null ? null : json_encode($body),
]);
$payload = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($payload["ok"])) throw new Exception(json_encode($payload["error"]));
return $payload["data"];
}
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Token = "YOUR_TOKEN"; // from https://product-shot-lab.skillsafe.ai/tokens.html
static readonly HttpClient Http = new HttpClient();
static async Task<string> Call(string method, string path, string body) {
var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
if (body != null)
req.Content = new StringContent(body, Encoding.UTF8, "application/json");
var res = await Http.SendAsync(req);
return await res.Content.ReadAsStringAsync();
}
2 · A token
Open tokens.html in the browser, sign in, and copy the token — that is the whole of it. A personal token carries your credit balance; a guest token carries none, so a guest can read prices but not run either lane.
3 · Who am I
Returns subject_type, subject_id and credits — and nothing
else. Signed in means subject_type == "user"; a guest token also resolves here, so
"the call succeeded" is not a sign-in test.
curl -s -X GET "https://api.skillsafe.ai/v1/app-api/me" \
-H "Authorization: Bearer $TOKEN"
print(call("GET", "/me"))
console.log(await call("GET", "/me"));
out, err := call("GET", "/me", nil)
fmt.Println(out, err)
System.out.println(call("GET", "/me", null));
puts call("GET", "/me")
print_r(call("GET", "/me"));
Console.WriteLine(await Call("GET", "/me", null));
4 · What will it cost
Free, and it starts no job. hold_credits is what gets reserved;
charged_credits on the finished job is what you actually pay, usually far less
because the hold prices the full output cap. Assert model_alias here — it is the
authoritative proof you are wired to the model you think you are.
curl -s -X POST "https://api.skillsafe.ai/v1/app-api/estimate" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"task": "describe", "guide": "<<the full text served at /describe-prompt.js>>", "language": "English", "product_name": "Stoneware pour-over mug, 350 ml", "category": "Home & living", "category_treatment": "warm, inviting, materials honest, scale obvious from the room", "category_avoid": "sterile studio light for lifestyle, competing decor", "marketplace": "Amazon", "marketplace_rules": "pure white background, product fills at least 85% of the frame, no props, text, logos or watermarks", "brand_colour": "", "shots_wanted": "packshot,lifestyle,detail,scale,alternate", "shot_specs": "<<one line per shot: job, camera, light, shadow, background, fill, ratio>>", "photo_count": "2", "$files": ["fil_<<from POST /files>>"]}'
print(call("POST", "/estimate", {
"task": "describe",
"guide": "<<the full text served at /describe-prompt.js>>",
"language": "English",
"product_name": "Stoneware pour-over mug, 350 ml",
"category": "Home & living",
"category_treatment": "warm, inviting, materials honest, scale obvious from the room",
"category_avoid": "sterile studio light for lifestyle, competing decor",
"marketplace": "Amazon",
"marketplace_rules": "pure white background, product fills at least 85% of the frame, no props, text, logos or watermarks",
"brand_colour": "",
"shots_wanted": "packshot,lifestyle,detail,scale,alternate",
"shot_specs": "<<one line per shot: job, camera, light, shadow, background, fill, ratio>>",
"photo_count": "2",
"$files": [
"fil_<<from POST /files>>"
]
}))
console.log(await call("POST", "/estimate", {
"task": "describe",
"guide": "<<the full text served at /describe-prompt.js>>",
"language": "English",
"product_name": "Stoneware pour-over mug, 350 ml",
"category": "Home & living",
"category_treatment": "warm, inviting, materials honest, scale obvious from the room",
"category_avoid": "sterile studio light for lifestyle, competing decor",
"marketplace": "Amazon",
"marketplace_rules": "pure white background, product fills at least 85% of the frame, no props, text, logos or watermarks",
"brand_colour": "",
"shots_wanted": "packshot,lifestyle,detail,scale,alternate",
"shot_specs": "<<one line per shot: job, camera, light, shadow, background, fill, ratio>>",
"photo_count": "2",
"$files": [
"fil_<<from POST /files>>"
]
}));
body := map[string]any{}
json.Unmarshal([]byte(`{"task": "describe", "guide": "<<the full text served at /describe-prompt.js>>", "language": "English", "product_name": "Stoneware pour-over mug, 350 ml", "category": "Home & living", "category_treatment": "warm, inviting, materials honest, scale obvious from the room", "category_avoid": "sterile studio light for lifestyle, competing decor", "marketplace": "Amazon", "marketplace_rules": "pure white background, product fills at least 85% of the frame, no props, text, logos or watermarks", "brand_colour": "", "shots_wanted": "packshot,lifestyle,detail,scale,alternate", "shot_specs": "<<one line per shot: job, camera, light, shadow, background, fill, ratio>>", "photo_count": "2", "$files": ["fil_<<from POST /files>>"]}`), &body)
out, err := call("POST", "/estimate", body)
fmt.Println(out, err)
String body = """
{"task": "describe", "guide": "<<the full text served at /describe-prompt.js>>", "language": "English", "product_name": "Stoneware pour-over mug, 350 ml", "category": "Home & living", "category_treatment": "warm, inviting, materials honest, scale obvious from the room", "category_avoid": "sterile studio light for lifestyle, competing decor", "marketplace": "Amazon", "marketplace_rules": "pure white background, product fills at least 85% of the frame, no props, text, logos or watermarks", "brand_colour": "", "shots_wanted": "packshot,lifestyle,detail,scale,alternate", "shot_specs": "<<one line per shot: job, camera, light, shadow, background, fill, ratio>>", "photo_count": "2", "$files": ["fil_<<from POST /files>>"]}
""";
System.out.println(call("POST", "/estimate", body));
puts call("POST", "/estimate", {
"task": "describe",
"guide": "<<the full text served at /describe-prompt.js>>",
"language": "English",
"product_name": "Stoneware pour-over mug, 350 ml",
"category": "Home & living",
"category_treatment": "warm, inviting, materials honest, scale obvious from the room",
"category_avoid": "sterile studio light for lifestyle, competing decor",
"marketplace": "Amazon",
"marketplace_rules": "pure white background, product fills at least 85% of the frame, no props, text, logos or watermarks",
"brand_colour": "",
"shots_wanted": "packshot,lifestyle,detail,scale,alternate",
"shot_specs": "<<one line per shot: job, camera, light, shadow, background, fill, ratio>>",
"photo_count": "2",
"$files": [
"fil_<<from POST /files>>"
]
})
print_r(call("POST", "/estimate", json_decode(<<<'J'
{"task": "describe", "guide": "<<the full text served at /describe-prompt.js>>", "language": "English", "product_name": "Stoneware pour-over mug, 350 ml", "category": "Home & living", "category_treatment": "warm, inviting, materials honest, scale obvious from the room", "category_avoid": "sterile studio light for lifestyle, competing decor", "marketplace": "Amazon", "marketplace_rules": "pure white background, product fills at least 85% of the frame, no props, text, logos or watermarks", "brand_colour": "", "shots_wanted": "packshot,lifestyle,detail,scale,alternate", "shot_specs": "<<one line per shot: job, camera, light, shadow, background, fill, ratio>>", "photo_count": "2", "$files": ["fil_<<from POST /files>>"]}
J, true)));
var body = @"{""task"": ""describe"", ""guide"": ""<<the full text served at /describe-prompt.js>>"", ""language"": ""English"", ""product_name"": ""Stoneware pour-over mug, 350 ml"", ""category"": ""Home & living"", ""category_treatment"": ""warm, inviting, materials honest, scale obvious from the room"", ""category_avoid"": ""sterile studio light for lifestyle, competing decor"", ""marketplace"": ""Amazon"", ""marketplace_rules"": ""pure white background, product fills at least 85% of the frame, no props, text, logos or watermarks"", ""brand_colour"": """", ""shots_wanted"": ""packshot,lifestyle,detail,scale,alternate"", ""shot_specs"": ""<<one line per shot: job, camera, light, shadow, background, fill, ratio>>"", ""photo_count"": ""2"", ""$files"": [""fil_<<from POST /files>>""]}";
Console.WriteLine(await Call("POST", "/estimate", body));
An image run is priced per picture and its hold does not move with prompt length, so one probe covers every prompt you will ever send. 1 credit = $0.0001.
5 · Compose a prompt
/run returns a job_id; poll /jobs/{id} until
status is succeeded or failed. Send an
Idempotency-Key header if you retry — but salt it per attempt, because
an idempotent replay returns the original job even when that job failed.
curl -s -X POST "https://api.skillsafe.ai/v1/app-api/run" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"task": "describe", "guide": "<<the full text served at /describe-prompt.js>>", "language": "English", "product_name": "Stoneware pour-over mug, 350 ml", "category": "Home & living", "category_treatment": "warm, inviting, materials honest, scale obvious from the room", "category_avoid": "sterile studio light for lifestyle, competing decor", "marketplace": "Amazon", "marketplace_rules": "pure white background, product fills at least 85% of the frame, no props, text, logos or watermarks", "brand_colour": "", "shots_wanted": "packshot,lifestyle,detail,scale,alternate", "shot_specs": "<<one line per shot: job, camera, light, shadow, background, fill, ratio>>", "photo_count": "2", "$files": ["fil_<<from POST /files>>"]}'
print(call("POST", "/run", {
"task": "describe",
"guide": "<<the full text served at /describe-prompt.js>>",
"language": "English",
"product_name": "Stoneware pour-over mug, 350 ml",
"category": "Home & living",
"category_treatment": "warm, inviting, materials honest, scale obvious from the room",
"category_avoid": "sterile studio light for lifestyle, competing decor",
"marketplace": "Amazon",
"marketplace_rules": "pure white background, product fills at least 85% of the frame, no props, text, logos or watermarks",
"brand_colour": "",
"shots_wanted": "packshot,lifestyle,detail,scale,alternate",
"shot_specs": "<<one line per shot: job, camera, light, shadow, background, fill, ratio>>",
"photo_count": "2",
"$files": [
"fil_<<from POST /files>>"
]
}))
console.log(await call("POST", "/run", {
"task": "describe",
"guide": "<<the full text served at /describe-prompt.js>>",
"language": "English",
"product_name": "Stoneware pour-over mug, 350 ml",
"category": "Home & living",
"category_treatment": "warm, inviting, materials honest, scale obvious from the room",
"category_avoid": "sterile studio light for lifestyle, competing decor",
"marketplace": "Amazon",
"marketplace_rules": "pure white background, product fills at least 85% of the frame, no props, text, logos or watermarks",
"brand_colour": "",
"shots_wanted": "packshot,lifestyle,detail,scale,alternate",
"shot_specs": "<<one line per shot: job, camera, light, shadow, background, fill, ratio>>",
"photo_count": "2",
"$files": [
"fil_<<from POST /files>>"
]
}));
body := map[string]any{}
json.Unmarshal([]byte(`{"task": "describe", "guide": "<<the full text served at /describe-prompt.js>>", "language": "English", "product_name": "Stoneware pour-over mug, 350 ml", "category": "Home & living", "category_treatment": "warm, inviting, materials honest, scale obvious from the room", "category_avoid": "sterile studio light for lifestyle, competing decor", "marketplace": "Amazon", "marketplace_rules": "pure white background, product fills at least 85% of the frame, no props, text, logos or watermarks", "brand_colour": "", "shots_wanted": "packshot,lifestyle,detail,scale,alternate", "shot_specs": "<<one line per shot: job, camera, light, shadow, background, fill, ratio>>", "photo_count": "2", "$files": ["fil_<<from POST /files>>"]}`), &body)
out, err := call("POST", "/run", body)
fmt.Println(out, err)
String body = """
{"task": "describe", "guide": "<<the full text served at /describe-prompt.js>>", "language": "English", "product_name": "Stoneware pour-over mug, 350 ml", "category": "Home & living", "category_treatment": "warm, inviting, materials honest, scale obvious from the room", "category_avoid": "sterile studio light for lifestyle, competing decor", "marketplace": "Amazon", "marketplace_rules": "pure white background, product fills at least 85% of the frame, no props, text, logos or watermarks", "brand_colour": "", "shots_wanted": "packshot,lifestyle,detail,scale,alternate", "shot_specs": "<<one line per shot: job, camera, light, shadow, background, fill, ratio>>", "photo_count": "2", "$files": ["fil_<<from POST /files>>"]}
""";
System.out.println(call("POST", "/run", body));
puts call("POST", "/run", {
"task": "describe",
"guide": "<<the full text served at /describe-prompt.js>>",
"language": "English",
"product_name": "Stoneware pour-over mug, 350 ml",
"category": "Home & living",
"category_treatment": "warm, inviting, materials honest, scale obvious from the room",
"category_avoid": "sterile studio light for lifestyle, competing decor",
"marketplace": "Amazon",
"marketplace_rules": "pure white background, product fills at least 85% of the frame, no props, text, logos or watermarks",
"brand_colour": "",
"shots_wanted": "packshot,lifestyle,detail,scale,alternate",
"shot_specs": "<<one line per shot: job, camera, light, shadow, background, fill, ratio>>",
"photo_count": "2",
"$files": [
"fil_<<from POST /files>>"
]
})
print_r(call("POST", "/run", json_decode(<<<'J'
{"task": "describe", "guide": "<<the full text served at /describe-prompt.js>>", "language": "English", "product_name": "Stoneware pour-over mug, 350 ml", "category": "Home & living", "category_treatment": "warm, inviting, materials honest, scale obvious from the room", "category_avoid": "sterile studio light for lifestyle, competing decor", "marketplace": "Amazon", "marketplace_rules": "pure white background, product fills at least 85% of the frame, no props, text, logos or watermarks", "brand_colour": "", "shots_wanted": "packshot,lifestyle,detail,scale,alternate", "shot_specs": "<<one line per shot: job, camera, light, shadow, background, fill, ratio>>", "photo_count": "2", "$files": ["fil_<<from POST /files>>"]}
J, true)));
var body = @"{""task"": ""describe"", ""guide"": ""<<the full text served at /describe-prompt.js>>"", ""language"": ""English"", ""product_name"": ""Stoneware pour-over mug, 350 ml"", ""category"": ""Home & living"", ""category_treatment"": ""warm, inviting, materials honest, scale obvious from the room"", ""category_avoid"": ""sterile studio light for lifestyle, competing decor"", ""marketplace"": ""Amazon"", ""marketplace_rules"": ""pure white background, product fills at least 85% of the frame, no props, text, logos or watermarks"", ""brand_colour"": """", ""shots_wanted"": ""packshot,lifestyle,detail,scale,alternate"", ""shot_specs"": ""<<one line per shot: job, camera, light, shadow, background, fill, ratio>>"", ""photo_count"": ""2"", ""$files"": [""fil_<<from POST /files>>""]}";
Console.WriteLine(await Call("POST", "/run", body));
Then poll:
curl -s -X GET "https://api.skillsafe.ai/v1/app-api/jobs/job_123" \
-H "Authorization: Bearer $TOKEN"
print(call("GET", "/jobs/job_123"))
console.log(await call("GET", "/jobs/job_123"));
out, err := call("GET", "/jobs/job_123", nil)
fmt.Println(out, err)
System.out.println(call("GET", "/jobs/job_123", null));
puts call("GET", "/jobs/job_123")
print_r(call("GET", "/jobs/job_123"));
Console.WriteLine(await Call("GET", "/jobs/job_123", null));
6 · Render the prompt
Same /run endpoint, different body. Takes up to three minutes; poll at a couple of
seconds. A failed job carries error as a plain string about as often as an object,
so read both shapes.
curl -s -X POST "https://api.skillsafe.ai/v1/app-api/run" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"instruction": "<<one shot brief, as edited in the lab>>", "$model": "gpt-image"}'
print(call("POST", "/run", {
"instruction": "<<one shot brief, as edited in the lab>>",
"$model": "gpt-image"
}))
console.log(await call("POST", "/run", {
"instruction": "<<one shot brief, as edited in the lab>>",
"$model": "gpt-image"
}));
body := map[string]any{}
json.Unmarshal([]byte(`{"instruction": "<<one shot brief, as edited in the lab>>", "$model": "gpt-image"}`), &body)
out, err := call("POST", "/run", body)
fmt.Println(out, err)
String body = """
{"instruction": "<<one shot brief, as edited in the lab>>", "$model": "gpt-image"}
""";
System.out.println(call("POST", "/run", body));
puts call("POST", "/run", {
"instruction": "<<one shot brief, as edited in the lab>>",
"$model": "gpt-image"
})
print_r(call("POST", "/run", json_decode(<<<'J'
{"instruction": "<<one shot brief, as edited in the lab>>", "$model": "gpt-image"}
J, true)));
var body = @"{""instruction"": ""<<one shot brief, as edited in the lab>>"", ""$model"": ""gpt-image""}";
Console.WriteLine(await Call("POST", "/run", body));
7 · Streaming
# Streaming is for the COMPOSE lane only. An image run sends no deltas — use /run and poll.
curl -N -X POST "https://api.skillsafe.ai/v1/app-api/run-stream" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"task": "describe", "guide": "<<the full text served at /describe-prompt.js>>", "language": "English", "product_name": "Stoneware pour-over mug, 350 ml", "category": "Home & living", "category_treatment": "warm, inviting, materials honest, scale obvious from the room", "category_avoid": "sterile studio light for lifestyle, competing decor", "marketplace": "Amazon", "marketplace_rules": "pure white background, product fills at least 85% of the frame, no props, text, logos or watermarks", "brand_colour": "", "shots_wanted": "packshot,lifestyle,detail,scale,alternate", "shot_specs": "<<one line per shot: job, camera, light, shadow, background, fill, ratio>>", "photo_count": "2", "$files": ["fil_<<from POST /files>>"]}'
# Streaming is for the COMPOSE lane only. An image run sends no deltas — use /run and poll.
import requests
with requests.post(BASE + "/run-stream", headers={"Authorization": "Bearer " + TOKEN},
json=COMPOSE_BODY, stream=True) as r:
for line in r.iter_lines():
if line.startswith(b"data: "):
print(line[6:].decode())
// Streaming is for the COMPOSE lane only. An image run sends no deltas — use /run and poll.
const res = await fetch(BASE + "/run-stream", {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
body: JSON.stringify(COMPOSE_BODY),
});
const reader = res.body.getReader();
const dec = new TextDecoder();
for (;;) {
const { value, done } = await reader.read();
if (done) break;
process.stdout.write(dec.decode(value, { stream: true }));
}
// Streaming is for the COMPOSE lane only. An image run sends no deltas — use /run and poll.
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
sc := bufio.NewScanner(res.Body)
for sc.Scan() {
if line := sc.Text(); strings.HasPrefix(line, "data: ") {
fmt.Println(line[6:])
}
}
// Streaming is for the COMPOSE lane only. An image run sends no deltas — use /run and poll.
var req = HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(composeBody))
.build();
HttpClient.newHttpClient()
.send(req, HttpResponse.BodyHandlers.ofLines())
.body()
.filter(l -> l.startsWith("data: "))
.forEach(l -> System.out.println(l.substring(6)));
# Streaming is for the COMPOSE lane only. An image run sends no deltas — use /run and poll.
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req.body = JSON.dump(COMPOSE_BODY)
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
res.read_body { |chunk| print chunk }
end
end
<?php
// Streaming is for the COMPOSE lane only. An image run sends no deltas — use /run and poll.
$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($composeBody),
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) { echo $chunk; return strlen($chunk); },
]);
curl_exec($ch);
curl_close($ch);
// Streaming is for the COMPOSE lane only. An image run sends no deltas — use /run and poll.
var req = new HttpRequestMessage(HttpMethod.Post, Base + "/run-stream");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
req.Content = new StringContent(composeBody, Encoding.UTF8, "application/json");
var res = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
string line;
while ((line = await reader.ReadLineAsync()) != null)
if (line.StartsWith("data: ")) Console.WriteLine(line[6..]);
Templates and the example corpus come from product-shot-lab (MIT). Back to the lab.