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| HTTP | code | Meaning |
|---|---|---|
| 400 | VALIDATION_ERROR | Bad input shape — see error.details. |
| 401 | UNAUTHORIZED | Missing/expired token. Mint or refresh one (step 1). |
| 402 | payment_required | Balance below the run's hold. Top up, or call /estimate first. |
| 404 | NOT_FOUND | Wrong path or job id. |
| 429 | RATE_LIMITED | Back off and retry with jitter. |
| 5xx | — | Platform 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());
/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:
| Field | Type | Meaning |
|---|---|---|
article | string, required | The full article text. The app clips very long articles in the middle and says so — do the same if yours exceed ~29k chars. |
style_id | string, required | A style slug from the zyncli-template repo, e.g. signal-editorial. |
style_name | string | Its display name, e.g. Signal Editorial. |
style_guide | string, required | The 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. |
notes | string | Optional instructions (audience, emphasis, avoid-list). |
prescan | object | Optional counts: {words, paragraphs, headings, clipped}. |
plan_instructions | string, required | The 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_note | string | Optional: sent only on a retry after a malformed reply, stating what was wrong with the previous one. |
$model | string | Optional 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()!);
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.
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.