Product Shot Lab

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

codewhat happenedwhat to do
UNAUTHORIZEDNo token, or it expired.Mint a new one — step 2.
INSUFFICIENT_CREDITSBalance below the hold.Call /estimate first and compare against /me.
VALIDATION_ERRORThe body was not the input object.Post the fields at the top level — there is no input wrapper.
RATE_LIMITEDToo many calls.Back off; do not tight-loop.
NOT_FOUNDUnknown job id.Job ids come from /run and are per-account.
The body is the input object itself. Wrapping it as {"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.

laneselected bymodelreturns
describe"task": "describe"gpt-terragpt-5.6-terraone JSON object: the product card and one brief per shot
render"$model": "gpt-image"gpt-image-2job.output.images[0].b64

describe — input fields

fieldtypewhat it is
taskstringalways "describe"
$filesstring[]1-4 file ids from POST /files — the product photos the reader looks at
guidestringthe reading guide, served verbatim at /describe-prompt.js
languagestringthe language to write the card and briefs in, by name
product_name, brand_colourstringwhat the seller typed; may be empty
category, category_treatment, category_avoidstringthe category's conventions from shots.js
marketplace, marketplace_rulesstringthe marketplace and its packshot rules
shots_wantedstringcomma-separated shot ids: packshot, lifestyle, detail, scale, alternate, flatlay, packaging, hero
shot_specsstringone line per shot: job, camera, light, shadow, background, fill, ratio
photo_countstringhow many photos are attached

Reference pictures — the compose lane only

An image-generation run refuses attachments. 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.

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.

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.

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.

Then poll:

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.

7 · Streaming


Templates and the example corpus come from product-shot-lab (MIT). Back to the lab.