Illustration Desk — API tutorial Open the app

Drive the illustration planner from code

Everything the app does happens through the SkillSafe App API — a plain HTTPS API you can call from any language. Base URL: https://api.skillsafe.ai/v1/app-api. Every request carries Authorization: Bearer <token>. The visual styles come from the open-source zyncli-template library; the planner needs the chosen style's guide passed in the request (step 3 shows where to get it).

The envelope

Every response is JSON with exactly one of two shapes:

{"ok": true,  "data": { ... }}                                   // success
{"ok": false, "error": {"code": "...", "message": "...", "details": { ... }}}  // failure
HTTPcodeMeaning
400VALIDATION_ERRORBad input shape — see error.details.
401UNAUTHORIZEDMissing/expired token. Mint or refresh one (step 1).
402payment_requiredBalance below the run's hold. Top up, or call /estimate first.
404NOT_FOUNDWrong path or job id.
429RATE_LIMITEDBack off and retry with jitter.
5xxPlatform hiccup. Idempotent retries are safe if you send Idempotency-Key.

Step 0 — a tiny client helper

All later steps reuse this: read the token from the environment, POST JSON, unwrap the envelope.

# cURL needs no helper — export your token once (get it in step 1):
export SKILLSAFE_TOKEN="YOUR_TOKEN"
BASE=https://api.skillsafe.ai/v1/app-api
import json, os, urllib.request

BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = os.environ["SKILLSAFE_TOKEN"]

def api(method, path, body=None, headers=None):
    req = urllib.request.Request(BASE + path, method=method,
        data=None if body is None else json.dumps(body).encode(),
        headers={"Authorization": "Bearer " + TOKEN,
                 "Content-Type": "application/json", **(headers or {})})
    with urllib.request.urlopen(req) as r:
        out = json.load(r)
    if not out.get("ok"):
        raise RuntimeError(out["error"]["message"])
    return out["data"]
// Node 18+ (built-in fetch). Set SKILLSAFE_TOKEN in your shell first.
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // or read it from your environment/config

async function api(method, path, body, headers = {}) {
  const res = await fetch(BASE + path, {
    method,
    headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", ...headers },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const out = await res.json();
  if (!out.ok) throw new Error(out.error.message);
  return out.data;
}
package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "net/http"
    "os"
)

const base = "https://api.skillsafe.ai/v1/app-api"

func api(method, path string, body any, out any) error {
    var buf bytes.Buffer
    if body != nil {
        json.NewEncoder(&buf).Encode(body)
    }
    req, _ := http.NewRequest(method, base+path, &buf)
    req.Header.Set("Authorization", "Bearer "+os.Getenv("SKILLSAFE_TOKEN"))
    req.Header.Set("Content-Type", "application/json")
    res, err := http.DefaultClient.Do(req)
    if err != nil {
        return err
    }
    defer res.Body.Close()
    var env struct {
        Ok    bool            `json:"ok"`
        Data  json.RawMessage `json:"data"`
        Error *struct{ Message string } `json:"error"`
    }
    if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
        return err
    }
    if !env.Ok {
        return fmt.Errorf("api: %s", env.Error.Message)
    }
    return json.Unmarshal(env.Data, out)
}
import java.net.URI;
import java.net.http.*;

public class SkillSafe {
    static final String BASE = "https://api.skillsafe.ai/v1/app-api";
    static final String TOKEN = System.getenv("SKILLSAFE_TOKEN");
    static final HttpClient HTTP = HttpClient.newHttpClient();

    static String api(String method, String path, String jsonBody) throws Exception {
        var b = HttpRequest.newBuilder(URI.create(BASE + path))
            .header("Authorization", "Bearer " + TOKEN)
            .header("Content-Type", "application/json");
        var req = (jsonBody == null ? b.GET()
            : b.method(method, HttpRequest.BodyPublishers.ofString(jsonBody))).build();
        var res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
        return res.body(); // parse with your JSON library; check the "ok" field
    }
}
require "net/http"
require "json"

BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN")

def api(method, path, body = nil)
  uri = URI(BASE + path)
  req = Net::HTTP.const_get(method.capitalize).new(uri)
  req["Authorization"] = "Bearer #{TOKEN}"
  req["Content-Type"] = "application/json"
  req.body = body.to_json if body
  out = JSON.parse(Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }.body)
  raise out.dig("error", "message") unless out["ok"]
  out["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";

function api(string $method, string $path, ?array $body = null) {
    $token = getenv("SKILLSAFE_TOKEN");
    $opts = ["http" => [
        "method" => $method,
        "header" => "Authorization: Bearer $token\r\nContent-Type: application/json",
        "content" => $body === null ? "" : json_encode($body),
        "ignore_errors" => true,
    ]];
    $out = json_decode(file_get_contents(BASE . $path, false, stream_context_create($opts)), true);
    if (!($out["ok"] ?? false)) throw new Exception($out["error"]["message"] ?? "api error");
    return $out["data"];
}
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;

static class SkillSafe {
    const string Base = "https://api.skillsafe.ai/v1/app-api";
    static readonly HttpClient Http = new();

    public static async Task<JsonElement> Api(HttpMethod method, string path, object? body = null) {
        var token = Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN");
        var req = new HttpRequestMessage(method, Base + path);
        req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
        if (body != null)
            req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
        var doc = JsonDocument.Parse(await (await Http.SendAsync(req)).Content.ReadAsStringAsync());
        if (!doc.RootElement.GetProperty("ok").GetBoolean())
            throw new Exception(doc.RootElement.GetProperty("error").GetProperty("message").GetString());
        return doc.RootElement.GetProperty("data");
    }
}

Step 1 — get a token

The easy way: open the token page in your browser — it shows this device's token, lets you sign in for a personal one, and copies a ready-made export SKILLSAFE_TOKEN="…" line. Fully scripted (no browser), mint a guest token:

curl -s https://api.skillsafe.ai/v1/app-api/guest \
  -H "Content-Type: application/json" \
  -d '{"slug": "illustration-desk"}'
# → {"ok":true,"data":{"token":"aut_...","guest_id":"gst_..."}}
# One unauthenticated POST — no helper needed:
import json, urllib.request
req = urllib.request.Request("https://api.skillsafe.ai/v1/app-api/guest",
    data=json.dumps({"slug": "illustration-desk"}).encode(),
    headers={"Content-Type": "application/json"})
print(json.load(urllib.request.urlopen(req))["data"]["token"])
const res = await fetch("https://api.skillsafe.ai/v1/app-api/guest", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ slug: "illustration-desk" }),
});
console.log((await res.json()).data.token); // aut_...
body := bytes.NewBufferString(`{"slug":"illustration-desk"}`)
res, err := http.Post("https://api.skillsafe.ai/v1/app-api/guest",
    "application/json", body)
// decode → data.token
var req = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/guest"))
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"illustration-desk\"}"))
    .build();
// send, parse JSON → data.token
uri = URI("https://api.skillsafe.ai/v1/app-api/guest")
res = Net::HTTP.post(uri, {slug: "illustration-desk"}.to_json,
                     "Content-Type" => "application/json")
puts JSON.parse(res.body).dig("data", "token")
$res = api("POST", "/guest", ["slug" => "illustration-desk"]);
echo $res["token"];  // aut_... (temporarily call api() with an empty token)
var data = await SkillSafe.Api(HttpMethod.Post, "/guest",
    new { slug = "illustration-desk" });   // works with an empty token
Console.WriteLine(data.GetProperty("token").GetString());
Guest tokens can call /me and /estimate, and can run only if the app sponsors guest usage. For real runs, sign in on the token page and use the personal token it shows.

Step 2 — who am I, and what's my balance?

curl -s $BASE/me -H "Authorization: Bearer $SKILLSAFE_TOKEN"
# → {"ok":true,"data":{"subject_type":"user","subject_id":"...","credits":123456}}
me = api("GET", "/me")
print(me["subject_type"], me["credits"])
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
var me struct {
    SubjectType string `json:"subject_type"`
    Credits     int64  `json:"credits"`
}
_ = api("GET", "/me", nil, &me)
String me = SkillSafe.api("GET", "/me", null);
me = api("get", "/me")
puts "#{me["subject_type"]} #{me["credits"]}"
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"];
var me = await SkillSafe.Api(HttpMethod.Get, "/me");
Console.WriteLine(me.GetProperty("credits").GetInt64());

Credits are ten-thousandths of a US dollar: 10000 credits = $1.00.

Step 3 — the input, and a free estimate

The planner takes one JSON object — the same shape the app's form submits:

FieldTypeMeaning
articlestring, requiredThe full article text. The app clips very long articles in the middle and says so — do the same if yours exceed ~29k chars.
style_idstring, requiredA style slug from the zyncli-template repo, e.g. signal-editorial.
style_namestringIts display name, e.g. Signal Editorial.
style_guidestring, requiredThe COMPLETE markdown of skills/{style_id}/references/style-guide.md from the repo. The app ships all 25 in /styles-data.js (window.STYLES[n].guide); scripts can fetch that file from this origin and reuse it.
image_count"auto" or "1"–"9"How many shots to plan.
notesstringOptional instructions (audience, emphasis, avoid-list).
prescanobjectOptional counts: {words, paragraphs, headings, clipped}.
plan_instructionsstring, requiredThe planner's full instruction set — the exact text served at /planner-prompt.js (window.PLANNER_INSTRUCTIONS). The app's system prompt is a tiny mode router (so image runs stay clean — step 6); without this field the planner replies with an error object instead of a plan.
retry_notestringOptional: sent only on a retry after a malformed reply, stating what was wrong with the previous one.
$modelstringOptional model override, e.g. claude-sonnet-5 — or an image model (step 6).

POST /estimate is free, creates no job, and returns the worst-case hold:

# Grab a style guide + the planner instructions from the app's own bundle, then estimate:
GUIDE=$(curl -s https://illustration-desk.skillsafe.ai/styles-data.js \
  | sed 's/^window.STYLES = //; s/;$//' \
  | jq -r '.[] | select(.id=="signal-editorial") | .guide')
PLAN=$(curl -s https://illustration-desk.skillsafe.ai/planner-prompt.js \
  | grep '^window.PLANNER_INSTRUCTIONS' \
  | sed 's/^window.PLANNER_INSTRUCTIONS = //; s/;$//' | jq -r .)
jq -n --arg article "$(cat article.txt)" --arg guide "$GUIDE" --arg plan "$PLAN" \
  '{article:$article, style_id:"signal-editorial", style_name:"Signal Editorial",
    style_guide:$guide, image_count:"auto", notes:"", plan_instructions:$plan}' > /tmp/input.json
curl -s $BASE/estimate -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" -d @/tmp/input.json
# → data.hold_credits (reserved, not the price), data.model, data.min_credits
import re, urllib.request
raw = urllib.request.urlopen("https://illustration-desk.skillsafe.ai/styles-data.js").read().decode()
styles = json.loads(re.sub(r"^window\.STYLES = |;\s*$", "", raw.split("\n", 1)[1]))
style = next(s for s in styles if s["id"] == "signal-editorial")
praw = urllib.request.urlopen("https://illustration-desk.skillsafe.ai/planner-prompt.js").read().decode()
pline = next(l for l in praw.splitlines() if l.startswith("window.PLANNER_INSTRUCTIONS"))
plan_instructions = json.loads(pline.split("= ", 1)[1].rstrip(";"))

inp = {"article": open("article.txt").read(), "style_id": style["id"],
       "style_name": style["name"], "style_guide": style["guide"],
       "image_count": "auto", "notes": "", "plan_instructions": plan_instructions}
est = api("POST", "/estimate", inp)
print(est["hold_credits"], "reserved worst-case on", est["model"])
const raw = await (await fetch("https://illustration-desk.skillsafe.ai/styles-data.js")).text();
const styles = JSON.parse(raw.slice(raw.indexOf("[")).replace(/;\s*$/, ""));
const style = styles.find(s => s.id === "signal-editorial");
const praw = await (await fetch("https://illustration-desk.skillsafe.ai/planner-prompt.js")).text();
const planInstructions = JSON.parse(praw.slice(praw.indexOf('= "') + 2, praw.lastIndexOf(";")));

const input = { article, style_id: style.id, style_name: style.name,
                style_guide: style.guide, image_count: "auto", notes: "",
                plan_instructions: planInstructions };
const est = await api("POST", "/estimate", input);
console.log(est.hold_credits, "reserved worst-case on", est.model);
// planInstructions: fetch /planner-prompt.js from the app origin and JSON-decode
// the string after `window.PLANNER_INSTRUCTIONS = ` (see the JS tab).
input := map[string]any{
    "article": article, "style_id": "signal-editorial",
    "style_name": "Signal Editorial", "style_guide": guide,
    "image_count": "auto", "notes": "", "plan_instructions": planInstructions,
}
var est struct {
    HoldCredits int64  `json:"hold_credits"`
    Model       string `json:"model"`
}
_ = api("POST", "/estimate", input, &est)
// Include plan_instructions: fetch /planner-prompt.js from the app origin and
// JSON-decode the string after `window.PLANNER_INSTRUCTIONS = `.
String input = buildInputJson(article, styleId, styleGuide, planInstructions);
String est = SkillSafe.api("POST", "/estimate", input);
# plan_instructions: fetch /planner-prompt.js from the app origin and JSON-parse
# the string after `window.PLANNER_INSTRUCTIONS = ` (see the JS tab).
input = {article: File.read("article.txt"), style_id: "signal-editorial",
         style_name: "Signal Editorial", style_guide: guide,
         image_count: "auto", notes: "", plan_instructions: plan_instructions}
est = api("post", "/estimate", input)
puts "#{est["hold_credits"]} reserved worst-case on #{est["model"]}"
// $planInstructions: fetch /planner-prompt.js from the app origin and json_decode
// the string after `window.PLANNER_INSTRUCTIONS = ` (see the JS tab).
$input = ["article" => file_get_contents("article.txt"),
          "style_id" => "signal-editorial", "style_name" => "Signal Editorial",
          "style_guide" => $guide, "image_count" => "auto", "notes" => "",
          "plan_instructions" => $planInstructions];
$est = api("POST", "/estimate", $input);
echo $est["hold_credits"];
// planInstructions: fetch /planner-prompt.js from the app origin and JSON-decode
// the string after `window.PLANNER_INSTRUCTIONS = ` (see the JS tab).
var input = new { article, style_id = "signal-editorial",
                  style_name = "Signal Editorial", style_guide = guide,
                  image_count = "auto", notes = "", plan_instructions = planInstructions };
var est = await SkillSafe.Api(HttpMethod.Post, "/estimate", input);
Console.WriteLine(est.GetProperty("hold_credits").GetInt64());
hold_credits is a reservation priced at the full output cap — the settled charge is usually far lower. If your balance sits between min_credits and hold_credits the run still executes with a reduced cap and may return "truncated": true.

Step 4 — run and poll

JOB=$(curl -s $BASE/run -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: my-article-v1-attempt1" \
  -d @/tmp/input.json | jq -r .data.job_id)

while :; do
  OUT=$(curl -s $BASE/jobs/$JOB -H "Authorization: Bearer $SKILLSAFE_TOKEN")
  STATUS=$(echo "$OUT" | jq -r .data.status)
  [ "$STATUS" = succeeded ] || [ "$STATUS" = failed ] && break
  sleep 2
done
echo "$OUT" | jq -r .data.output.output > plan.json   # the plan (one JSON object)
echo "$OUT" | jq .data.charged_credits
import time
job = api("POST", "/run", inp, headers={"Idempotency-Key": "my-article-v1-attempt1"})
while True:
    j = api("GET", "/jobs/" + job["job_id"])
    if j["status"] in ("succeeded", "failed"):
        break
    time.sleep(2)
plan = json.loads(j["output"]["output"])
print(len(plan["shots"]), "shots,", j["charged_credits"], "credits")
const job = await api("POST", "/run", input, { "Idempotency-Key": "my-article-v1-attempt1" });
let j;
for (;;) {
  j = await api("GET", "/jobs/" + job.job_id);
  if (j.status === "succeeded" || j.status === "failed") break;
  await new Promise(r => setTimeout(r, 2000));
}
const plan = JSON.parse(j.output.output);
console.log(plan.shots.length, "shots,", j.charged_credits, "credits");
var job struct{ JobID string `json:"job_id"` }
_ = api("POST", "/run", input, &job) // add the Idempotency-Key header in your helper
for {
    var j struct {
        Status  string `json:"status"`
        Output  struct{ Output string `json:"output"` } `json:"output"`
        Charged int64  `json:"charged_credits"`
    }
    _ = api("GET", "/jobs/"+job.JobID, nil, &j)
    if j.Status == "succeeded" || j.Status == "failed" {
        break
    }
    time.Sleep(2 * time.Second)
}
String job = SkillSafe.api("POST", "/run", input); // add Idempotency-Key header
// poll GET /jobs/{job_id} every 2s until status is succeeded/failed,
// then JSON-parse data.output.output — that string is the plan object.
job = api("post", "/run", input)
loop do
  @j = api("get", "/jobs/#{job["job_id"]}")
  break if %w[succeeded failed].include?(@j["status"])
  sleep 2
end
plan = JSON.parse(@j.dig("output", "output"))
puts "#{plan["shots"].length} shots"
$job = api("POST", "/run", $input);
do {
    sleep(2);
    $j = api("GET", "/jobs/" . $job["job_id"]);
} while (!in_array($j["status"], ["succeeded", "failed"]));
$plan = json_decode($j["output"]["output"], true);
echo count($plan["shots"]), " shots";
var job = await SkillSafe.Api(HttpMethod.Post, "/run", input);
JsonElement j;
do {
    await Task.Delay(2000);
    j = await SkillSafe.Api(HttpMethod.Get, "/jobs/" + job.GetProperty("job_id").GetString());
} while (j.GetProperty("status").GetString() is not ("succeeded" or "failed"));
var plan = JsonDocument.Parse(j.GetProperty("output").GetProperty("output").GetString()!);
Always send an Idempotency-Key derived from your input plus an attempt counter — a retried request with the same key replays the finished result instead of billing twice.

Step 5 — stream instead of polling

POST /run-stream returns Server-Sent Events: delta events carry output text as it generates, and a final done event carries the full payload (job_id, status, charged_credits, output, truncated).

curl -sN $BASE/run-stream -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: my-article-v1-attempt1" \
  -d @/tmp/input.json
# event: delta   data: {"text":"{\"reading\":..."}
# event: done    data: {"job_id":"...","status":"succeeded","charged_credits":...,"output":{"output":"..."}}
req = urllib.request.Request(BASE + "/run-stream",
    data=json.dumps(inp).encode(),
    headers={"Authorization": "Bearer " + TOKEN,
             "Content-Type": "application/json",
             "Idempotency-Key": "my-article-v1-attempt1"})
with urllib.request.urlopen(req) as r:
    for raw_line in r:
        line = raw_line.decode().strip()
        if line.startswith("data:"):
            print(line[5:].strip()[:80])  # deltas, then the done payload
const res = await fetch(BASE + "/run-stream", {
  method: "POST",
  headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json",
             "Idempotency-Key": "my-article-v1-attempt1" },
  body: JSON.stringify(input),
});
const reader = res.body.getReader();
const dec = new TextDecoder();
for (;;) {
  const { done, value } = await reader.read();
  if (done) break;
  process.stdout.write(dec.decode(value)); // parse SSE frames as they arrive
}
req, _ := http.NewRequest("POST", base+"/run-stream", &buf)
req.Header.Set("Accept", "text/event-stream")
// ...same auth headers; read res.Body line by line, frames split on \n\n
// Use an SSE client (e.g. okhttp-sse). Frames: event name + JSON data line;
// accumulate delta.text, stop on the done event.
# Net::HTTP with a block: read the body in chunks, split frames on "\n\n",
# JSON-parse lines after "data:".
// stream_context_create + fopen on /run-stream; fgets() line by line,
// frames split on blank lines; accumulate delta text, stop on "event: done".
// HttpCompletionOption.ResponseHeadersRead + StreamReader.ReadLineAsync();
// frames split on blank lines, JSON after "data:".

Step 6 — render a shot as an image

Each plan shot carries a standalone prompt. Send it back as a run with an image-model override and you get the finished picture — this is exactly what the app's "Render image" buttons do. The input is {"instruction": shot.prompt, "$model": "gpt-image"} (~25¢/image held; settles lower — the app's own renders have settled from about 1¢). Pricing is per image, not per token. One 1024×1024 image per run; text-to-image only ($files is rejected); /run-stream sends no deltas for image runs — just job then done — so plain /run + poll is simpler.

PROMPT=$(jq -r '.shots[0].prompt' plan.json)
JOB=$(jq -n --arg p "$PROMPT" '{instruction:$p, "$model":"gpt-image"}' \
  | curl -s $BASE/run -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
      -H "Content-Type: application/json" \
      -H "Idempotency-Key: my-article-v1-shot1-gptimage-1" -d @- | jq -r .data.job_id)
# poll as in step 4, then:
curl -s $BASE/jobs/$JOB -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  | jq -r .data.output.images[0].b64 | base64 -d > shot-1.png
import base64
job = api("POST", "/run", {"instruction": plan["shots"][0]["prompt"], "$model": "gpt-image"},
          headers={"Idempotency-Key": "my-article-v1-shot1-gptimage-1"})
while True:
    j = api("GET", "/jobs/" + job["job_id"])
    if j["status"] in ("succeeded", "failed"):
        break
    time.sleep(2)
img = j["output"]["images"][0]          # {"content_type": "image/png", "b64": "..."}
open("shot-1.png", "wb").write(base64.b64decode(img["b64"]))
const job = await api("POST", "/run",
  { instruction: plan.shots[0].prompt, "$model": "gpt-image" },
  { "Idempotency-Key": "my-article-v1-shot1-gptimage-1" });
let j;
for (;;) {
  j = await api("GET", "/jobs/" + job.job_id);
  if (j.status === "succeeded" || j.status === "failed") break;
  await new Promise(r => setTimeout(r, 2000));
}
const img = j.output.images[0];         // in a browser: src = `data:${img.content_type};base64,${img.b64}`
require("fs").writeFileSync("shot-1.png", Buffer.from(img.b64, "base64"));
input := map[string]any{"instruction": prompt, "$model": "gpt-image"}
var job struct{ JobID string `json:"job_id"` }
_ = api("POST", "/run", input, &job) // Idempotency-Key header as in step 4
// poll /jobs/{id} as in step 4, then decode output.images[0].b64 with
// base64.StdEncoding and write the PNG bytes to disk.
// Body: {"instruction": shotPrompt, "$model": "gpt-image"} — poll /jobs/{id}
// as in step 4, then Base64.getDecoder().decode(output.images[0].b64)
// and write the bytes to shot-1.png.
job = api("post", "/run", {instruction: plan["shots"][0]["prompt"], "$model" => "gpt-image"})
# poll as in step 4, then:
img = @j.dig("output", "images", 0)
File.binwrite("shot-1.png", Base64.decode64(img["b64"]))
$job = api("POST", "/run", ["instruction" => $plan["shots"][0]["prompt"], '$model' => "gpt-image"]);
// poll as in step 4, then:
$img = $j["output"]["images"][0];
file_put_contents("shot-1.png", base64_decode($img["b64"]));
var body = new Dictionary<string, object> {
    ["instruction"] = prompt, ["$model"] = "gpt-image" };
var job = await SkillSafe.Api(HttpMethod.Post, "/run", body);
// poll /jobs/{id} as in step 4, then Convert.FromBase64String on
// output.images[0].b64 and File.WriteAllBytes("shot-1.png", bytes).
/estimate accepts the same body and returns the per-image hold before you commit. Renders are square 1024×1024 previews; the prompts themselves are written for 16:9, so re-run them through your production generator for final art.
Stick to gpt-image. The platform also lists a budget flux-klein image model, but this app's runs against it failed consistently server-side ("status": "failed", error as a plain string: “Run failed — the platform could not complete this request”; nothing charged), so the app no longer offers it. A failed run costs nothing, so you can experiment — but gpt-image is the supported path and accepts full-length prompts.

The output contract

output.output is one JSON object — exactly what the app renders:

{
  "reading": {
    "title": "...", "core_argument": "...", "content_type": "...",
    "anchors": [{"idea": "...", "kind": "core argument | contrast | turning point | system relationship | cause and effect | conclusion", "where": "..."}]
  },
  "shots": [{
    "order": 1, "title": "...", "placement": "...", "purpose": "...", "idea": "...",
    "structure": "a composition pattern name from the style guide",
    "composition": "...", "elements": ["..."],
    "labels": ["verbatim short labels from the article, or empty"],
    "prompt": "complete standalone prompt with the style's exact palette hex codes",
    "avoid": "..."
  }],
  "set_notes": {"consistency": "...", "anti_repetition": "..."},
  "skipped": [{"idea": "...", "reason": "..."}]
}

Validate like the app does: every labels entry must appear verbatim in your article (drop any that don't), structure should be one of the style's composition pattern names, and consecutive shots should not repeat a structure. Each prompt is standalone — paste it into any image model as-is.

Styles © their authors, from hahayang888/zyncli-template (MIT). Token management: tokens page.