Anime Art Generator draws original anime illustrations and writes shot-by-shot storyboards. The web app is a thin front end over a public HTTPS API, so anything you can click you can also call. This page is the whole contract: the two request shapes, the response envelope, the polling and streaming mechanics, and the vocabulary the storyboard lane speaks.
Base URL:
https://api.skillsafe.ai/v1/app-api
Authentication is one header:
Authorization: Bearer YOUR_TOKEN
The token is an app-scoped credential that begins with aut_. There is no
app-slug header and no slug in the path — the token itself says which app you are
calling, which is why a token minted for another app will authenticate and then behave nothing
like this one. Grab yours from tokens.html.
The envelope
Every response body, at every status code, is the same two-branch envelope. Read
data; never read fields off the top level.
{"ok": true, "data": { ... }}
{"ok": false, "error": {"code": "...", "message": "...", "details": { ... }}}
Check ok before you touch anything else. A failed run and a
transport failure look different: the first is a well-formed envelope with
ok: false, the second is not JSON at all. The helper in step 1 handles both.
Endpoints
| Method and path | Costs credits | What it is for |
|---|---|---|
GET /me | No | Who the token belongs to, and the balance. |
POST /estimate | No | The hold that a run of this shape would reserve. |
POST /run | Yes | Starts a job, returns job_id immediately. |
GET /jobs/{job_id} | No | Poll a job to a terminal state. |
POST /run-stream | Yes | Runs and streams text back as SSE. |
0. Two lanes, two body shapes
This is the one thing to get right before anything else. Anime Art Generator serves two completely
different jobs behind a single /run endpoint, and the shape of the body you post
decides which one you get. Post the wrong shape and the API does not stop you: you get
200 OK, a charged job, and a result that is quietly the wrong kind of thing.
| Lane | Body you send | Where the answer is |
|---|---|---|
| illustration an image run |
instruction — the compiled brief$model — "gpt-image"and nothing else |
data.output.images[0].b64data.output.images[0].content_typedata.output.output is empty |
| storyboard a text run, streamable |
task: "storyboard", premise, count, register, pace, board_instructions |
data.output.output — one JSON objector SSE deltas from /run-stream |
The illustration body is exactly two keys. There is no task
field on that lane and no room for one. Everything you put in the body of an image run is
concatenated into the single block of text the renderer receives, and the renderer paints text
it is given. Add "task": "illustration" or "count": 1 or a stray
"style" and you will get a picture with the words task illustration
lettered across it. Two keys. Nothing else.
The $model override is what makes it an image run at all.
Drop it and the request goes to the app's text model, which will happily write you several
paragraphs describing the illustration you wanted. It also changes how the run is priced:
per image instead of per token.
One run is one picture. There is no batch parameter. Six illustrations are
six calls to /run, six holds, and six jobs to poll.
# Lane 1 - illustration. Exactly two keys, and BRIEF is the compiled brief from step 5.
cat > image-body.json <<'JSON'
{"instruction": "Original anime illustration of an invented character design ...", "$model": "gpt-image"}
JSON
# Lane 2 - storyboard. A task router plus its parameters.
cat > board-body.json <<'JSON'
{"task": "storyboard",
"premise": "A night courier finds the market arcade already shuttered.",
"count": 5,
"register": "ova90s",
"pace": "quiet",
"board_instructions": "...the full text served at /board-prompt.js..."}
JSON
# BRIEF and BOARD_PROMPT are built in steps 5 and 6.
# Lane 1 - illustration. Exactly two keys. Every extra key is painted into the picture.
IMAGE_BODY = {
"instruction": BRIEF,
"$model": "gpt-image",
}
# Lane 2 - storyboard. A task router plus its parameters.
BOARD_BODY = {
"task": "storyboard",
"premise": "A night courier finds the market arcade already shuttered.",
"count": 5,
"register": "ova90s",
"pace": "quiet",
"board_instructions": BOARD_PROMPT,
}
// BRIEF and BOARD_PROMPT are built in steps 5 and 6.
// Lane 1 - illustration. Exactly two keys. Every extra key is painted into the picture.
const IMAGE_BODY = {
instruction: BRIEF,
$model: "gpt-image",
};
// Lane 2 - storyboard. A task router plus its parameters.
const BOARD_BODY = {
task: "storyboard",
premise: "A night courier finds the market arcade already shuttered.",
count: 5,
register: "ova90s",
pace: "quiet",
board_instructions: BOARD_PROMPT,
};
// BRIEF and BoardPrompt are built in steps 5 and 6.
// $model is not a legal Go field name, so both bodies are maps, not structs.
// Lane 1 - illustration. Exactly two keys. Every extra key is painted into the picture.
imageBody := map[string]any{
"instruction": BRIEF,
"$model": "gpt-image",
}
// Lane 2 - storyboard. A task router plus its parameters.
boardBody := map[string]any{
"task": "storyboard",
"premise": "A night courier finds the market arcade already shuttered.",
"count": 5,
"register": "ova90s",
"pace": "quiet",
"board_instructions": BoardPrompt,
}
// BRIEF and BOARD_PROMPT are built in steps 5 and 6.
// The JDK ships no JSON writer, so these are assembled as strings here;
// in real code use Jackson or Gson and keep the key names exactly as shown.
// Lane 1 - illustration. Exactly two keys. Every extra key is painted into the picture.
String imageBody = "{\"instruction\": " + jsonString(BRIEF) + ", \"$model\": \"gpt-image\"}";
// Lane 2 - storyboard. A task router plus its parameters.
String boardBody = "{"
+ "\"task\": \"storyboard\","
+ "\"premise\": \"A night courier finds the market arcade already shuttered.\","
+ "\"count\": 5,"
+ "\"register\": \"ova90s\","
+ "\"pace\": \"quiet\","
+ "\"board_instructions\": " + jsonString(BOARD_PROMPT)
+ "}";
# BRIEF and BOARD_PROMPT are built in steps 5 and 6.
# Quote the '$model' key with single quotes so nothing tries to interpolate it.
# Lane 1 - illustration. Exactly two keys. Every extra key is painted into the picture.
IMAGE_BODY = {
'instruction' => BRIEF,
'$model' => 'gpt-image'
}
# Lane 2 - storyboard. A task router plus its parameters.
BOARD_BODY = {
'task' => 'storyboard',
'premise' => 'A night courier finds the market arcade already shuttered.',
'count' => 5,
'register' => 'ova90s',
'pace' => 'quiet',
'board_instructions' => BOARD_PROMPT
}
<?php
// $BRIEF and $BOARD_PROMPT are built in steps 5 and 6.
// Single-quote the '$model' key. In a double-quoted string PHP would try to
// interpolate a variable named $model and send you an empty key.
// Lane 1 - illustration. Exactly two keys. Every extra key is painted into the picture.
$imageBody = [
'instruction' => $BRIEF,
'$model' => 'gpt-image',
];
// Lane 2 - storyboard. A task router plus its parameters.
$boardBody = [
'task' => 'storyboard',
'premise' => 'A night courier finds the market arcade already shuttered.',
'count' => 5,
'register' => 'ova90s',
'pace' => 'quiet',
'board_instructions' => $BOARD_PROMPT,
];
// Brief and BoardPrompt are built in steps 5 and 6.
// $model is not a legal C# member name, so use a dictionary rather than an
// anonymous object.
// Lane 1 - illustration. Exactly two keys. Every extra key is painted into the picture.
var imageBody = new Dictionary<string, object> {
["instruction"] = Brief,
["$model"] = "gpt-image",
};
// Lane 2 - storyboard. A task router plus its parameters.
var boardBody = new Dictionary<string, object> {
["task"] = "storyboard",
["premise"] = "A night courier finds the market arcade already shuttered.",
["count"] = 5,
["register"] = "ova90s",
["pace"] = "quiet",
["board_instructions"] = BoardPrompt,
};
Content policy
Anime Art Generator refuses three things. In the browser the refusal happens client-side, before a single credit is reserved — which means that as an API caller you are standing on the other side of that guard. Nothing about that makes the rules optional. The same three rules are written into the app's system prompt and enforced independently on every run, so a request that the web app would have blocked will instead be refused after you have paid for the attempt.
- No sexualised or suggestive imagery, at any apparent age, in any register. Chibi and comedic registers are not an exemption; they are just registers.
- Nothing suggestive, romantic or revealing involving a character who reads as a minor. How a character reads is what matters, not what a prompt asserts about them.
- Invented characters only. No likeness of a real person, living or dead, and no existing copyrighted character. Anime Art Generator is for designs that did not exist before the run.
Every brief the app compiles opens with a fixed, non-removable clause asking for an invented
character design in a clothed, wholesome scene, and closes with a negative clause that keeps
text, watermarks and duplicated limbs out of the frame. Both are visible in the worked example
in step 5. If you compile your own instruction rather than reusing the app's,
carry an equivalent opening clause. It is not decoration; it is the sentence
that keeps an ambiguous subject description from resolving somewhere you did not intend.
On the storyboard lane, a request the model declines does not fail the run. It comes back in
the refusals array with what was asked and why, and the remaining panels are
written normally.
1. A tiny client
Three things repeat on every call: the base URL, the bearer header, and unwrapping the
envelope. Write them once. The helper below takes a method and a path, sends JSON, raises when
ok is false, and returns data — every later step assumes it
exists and calls it call.
Give the error object a real home while you are at it. error.code is the field
you branch on; error.message is for humans; error.details carries
the useful specifics, such as the min_credits you fell short of.
# Save as af.sh and source it. Every later snippet uses af_call.
ANIME_FORGE_TOKEN="YOUR_TOKEN"
AF_BASE="https://api.skillsafe.ai/v1/app-api"
af_call() { # af_call METHOD PATH [BODY_FILE]
method="$1"; path="$2"; body_file="$3"
if [ -n "$body_file" ]; then
curl -sS -X "$method" "$AF_BASE$path" \
-H "Authorization: Bearer $ANIME_FORGE_TOKEN" \
-H "Content-Type: application/json" \
--data-binary "@$body_file"
else
curl -sS -X "$method" "$AF_BASE$path" \
-H "Authorization: Bearer $ANIME_FORGE_TOKEN"
fi
}
# Unwrap with jq. .ok is the gate; everything you want is under .data.
af_data() { jq -e 'if .ok then .data else error("app-api: " + .error.code + ": " + .error.message) end'; }
af_call GET /me | af_data
import base64
import json
import time
import requests
TOKEN = "YOUR_TOKEN" # from https://anime-art-generator.skillsafe.ai/tokens.html
BASE = "https://api.skillsafe.ai/v1/app-api"
class AppError(RuntimeError):
def __init__(self, err):
self.code = err.get("code", "unknown")
self.details = err.get("details") or {}
super().__init__("{0}: {1}".format(self.code, err.get("message", "")))
def call(method, path, body=None, headers=None):
"""POST/GET the app API and return the unwrapped `data` object."""
hdrs = {"Authorization": "Bearer " + TOKEN, "Content-Type": "application/json"}
if headers:
hdrs.update(headers)
res = requests.request(method, BASE + path, json=body, headers=hdrs, timeout=300)
try:
env = res.json()
except ValueError:
raise AppError({"code": "transport", "message": "HTTP %d: %s" % (res.status_code, res.text[:200])})
if not env.get("ok"):
raise AppError(env.get("error") or {"code": "unknown", "message": res.text[:200]})
return env["data"]
const TOKEN = "YOUR_TOKEN"; // from https://anime-art-generator.skillsafe.ai/tokens.html
const BASE = "https://api.skillsafe.ai/v1/app-api";
class AppError extends Error {
constructor(err) {
super(`${err.code}: ${err.message}`);
this.name = "AppError";
this.code = err.code;
this.details = err.details || {};
}
}
/** Calls the app API and resolves with the unwrapped `data` object. */
async function call(method, path, body, headers = {}) {
const res = await fetch(BASE + path, {
method,
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
...headers,
},
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await res.text();
let env;
try {
env = JSON.parse(text);
} catch {
throw new AppError({ code: "transport", message: `HTTP ${res.status}: ${text.slice(0, 200)}` });
}
if (!env.ok) throw new AppError(env.error || { code: "unknown", message: text.slice(0, 200) });
return env.data;
}
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"time"
)
const (
Token = "YOUR_TOKEN" // from https://anime-art-generator.skillsafe.ai/tokens.html
Base = "https://api.skillsafe.ai/v1/app-api"
)
var client = &http.Client{Timeout: 300 * time.Second}
type apiError struct {
Code string `json:"code"`
Message string `json:"message"`
Details json.RawMessage `json:"details"`
}
func (e *apiError) Error() string { return e.Code + ": " + e.Message }
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error *apiError `json:"error"`
}
// call sends JSON and returns the raw `data` object for the caller to unmarshal.
func call(method, path string, body any, extra map[string]string) (json.RawMessage, error) {
var payload []byte
if body != nil {
var err error
if payload, err = json.Marshal(body); err != nil {
return nil, err
}
}
req, err := http.NewRequest(method, Base+path, bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+Token)
req.Header.Set("Content-Type", "application/json")
for k, v := range extra {
req.Header.Set(k, v)
}
res, err := client.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var env envelope
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return nil, fmt.Errorf("transport: HTTP %d: %w", res.StatusCode, err)
}
if !env.OK {
if env.Error == nil {
return nil, fmt.Errorf("unknown: HTTP %d", res.StatusCode)
}
return nil, env.Error
}
return env.Data, nil
}
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Map;
public class AnimeArtGenerator {
static final String TOKEN = "YOUR_TOKEN"; // from https://anime-art-generator.skillsafe.ai/tokens.html
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final HttpClient HTTP = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(20))
.build();
static class AppException extends RuntimeException {
AppException(String body) { super("app-api: " + body); }
}
/**
* Sends JSON and returns the raw envelope. The JDK has no JSON parser, so hand
* the result to Jackson or Gson and read the `data` member from it.
*/
static String call(String method, String path, String jsonBody, Map<String, String> extra)
throws Exception {
HttpRequest.BodyPublisher pub = jsonBody == null
? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(jsonBody);
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + path))
.timeout(Duration.ofMinutes(5))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.method(method, pub);
if (extra != null) extra.forEach(b::header);
HttpResponse<String> res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
String body = res.body();
if (res.statusCode() >= 400 || body.contains("\"ok\":false") || body.contains("\"ok\": false")) {
throw new AppException(body);
}
return body;
}
/** Minimal JSON string escaper, used to build request bodies by hand. */
static String jsonString(String s) {
StringBuilder out = new StringBuilder("\"");
for (char c : s.toCharArray()) {
switch (c) {
case '"' -> out.append("\\\"");
case '\\' -> out.append("\\\\");
case '\n' -> out.append("\\n");
default -> out.append(c);
}
}
return out.append('"').toString();
}
}
require 'base64'
require 'json'
require 'net/http'
require 'uri'
TOKEN = 'YOUR_TOKEN' # from https://anime-art-generator.skillsafe.ai/tokens.html
BASE = 'https://api.skillsafe.ai/v1/app-api'
class AppError < StandardError
attr_reader :code, :details
def initialize(err)
@code = err['code'] || 'unknown'
@details = err['details'] || {}
super("#{@code}: #{err['message']}")
end
end
# Sends JSON and returns the unwrapped `data` object.
def call(method, path, body = nil, extra = {})
uri = URI(BASE + path)
req = Net::HTTP.const_get(method.capitalize).new(uri)
req['Authorization'] = "Bearer #{TOKEN}"
req['Content-Type'] = 'application/json'
extra.each { |k, v| req[k] = v }
req.body = JSON.dump(body) unless body.nil?
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, read_timeout: 300) do |http|
http.request(req)
end
env = begin
JSON.parse(res.body)
rescue JSON::ParserError
raise AppError, { 'code' => 'transport', 'message' => "HTTP #{res.code}: #{res.body[0, 200]}" }
end
raise AppError, (env['error'] || { 'message' => res.body[0, 200] }) unless env['ok']
env['data']
end
<?php
$TOKEN = 'YOUR_TOKEN'; // from https://anime-art-generator.skillsafe.ai/tokens.html
$BASE = 'https://api.skillsafe.ai/v1/app-api';
class AppError extends RuntimeException {
public string $code;
public array $details;
public function __construct(array $err) {
$this->code = $err['code'] ?? 'unknown';
$this->details = $err['details'] ?? [];
parent::__construct($this->code . ': ' . ($err['message'] ?? ''));
}
}
/** Sends JSON and returns the unwrapped `data` array. */
function af_call(string $method, string $path, ?array $body = null, array $extra = []): array {
global $TOKEN, $BASE;
$headers = array_merge([
'Authorization: Bearer ' . $TOKEN,
'Content-Type: application/json',
], $extra);
$ch = curl_init($BASE . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 300,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
$env = json_decode((string) $raw, true);
if (!is_array($env)) {
throw new AppError(['code' => 'transport', 'message' => "HTTP $status"]);
}
if (empty($env['ok'])) {
throw new AppError($env['error'] ?? ['message' => substr((string) $raw, 0, 200)]);
}
return $env['data'];
}
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
const string Token = "YOUR_TOKEN"; // from https://anime-art-generator.skillsafe.ai/tokens.html
const string BaseUrl = "https://api.skillsafe.ai/v1/app-api";
var http = new HttpClient { Timeout = TimeSpan.FromMinutes(5) };
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", Token);
// Sends JSON and returns the unwrapped `data` element.
async Task<JsonElement> Call(HttpMethod method, string path, object body = null,
IEnumerable<KeyValuePair<string, string>> extra = null)
{
using var req = new HttpRequestMessage(method, BaseUrl + path);
if (body != null)
{
req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
}
if (extra != null)
{
foreach (var h in extra) req.Headers.Add(h.Key, h.Value);
}
using var res = await http.SendAsync(req);
var text = await res.Content.ReadAsStringAsync();
JsonDocument doc;
try { doc = JsonDocument.Parse(text); }
catch (JsonException) { throw new Exception("transport: HTTP " + (int) res.StatusCode); }
using (doc)
{
var env = doc.RootElement;
if (!env.GetProperty("ok").GetBoolean())
{
var err = env.GetProperty("error");
throw new Exception(err.GetProperty("code").GetString() + ": "
+ err.GetProperty("message").GetString());
}
return env.GetProperty("data").Clone();
}
}
2. Getting a token
Open tokens.html on this site, sign in, and copy the token it shows you. It looks like this:
app token aut_…
The token is scoped to Anime Art Generator, so it cannot be pointed at another app, and it needs no companion header — no slug, no app id, nothing. Send it and you are calling this app.
A token can spend the credits of whoever minted it. Treat it exactly as you would treat a password with a billing relationship attached. It belongs in a secret manager, a keychain, or your CI provider's encrypted variables. It does not belong in a git repository, in a container image, in a log line, or anywhere in a front-end bundle — shipping it to a browser publishes it to everyone who opens the page. If one leaks, mint a fresh one from tokens.html; that is the whole remediation.
In every snippet below the token is a constant named TOKEN with
the placeholder value YOUR_TOKEN, so the code reads clearly. In anything you
actually deploy, replace that literal with a lookup against your secret store.
# Set it once per shell. Leading space keeps it out of history in most shells.
ANIME_FORGE_TOKEN="YOUR_TOKEN"
# Or prompt for it, so it never lands in a file at all:
# read -rs -p 'Anime Art Generator token: ' ANIME_FORGE_TOKEN; echo
case "$ANIME_FORGE_TOKEN" in
aut_*) echo "ok: app-scoped token" ;;
*) echo "not an app token - copy one from /tokens.html" >&2; exit 1 ;;
esac
TOKEN = "YOUR_TOKEN" # placeholder; see load_token() below
def load_token():
"""Return the app token.
Swap the constant for a real lookup: AWS Secrets Manager, Vault,
Google Secret Manager, the 1Password CLI, your CI provider's encrypted
variables - anything that is not a checked-in file.
"""
token = TOKEN
if not token.startswith("aut_"):
raise SystemExit("not an app-scoped token: copy one from /tokens.html")
return token
print("token loaded, length", len(load_token()))
const TOKEN = "YOUR_TOKEN"; // placeholder; see loadToken() below
/**
* Returns the app token. Replace the constant with a call into your secret
* store. Never bundle this into anything a browser downloads: a token in a
* front-end build is a token you have published.
*/
function loadToken() {
const token = TOKEN;
if (!token.startsWith("aut_")) {
throw new Error("not an app-scoped token: copy one from /tokens.html");
}
return token;
}
console.log("token loaded, length", loadToken().length);
package main
import (
"fmt"
"log"
"strings"
)
const Token = "YOUR_TOKEN" // placeholder; see loadToken below
// loadToken returns the app token. Replace the constant with a read from your
// secret store - Vault, Secrets Manager, a mounted secret file with 0400 on it.
func loadToken() string {
if !strings.HasPrefix(Token, "aut_") {
log.Fatal("not an app-scoped token: copy one from /tokens.html")
}
return Token
}
func main() {
fmt.Println("token loaded, length", len(loadToken()))
}
public class Tokens {
static final String TOKEN = "YOUR_TOKEN"; // placeholder; see loadToken()
/**
* Returns the app token. Replace the constant with your secret manager's
* client. Do not put it in application.properties and do not log it.
*/
static String loadToken() {
String token = TOKEN;
if (!token.startsWith("aut_")) {
throw new IllegalStateException("not an app-scoped token: copy one from /tokens.html");
}
return token;
}
public static void main(String[] args) {
System.out.println("token loaded, length " + loadToken().length());
}
}
TOKEN = 'YOUR_TOKEN' # placeholder; see load_token below
# Returns the app token. Replace the constant with Rails credentials, a Vault
# client, or whatever your deploy already uses for database passwords.
def load_token
token = TOKEN
abort 'not an app-scoped token: copy one from /tokens.html' unless token.start_with?('aut_')
token
end
puts "token loaded, length #{load_token.length}"
<?php
$TOKEN = 'YOUR_TOKEN'; // placeholder; see load_token() below
/**
* Returns the app token. Replace the constant with your secret store's client.
* Keep it out of the document root and out of version control.
*/
function load_token(): string {
global $TOKEN;
if (!str_starts_with($TOKEN, 'aut_')) {
throw new RuntimeException('not an app-scoped token: copy one from /tokens.html');
}
return $TOKEN;
}
echo 'token loaded, length ' . strlen(load_token()) . PHP_EOL;
using System;
const string Token = "YOUR_TOKEN"; // placeholder; see LoadToken() below
// Returns the app token. Replace the constant with IConfiguration backed by
// Azure Key Vault, AWS Secrets Manager, or the .NET user-secrets store.
static string LoadToken()
{
var token = Token;
if (!token.StartsWith("aut_", StringComparison.Ordinal))
{
throw new InvalidOperationException("not an app-scoped token: copy one from /tokens.html");
}
return token;
}
Console.WriteLine("token loaded, length " + LoadToken().Length);
3. Check the session and the balance
GET /me is free, instant, and the right first call in any script. It answers
two questions: is this token attached to a real account, and can it afford what you are about
to do.
/me returns exactly three fields. This is the single most
copied mistake against this API, so it is worth stating flatly:
| Field | Type | Meaning |
|---|---|---|
subject_type | string | "user" or "guest". |
subject_id | string | An opaque identifier. Stable, but not a user id you can look anything up with. |
credits | number | The spendable balance right now. |
There is no email, no name, no display name, no id. Code that reaches for
me.email or me.id reads undefined and then fails somewhere far away
from the cause. Signed in means subject_type === "user" —
that comparison is the entire test. A guest subject has a balance too, but it is
the publisher's sponsored allowance, not an account, and it can run out mid-script.
ANIME_FORGE_TOKEN="YOUR_TOKEN"
curl -sS "https://api.skillsafe.ai/v1/app-api/me" \
-H "Authorization: Bearer $ANIME_FORGE_TOKEN"
# {"ok":true,"data":{"subject_type":"user","subject_id":"sub_...","credits":48210}}
# Signed in is subject_type == "user". Nothing else in there says so.
curl -sS "https://api.skillsafe.ai/v1/app-api/me" \
-H "Authorization: Bearer $ANIME_FORGE_TOKEN" \
| jq -e '.data.subject_type == "user"' >/dev/null \
&& echo "signed in" || echo "guest token"
me = call("GET", "/me")
# -> {"subject_type": "user", "subject_id": "sub_...", "credits": 48210}
# Those three keys are all of it. No email, no name, no id.
signed_in = me["subject_type"] == "user"
if not signed_in:
raise SystemExit("guest token - sign in at https://anime-art-generator.skillsafe.ai/ and mint a new one")
print("credits:", me["credits"])
const me = await call("GET", "/me");
// -> { subject_type: "user", subject_id: "sub_...", credits: 48210 }
// Those three keys are all of it. No email, no name, no id.
const signedIn = me.subject_type === "user";
if (!signedIn) {
throw new Error("guest token - sign in at https://anime-art-generator.skillsafe.ai/ and mint a new one");
}
console.log("credits:", me.credits);
type Me struct {
SubjectType string `json:"subject_type"`
SubjectID string `json:"subject_id"`
Credits float64 `json:"credits"`
}
// Three fields, and that is the whole struct. No email, no name, no id.
raw, err := call("GET", "/me", nil, nil)
if err != nil {
log.Fatal(err)
}
var me Me
if err := json.Unmarshal(raw, &me); err != nil {
log.Fatal(err)
}
if me.SubjectType != "user" { // signed in is exactly this comparison
log.Fatal("guest token - sign in and mint a new one")
}
fmt.Println("credits:", me.Credits)
// GET /me returns exactly subject_type, subject_id and credits.
// No email, no name, no id - do not model fields that are not there.
String envelope = AnimeArtGenerator.call("GET", "/me", null, null);
System.out.println(envelope);
// With Jackson:
// JsonNode data = new ObjectMapper().readTree(envelope).get("data");
// boolean signedIn = "user".equals(data.get("subject_type").asText());
// if (!signedIn) throw new IllegalStateException("guest token - mint a new one");
// System.out.println("credits: " + data.get("credits").asLong());
me = call('GET', '/me')
# => {"subject_type"=>"user", "subject_id"=>"sub_...", "credits"=>48210}
# Those three keys are all of it. No email, no name, no id.
signed_in = me['subject_type'] == 'user'
abort 'guest token - sign in at https://anime-art-generator.skillsafe.ai/ and mint a new one' unless signed_in
puts "credits: #{me['credits']}"
<?php
$me = af_call('GET', '/me');
// => ['subject_type' => 'user', 'subject_id' => 'sub_...', 'credits' => 48210]
// Those three keys are all of it. No email, no name, no id.
$signedIn = $me['subject_type'] === 'user';
if (!$signedIn) {
throw new RuntimeException('guest token - sign in and mint a new one');
}
echo 'credits: ' . $me['credits'] . PHP_EOL;
var me = await Call(HttpMethod.Get, "/me");
// -> { "subject_type": "user", "subject_id": "sub_...", "credits": 48210 }
// Those three properties are all of it. No email, no name, no id.
var signedIn = me.GetProperty("subject_type").GetString() == "user";
if (!signedIn)
{
throw new InvalidOperationException("guest token - sign in and mint a new one");
}
Console.WriteLine("credits: " + me.GetProperty("credits").GetInt64());
4. Estimate before you run
POST /estimate takes the same body you would send to /run and
tells you what that run would reserve. It is free, it creates no job, and it charges nothing.
Call it once at start-up and cache the answer.
For an image model the hold is per image and does not vary with prompt
length. A twelve-word instruction and the 1,700-character brief in step 5 reserve
exactly the same amount, because the renderer prices pictures, not tokens. That is why one
estimate is enough for every brief you will ever send on the illustration lane — and why
it is safe to estimate with a placeholder string instead of building a real brief first. A
batch of N pictures is N separate runs and reserves N times the
returned hold_credits; there is no volume discount and no batching endpoint.
| Field | Type | What it tells you |
|---|---|---|
model | string | The concrete model the run would reach. |
model_alias | string | The alias you asked for, echoed back — gpt-image here. Worth asserting on: if it is not what you sent, your $model key did not survive serialisation. |
markup_bps | number | The publisher's markup in basis points, already folded into the numbers below. |
hold_credits | number | What /run would reserve against your balance. |
min_credits | number | The balance floor. Below this the run is rejected with payment_required before anything starts. |
sponsor_enabled | boolean | Whether the publisher is paying for guest runs. |
hold_credits is a reservation, not a price. It is deliberately
pessimistic: the ceiling of what the run could conceivably cost, taken out of your available
balance for the duration and released when the job settles. The gap is large. One measured
illustration on this app reserved 2,652 credits ($0.265) and settled at
618 ($0.062) — and a sibling app measured 96 on the same renderer, so
treat the settled figure as variable and the hold as the only number you can plan against. Budget your concurrency against the hold, because that is what determines
how many runs you can have in flight at once; report your spend from the
charged_credits on the terminal job, because that is what you actually paid.
ANIME_FORGE_TOKEN="YOUR_TOKEN"
# The instruction can be a placeholder: an image hold does not depend on it.
curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/estimate" \
-H "Authorization: Bearer $ANIME_FORGE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"instruction": "estimate probe", "$model": "gpt-image"}' | jq '.data'
# {
# "model": "gpt-image-2",
# "model_alias": "gpt-image",
# "markup_bps": 2000,
# "hold_credits": 2652,
# "min_credits": 2652,
# "sponsor_enabled": false
# }
# Six pictures reserve six times hold_credits, one run at a time.
# The instruction can be a placeholder: an image hold does not depend on it.
est = call("POST", "/estimate", {"instruction": "estimate probe", "$model": "gpt-image"})
assert est["model_alias"] == "gpt-image", "the $model key did not reach the API"
hold = est["hold_credits"]
print("model {0} holds {1} credits per picture".format(est["model"], hold))
print("floor {0}, markup {1} bps".format(est["min_credits"], est["markup_bps"]))
# Budget a batch against the hold, not against what a picture really costs.
batch = 6
me = call("GET", "/me")
if me["credits"] < hold * batch:
raise SystemExit("need {0} credits for {1} pictures, have {2}".format(hold * batch, batch, me["credits"]))
// The instruction can be a placeholder: an image hold does not depend on it.
const est = await call("POST", "/estimate", {
instruction: "estimate probe",
$model: "gpt-image",
});
if (est.model_alias !== "gpt-image") throw new Error("the $model key did not reach the API");
const hold = est.hold_credits;
console.log(`model ${est.model} holds ${hold} credits per picture`);
console.log(`floor ${est.min_credits}, markup ${est.markup_bps} bps`);
// Budget a batch against the hold, not against what a picture really costs.
const batch = 6;
const me = await call("GET", "/me");
if (me.credits < hold * batch) {
throw new Error(`need ${hold * batch} credits for ${batch} pictures, have ${me.credits}`);
}
type Estimate struct {
Model string `json:"model"`
ModelAlias string `json:"model_alias"`
MarkupBPS int `json:"markup_bps"`
HoldCredits float64 `json:"hold_credits"`
MinCredits float64 `json:"min_credits"`
SponsorEnabled bool `json:"sponsor_enabled"`
}
// The instruction can be a placeholder: an image hold does not depend on it.
probe := map[string]any{"instruction": "estimate probe", "$model": "gpt-image"}
raw, err := call("POST", "/estimate", probe, nil)
if err != nil {
log.Fatal(err)
}
var est Estimate
if err := json.Unmarshal(raw, &est); err != nil {
log.Fatal(err)
}
if est.ModelAlias != "gpt-image" {
log.Fatal("the $model key did not reach the API")
}
fmt.Printf("model %s holds %.0f credits per picture (floor %.0f, markup %d bps)\n",
est.Model, est.HoldCredits, est.MinCredits, est.MarkupBPS)
// Six pictures reserve six holds.
fmt.Printf("a batch of 6 needs %.0f credits available\n", est.HoldCredits*6)
// The instruction can be a placeholder: an image hold does not depend on it.
String probe = "{\"instruction\": \"estimate probe\", \"$model\": \"gpt-image\"}";
String envelope = AnimeArtGenerator.call("POST", "/estimate", probe, null);
System.out.println(envelope);
// {"ok":true,"data":{"model":"gpt-image-2","model_alias":"gpt-image",
// "markup_bps":2000,"hold_credits":2652,"min_credits":2652,"sponsor_enabled":false}}
// With Jackson:
// JsonNode d = new ObjectMapper().readTree(envelope).get("data");
// if (!"gpt-image".equals(d.get("model_alias").asText()))
// throw new IllegalStateException("the $model key did not reach the API");
// long hold = d.get("hold_credits").asLong(); // per picture
// long needed = hold * 6; // six pictures, six holds
// System.out.println("a batch of 6 needs " + needed + " credits available");
# The instruction can be a placeholder: an image hold does not depend on it.
est = call('POST', '/estimate', { 'instruction' => 'estimate probe', '$model' => 'gpt-image' })
abort 'the $model key did not reach the API' unless est['model_alias'] == 'gpt-image'
hold = est['hold_credits']
puts "model #{est['model']} holds #{hold} credits per picture"
puts "floor #{est['min_credits']}, markup #{est['markup_bps']} bps"
# Budget a batch against the hold, not against what a picture really costs.
batch = 6
me = call('GET', '/me')
abort "need #{hold * batch} credits, have #{me['credits']}" if me['credits'] < hold * batch
<?php
// The instruction can be a placeholder: an image hold does not depend on it.
$est = af_call('POST', '/estimate', [
'instruction' => 'estimate probe',
'$model' => 'gpt-image',
]);
if ($est['model_alias'] !== 'gpt-image') {
throw new RuntimeException('the $model key did not reach the API');
}
$hold = $est['hold_credits'];
printf("model %s holds %d credits per picture\n", $est['model'], $hold);
printf("floor %d, markup %d bps\n", $est['min_credits'], $est['markup_bps']);
// Budget a batch against the hold, not against what a picture really costs.
$batch = 6;
$me = af_call('GET', '/me');
if ($me['credits'] < $hold * $batch) {
throw new RuntimeException("need " . ($hold * $batch) . " credits, have {$me['credits']}");
}
// The instruction can be a placeholder: an image hold does not depend on it.
var probe = new Dictionary<string, object> {
["instruction"] = "estimate probe",
["$model"] = "gpt-image",
};
var est = await Call(HttpMethod.Post, "/estimate", probe);
if (est.GetProperty("model_alias").GetString() != "gpt-image")
{
throw new InvalidOperationException("the $model key did not reach the API");
}
var hold = est.GetProperty("hold_credits").GetInt64();
Console.WriteLine($"model {est.GetProperty("model").GetString()} holds {hold} credits per picture");
Console.WriteLine($"floor {est.GetProperty("min_credits").GetInt64()}, markup {est.GetProperty("markup_bps").GetInt32()} bps");
// Budget a batch against the hold, not against what a picture really costs.
const int Batch = 6;
var me = await Call(HttpMethod.Get, "/me");
if (me.GetProperty("credits").GetInt64() < hold * Batch)
{
throw new InvalidOperationException("not enough credits for " + Batch + " pictures");
}
5. Draw a picture: /run then poll
POST /run does not wait. It reserves the hold, queues the job, and returns
{"job_id": "..."} straight away. You then poll GET /jobs/{job_id}
until status is succeeded or failed.
| Field on the job | When it appears | Meaning |
|---|---|---|
job_id | Always | The handle you poll with. |
status | Always | queued, running, succeeded, failed. The first two are non-terminal; keep polling. |
output | On succeeded | Holds images and output. |
charged_credits | On any terminal status | What you actually paid, after the hold was released. This, not hold_credits, is your spend. |
error | On failed | Same code and message shape as the envelope error. |
Poll every 1.5 seconds and give up after 240. A picture normally lands in
20 to 40 seconds. Tighter polling buys you nothing and will earn you a
rate_limited; a shorter ceiling will abandon jobs that were about to succeed and
that you have already paid for.
The image is base64 in the job output. Read
output.images[0].b64 and output.images[0].content_type. Note that
output.output — the field the text lane uses — is the empty
string on an image run. It is not null and not missing, so a truthiness check on it
will not tell you which lane you are in; look for images instead.
Idempotency
Send an Idempotency-Key header on POST /run and a retry after a
dropped connection will attach to the job you already started instead of paying for a second
one. But replaying a key returns the original job even when that job failed.
A key derived only from your inputs therefore turns a transient renderer failure into a
permanent one: every retry re-serves the same failed job, forever. Put a per-attempt salt in
the key — an attempt counter or a fresh UUID per try — so a deliberate retry is a
new run while an accidental duplicate is not.
The brief below is the real thing: exactly what the app compiles and sends when you press the button. Note the opening clause about an invented character in a clothed, wholesome scene, and the closing clause listing what must not appear. Keep both if you write your own.
ANIME_FORGE_TOKEN="YOUR_TOKEN"
BASE="https://api.skillsafe.ai/v1/app-api"
# Exactly two keys. The brief goes in a file so the shell never has to quote it.
cat > brief.json <<'JSON'
{"instruction": "Original anime illustration of an invented character design — not an existing copyrighted character and not the likeness of any real person. Everyone in frame is fully and modestly clothed, in an ordinary wholesome moment. Subject: a night courier in a long grey coat, cropped dark hair, a canvas satchel over one shoulder, standing under the last lit lamp of a closed market arcade. Style: drawn as a 1990s direct-to-video OVA cel: hand-inked lines of varying weight, dense airbrushed gradients, deep saturated cel paint, slight film grain and the faint softness of an analogue transfer. Linework: brush lineart that tapers and swells, heavier on shadow-side contours. Shading: soft airbrushed gradients blending shadow into light. Palette: a cool palette of indigo, teal and pale cyan with a single warm accent. Framing: a wide establishing shot, the figure small inside a readable place. Composition: three clear depth layers, something framing the shot in the foreground. Background: a narrow city street at night, signage glowing out of focus. Mood: quietly melancholy, gaze lowered and away. Lighting: warm lantern light from below the eyeline, strong falloff into dark. Era: finished the way 1990s cel animation was, painted acetate over painted backgrounds. Finish: framed as a still from the episode itself, nothing posed for the camera. This version: leaning against something just out of frame. Consistent line weight, correct hand and eye anatomy, pupils and highlights aligned, clothing folds that follow the pose, and a single coherent light direction. No text, letters, numbers, captions, speech bubbles, watermarks, signatures, logos, borders, frames, collage panels, split screens or duplicated limbs anywhere in the image.", "$model": "gpt-image"}
JSON
# A per-attempt salt: retrying deliberately must not replay a failed job.
ATTEMPT=1
IDEM="courier-arcade-$(date +%Y%m%d)-attempt-$ATTEMPT"
JOB=$(curl -sS -X POST "$BASE/run" \
-H "Authorization: Bearer $ANIME_FORGE_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $IDEM" \
--data-binary @brief.json | jq -r '.data.job_id')
echo "job $JOB"
DEADLINE=$(( $(date +%s) + 240 ))
while :; do
RES=$(curl -sS "$BASE/jobs/$JOB" -H "Authorization: Bearer $ANIME_FORGE_TOKEN")
STATUS=$(printf '%s' "$RES" | jq -r '.data.status')
[ "$STATUS" = "succeeded" ] && break
[ "$STATUS" = "failed" ] && { printf '%s' "$RES" | jq '.data.error'; exit 1; }
[ "$(date +%s)" -ge "$DEADLINE" ] && { echo "timed out after 240s" >&2; exit 1; }
sleep 1.5
done
printf '%s' "$RES" | jq -r '.data.output.images[0].b64' | base64 --decode > panel.png
printf '%s' "$RES" | jq -r '"type \(.data.output.images[0].content_type), charged \(.data.charged_credits)"'
# output.output is "" on an image run - the picture is only ever in images[0].
import base64, time, uuid
BRIEF = "Original anime illustration of an invented character design — not an existing copyrighted character and not the likeness of any real person. Everyone in frame is fully and modestly clothed, in an ordinary wholesome moment. Subject: a night courier in a long grey coat, cropped dark hair, a canvas satchel over one shoulder, standing under the last lit lamp of a closed market arcade. Style: drawn as a 1990s direct-to-video OVA cel: hand-inked lines of varying weight, dense airbrushed gradients, deep saturated cel paint, slight film grain and the faint softness of an analogue transfer. Linework: brush lineart that tapers and swells, heavier on shadow-side contours. Shading: soft airbrushed gradients blending shadow into light. Palette: a cool palette of indigo, teal and pale cyan with a single warm accent. Framing: a wide establishing shot, the figure small inside a readable place. Composition: three clear depth layers, something framing the shot in the foreground. Background: a narrow city street at night, signage glowing out of focus. Mood: quietly melancholy, gaze lowered and away. Lighting: warm lantern light from below the eyeline, strong falloff into dark. Era: finished the way 1990s cel animation was, painted acetate over painted backgrounds. Finish: framed as a still from the episode itself, nothing posed for the camera. This version: leaning against something just out of frame. Consistent line weight, correct hand and eye anatomy, pupils and highlights aligned, clothing folds that follow the pose, and a single coherent light direction. No text, letters, numbers, captions, speech bubbles, watermarks, signatures, logos, borders, frames, collage panels, split screens or duplicated limbs anywhere in the image."
def draw(brief, attempt=1, path="panel.png"):
# The salt is what stops a retry from replaying a job that already failed.
key = "courier-arcade:{0}:attempt-{1}".format(uuid.uuid5(uuid.NAMESPACE_URL, brief[:64]), attempt)
started = call("POST", "/run",
{"instruction": brief, "$model": "gpt-image"}, # exactly two keys
headers={"Idempotency-Key": key})
job_id = started["job_id"]
deadline = time.time() + 240 # a picture normally lands in 20-40s
while True:
job = call("GET", "/jobs/" + job_id)
if job["status"] == "succeeded":
break
if job["status"] == "failed":
raise AppError(job.get("error") or {"code": "failed", "message": job_id})
if time.time() > deadline:
raise TimeoutError("job {0} still {1} after 240s".format(job_id, job["status"]))
time.sleep(1.5)
image = job["output"]["images"][0]
with open(path, "wb") as fh:
fh.write(base64.b64decode(image["b64"]))
# job["output"]["output"] is "" on an image run - do not look for the picture there.
print(path, image["content_type"], "charged", job["charged_credits"])
return path
draw(BRIEF)
import { writeFile } from "node:fs/promises";
import { randomUUID } from "node:crypto";
const BRIEF = "Original anime illustration of an invented character design — not an existing copyrighted character and not the likeness of any real person. Everyone in frame is fully and modestly clothed, in an ordinary wholesome moment. Subject: a night courier in a long grey coat, cropped dark hair, a canvas satchel over one shoulder, standing under the last lit lamp of a closed market arcade. Style: drawn as a 1990s direct-to-video OVA cel: hand-inked lines of varying weight, dense airbrushed gradients, deep saturated cel paint, slight film grain and the faint softness of an analogue transfer. Linework: brush lineart that tapers and swells, heavier on shadow-side contours. Shading: soft airbrushed gradients blending shadow into light. Palette: a cool palette of indigo, teal and pale cyan with a single warm accent. Framing: a wide establishing shot, the figure small inside a readable place. Composition: three clear depth layers, something framing the shot in the foreground. Background: a narrow city street at night, signage glowing out of focus. Mood: quietly melancholy, gaze lowered and away. Lighting: warm lantern light from below the eyeline, strong falloff into dark. Era: finished the way 1990s cel animation was, painted acetate over painted backgrounds. Finish: framed as a still from the episode itself, nothing posed for the camera. This version: leaning against something just out of frame. Consistent line weight, correct hand and eye anatomy, pupils and highlights aligned, clothing folds that follow the pose, and a single coherent light direction. No text, letters, numbers, captions, speech bubbles, watermarks, signatures, logos, borders, frames, collage panels, split screens or duplicated limbs anywhere in the image.";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function draw(brief, path = "panel.png") {
// A fresh salt per attempt: replaying a key re-serves the original job, failures included.
const key = `courier-arcade:${randomUUID()}`;
const started = await call(
"POST",
"/run",
{ instruction: brief, $model: "gpt-image" }, // exactly two keys
{ "Idempotency-Key": key },
);
const deadline = Date.now() + 240_000; // a picture normally lands in 20-40s
let job;
for (;;) {
job = await call("GET", `/jobs/${started.job_id}`);
if (job.status === "succeeded") break;
if (job.status === "failed") throw new AppError(job.error || { code: "failed", message: started.job_id });
if (Date.now() > deadline) throw new Error(`job ${started.job_id} still ${job.status} after 240s`);
await sleep(1500);
}
const image = job.output.images[0];
await writeFile(path, Buffer.from(image.b64, "base64"));
// job.output.output is "" on an image run - the picture is only in images[0].
console.log(path, image.content_type, "charged", job.charged_credits);
return path;
}
await draw(BRIEF);
const BRIEF = "Original anime illustration of an invented character design — not an existing copyrighted character and not the likeness of any real person. Everyone in frame is fully and modestly clothed, in an ordinary wholesome moment. Subject: a night courier in a long grey coat, cropped dark hair, a canvas satchel over one shoulder, standing under the last lit lamp of a closed market arcade. Style: drawn as a 1990s direct-to-video OVA cel: hand-inked lines of varying weight, dense airbrushed gradients, deep saturated cel paint, slight film grain and the faint softness of an analogue transfer. Linework: brush lineart that tapers and swells, heavier on shadow-side contours. Shading: soft airbrushed gradients blending shadow into light. Palette: a cool palette of indigo, teal and pale cyan with a single warm accent. Framing: a wide establishing shot, the figure small inside a readable place. Composition: three clear depth layers, something framing the shot in the foreground. Background: a narrow city street at night, signage glowing out of focus. Mood: quietly melancholy, gaze lowered and away. Lighting: warm lantern light from below the eyeline, strong falloff into dark. Era: finished the way 1990s cel animation was, painted acetate over painted backgrounds. Finish: framed as a still from the episode itself, nothing posed for the camera. This version: leaning against something just out of frame. Consistent line weight, correct hand and eye anatomy, pupils and highlights aligned, clothing folds that follow the pose, and a single coherent light direction. No text, letters, numbers, captions, speech bubbles, watermarks, signatures, logos, borders, frames, collage panels, split screens or duplicated limbs anywhere in the image."
type Image struct {
B64 string `json:"b64"`
ContentType string `json:"content_type"`
}
type Job struct {
JobID string `json:"job_id"`
Status string `json:"status"`
Output struct {
Images []Image `json:"images"`
Output string `json:"output"` // "" on an image run
} `json:"output"`
ChargedCredits float64 `json:"charged_credits"`
Error *apiError `json:"error"`
}
func draw(brief, path string, attempt int) error {
// The attempt number is the salt: without it a retry replays a failed job.
key := fmt.Sprintf("courier-arcade-%s-attempt-%d", time.Now().Format("20060102"), attempt)
body := map[string]any{"instruction": brief, "$model": "gpt-image"} // exactly two keys
raw, err := call("POST", "/run", body, map[string]string{"Idempotency-Key": key})
if err != nil {
return err
}
var started struct {
JobID string `json:"job_id"`
}
if err := json.Unmarshal(raw, &started); err != nil {
return err
}
deadline := time.Now().Add(240 * time.Second) // a picture lands in 20-40s
var job Job
for {
raw, err = call("GET", "/jobs/"+started.JobID, nil, nil)
if err != nil {
return err
}
if err := json.Unmarshal(raw, &job); err != nil {
return err
}
if job.Status == "succeeded" {
break
}
if job.Status == "failed" {
return job.Error
}
if time.Now().After(deadline) {
return fmt.Errorf("job %s still %s after 240s", started.JobID, job.Status)
}
time.Sleep(1500 * time.Millisecond)
}
pixels, err := base64.StdEncoding.DecodeString(job.Output.Images[0].B64)
if err != nil {
return err
}
if err := os.WriteFile(path, pixels, 0o644); err != nil {
return err
}
fmt.Println(path, job.Output.Images[0].ContentType, "charged", job.ChargedCredits)
return nil
}
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Base64;
import java.util.Map;
import java.util.UUID;
// The verbatim brief the app compiles. Keep the opening and closing clauses.
static final String BRIEF = "Original anime illustration of an invented character design — not an existing copyrighted character and not the likeness of any real person. Everyone in frame is fully and modestly clothed, in an ordinary wholesome moment. Subject: a night courier in a long grey coat, cropped dark hair, a canvas satchel over one shoulder, standing under the last lit lamp of a closed market arcade. Style: drawn as a 1990s direct-to-video OVA cel: hand-inked lines of varying weight, dense airbrushed gradients, deep saturated cel paint, slight film grain and the faint softness of an analogue transfer. Linework: brush lineart that tapers and swells, heavier on shadow-side contours. Shading: soft airbrushed gradients blending shadow into light. Palette: a cool palette of indigo, teal and pale cyan with a single warm accent. Framing: a wide establishing shot, the figure small inside a readable place. Composition: three clear depth layers, something framing the shot in the foreground. Background: a narrow city street at night, signage glowing out of focus. Mood: quietly melancholy, gaze lowered and away. Lighting: warm lantern light from below the eyeline, strong falloff into dark. Era: finished the way 1990s cel animation was, painted acetate over painted backgrounds. Finish: framed as a still from the episode itself, nothing posed for the camera. This version: leaning against something just out of frame. Consistent line weight, correct hand and eye anatomy, pupils and highlights aligned, clothing folds that follow the pose, and a single coherent light direction. No text, letters, numbers, captions, speech bubbles, watermarks, signatures, logos, borders, frames, collage panels, split screens or duplicated limbs anywhere in the image.";
static Path draw(String brief, Path out) throws Exception {
// A fresh UUID per attempt. Reusing a key re-serves the original job, failures included.
Map<String, String> idem = Map.of("Idempotency-Key", "courier-arcade:" + UUID.randomUUID());
// Exactly two keys on the illustration lane.
String body = "{\"instruction\": " + AnimeArtGenerator.jsonString(brief) + ", \"$model\": \"gpt-image\"}";
String startedEnv = AnimeArtGenerator.call("POST", "/run", body, idem);
// Parse with Jackson or Gson; shown here as the fields you need.
String jobId = readString(startedEnv, "data", "job_id");
long deadline = System.currentTimeMillis() + 240_000L; // 20-40s is typical
String jobEnv;
String status;
while (true) {
jobEnv = AnimeArtGenerator.call("GET", "/jobs/" + jobId, null, null);
status = readString(jobEnv, "data", "status");
if ("succeeded".equals(status)) break;
if ("failed".equals(status)) throw new IllegalStateException("job failed: " + jobEnv);
if (System.currentTimeMillis() > deadline) {
throw new IllegalStateException("job " + jobId + " still " + status + " after 240s");
}
Thread.sleep(1500L);
}
// data.output.images[0].b64, and data.output.output is "" on an image run.
String b64 = readString(jobEnv, "data", "output", "images", "0", "b64");
Files.write(out, Base64.getDecoder().decode(b64));
System.out.println(out + " charged " + readString(jobEnv, "data", "charged_credits"));
return out;
}
require 'securerandom'
BRIEF = 'Original anime illustration of an invented character design — not an existing copyrighted character and not the likeness of any real person. Everyone in frame is fully and modestly clothed, in an ordinary wholesome moment. Subject: a night courier in a long grey coat, cropped dark hair, a canvas satchel over one shoulder, standing under the last lit lamp of a closed market arcade. Style: drawn as a 1990s direct-to-video OVA cel: hand-inked lines of varying weight, dense airbrushed gradients, deep saturated cel paint, slight film grain and the faint softness of an analogue transfer. Linework: brush lineart that tapers and swells, heavier on shadow-side contours. Shading: soft airbrushed gradients blending shadow into light. Palette: a cool palette of indigo, teal and pale cyan with a single warm accent. Framing: a wide establishing shot, the figure small inside a readable place. Composition: three clear depth layers, something framing the shot in the foreground. Background: a narrow city street at night, signage glowing out of focus. Mood: quietly melancholy, gaze lowered and away. Lighting: warm lantern light from below the eyeline, strong falloff into dark. Era: finished the way 1990s cel animation was, painted acetate over painted backgrounds. Finish: framed as a still from the episode itself, nothing posed for the camera. This version: leaning against something just out of frame. Consistent line weight, correct hand and eye anatomy, pupils and highlights aligned, clothing folds that follow the pose, and a single coherent light direction. No text, letters, numbers, captions, speech bubbles, watermarks, signatures, logos, borders, frames, collage panels, split screens or duplicated limbs anywhere in the image.'
def draw(brief, path = 'panel.png')
# A fresh salt per attempt: replaying a key re-serves the original job, failures included.
key = "courier-arcade:#{SecureRandom.uuid}"
# Exactly two keys on the illustration lane.
started = call('POST', '/run',
{ 'instruction' => brief, '$model' => 'gpt-image' },
{ 'Idempotency-Key' => key })
job_id = started['job_id']
deadline = Time.now + 240 # a picture normally lands in 20-40s
job = nil
loop do
job = call('GET', "/jobs/#{job_id}")
break if job['status'] == 'succeeded'
raise AppError, (job['error'] || { 'code' => 'failed', 'message' => job_id }) if job['status'] == 'failed'
raise "job #{job_id} still #{job['status']} after 240s" if Time.now > deadline
sleep 1.5
end
image = job['output']['images'][0]
File.binwrite(path, Base64.decode64(image['b64']))
# job['output']['output'] is '' on an image run - the picture is only in images[0].
puts "#{path} #{image['content_type']} charged #{job['charged_credits']}"
path
end
draw(BRIEF)
<?php
$BRIEF = 'Original anime illustration of an invented character design — not an existing copyrighted character and not the likeness of any real person. Everyone in frame is fully and modestly clothed, in an ordinary wholesome moment. Subject: a night courier in a long grey coat, cropped dark hair, a canvas satchel over one shoulder, standing under the last lit lamp of a closed market arcade. Style: drawn as a 1990s direct-to-video OVA cel: hand-inked lines of varying weight, dense airbrushed gradients, deep saturated cel paint, slight film grain and the faint softness of an analogue transfer. Linework: brush lineart that tapers and swells, heavier on shadow-side contours. Shading: soft airbrushed gradients blending shadow into light. Palette: a cool palette of indigo, teal and pale cyan with a single warm accent. Framing: a wide establishing shot, the figure small inside a readable place. Composition: three clear depth layers, something framing the shot in the foreground. Background: a narrow city street at night, signage glowing out of focus. Mood: quietly melancholy, gaze lowered and away. Lighting: warm lantern light from below the eyeline, strong falloff into dark. Era: finished the way 1990s cel animation was, painted acetate over painted backgrounds. Finish: framed as a still from the episode itself, nothing posed for the camera. This version: leaning against something just out of frame. Consistent line weight, correct hand and eye anatomy, pupils and highlights aligned, clothing folds that follow the pose, and a single coherent light direction. No text, letters, numbers, captions, speech bubbles, watermarks, signatures, logos, borders, frames, collage panels, split screens or duplicated limbs anywhere in the image.';
function draw(string $brief, string $path = 'panel.png'): string {
// A fresh salt per attempt: replaying a key re-serves the original job, failures included.
$key = 'courier-arcade:' . bin2hex(random_bytes(8));
// Exactly two keys on the illustration lane. Single-quote '$model'.
$started = af_call('POST', '/run', [
'instruction' => $brief,
'$model' => 'gpt-image',
], ['Idempotency-Key: ' . $key]);
$jobId = $started['job_id'];
$deadline = time() + 240; // a picture normally lands in 20-40s
while (true) {
$job = af_call('GET', '/jobs/' . $jobId);
if ($job['status'] === 'succeeded') break;
if ($job['status'] === 'failed') {
throw new AppError($job['error'] ?? ['code' => 'failed', 'message' => $jobId]);
}
if (time() > $deadline) {
throw new RuntimeException("job $jobId still {$job['status']} after 240s");
}
usleep(1_500_000);
}
$image = $job['output']['images'][0];
file_put_contents($path, base64_decode($image['b64']));
// $job['output']['output'] is '' on an image run - look in images[0] only.
printf("%s %s charged %d\n", $path, $image['content_type'], $job['charged_credits']);
return $path;
}
draw($BRIEF);
using System.IO;
const string Brief = "Original anime illustration of an invented character design — not an existing copyrighted character and not the likeness of any real person. Everyone in frame is fully and modestly clothed, in an ordinary wholesome moment. Subject: a night courier in a long grey coat, cropped dark hair, a canvas satchel over one shoulder, standing under the last lit lamp of a closed market arcade. Style: drawn as a 1990s direct-to-video OVA cel: hand-inked lines of varying weight, dense airbrushed gradients, deep saturated cel paint, slight film grain and the faint softness of an analogue transfer. Linework: brush lineart that tapers and swells, heavier on shadow-side contours. Shading: soft airbrushed gradients blending shadow into light. Palette: a cool palette of indigo, teal and pale cyan with a single warm accent. Framing: a wide establishing shot, the figure small inside a readable place. Composition: three clear depth layers, something framing the shot in the foreground. Background: a narrow city street at night, signage glowing out of focus. Mood: quietly melancholy, gaze lowered and away. Lighting: warm lantern light from below the eyeline, strong falloff into dark. Era: finished the way 1990s cel animation was, painted acetate over painted backgrounds. Finish: framed as a still from the episode itself, nothing posed for the camera. This version: leaning against something just out of frame. Consistent line weight, correct hand and eye anatomy, pupils and highlights aligned, clothing folds that follow the pose, and a single coherent light direction. No text, letters, numbers, captions, speech bubbles, watermarks, signatures, logos, borders, frames, collage panels, split screens or duplicated limbs anywhere in the image.";
async Task<string> Draw(string brief, string path = "panel.png")
{
// A fresh salt per attempt: replaying a key re-serves the original job, failures included.
var key = "courier-arcade:" + Guid.NewGuid().ToString("N");
// Exactly two keys on the illustration lane.
var body = new Dictionary<string, object> {
["instruction"] = brief,
["$model"] = "gpt-image",
};
var started = await Call(HttpMethod.Post, "/run", body,
new[] { new KeyValuePair<string, string>("Idempotency-Key", key) });
var jobId = started.GetProperty("job_id").GetString();
var deadline = DateTime.UtcNow.AddSeconds(240); // a picture lands in 20-40s
JsonElement job;
while (true)
{
job = await Call(HttpMethod.Get, "/jobs/" + jobId);
var status = job.GetProperty("status").GetString();
if (status == "succeeded") break;
if (status == "failed") throw new Exception("job failed: " + job.GetProperty("error"));
if (DateTime.UtcNow > deadline) throw new TimeoutException("job " + jobId + " still " + status);
await Task.Delay(1500);
}
var image = job.GetProperty("output").GetProperty("images")[0];
await File.WriteAllBytesAsync(path, Convert.FromBase64String(image.GetProperty("b64").GetString()));
// output.output is "" on an image run - the picture is only in images[0].
Console.WriteLine($"{path} {image.GetProperty("content_type").GetString()} charged {job.GetProperty("charged_credits").GetInt64()}");
return path;
}
await Draw(Brief);
6. Board a sequence: /run-stream
The storyboard lane turns a one-line premise into an ordered set of panels, each one already carrying the attribute set the illustration lane needs. It is a text run, so it streams.
| Field | Type | Notes |
|---|---|---|
task | string | Must be the literal "storyboard". This is the router. |
premise | string | One or two sentences. The whole sequence is derived from it. |
count | number | How many panels, 3 to 8. |
register | string | The drawing register for the board. One of the seven below. |
pace | string | steady, slow, fast, quiet or comedic. Governs how much happens between panels. |
board_instructions | string | The lane's full instruction set. Required. See below. |
board_instructions is not optional
Anime Art Generator keeps the storyboard lane's long instruction set out of its system prompt and ships it as a served asset instead, so the illustration lane's prompt can stay four lines long and free of storyboard vocabulary it would otherwise try to paint. The consequence for you is direct: the app fetches that asset from its own bundle and sends it on every single storyboard run, and so must you. Omit it and the model has no schema, no vocabulary and no panel discipline; you get loose prose where a JSON object should be.
It is served at https://anime-art-generator.skillsafe.ai/board-prompt.js. The asset is a JavaScript module so the app can import it; if you fetch it over HTTP, send the instruction text it exports. Fetch it once at start-up, cache it in memory, and reuse the same string for every run — it does not vary per request.
What comes back
One JSON object. Nothing before it, nothing after it, no code fence. On
/run it arrives whole at data.output.output as a string you parse;
on /run-stream it arrives as text deltas you concatenate into the same string.
| Key | Type | Contents |
|---|---|---|
board | object | title, logline, register. |
panels | array | Reading order. One object per panel: order (number), then beat, caption, action and look (all strings), then attributes. |
panels[].attributes | object | Exactly eleven string keys: register, linework, shading, palette, finish, framing, composition, background, mood, lighting, era. Every value is drawn from the vocabulary below. |
board_notes | object | flow and continuity — how the panels read as a sequence, and what has to stay consistent across them. |
refusals | array | Zero or more {asked, reason} objects. Usually empty. A refused panel does not fail the run. |
Abbreviated to one panel:
{
"board": {
"title": "Last Lamp",
"logline": "A night courier reaches the arcade after the shutters are down.",
"register": "ova90s"
},
"panels": [
{
"order": 1,
"beat": "arrival",
"caption": "Everything is already closed.",
"action": "The courier stops at the arcade mouth, the satchel swinging forward.",
"look": "Wide, the figure small under the single lamp still burning.",
"attributes": {
"register": "ova90s",
"linework": "variable",
"shading": "airbrush",
"palette": "cool-twilight",
"finish": "production",
"framing": "wide",
"composition": "layers",
"background": "citynight",
"mood": "melancholy",
"lighting": "lantern",
"era": "1990s"
}
}
],
"board_notes": {
"flow": "Three wides bracket one close-up so the turn lands where it should.",
"continuity": "The satchel stays on the left shoulder; the lamp is the only warm source."
},
"refusals": []
}
Controlled vocabulary
Every value in attributes comes from this closed set. These are the same
tokens the illustration lane's brief compiler understands, which is what lets a panel be drawn
without a translation step.
| Field | Allowed values |
|---|---|
register | cel, watercolour, ova90s, modern, chibi, manga, pastel |
linework | clean, bold, variable, sketchy, fine, none |
shading | flat, twotone, airbrush, painterly, screentone, minimal |
palette | natural, warm-sunset, cool-twilight, pop, muted, pastel, neon, sepia, duotone, mono |
finish | production, keyvisual, novelcover, panel, concept, sticker |
framing | closeup, bust, halfbody, fullbody, wide, lowangle, highangle, dutch, overshoulder |
composition | thirds, centred, diagonal, layers, negative-left, negative-right |
background | plain, white, speedlines, classroom, citynight, shrine, seaside, forest, rooftop, interior, abstract |
mood | cheerful, calm, melancholy, determined, tense, wistful, comedic, awe, weary |
lighting | daylight, golden, backlit, neon, moonlight, noon, lantern, overcast, toplight |
era | now, 2010s, 1990s, 1980s, 1970s, timeless |
A value outside the vocabulary is not an error. The app
replaces it with that field's documented default and reports the substitution rather than
failing the board. So validate the attributes you receive if the exact token matters to your
pipeline — a run that quietly swapped screentone for something else still
returns ok: true, and you will only notice in the picture.
SSE framing
/run-stream answers with text/event-stream: lines beginning
data: followed by one JSON payload, each event terminated by a blank line.
data: {"delta": "{\"board\": {\"title\": \"Last"}
data: {"delta": " Lamp\", \"logline\": \"A night"}
data: {"job": {"job_id": "job_...", "status": "succeeded", "charged_credits": 41, "output": {"output": "{ ...the whole object... }"}}}
Two rules make a stream reader robust here. Accumulate only the text chunk
— concatenating the deltas in arrival order reproduces exactly the string
/run would have returned at data.output.output, and it is not valid
JSON until the last one has arrived, so parse once at the end and never mid-stream.
Skip frames you do not recognise — a payload that is not JSON, or that
carries neither a chunk nor the terminal job, is informational; ignoring it costs nothing and
keeps your reader working when a frame type is added. Stop when the frame carrying the
terminal job arrives; that frame is also where charged_credits lives.
ANIME_FORGE_TOKEN="YOUR_TOKEN"
BASE="https://api.skillsafe.ai/v1/app-api"
# Fetch the instruction set once and fold it into the body as a JSON string.
curl -sS "https://anime-art-generator.skillsafe.ai/board-prompt.js" -o board-prompt.txt
jq -n --rawfile prompt board-prompt.txt '{
task: "storyboard",
premise: "A night courier reaches the arcade after the shutters are down.",
count: 5,
register: "ova90s",
pace: "quiet",
board_instructions: $prompt
}' > board-body.json
# -N and --no-buffer: without them curl holds the stream in its output buffer.
curl -sS -N --no-buffer -X POST "$BASE/run-stream" \
-H "Authorization: Bearer $ANIME_FORGE_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: text/event-stream" \
--data-binary @board-body.json \
| sed -u -n 's/^data: //p' \
| jq -j --unbuffered 'if .delta then .delta elif .text then .text else empty end' \
> board.json
jq '.board.title, (.panels | length), .refusals' board.json
import json
import requests
# Cache this. It is the same string on every run.
BOARD_PROMPT = requests.get("https://anime-art-generator.skillsafe.ai/board-prompt.js", timeout=30).text
def board(premise, count=5, register="ova90s", pace="quiet"):
body = {
"task": "storyboard",
"premise": premise,
"count": count, # 3 to 8
"register": register,
"pace": pace,
"board_instructions": BOARD_PROMPT, # required, every run
}
res = requests.post(
BASE + "/run-stream",
json=body,
headers={"Authorization": "Bearer " + TOKEN,
"Content-Type": "application/json",
"Accept": "text/event-stream"},
stream=True, timeout=300,
)
res.raise_for_status()
chunks, job = [], None
for line in res.iter_lines(decode_unicode=True):
if not line or not line.startswith("data: "):
continue
try:
frame = json.loads(line[6:])
except ValueError:
continue # not JSON: informational, skip it
piece = frame.get("delta") or frame.get("text")
if piece:
chunks.append(piece)
if frame.get("job"):
job = frame["job"]
break
# Parse once, at the end. Mid-stream the buffer is not valid JSON.
doc = json.loads("".join(chunks))
if job:
print("charged", job.get("charged_credits"))
return doc
doc = board("A night courier reaches the arcade after the shutters are down.")
print(doc["board"]["title"], len(doc["panels"]), doc["refusals"])
// Cache this. It is the same string on every run.
const BOARD_PROMPT = await (await fetch("https://anime-art-generator.skillsafe.ai/board-prompt.js")).text();
async function board(premise, { count = 5, register = "ova90s", pace = "quiet" } = {}) {
const res = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
Accept: "text/event-stream",
},
body: JSON.stringify({
task: "storyboard",
premise,
count, // 3 to 8
register,
pace,
board_instructions: BOARD_PROMPT, // required, every run
}),
});
if (!res.ok) throw new Error(`run-stream: HTTP ${res.status}`);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let text = "";
let job = null;
outer: for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let split;
while ((split = buffer.indexOf("\n\n")) !== -1) {
const event = buffer.slice(0, split);
buffer = buffer.slice(split + 2);
for (const line of event.split("\n")) {
if (!line.startsWith("data: ")) continue;
let frame;
try {
frame = JSON.parse(line.slice(6));
} catch {
continue; // not JSON: informational, skip it
}
const piece = frame.delta ?? frame.text;
if (piece) text += piece;
if (frame.job) {
job = frame.job;
break outer;
}
}
}
}
// Parse once, at the end. Mid-stream the buffer is not valid JSON.
const doc = JSON.parse(text);
if (job) console.log("charged", job.charged_credits);
return doc;
}
const doc = await board("A night courier reaches the arcade after the shutters are down.");
console.log(doc.board.title, doc.panels.length, doc.refusals);
// boardPrompt is fetched once at start-up from
// https://anime-art-generator.skillsafe.ai/board-prompt.js and cached.
func board(premise, boardPrompt string, count int) (map[string]any, error) {
body, _ := json.Marshal(map[string]any{
"task": "storyboard",
"premise": premise,
"count": count, // 3 to 8
"register": "ova90s",
"pace": "quiet",
"board_instructions": boardPrompt, // required, every run
})
req, _ := http.NewRequest("POST", Base+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+Token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
res, err := client.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var text strings.Builder
var job map[string]any
scanner := bufio.NewScanner(res.Body)
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) // frames can be long
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "data: ") {
continue // blank separators and comments
}
var frame map[string]any
if err := json.Unmarshal([]byte(line[6:]), &frame); err != nil {
continue // not JSON: informational, skip it
}
if piece, ok := frame["delta"].(string); ok {
text.WriteString(piece)
} else if piece, ok := frame["text"].(string); ok {
text.WriteString(piece)
}
if j, ok := frame["job"].(map[string]any); ok {
job = j
break
}
}
if err := scanner.Err(); err != nil {
return nil, err
}
// Parse once, at the end. Mid-stream the buffer is not valid JSON.
var doc map[string]any
if err := json.Unmarshal([]byte(text.String()), &doc); err != nil {
return nil, err
}
if job != nil {
fmt.Println("charged", job["charged_credits"])
}
return doc, nil
}
import java.net.URI;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.stream.Stream;
// BOARD_PROMPT is fetched once at start-up from
// https://anime-art-generator.skillsafe.ai/board-prompt.js and cached in a static field.
static String board(String premise, int count, String boardPrompt) throws Exception {
String body = "{"
+ "\"task\": \"storyboard\","
+ "\"premise\": " + AnimeArtGenerator.jsonString(premise) + ","
+ "\"count\": " + count + "," // 3 to 8
+ "\"register\": \"ova90s\","
+ "\"pace\": \"quiet\","
+ "\"board_instructions\": " + AnimeArtGenerator.jsonString(boardPrompt) // required
+ "}";
HttpRequest req = HttpRequest.newBuilder(URI.create(AnimeArtGenerator.BASE + "/run-stream"))
.header("Authorization", "Bearer " + AnimeArtGenerator.TOKEN)
.header("Content-Type", "application/json")
.header("Accept", "text/event-stream")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
// ofLines streams the response line by line instead of buffering it whole.
HttpResponse<Stream<String>> res =
AnimeArtGenerator.HTTP.send(req, HttpResponse.BodyHandlers.ofLines());
StringBuilder text = new StringBuilder();
for (String line : (Iterable<String>) res.body()::iterator) {
if (!line.startsWith("data: ")) continue; // blank separators
String payload = line.substring(6);
if (!payload.startsWith("{")) continue; // not JSON: skip it
String piece = readOptionalString(payload, "delta"); // your JSON library
if (piece != null) text.append(piece);
if (payload.contains("\"job\"")) break; // terminal frame
}
// Parse once, at the end. Mid-stream the buffer is not valid JSON.
return text.toString();
}
require 'json'
require 'net/http'
# Cache this. It is the same string on every run.
BOARD_PROMPT = Net::HTTP.get(URI('https://anime-art-generator.skillsafe.ai/board-prompt.js'))
def board(premise, count: 5, register: 'ova90s', pace: 'quiet')
uri = URI(BASE + '/run-stream')
req = Net::HTTP::Post.new(uri)
req['Authorization'] = "Bearer #{TOKEN}"
req['Content-Type'] = 'application/json'
req['Accept'] = 'text/event-stream'
req.body = JSON.dump(
'task' => 'storyboard',
'premise' => premise,
'count' => count, # 3 to 8
'register' => register,
'pace' => pace,
'board_instructions' => BOARD_PROMPT # required, every run
)
text = +''
job = nil
buffer = +''
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, read_timeout: 300) do |http|
http.request(req) do |res|
res.read_body do |chunk|
buffer << chunk
while (i = buffer.index("\n"))
line = buffer.slice!(0..i).chomp
next unless line.start_with?('data: ')
frame = begin
JSON.parse(line[6..])
rescue JSON::ParserError
next # not JSON: informational, skip it
end
piece = frame['delta'] || frame['text']
text << piece if piece
job = frame['job'] if frame['job']
end
end
end
end
# Parse once, at the end. Mid-stream the buffer is not valid JSON.
puts "charged #{job['charged_credits']}" if job
JSON.parse(text)
end
doc = board('A night courier reaches the arcade after the shutters are down.')
puts doc['board']['title'], doc['panels'].length, doc['refusals'].inspect
<?php
// Cache this. It is the same string on every run.
$BOARD_PROMPT = file_get_contents('https://anime-art-generator.skillsafe.ai/board-prompt.js');
function board(string $premise, int $count = 5): array {
global $TOKEN, $BASE, $BOARD_PROMPT;
$payload = json_encode([
'task' => 'storyboard',
'premise' => $premise,
'count' => $count, // 3 to 8
'register' => 'ova90s',
'pace' => 'quiet',
'board_instructions' => $BOARD_PROMPT, // required, every run
]);
$text = '';
$job = null;
$buffer = '';
$ch = curl_init($BASE . '/run-stream');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $TOKEN,
'Content-Type: application/json',
'Accept: text/event-stream',
],
// A write callback keeps cURL from buffering the whole stream.
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$text, &$job, &$buffer) {
$buffer .= $chunk;
while (($i = strpos($buffer, "\n")) !== false) {
$line = substr($buffer, 0, $i);
$buffer = substr($buffer, $i + 1);
if (!str_starts_with($line, 'data: ')) continue;
$frame = json_decode(substr($line, 6), true);
if (!is_array($frame)) continue; // not JSON: skip it
$piece = $frame['delta'] ?? $frame['text'] ?? null;
if ($piece !== null) $text .= $piece;
if (isset($frame['job'])) $job = $frame['job'];
}
return strlen($chunk);
},
]);
curl_exec($ch);
curl_close($ch);
// Parse once, at the end. Mid-stream the buffer is not valid JSON.
if ($job !== null) {
printf("charged %d\n", $job['charged_credits']);
}
return json_decode($text, true);
}
$doc = board('A night courier reaches the arcade after the shutters are down.');
printf("%s, %d panels, %d refusals\n", $doc['board']['title'], count($doc['panels']), count($doc['refusals']));
using System.IO;
using System.Net.Http;
// Cache this. It is the same string on every run.
var boardPrompt = await http.GetStringAsync("https://anime-art-generator.skillsafe.ai/board-prompt.js");
async Task<JsonElement> Board(string premise, int count = 5)
{
var payload = JsonSerializer.Serialize(new Dictionary<string, object> {
["task"] = "storyboard",
["premise"] = premise,
["count"] = count, // 3 to 8
["register"] = "ova90s",
["pace"] = "quiet",
["board_instructions"] = boardPrompt, // required, every run
});
using var req = new HttpRequestMessage(HttpMethod.Post, BaseUrl + "/run-stream");
req.Content = new StringContent(payload, Encoding.UTF8, "application/json");
req.Headers.Add("Accept", "text/event-stream");
// ResponseHeadersRead hands back the stream before the body is complete.
using var res = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
res.EnsureSuccessStatusCode();
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
var text = new StringBuilder();
JsonElement job = default;
var haveJob = false;
string line;
while ((line = await reader.ReadLineAsync()) != null)
{
if (!line.StartsWith("data: ", StringComparison.Ordinal)) continue;
JsonElement frame;
try { frame = JsonDocument.Parse(line.Substring(6)).RootElement.Clone(); }
catch (JsonException) { continue; } // not JSON: informational, skip it
if (frame.TryGetProperty("delta", out var d) || frame.TryGetProperty("text", out d))
{
text.Append(d.GetString());
}
if (frame.TryGetProperty("job", out job)) { haveJob = true; break; }
}
// Parse once, at the end. Mid-stream the buffer is not valid JSON.
if (haveJob) Console.WriteLine("charged " + job.GetProperty("charged_credits").GetInt64());
return JsonDocument.Parse(text.ToString()).RootElement.Clone();
}
var doc = await Board("A night courier reaches the arcade after the shutters are down.");
Console.WriteLine(doc.GetProperty("board").GetProperty("title").GetString());
7. Putting it together
The two lanes are designed to meet in the middle: a storyboard panel already carries eleven attribute tokens, and the illustration lane's brief is those tokens written out as prose. A full pipeline is three moves.
- Estimate once, at start-up. The image hold never changes, so cache
hold_creditsand use it to decide how many pictures you can have in flight. - Board the premise on
/run-stream, sendingboard_instructionswith it, and parse the single JSON object out of the accumulated deltas. - Compile a panel into an instruction and send it down the illustration
lane: opening clause, the panel's
actionas the subject, one labelled sentence per attribute, then the closing negative clause. Two keys, poll, decode.
The compiler is the only piece that is genuinely yours to write, and it is mechanical: each
attribute token maps to a fixed phrase, and the phrases are joined in a fixed order. Keep the
opening clause and the negative clause exactly where they are — the first is what makes
the character invented, the second is what keeps captions and watermarks out of the frame.
Compile from panel.attributes, not from panel.look: the prose fields
are for a human reading the board, the tokens are what the renderer was tuned on.
Panels are independent runs, so nothing carries between them automatically.
If you want a consistent character across a whole board, put the same subject sentence in
every panel's brief and let board_notes.continuity tell you which details have to
stay fixed.
ANIME_FORGE_TOKEN="YOUR_TOKEN"
BASE="https://api.skillsafe.ai/v1/app-api"
OPENING='Original anime illustration of an invented character design — not an existing copyrighted character and not the likeness of any real person. Everyone in frame is fully and modestly clothed, in an ordinary wholesome moment.'
NEGATIVES='No text, letters, numbers, captions, speech bubbles, watermarks, signatures, logos, borders, frames, collage panels, split screens or duplicated limbs anywhere in the image.'
# 1. Estimate once. The number is the same for every brief.
curl -sS -X POST "$BASE/estimate" \
-H "Authorization: Bearer $ANIME_FORGE_TOKEN" -H "Content-Type: application/json" \
-d '{"instruction": "probe", "$model": "gpt-image"}' | jq '.data.hold_credits'
# 2. Board the premise (body built in step 6) and keep the JSON object.
curl -sS -N --no-buffer -X POST "$BASE/run-stream" \
-H "Authorization: Bearer $ANIME_FORGE_TOKEN" -H "Content-Type: application/json" \
--data-binary @board-body.json \
| sed -u -n 's/^data: //p' \
| jq -j --unbuffered 'if .delta then .delta else empty end' > board.json
# 3. Compile panel 3 into an instruction: opening, subject, attributes, negatives.
jq -r --arg open "$OPENING" --arg neg "$NEGATIVES" '
.panels[] | select(.order == 3) as $p
| [$open,
"Subject: " + $p.action,
"Style: drawn in the " + $p.attributes.register + " register.",
"Linework: " + $p.attributes.linework + ".",
"Shading: " + $p.attributes.shading + ".",
"Palette: " + $p.attributes.palette + ".",
"Framing: " + $p.attributes.framing + ".",
"Composition: " + $p.attributes.composition + ".",
"Background: " + $p.attributes.background + ".",
"Mood: " + $p.attributes.mood + ".",
"Lighting: " + $p.attributes.lighting + ".",
"Era: " + $p.attributes.era + ".",
"Finish: " + $p.attributes.finish + ".",
$neg] | join(" ")' board.json > panel3.txt
jq -n --rawfile i panel3.txt '{instruction: $i, "$model": "gpt-image"}' > panel3.json
# Then POST /run with panel3.json and poll, exactly as in step 5.
OPENING = ("Original anime illustration of an invented character design — not an existing "
"copyrighted character and not the likeness of any real person. Everyone in frame is "
"fully and modestly clothed, in an ordinary wholesome moment.")
NEGATIVES = ("No text, letters, numbers, captions, speech bubbles, watermarks, signatures, logos, "
"borders, frames, collage panels, split screens or duplicated limbs anywhere in the "
"image.")
# One fixed phrase per token. Abbreviated here; the full table is the app's own.
PHRASES = {
"register": {"ova90s": "drawn as a 1990s direct-to-video OVA cel",
"cel": "drawn as a traditional painted animation cel"},
"linework": {"variable": "brush lineart that tapers and swells",
"clean": "even, confident lines of a single weight"},
"shading": {"airbrush": "soft airbrushed gradients blending shadow into light"},
"palette": {"cool-twilight": "a cool palette of indigo, teal and pale cyan"},
"framing": {"wide": "a wide establishing shot, the figure small inside a readable place"},
"composition": {"layers": "three clear depth layers"},
"background": {"citynight": "a narrow city street at night, signage glowing out of focus"},
"mood": {"melancholy": "quietly melancholy, gaze lowered and away"},
"lighting": {"lantern": "warm lantern light from below the eyeline"},
"era": {"1990s": "finished the way 1990s cel animation was"},
"finish": {"production": "framed as a still from the episode itself"},
}
ORDER = ["register", "linework", "shading", "palette", "framing",
"composition", "background", "mood", "lighting", "era", "finish"]
LABELS = {"register": "Style", "linework": "Linework", "shading": "Shading",
"palette": "Palette", "framing": "Framing", "composition": "Composition",
"background": "Background", "mood": "Mood", "lighting": "Lighting",
"era": "Era", "finish": "Finish"}
def compile_brief(panel):
"""Turn one storyboard panel into an illustration-lane instruction."""
parts = [OPENING, "Subject: " + panel["action"]]
attrs = panel["attributes"]
for field in ORDER:
token = attrs[field]
phrase = PHRASES.get(field, {}).get(token, token)
parts.append("{0}: {1}.".format(LABELS[field], phrase))
parts.append(NEGATIVES)
return " ".join(parts)
# 1. Estimate once - the hold is identical for every brief.
hold = call("POST", "/estimate", {"instruction": "probe", "$model": "gpt-image"})["hold_credits"]
# 2. Board the premise (board() is from step 6).
doc = board("A night courier reaches the arcade after the shutters are down.", count=5)
# 3. Draw panel 3 (draw() is from step 5).
panel3 = next(p for p in doc["panels"] if p["order"] == 3)
draw(compile_brief(panel3), path="panel-03.png")
print("reserved", hold, "per picture")
const OPENING =
"Original anime illustration of an invented character design — not an existing copyrighted " +
"character and not the likeness of any real person. Everyone in frame is fully and modestly " +
"clothed, in an ordinary wholesome moment.";
const NEGATIVES =
"No text, letters, numbers, captions, speech bubbles, watermarks, signatures, logos, borders, " +
"frames, collage panels, split screens or duplicated limbs anywhere in the image.";
// One fixed phrase per token; abbreviated. Unknown tokens fall through as themselves.
const PHRASES = {
register: { ova90s: "drawn as a 1990s direct-to-video OVA cel" },
linework: { variable: "brush lineart that tapers and swells" },
shading: { airbrush: "soft airbrushed gradients blending shadow into light" },
palette: { "cool-twilight": "a cool palette of indigo, teal and pale cyan" },
framing: { wide: "a wide establishing shot, the figure small inside a readable place" },
composition: { layers: "three clear depth layers" },
background: { citynight: "a narrow city street at night, signage glowing out of focus" },
mood: { melancholy: "quietly melancholy, gaze lowered and away" },
lighting: { lantern: "warm lantern light from below the eyeline" },
era: { "1990s": "finished the way 1990s cel animation was" },
finish: { production: "framed as a still from the episode itself" },
};
const LABELS = {
register: "Style", linework: "Linework", shading: "Shading", palette: "Palette",
framing: "Framing", composition: "Composition", background: "Background",
mood: "Mood", lighting: "Lighting", era: "Era", finish: "Finish",
};
/** Turns one storyboard panel into an illustration-lane instruction. */
function compileBrief(panel) {
const parts = [OPENING, `Subject: ${panel.action}`];
for (const field of Object.keys(LABELS)) {
const token = panel.attributes[field];
parts.push(`${LABELS[field]}: ${PHRASES[field]?.[token] ?? token}.`);
}
parts.push(NEGATIVES);
return parts.join(" ");
}
// 1. Estimate once; 2. board; 3. draw panel 3.
const { hold_credits: hold } = await call("POST", "/estimate", { instruction: "probe", $model: "gpt-image" });
const doc = await board("A night courier reaches the arcade after the shutters are down.");
const panel3 = doc.panels.find((p) => p.order === 3);
await draw(compileBrief(panel3), "panel-03.png");
console.log("reserved", hold, "per picture");
var labels = []struct{ Field, Label string }{
{"register", "Style"}, {"linework", "Linework"}, {"shading", "Shading"},
{"palette", "Palette"}, {"framing", "Framing"}, {"composition", "Composition"},
{"background", "Background"}, {"mood", "Mood"}, {"lighting", "Lighting"},
{"era", "Era"}, {"finish", "Finish"},
}
// phrases maps a vocabulary token to its clause; abbreviated here.
var phrases = map[string]map[string]string{
"register": {"ova90s": "drawn as a 1990s direct-to-video OVA cel"},
"linework": {"variable": "brush lineart that tapers and swells"},
"lighting": {"lantern": "warm lantern light from below the eyeline"},
}
// compileBrief turns one storyboard panel into an illustration-lane instruction.
func compileBrief(panel map[string]any) string {
attrs, _ := panel["attributes"].(map[string]any)
parts := []string{opening, "Subject: " + panel["action"].(string)}
for _, l := range labels {
token, _ := attrs[l.Field].(string)
phrase := token
if p, ok := phrases[l.Field][token]; ok {
phrase = p
}
parts = append(parts, l.Label+": "+phrase+".")
}
return strings.Join(append(parts, negatives), " ")
}
func pipeline(premise, boardPrompt string) error {
// 1. Estimate once - the hold is identical for every brief.
raw, err := call("POST", "/estimate",
map[string]any{"instruction": "probe", "$model": "gpt-image"}, nil)
if err != nil {
return err
}
var est Estimate
json.Unmarshal(raw, &est)
// 2. Board the premise.
doc, err := board(premise, boardPrompt, 5)
if err != nil {
return err
}
// 3. Draw panel 3.
for _, p := range doc["panels"].([]any) {
panel := p.(map[string]any)
if panel["order"].(float64) == 3 {
return draw(compileBrief(panel), "panel-03.png", 1)
}
}
return fmt.Errorf("no panel 3 in a board of %d", len(doc["panels"].([]any)))
}
import java.util.LinkedHashMap;
import java.util.Map;
// Field -> sentence label, in the order the brief reads.
static final Map<String, String> LABELS = new LinkedHashMap<>(Map.of());
static {
LABELS.put("register", "Style");
LABELS.put("linework", "Linework");
LABELS.put("shading", "Shading");
LABELS.put("palette", "Palette");
LABELS.put("framing", "Framing");
LABELS.put("composition", "Composition");
LABELS.put("background", "Background");
LABELS.put("mood", "Mood");
LABELS.put("lighting", "Lighting");
LABELS.put("era", "Era");
LABELS.put("finish", "Finish");
}
/** Turns one storyboard panel into an illustration-lane instruction. */
static String compileBrief(String action, Map<String, String> attributes) {
StringBuilder sb = new StringBuilder(OPENING);
sb.append(" Subject: ").append(action);
LABELS.forEach((field, label) -> {
String token = attributes.get(field);
// Look the token up in your phrase table; fall through to the token itself.
sb.append(' ').append(label).append(": ").append(phraseFor(field, token)).append('.');
});
return sb.append(' ').append(NEGATIVES).toString();
}
// 1. Estimate once, 2. board the premise, 3. draw panel 3.
String estimate = AnimeArtGenerator.call("POST", "/estimate",
"{\"instruction\": \"probe\", \"$model\": \"gpt-image\"}", null);
String boardJson = board("A night courier reaches the arcade after the shutters are down.", 5, BOARD_PROMPT);
// Parse boardJson, pick the panel whose order == 3, then:
// draw(compileBrief(panel.action, panel.attributes), Path.of("panel-03.png"));
OPENING = 'Original anime illustration of an invented character design — not an existing ' \
'copyrighted character and not the likeness of any real person. Everyone in frame is ' \
'fully and modestly clothed, in an ordinary wholesome moment.'
NEGATIVES = 'No text, letters, numbers, captions, speech bubbles, watermarks, signatures, logos, ' \
'borders, frames, collage panels, split screens or duplicated limbs anywhere in the image.'
LABELS = {
'register' => 'Style', 'linework' => 'Linework', 'shading' => 'Shading',
'palette' => 'Palette', 'framing' => 'Framing', 'composition' => 'Composition',
'background' => 'Background', 'mood' => 'Mood', 'lighting' => 'Lighting',
'era' => 'Era', 'finish' => 'Finish'
}.freeze
# One fixed phrase per token; abbreviated. Unknown tokens fall through as themselves.
PHRASES = {
'register' => { 'ova90s' => 'drawn as a 1990s direct-to-video OVA cel' },
'linework' => { 'variable' => 'brush lineart that tapers and swells' },
'lighting' => { 'lantern' => 'warm lantern light from below the eyeline' }
}.freeze
# Turns one storyboard panel into an illustration-lane instruction.
def compile_brief(panel)
attrs = panel['attributes']
parts = [OPENING, "Subject: #{panel['action']}"]
LABELS.each do |field, label|
token = attrs[field]
parts << "#{label}: #{PHRASES.dig(field, token) || token}."
end
(parts << NEGATIVES).join(' ')
end
# 1. Estimate once; 2. board; 3. draw panel 3.
hold = call('POST', '/estimate', { 'instruction' => 'probe', '$model' => 'gpt-image' })['hold_credits']
doc = board('A night courier reaches the arcade after the shutters are down.')
panel3 = doc['panels'].find { |p| p['order'] == 3 }
draw(compile_brief(panel3), 'panel-03.png')
puts "reserved #{hold} per picture"
<?php
const OPENING = 'Original anime illustration of an invented character design — not an existing '
. 'copyrighted character and not the likeness of any real person. Everyone in frame is fully '
. 'and modestly clothed, in an ordinary wholesome moment.';
const NEGATIVES = 'No text, letters, numbers, captions, speech bubbles, watermarks, signatures, '
. 'logos, borders, frames, collage panels, split screens or duplicated limbs anywhere in the image.';
const LABELS = [
'register' => 'Style', 'linework' => 'Linework', 'shading' => 'Shading',
'palette' => 'Palette', 'framing' => 'Framing', 'composition' => 'Composition',
'background' => 'Background', 'mood' => 'Mood', 'lighting' => 'Lighting',
'era' => 'Era', 'finish' => 'Finish',
];
// One fixed phrase per token; abbreviated. Unknown tokens fall through as themselves.
const PHRASES = [
'register' => ['ova90s' => 'drawn as a 1990s direct-to-video OVA cel'],
'linework' => ['variable' => 'brush lineart that tapers and swells'],
'lighting' => ['lantern' => 'warm lantern light from below the eyeline'],
];
/** Turns one storyboard panel into an illustration-lane instruction. */
function compile_brief(array $panel): string {
$parts = [OPENING, 'Subject: ' . $panel['action']];
foreach (LABELS as $field => $label) {
$token = $panel['attributes'][$field];
$parts[] = $label . ': ' . (PHRASES[$field][$token] ?? $token) . '.';
}
$parts[] = NEGATIVES;
return implode(' ', $parts);
}
// 1. Estimate once; 2. board; 3. draw panel 3.
$hold = af_call('POST', '/estimate', ['instruction' => 'probe', '$model' => 'gpt-image'])['hold_credits'];
$doc = board('A night courier reaches the arcade after the shutters are down.');
$panel3 = current(array_filter($doc['panels'], fn ($p) => $p['order'] === 3));
draw(compile_brief($panel3), 'panel-03.png');
echo "reserved $hold per picture\n";
const string Opening =
"Original anime illustration of an invented character design — not an existing copyrighted " +
"character and not the likeness of any real person. Everyone in frame is fully and modestly " +
"clothed, in an ordinary wholesome moment.";
const string Negatives =
"No text, letters, numbers, captions, speech bubbles, watermarks, signatures, logos, borders, " +
"frames, collage panels, split screens or duplicated limbs anywhere in the image.";
// Field -> sentence label, in the order the brief reads.
var labels = new (string Field, string Label)[] {
("register", "Style"), ("linework", "Linework"), ("shading", "Shading"),
("palette", "Palette"), ("framing", "Framing"), ("composition", "Composition"),
("background", "Background"), ("mood", "Mood"), ("lighting", "Lighting"),
("era", "Era"), ("finish", "Finish"),
};
// One fixed phrase per token; abbreviated. Unknown tokens fall through as themselves.
var phrases = new Dictionary<string, Dictionary<string, string>> {
["register"] = new() { ["ova90s"] = "drawn as a 1990s direct-to-video OVA cel" },
["linework"] = new() { ["variable"] = "brush lineart that tapers and swells" },
["lighting"] = new() { ["lantern"] = "warm lantern light from below the eyeline" },
};
// Turns one storyboard panel into an illustration-lane instruction.
string CompileBrief(JsonElement panel)
{
var attrs = panel.GetProperty("attributes");
var sb = new StringBuilder(Opening);
sb.Append(" Subject: ").Append(panel.GetProperty("action").GetString());
foreach (var (field, label) in labels)
{
var token = attrs.GetProperty(field).GetString();
var phrase = phrases.TryGetValue(field, out var table) && table.TryGetValue(token, out var p)
? p : token;
sb.Append(' ').Append(label).Append(": ").Append(phrase).Append('.');
}
return sb.Append(' ').Append(Negatives).ToString();
}
// 1. Estimate once; 2. board; 3. draw panel 3.
var estimate = await Call(HttpMethod.Post, "/estimate",
new Dictionary<string, object> { ["instruction"] = "probe", ["$model"] = "gpt-image" });
var hold = estimate.GetProperty("hold_credits").GetInt64();
var board = await Board("A night courier reaches the arcade after the shutters are down.");
foreach (var panel in board.GetProperty("panels").EnumerateArray())
{
if (panel.GetProperty("order").GetInt32() != 3) continue;
await Draw(CompileBrief(panel), "panel-03.png");
}
Console.WriteLine("reserved " + hold + " per picture");
Error codes
An error is a normal envelope with ok: false. Branch on
error.code, not on the HTTP status — the status is a summary, the code is the
fact.
| HTTP | error.code | What to do |
|---|---|---|
| 401 | unauthorized |
The token is missing, malformed or no longer valid. Do not retry — mint a fresh one from tokens.html and check the header really reads Bearer followed by the token. |
| 402 | payment_required |
The balance is below min_credits, so the hold could not be taken and nothing ran. Top up, or lower your in-flight count. error.details carries the shortfall. |
| 400 | validation_error |
The body is malformed. On the illustration lane the usual cause is a missing or mangled $model; on the storyboard lane it is a count outside 3 to 8 or a missing task. Fix the body; retrying unchanged will fail identically. |
| 429 | rate_limited |
Too many requests. Back off exponentially with jitter and retry. Polling faster than every 1.5 s is the most common way to land here. |
| 500 / 502 | internal |
The renderer failed. The run is not billed and the hold is released. Retry — with a fresh idempotency salt, or the replay will hand you the same failed job back. |
Before you ship
- Illustration bodies carry two keys:
instructionand$model. - Signed in is
subject_type === "user". There is no email, name or id on/me. - The picture is
output.images[0].b64;output.outputis empty on that lane. - Every storyboard run carries
board_instructions. - Idempotency keys carry a per-attempt salt.
- Report spend from
charged_credits, budget concurrency fromhold_credits. - The token lives in a secret store, never in a repository or a browser bundle.