Banner Forge — API

Turn a campaign brief into rendered banners, from your own tools.

API tokens Open the app

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

HTTPcodeWhat it means and what to do
400validation_errorThe 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.
401unauthorizedNo token, a malformed token, or a token that has expired. Mint a new guest token or sign in again.
402payment_requiredThe balance cannot cover this run's minimum. Call /estimate first and compare min_credits against /me's credits.
404not_foundUnknown 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.
409conflictAn Idempotency-Key was reused with a different body. Change the attempt counter in the key when the input changes.
429rate_limitedToo many requests. Back off and retry; do not tight-loop.
500internal_errorTransient. Retry with the same Idempotency-Key so the retry cannot bill twice.
The one call that costs money is /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.

Writing records. The query endpoint is 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.

FieldTypeMeaning
briefstringProduct or event, audience, message, tone. The hard boundary on claims: nothing beyond the brief may appear in the copy.
existingobject{headline, subtext, cta}, any of which may be empty. A draft the model may sharpen but whose meaning it must keep.
brandobject{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.
sizesarray{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_preferencestringauto, or one of minimalist gradient bold-type geometric neon duotone glass editorial. When named, one option must use it.
current_datetimestringThe caller's local time.
prescan.headline_chars, subtext_chars, cta_charsnumberMeasured lengths of the draft copy.
prescan.cta_starts_with_verbbooleanWhether the draft CTA leads with an action verb, or null with no draft.
prescan.smallest_format, has_ad_formatmixedThe most constraining target, and whether any target is an ad format held to the source skill's 20% text-ratio guideline.
prescan.brand_contrastobjectMeasured 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_lintarray{level, id, label} per design rule the engine measured on the current draft; empty with no draft.
prescan.stylesarrayThe eight legal style ids, so a scripted client never guesses.
retry_notestringSend 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.

FieldConstraint
campaign_nameTwo to four words. Used as the record title and the export file name.
summaryTwo or three sentences: what the brief asks for and how the three directions differ.
optionsExactly 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 / ctaAt 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_notesNon-empty array. One observation per copy change, so the user sees what moved and why.
brand_notesNon-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_stepsThree to six concrete actions, in order.
Two prohibitions matter most. No invented claims: the copy may sharpen the brief's message but never extend it — a customer count, an uptime figure or a discount that the brief does not state must not appear, however good it would look on a banner. And contrast is measured, not asserted: the app runs the same lint on the model's palettes as on the user's draft, so a sub-4.5:1 pairing is printed in public next to the render. If you build your own client, keep both checks in your render path.

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.

Reading the module in Node needs only a 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.