Turn a campaign brief into three art directions from your own pipeline
Send a brief — product, audience, message, tone — plus the target formats and
an optional brand pair, and get back one JSON object: a campaign_name, a
summary, and options — exactly three directions in three
different styles, each carrying a six-color palette intended to pass 4.5:1
contrast, sharpened headline / subtext / cta copy,
a layout, a seeded background spec and a font
pairing — plus copy_notes justifying every copy change,
brand_notes and next_steps. The specs are deterministic: the
app's own bannerkit.js renders them to SVG and exact-dimension PNG, and your
pipeline can do the same. Wire it into a CMS to draft launch creative, batch-generate
social cards for a content calendar, or A/B three directions per campaign with the lint
as a quality gate. Every code step below is shown in cURL, Python, JavaScript, Go, Java,
Ruby, PHP and C#; pick a language once and the whole page follows.
Basics
Base URL https://api.skillsafe.ai/v1/app-api, app slug
banner-forge. Every request sends
Authorization: Bearer <token> and JSON bodies with
Content-Type: application/json. Responses are wrapped in an envelope:
{"data": …} on success, {"error": {"code", "message"}}
on failure. Designs are produced by the gpt-terra model alias
(currently gpt-5.6-terra) at a publisher markup of
1000 bps — 10%. Credits are in units of 1/10 000 of a US
dollar, so 10 000 credits is $1.00.
POST /guest GET /me POST /estimate POST /run GET /jobs/{id} POST /run-stream POST /collections/reports/query
Error codes
| HTTP | code | What it means and what to do |
|---|---|---|
400 | validation_error | The body is missing a required field or a field has the wrong type. error.details names it. POST /guest in particular needs slug in the body — an X-App-Slug header is not accepted. |
401 | unauthorized | No token, a malformed token, or a token that has expired. Mint a new guest token or sign in again. |
402 | payment_required | The balance cannot cover this run's minimum. Call /estimate first and compare min_credits against /me's credits. |
404 | not_found | Unknown job id, unknown collection, or a record that belongs to another subject. Guest identities are per-token: a new guest token cannot see the previous guest's records. |
409 | conflict | An Idempotency-Key was reused with a different body. Change the attempt counter in the key when the input changes. |
429 | rate_limited | Too many requests. Back off and retry; do not tight-loop. |
500 | internal_error | Transient. Retry with the same Idempotency-Key so the retry cannot bill twice. |
/run and /run-stream.
/guest, /me and /estimate are free, so a client
can price a run, check the balance and prove the model binding without spending
anything.
Step 1 · Get a token
Two ways in. If you already use the app in a browser, open
the token page and press Copy shell export —
it hands you the exact export SKILLSAFE_TOKEN="…" line, with no DevTools
console involved. For a fully scripted client, POST /guest mints a guest
token with no browser at all. Guest tokens can call /me and the free
/estimate; a personal token is what bills plan runs to your own account.
# Option A — take the token this browser already has: open /tokens.html,
# press "Copy shell export", and paste the line it gives you.
export SKILLSAFE_TOKEN="aut_xxxxxxxxxxxxxxxxxxxx"
# Option B — mint a guest token with no browser at all. Guest tokens can call
# /me and the free /estimate; sign in for a personal token to bill plan runs
# to your own account.
curl -s -X POST https://api.skillsafe.ai/v1/app-api/guest \
-H 'Content-Type: application/json' \
-d '{"slug":"banner-forge"}'
# => {"data":{"token":"aut_...","subject_type":"guest","credits":0}}
import os, json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "banner-forge"
def call(path, body=None, token=None, method=None):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(BASE + path, data=data,
method=method or ("POST" if data else "GET"))
req.add_header("Content-Type", "application/json")
req.add_header("User-Agent", "banner-forge-client/1.0")
if token:
req.add_header("Authorization", "Bearer " + token)
with urllib.request.urlopen(req) as r:
return json.loads(r.read())["data"]
# Option A: the token from /tokens.html, kept in your environment.
token = os.environ.get("SKILLSAFE_TOKEN")
# Option B: a fresh guest token, no browser involved.
if not token:
token = call("/guest", {"slug": SLUG})["token"]
print(token[:12] + "...")
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "banner-forge";
async function call(path, { body, token, method } = {}) {
const res = await fetch(BASE + path, {
method: method || (body ? "POST" : "GET"),
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: "Bearer " + token } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const json = await res.json();
if (json.error) throw Object.assign(new Error(json.error.message), json.error);
return json.data;
}
// Option A: paste the token from /tokens.html (or read it from your own config).
let token = "YOUR_TOKEN";
// Option B: mint a guest token — good for /me and the free /estimate.
if (token === "YOUR_TOKEN") token = (await call("/guest", { body: { slug: SLUG } })).token;
console.log(token.slice(0, 12) + "...");
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
)
const base = "https://api.skillsafe.ai/v1/app-api"
const slug = "banner-forge"
type envelope struct {
Data json.RawMessage `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func call(path, token string, body any, out any) error {
var rdr io.Reader
method := "GET"
if body != nil {
b, _ := json.Marshal(body)
rdr = bytes.NewReader(b)
method = "POST"
}
req, _ := http.NewRequest(method, base+path, rdr)
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
var env envelope
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return err
}
if env.Error != nil {
return errors.New(env.Error.Code + ": " + env.Error.Message)
}
if out != nil {
return json.Unmarshal(env.Data, out)
}
return nil
}
func main() {
token := os.Getenv("SKILLSAFE_TOKEN")
if token == "" {
var guest struct{ Token string `json:"token"` }
if err := call("/guest", "", map[string]string{"slug": slug}, &guest); err != nil {
panic(err)
}
token = guest.Token
}
fmt.Println(token[:12] + "...")
}
import java.net.URI;
import java.net.http.*;
import java.util.Map;
public class CiteReady {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String SLUG = "banner-forge";
static final HttpClient HTTP = HttpClient.newHttpClient();
static String call(String path, String token, String jsonBody) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Content-Type", "application/json");
if (token != null) b.header("Authorization", "Bearer " + token);
b = jsonBody == null ? b.GET()
: b.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
HttpResponse<String> res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
return res.body(); // {"data":...} or {"error":{...}} — parse with your JSON library
}
public static void main(String[] args) throws Exception {
String token = System.getenv("SKILLSAFE_TOKEN");
if (token == null) {
// POST /guest returns {"data":{"token":"aut_..."}}
System.out.println(call("/guest", null, "{\"slug\":\"" + SLUG + "\"}"));
} else {
System.out.println(token.substring(0, 12) + "...");
}
}
}
require "json"
require "net/http"
BASE = URI("https://api.skillsafe.ai/v1/app-api")
SLUG = "banner-forge"
def call(path, body: nil, token: nil, method: nil)
uri = URI(BASE.to_s + path)
req = (method || (body ? "POST" : "GET")) == "POST" ?
Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri)
req["Content-Type"] = "application/json"
req["Authorization"] = "Bearer #{token}" if token
req.body = JSON.generate(body) if body
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
json = JSON.parse(res.body)
raise "#{json['error']['code']}: #{json['error']['message']}" if json["error"]
json["data"]
end
token = ENV["SKILLSAFE_TOKEN"] || call("/guest", body: { slug: SLUG })["token"]
puts token[0, 12] + "..."
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "banner-forge";
function call(string $path, ?array $body = null, ?string $token = null): array {
$headers = ["Content-Type: application/json"];
if ($token) { $headers[] = "Authorization: Bearer " . $token; }
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
$json = json_decode(curl_exec($ch), true);
curl_close($ch);
if (isset($json["error"])) {
throw new RuntimeException($json["error"]["code"] . ": " . $json["error"]["message"]);
}
return $json["data"];
}
$token = getenv("SKILLSAFE_TOKEN") ?: call("/guest", ["slug" => SLUG])["token"];
echo substr($token, 0, 12) . "...\n";
using System;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading.Tasks;
class CiteReady {
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Slug = "banner-forge";
static readonly HttpClient Http = new HttpClient();
static async Task<JsonElement> Call(string path, object body = null, string token = null) {
var req = new HttpRequestMessage(body == null ? HttpMethod.Get : HttpMethod.Post, Base + path);
if (token != null) req.Headers.Add("Authorization", "Bearer " + token);
if (body != null) req.Content = JsonContent.Create(body);
var res = await Http.SendAsync(req);
var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
if (doc.RootElement.TryGetProperty("error", out var err))
throw new Exception(err.GetProperty("code").GetString() + ": " + err.GetProperty("message").GetString());
return doc.RootElement.GetProperty("data");
}
static async Task Main() {
var token = Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN");
if (token == null) {
var guest = await Call("/guest", new { slug = Slug });
token = guest.GetProperty("token").GetString();
}
Console.WriteLine(token.Substring(0, 12) + "...");
}
}
Step 2 · Check who you are and what you can spend
GET /me returns subject_type (user or
guest), subject_id and credits. Compare
credits against /estimate's min_credits
before submitting a run — a 402 after submit is a client bug, not a user
problem.
curl -s https://api.skillsafe.ai/v1/app-api/me \
-H "Authorization: Bearer $SKILLSAFE_TOKEN"
# => {"data":{"subject_type":"user","subject_id":"usr_...","credits":184213}}
#
# subject_type is "user" for a personal token and "guest" for a guest one.
# credits is in credit units: 10 000 credits = $1.00.
me = call("/me", token=token)
print(me["subject_type"], me["credits"], "credits",
"= $%.2f" % (me["credits"] / 10000))
const me = await call("/me", { token });
console.log(me.subject_type, me.credits, "credits =",
"$" + (me.credits / 10000).toFixed(2));
var me struct {
SubjectType string `json:"subject_type"`
SubjectID string `json:"subject_id"`
Credits int64 `json:"credits"`
}
if err := call("/me", token, nil, &me); err != nil {
panic(err)
}
fmt.Printf("%s %d credits = $%.2f\n", me.SubjectType, me.Credits, float64(me.Credits)/10000)
// GET /me — {"data":{"subject_type":"user","credits":184213}}
String me = call("/me", token, null);
System.out.println(me);
me = call("/me", token: token)
puts "#{me['subject_type']} #{me['credits']} credits = $#{'%.2f' % (me['credits'] / 10000.0)}"
$me = call("/me", null, $token);
printf("%s %d credits = $%.2f\n", $me["subject_type"], $me["credits"], $me["credits"] / 10000);
var me = await Call("/me", null, token);
var credits = me.GetProperty("credits").GetInt64();
Console.WriteLine($"{me.GetProperty("subject_type").GetString()} {credits} credits = ${credits / 10000.0:F2}");
Step 3 · Price the run — free, and it proves the model binding
POST /estimate takes the same body as /run, creates no job
and charges nothing. It returns model, model_alias,
markup_bps, hold_credits, min_credits and
sponsor_enabled. Present hold_credits as reserved,
never as the price: the hold covers the full output cap, and the settled
charged_credits is usually far lower.
# /estimate is free: no job is created, no credits are held, nothing is charged.
# Use it to show a price and to prove the model binding before you spend anything.
curl -s -X POST https://api.skillsafe.ai/v1/app-api/estimate \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H 'Content-Type: application/json' \
-d @design-input.json
# => {"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra","markup_bps":1000,
# "hold_credits":3120,"min_credits":260,"sponsor_enabled":false}}
est = call("/estimate", body=design_input, token=token)
print("model", est["model"], "alias", est["model_alias"], "markup", est["markup_bps"])
print("reserved up to $%.4f" % (est["hold_credits"] / 10000))
if me["credits"] < est["min_credits"]:
raise SystemExit("balance below the model minimum — top up before running")
const est = await call("/estimate", { body: designInput, token });
console.log(est.model, est.model_alias, est.markup_bps);
console.log("reserved up to $" + (est.hold_credits / 10000).toFixed(4));
if (me.credits < est.min_credits) throw new Error("balance below the model minimum");
var est struct {
Model string `json:"model"`
ModelAlias string `json:"model_alias"`
MarkupBps int `json:"markup_bps"`
HoldCredits int64 `json:"hold_credits"`
MinCredits int64 `json:"min_credits"`
}
if err := call("/estimate", token, designInput, &est); err != nil {
panic(err)
}
fmt.Printf("%s (%s) markup %d bps, reserve $%.4f\n",
est.Model, est.ModelAlias, est.MarkupBps, float64(est.HoldCredits)/10000)
// POST /estimate with the same body you would send to /run. Free, no job.
String est = call("/estimate", token, designInputJson);
System.out.println(est);
est = call("/estimate", body: design_input, token: token)
puts "#{est['model']} (#{est['model_alias']}) markup #{est['markup_bps']} bps"
puts "reserved up to $#{'%.4f' % (est['hold_credits'] / 10000.0)}"
$est = call("/estimate", $design_input, $token);
printf("%s (%s) markup %d bps, reserve $%.4f\n",
$est["model"], $est["model_alias"], $est["markup_bps"], $est["hold_credits"] / 10000);
var est = await Call("/estimate", designInput, token);
Console.WriteLine(est.GetProperty("model").GetString() + " / " +
est.GetProperty("model_alias").GetString() + " markup " +
est.GetProperty("markup_bps").GetInt32() + " bps");
Step 4 · Run the design pass and poll for it
POST /run returns {"job_id"}; poll
GET /jobs/{id} until status is succeeded or
failed, then read data.output.output — the campaign as a JSON
string. Always send Idempotency-Key, derived from the input
plus an attempt counter: a network blip or a retry after a malformed reply must never
bill the same plan twice. Reuse the key for a retry of the same input; bump the
attempt counter only when the input itself changes.
# Metered. Always send Idempotency-Key: a retry with the same key returns the
# same job instead of billing twice.
KEY="banner-forge:$(shasum -a 256 design-input.json | cut -c1-16):a1"
JOB=$(curl -s -X POST https://api.skillsafe.ai/v1/app-api/run \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H 'Content-Type: application/json' \
-H "Idempotency-Key: $KEY" \
-d @design-input.json | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')
# Poll until terminal.
while true; do
OUT=$(curl -s "https://api.skillsafe.ai/v1/app-api/jobs/$JOB" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN")
STATUS=$(printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["status"])')
[ "$STATUS" = "succeeded" ] || [ "$STATUS" = "failed" ] && break
sleep 2
done
printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["output"]["output"])'
# => the plan, as one JSON object (see the output contract below).
import hashlib, time
def idem_key(inp, attempt=1):
seed = "\u0020".join(str(inp.get(k, "")) for k in
("brief", "style_preference"))
return "banner-forge:%s:a%d" % (hashlib.sha256(seed.encode()).hexdigest()[:16], attempt)
def run_design(inp, token, attempt=1):
data = json.dumps(inp).encode()
req = urllib.request.Request(BASE + "/run", data=data, method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("Authorization", "Bearer " + token)
req.add_header("Idempotency-Key", idem_key(inp, attempt))
with urllib.request.urlopen(req) as r:
job_id = json.loads(r.read())["data"]["job_id"]
while True:
job = call("/jobs/" + job_id, token=token)
if job["status"] in ("succeeded", "failed"):
break
time.sleep(2)
if job["status"] == "failed":
raise RuntimeError(job.get("error") or "run failed")
return json.loads(job["output"]["output"])
plan = run_design(design_input, token)
print(plan["campaign_name"], "-", len(plan["options"]), "directions")
import { createHash } from "node:crypto";
function idemKey(inp, attempt = 1) {
const seed = ["brief", "style_preference"]
.map((k) => String(inp[k] ?? "")).join(" ");
return `banner-forge:${createHash("sha256").update(seed).digest("hex").slice(0, 16)}:a${attempt}`;
}
async function runDesign(inp, token, attempt = 1) {
const res = await fetch(BASE + "/run", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer " + token,
"Idempotency-Key": idemKey(inp, attempt),
},
body: JSON.stringify(inp),
});
const { data, error } = await res.json();
if (error) throw new Error(error.message);
let job;
do {
await new Promise((r) => setTimeout(r, 2000));
job = await call("/jobs/" + data.job_id, { token });
} while (job.status !== "succeeded" && job.status !== "failed");
if (job.status === "failed") throw new Error(job.error || "run failed");
return JSON.parse(job.output.output);
}
const plan = await runDesign(designInput, token);
console.log(plan.campaign_name, "-", plan.options.length + " directions");
import (
"crypto/sha256"
"encoding/hex"
"strings"
"time"
)
func idemKey(inp map[string]any, attempt int) string {
parts := []string{}
for _, k := range []string{"brief", "style_preference"} {
parts = append(parts, fmt.Sprint(inp[k]))
}
sum := sha256.Sum256([]byte(strings.Join(parts, " ")))
return fmt.Sprintf("banner-forge:%s:a%d", hex.EncodeToString(sum[:])[:16], attempt)
}
// POST /run with the Idempotency-Key header, then poll GET /jobs/{id} every two
// seconds until status is "succeeded" or "failed". job.Output.Output holds the
// plan as a JSON string; unmarshal it into your own plan struct.
func runDesign(inp map[string]any, token string) (string, error) {
b, _ := json.Marshal(inp)
req, _ := http.NewRequest("POST", base+"/run", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", idemKey(inp, 1))
res, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer res.Body.Close()
var env envelope
json.NewDecoder(res.Body).Decode(&env)
var started struct{ JobID string `json:"job_id"` }
json.Unmarshal(env.Data, &started)
for {
var job struct {
Status string `json:"status"`
Output struct{ Output string `json:"output"` } `json:"output"`
}
if err := call("/jobs/"+started.JobID, token, nil, &job); err != nil {
return "", err
}
if job.Status == "succeeded" {
return job.Output.Output, nil
}
if job.Status == "failed" {
return "", errors.New("run failed")
}
time.Sleep(2 * time.Second)
}
}
// POST /run must carry Idempotency-Key, derived from the input plus an attempt
// counter, so a network retry cannot bill the plan twice.
String key = "banner-forge:" + sha256Hex(brief + stylePreference).substring(0, 16) + ":a1";
HttpRequest run = HttpRequest.newBuilder(URI.create(BASE + "/run"))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token)
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString(designInputJson))
.build();
String started = HTTP.send(run, HttpResponse.BodyHandlers.ofString()).body();
// started => {"data":{"job_id":"job_..."}}
// then poll GET /jobs/{job_id} until status is succeeded or failed, and read
// data.output.output — the plan JSON as a string.
require "digest"
def idem_key(inp, attempt = 1)
seed = %w[brief style_preference].map { |k| inp[k].to_s }.join(" ")
"banner-forge:#{Digest::SHA256.hexdigest(seed)[0, 16]}:a#{attempt}"
end
def run_design(inp, token)
uri = URI(BASE.to_s + "/run")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req["Authorization"] = "Bearer #{token}"
req["Idempotency-Key"] = idem_key(inp)
req.body = JSON.generate(inp)
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
job_id = JSON.parse(res.body)["data"]["job_id"]
loop do
job = call("/jobs/#{job_id}", token: token)
return JSON.parse(job["output"]["output"]) if job["status"] == "succeeded"
raise "run failed" if job["status"] == "failed"
sleep 2
end
end
plan = run_design(design_input, token)
puts "#{plan['campaign_name']} - #{plan['options'].length} directions"
function idem_key(array $inp, int $attempt = 1): string {
$seed = implode(" ", array_map(fn($k) => (string)($inp[$k] ?? ""),
["brief", "style_preference"]));
return "banner-forge:" . substr(hash("sha256", $seed), 0, 16) . ":a" . $attempt;
}
function run_design(array $inp, string $token): array {
$ch = curl_init(BASE . "/run");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($inp),
CURLOPT_HTTPHEADER => [
"Content-Type: application/json",
"Authorization: Bearer " . $token,
"Idempotency-Key: " . idem_key($inp),
],
]);
$job_id = json_decode(curl_exec($ch), true)["data"]["job_id"];
curl_close($ch);
while (true) {
$job = call("/jobs/" . $job_id, null, $token);
if ($job["status"] === "succeeded") { return json_decode($job["output"]["output"], true); }
if ($job["status"] === "failed") { throw new RuntimeException("run failed"); }
sleep(2);
}
}
$plan = run_design($design_input, $token);
echo $plan["campaign_name"] . " - " . count($plan["options"]) . " directions" . "\n";
using System.Security.Cryptography;
using System.Text;
static string IdemKey(Dictionary<string, object> inp, int attempt = 1) {
var seed = string.Join(" ", new[] { "brief", "style_preference" }
.Select(k => inp.TryGetValue(k, out var v) ? v?.ToString() ?? "" : ""));
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(seed))).ToLowerInvariant();
return $"banner-forge:{hash[..16]}:a{attempt}";
}
var req = new HttpRequestMessage(HttpMethod.Post, Base + "/run") {
Content = JsonContent.Create(designInput)
};
req.Headers.Add("Authorization", "Bearer " + token);
req.Headers.Add("Idempotency-Key", IdemKey(designInput));
var started = JsonDocument.Parse(await (await Http.SendAsync(req)).Content.ReadAsStringAsync());
var jobId = started.RootElement.GetProperty("data").GetProperty("job_id").GetString();
// Poll GET /jobs/{jobId} every two seconds; on "succeeded", data.output.output is
// the plan as a JSON string.
Step 5 · Or stream it
POST /run-stream is the same call over server-sent events, which is what
the web app uses so it can show progress. The frame name arrives on the
event: line — job, delta, done — and
is not a type field inside the payload. Concatenate every
delta payload's text to rebuild the JSON, and read
charged_credits and truncated from the done frame.
If truncated is true the output cap was reduced to fit the balance: render
what parsed and tell the user, rather than presenting a clipped plan as complete.
# Server-sent events. Frame names arrive on the `event:` line, not as a field in
# the payload — `delta` carries text chunks, `job` the job id, `done` the
# settlement (charged_credits, truncated).
curl -N -X POST https://api.skillsafe.ai/v1/app-api/run-stream \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H 'Content-Type: application/json' \
-H "Idempotency-Key: $KEY" \
-d @design-input.json
# event: job
# data: {"job_id":"job_..."}
# event: delta
# data: {"text":"{\"campaign_name\":\"Calm Con"}
# ...
# event: done
# data: {"status":"succeeded","charged_credits":812,"truncated":false}
def run_stream(inp, token, attempt=1, on_delta=None):
data = json.dumps(inp).encode()
req = urllib.request.Request(BASE + "/run-stream", data=data, method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("Authorization", "Bearer " + token)
req.add_header("Idempotency-Key", idem_key(inp, attempt))
raw, event = "", None
with urllib.request.urlopen(req) as r:
for line in r:
line = line.decode().rstrip("\n")
if line.startswith("event:"):
event = line[6:].strip()
elif line.startswith("data:"):
payload = json.loads(line[5:].strip() or "{}")
if event == "delta":
raw += payload.get("text", "")
if on_delta:
on_delta(payload.get("text", ""))
elif event == "done":
return json.loads(raw), payload
raise RuntimeError("stream ended without a done frame")
plan, settle = run_stream(design_input, token)
print(plan["campaign_name"], "charged", settle["charged_credits"])
async function runStream(inp, token, onDelta, attempt = 1) {
const res = await fetch(BASE + "/run-stream", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer " + token,
"Idempotency-Key": idemKey(inp, attempt),
},
body: JSON.stringify(inp),
});
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "", raw = "", event = null;
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop();
for (const line of lines) {
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) {
const payload = JSON.parse(line.slice(5).trim() || "{}");
if (event === "delta") { raw += payload.text || ""; onDelta?.(payload.text || ""); }
else if (event === "done") return { plan: JSON.parse(raw), settle: payload };
}
}
}
throw new Error("stream ended without a done frame");
}
const { plan, settle } = await runStream(designInput, token, (t) => process.stdout.write(t));
console.log("\n", plan.campaign_name, "charged", settle.charged_credits);
// POST /run-stream and read the SSE frames. The frame name is on the `event:`
// line; `delta` payloads carry {"text":"..."} and concatenate into the plan JSON.
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", idemKey(inp, 1))
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 1<<20), 1<<20)
var raw strings.Builder
event := ""
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event:"):
event = strings.TrimSpace(line[6:])
case strings.HasPrefix(line, "data:"):
payload := strings.TrimSpace(line[5:])
if event == "delta" {
var d struct{ Text string `json:"text"` }
json.Unmarshal([]byte(payload), &d)
raw.WriteString(d.Text)
} else if event == "done" {
fmt.Println("settled:", payload)
fmt.Println("plan:", raw.String())
return
}
}
}
// POST /run-stream with BodyHandlers.ofLines() and fold the SSE frames yourself.
HttpRequest stream = HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token)
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString(designInputJson))
.build();
StringBuilder raw = new StringBuilder();
String[] event = { "" };
HTTP.send(stream, HttpResponse.BodyHandlers.ofLines()).body().forEach(line -> {
if (line.startsWith("event:")) {
event[0] = line.substring(6).trim();
} else if (line.startsWith("data:") && event[0].equals("delta")) {
// parse {"text":"..."} with your JSON library and append it
raw.append(extractText(line.substring(5).trim()));
}
});
System.out.println(raw); // the plan JSON
def run_stream(inp, token, attempt = 1)
uri = URI(BASE.to_s + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req["Authorization"] = "Bearer #{token}"
req["Idempotency-Key"] = idem_key(inp, attempt)
req.body = JSON.generate(inp)
raw = ""
event = nil
settle = nil
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
line = line.chomp
if line.start_with?("event:")
event = line[6..].strip
elsif line.start_with?("data:")
payload = JSON.parse(line[5..].strip.empty? ? "{}" : line[5..].strip)
raw << payload.fetch("text", "") if event == "delta"
settle = payload if event == "done"
end
end
end
end
end
[JSON.parse(raw), settle]
end
plan, settle = run_stream(design_input, token)
puts "#{plan['campaign_name']} charged #{settle['charged_credits']}"
// POST /run-stream with a write callback; the frame name arrives on `event:`.
$raw = "";
$event = "";
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($design_input),
CURLOPT_HTTPHEADER => [
"Content-Type: application/json",
"Authorization: Bearer " . $token,
"Idempotency-Key: " . idem_key($design_input),
],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$raw, &$event) {
foreach (explode("\n", $chunk) as $line) {
$line = rtrim($line);
if (str_starts_with($line, "event:")) {
$event = trim(substr($line, 6));
} elseif (str_starts_with($line, "data:") && $event === "delta") {
$payload = json_decode(trim(substr($line, 5)), true) ?: [];
$raw .= $payload["text"] ?? "";
}
}
return strlen($chunk);
},
]);
curl_exec($ch);
curl_close($ch);
$plan = json_decode($raw, true);
echo $plan["campaign_name"] . "\n";
var sreq = new HttpRequestMessage(HttpMethod.Post, Base + "/run-stream") {
Content = JsonContent.Create(designInput)
};
sreq.Headers.Add("Authorization", "Bearer " + token);
sreq.Headers.Add("Idempotency-Key", IdemKey(designInput));
using var sres = await Http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await sres.Content.ReadAsStreamAsync());
var raw = new StringBuilder();
string? evt = null, line;
while ((line = await reader.ReadLineAsync()) != null) {
if (line.StartsWith("event:")) {
evt = line[6..].Trim();
} else if (line.StartsWith("data:")) {
var payload = JsonDocument.Parse(line[5..].Trim() is { Length: > 0 } s ? s : "{}");
if (evt == "delta" && payload.RootElement.TryGetProperty("text", out var t))
raw.Append(t.GetString());
else if (evt == "done")
Console.WriteLine("settled: " + payload.RootElement);
}
}
Console.WriteLine(raw.ToString());
Step 6 · Read the campaign history — and search it by meaning
Past campaigns are stored in a declared collection named banners, with
title, brief, headline, style,
platform, option_count and ran_at as indexed
fields, and title, brief and headline as the
embedded (vector-searchable) ones. Every where entry must be an operator
object ({"eq": …}); a bare value is rejected. Operators:
eq ne lt lte gt gte in contains. Records are scoped to the calling subject,
and each POST /guest mints a new guest identity, so reuse one token
across writes and reads. The full model result and the editor draft ride along as
undeclared keys — stored and returned intact, just not filterable.
POST /collections/banners/query, but the record CRUD paths sit under
/records and wrap the document in a doc envelope:
POST /collections/banners/records with
{"doc": {…}} → {"data":{"record":{"record_id":"rec_…"}}}
GET /collections/banners/records/{record_id} ·
PUT /collections/banners/records/{record_id} ·
DELETE /collections/banners/records/{record_id}
Semantic search is
POST /collections/banners/similar with
{"text": "the neon one for the game studio", "limit": 8} — each hit
carries a cosine score. It is rate-limited to 30 requests/minute per IP and
costs roughly ten times a filtered query, so debounce it and prefer where
whenever an exact match would do. Indexing is asynchronous and only records written
after the collection was declared are searchable.
# Filtered query: the last duotone campaigns, newest first.
curl -s -X POST https://api.skillsafe.ai/v1/app-api/collections/banners/query \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"where":{"style":{"contains":"duotone"}},
"sort":{"field":"ran_at","dir":"desc"},"limit":20}'
# Semantic search over title + brief + headline (30/min per IP; ~10x a query):
curl -s -X POST https://api.skillsafe.ai/v1/app-api/collections/banners/similar \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"text":"the neon direction for the game studio","limit":8}'
res = call("/collections/banners/query", body={
"where": {"option_count": {"gte": 3}},
"sort": {"field": "ran_at", "dir": "desc"},
"limit": 24,
}, token=token)
for rec in res["records"]:
d = rec["doc"]
print(d["ran_at"], d["title"], "-", d["style"], "-", d["platform"])
hits = call("/collections/banners/similar",
body={"text": "the neon direction for the game studio", "limit": 8},
token=token)
for rec in hits["records"]:
print("%.2f" % rec.get("score", 0), rec["doc"]["title"])
const res = await call("/collections/banners/query", {
token,
body: {
where: { option_count: { gte: 3 } },
sort: { field: "ran_at", dir: "desc" },
limit: 24,
},
});
for (const rec of res.records) {
const d = rec.doc;
console.log(d.ran_at, d.title, "-", d.style, "-", d.platform);
}
const hits = await call("/collections/banners/similar", {
token,
body: { text: "the neon direction for the game studio", limit: 8 },
});
for (const rec of hits.records) console.log(rec.score, rec.doc.title);
// POST /collections/banners/query with an operator object per where field.
query := map[string]any{
"where": map[string]any{"option_count": map[string]any{"gte": 3}},
"sort": map[string]string{"field": "ran_at", "dir": "desc"},
"limit": 24,
}
var res struct {
Records []struct {
RecordID string `json:"record_id"`
Doc map[string]any `json:"doc"`
} `json:"records"`
}
if err := call("/collections/banners/query", token, query, &res); err != nil {
panic(err)
}
for _, r := range res.Records {
fmt.Println(r.Doc["ran_at"], r.Doc["title"], r.Doc["style"])
}
// POST /collections/banners/query
String q = "{\"where\":{\"option_count\":{\"gte\":3}}," +
"\"sort\":{\"field\":\"ran_at\",\"dir\":\"desc\"},\"limit\":24}";
System.out.println(call("/collections/banners/query", token, q));
// Semantic search: POST /collections/banners/similar {"text":"...","limit":8}
res = call("/collections/banners/query", body: {
"where" => { "option_count" => { "gte" => 3 } },
"sort" => { "field" => "ran_at", "dir" => "desc" },
"limit" => 24,
}, token: token)
res["records"].each do |rec|
d = rec["doc"]
puts "#{d['ran_at']} #{d['title']} - #{d['style']} - #{d['platform']}"
end
$res = call("/collections/banners/query", [
"where" => ["option_count" => ["gte" => 3]],
"sort" => ["field" => "ran_at", "dir" => "desc"],
"limit" => 24,
], $token);
foreach ($res["records"] as $rec) {
$d = $rec["doc"];
echo "{$d['ran_at']} {$d['title']} - {$d['style']}\n";
}
var q = new {
where = new { option_count = new { gte = 3 } },
sort = new { field = "ran_at", dir = "desc" },
limit = 24
};
var res = await Call("/collections/banners/query", q, token);
foreach (var rec in res.GetProperty("records").EnumerateArray()) {
var d = rec.GetProperty("doc");
Console.WriteLine($"{d.GetProperty("ran_at")} {d.GetProperty("title")}");
}
The input schema
These are the exact fields the app submits. The logo never travels — it is read
locally and composited into the exports client-side; the model designs around a logo
slot without ever seeing the file. prescan is the browser engine's
deterministic read of the draft — measured character counts and WCAG contrast
ratios — and it is what the reply is held to: a palette that cannot reach 4.5:1 is
flagged next to the rendered banner. A client that computes no prescan may send an empty
object; the design still works, it simply has nothing to be reconciled against.
| Field | Type | Meaning |
|---|---|---|
brief | string | Product or event, audience, message, tone. The hard boundary on claims: nothing beyond the brief may appear in the copy. |
existing | object | {headline, subtext, cta}, any of which may be empty. A draft the model may sharpen but whose meaning it must keep. |
brand | object | {primary, accent} as #rrggbb strings, either may be empty. When present, at least one option must be built around them and brand_notes must explain how. |
sizes | array | {id, label, w, h, kind} per target format; kind is social, ad or web. Small ad formats drop the subtext and dictate headline length. Built-in ids: x-header fb-cover li-banner li-sponsored yt-channel ig-post ig-portrait ig-story gdn-mpu gdn-leader gdn-sky gdn-mobile gdn-billboard gdn-large-rect hero og-card, plus custom for caller-supplied dimensions. |
style_preference | string | auto, or one of minimalist gradient bold-type geometric neon duotone glass editorial. When named, one option must use it. |
current_datetime | string | The caller's local time. |
prescan.headline_chars, subtext_chars, cta_chars | number | Measured lengths of the draft copy. |
prescan.cta_starts_with_verb | boolean | Whether the draft CTA leads with an action verb, or null with no draft. |
prescan.smallest_format, has_ad_format | mixed | The most constraining target, and whether any target is an ad format held to the source skill's 20% text-ratio guideline. |
prescan.brand_contrast | object | Measured WCAG ratios: {primary_on_white, primary_on_black, accent_on_white, accent_on_black, pair}, or null with no brand. A pair below 4.5 cannot be a text color and the reply is expected to say so. |
prescan.draft_lint | array | {level, id, label} per design rule the engine measured on the current draft; empty with no draft. |
prescan.styles | array | The eight legal style ids, so a scripted client never guesses. |
retry_note | string | Send only when re-asking after a malformed reply, with the same idempotency key seed and a bumped attempt counter. |
A complete body
{
"brief": "PulseBoard is an incident-response dashboard for on-call engineering teams. We are launching self-serve pricing this month. Audience: engineering managers and SREs at 50-500 person software companies. Message: every alert, runbook and timeline in one place, set up in under ten minutes. Tone: calm, competent, no hype. We can claim a 10-minute setup and a free 14-day trial; do not invent customer counts or uptime numbers.",
"existing": { "headline": "", "subtext": "", "cta": "" },
"brand": { "primary": "#1d4ed8", "accent": "#f59e0b" },
"sizes": [
{ "id": "x-header", "label": "X / Twitter header", "w": 1500, "h": 500, "kind": "social" },
{ "id": "li-banner", "label": "LinkedIn banner", "w": 1584, "h": 396, "kind": "social" },
{ "id": "og-card", "label": "Social share card", "w": 1200, "h": 630, "kind": "social" }
],
"style_preference": "auto",
"current_datetime": "2026-08-07T09:30:00+08:00 (Friday)",
"prescan": {
"headline_chars": 0,
"subtext_chars": 0,
"cta_chars": 0,
"cta_starts_with_verb": null,
"smallest_format": "og-card",
"has_ad_format": false,
"brand_contrast": {
"primary_on_white": 5.53, "primary_on_black": 3.42,
"accent_on_white": 2.15, "accent_on_black": 8.82, "pair": 2.57
},
"draft_lint": [],
"styles": ["minimalist", "gradient", "bold-type", "geometric", "neon", "duotone", "glass", "editorial"]
}
}
The output contract
The reply is one JSON object and nothing else. Parse defensively
anyway: strip a stray code fence, take the span from the first { to the
last }, and re-ask once with a retry_note and the same
idempotency seed if it does not parse. These are the fields the app's own render
path requires, and the constraints it enforces.
| Field | Constraint |
|---|---|
campaign_name | Two to four words. Used as the record title and the export file name. |
summary | Two or three sentences: what the brief asks for and how the three directions differ. |
options | Exactly three entries, three different style values from the eight legal ids — a repeated style is a hard rejection, as is a missing headline or a palette value that is not #rrggbb hex. Each entry: {name, style, rationale, palette, headline, subtext, cta, layout, background, font, per_size_notes}. |
options[].palette | {bg, bg2, fg, accent, cta_bg, cta_fg}, all hex. The engine measures fg-on-bg and cta_fg-on-cta_bg as WCAG ratios and prints any result under 4.5:1 next to the rendered banner. |
options[].headline / subtext / cta | At most 80 / 140 / 24 characters; the app clips to whole words. The CTA must lead with an action verb — the engine checks against its verb list and flags a miss. |
options[].layout | {align, text_scale}: left or center, and an integer 80–120 (percent of the engine's computed type size; out-of-range values are clamped). |
options[].background | {seed, density, angle, motif}: integer 1–999999 (vary it per option), 0–100, 0–359, and one of dots lines blobs grid waves rings auto. The same seed always renders the same background — specs are reproducible. |
options[].font | {heading, body} from sans serif mono — at most two families, enforced by construction. |
copy_notes | Non-empty array. One observation per copy change, so the user sees what moved and why. |
brand_notes | Non-empty whenever brand was supplied. If the brand pair cannot reach 4.5:1 as text, this is where the reply says so and names the non-text role it used instead. |
next_steps | Three to six concrete actions, in order. |
The free lane is client-side, and you can have it too
The rendering engine ships with the app as
bannerkit.js and calls no network: the sixteen-format
platform size table with per-format safe areas (YouTube's 1546×423 TV-safe box, the X
header's avatar reserve, the LinkedIn logo cutout, Instagram story chrome), the eight styles
as seeded procedural SVG generators, the shared layout solver (safe zones, adaptive type
fitting with per-format modes, CTA pill, logo slot), the design-rule lint (WCAG contrast
measured against both grounds a gradient, glass or duotone style paints, action-verb CTA,
two-font limit, minimum type sizes, the 20% ad text-ratio estimate measured from the type
ink), a deterministic contrast repair, a dependency-free STORE-method ZIP writer, and
exact-dimension PNG export via canvas. It exposes window.BannerKit.renderSVG(spec, size, assets),
lint(spec, sizes, hasLogo), layout(spec, size, hasLogo),
safeBox(size), contrast(hexA, hexB),
fixContrast(fg, bg, target), defaultSpec(style, seed),
makeZip(entries) and pngFromSvg(svg, w, h). A pipeline can render every direction the model
returns — or skip the model entirely and render its own specs — without
spending anything.
window stub
(global.window = {}); rendering and lint are pure string/number work with no
I/O. Only pngFromSvg needs a browser (canvas). The same spec always renders
the same SVG, so a CI job can diff creative changes byte-for-byte.