Drive Flipbook Canvas from your own code
Everything the app does goes through one metered endpoint pair — /run and
/run-stream — plus a free /estimate. What distinguishes one lane from
another is the task field and the instruction set sent alongside it, so the whole
API surface is: get a token, price the body, send the body, parse the contract.
https://api.skillsafe.ai/v1/app-api
Every response is wrapped: success is
{"data": {...}}, failure is
{"error": {"code": "...", "message": "...", "details": {...}}}.
Authorize with Authorization: Bearer <token>.
The task field comes first
One app, one system prompt, one model. Five lanes. Which lane runs is decided by
task, and the lane's complete instruction set travels in the same body as
lane_instructions — fetch it from /prompts.js, where it is
served as window.FLIP_PROMPTS.plan and friends. It lives in the input rather than in
the system prompt for a specific reason: an image run joins the system prompt into the image
prompt, and a five-lane instruction set there would swamp the plate description.
| task | modality | what it does | extra fields |
|---|---|---|---|
plan | text | Divide a subject into an annotated page | — |
render | image | Draw the plate | instruction, $model: gpt-image, and no task |
annotate | vision | Transcribe the drawn labels | $files: [plate] |
pick | vision | Name what was pressed | $files: [marker] |
read | text | Write the placard | — |
If task is missing or unrecognised the model is told to answer the closest lane and
name it in the reply's own lane field rather than blending two contracts. The client
should trust that field over its own guess.
task: "plan" — Divide a subject into one annotated page. (text)
The only lane you need. topic is the subject; on a child page it is the label that was pressed, and seed carries the one-sentence brief the drill lane wrote. zones comes back with normalized box rectangles — the app draws them and resolves presses against them, so this geometry is load-bearing rather than decorative. child_seeds is what makes free drilling possible: one label and brief per drillable zone, so opening a child page needs no further run.
Request body:
{
"task": "plan",
"lane_instructions": "<the full text served at /prompts.js, FLIP_PROMPTS.plan>",
"topic": "a Jingdezhen dragon kiln",
"seed": "",
"user_note": "concentrate on the firing chamber",
"current_label": "",
"path": [],
"depth": 0,
"max_depth": 3,
"style": {
"id": "isometric",
"name": "Isometric cutaway",
"clause": "isometric cutaway illustration viewed from a slight elevated angle of about 30 degrees, fine confident line work, muted natural colours, soft beige background #F5EFE6, matte paper feel, no harsh shadows, never photorealistic"
},
"lang_hint": "en",
"clipped": 0
}
A successful reply — for the text and vision lanes this is the string in done.output.output, parsed:
{
"lane": "plan",
"title": "A dragon kiln in cross-section",
"caption": "A **Jingdezhen** egg-shaped wood kiln in section: 18 m long, 300 cubic metres, no fan anywhere - draught comes from a 12 m brick chimney and the slope of the floor. The head reaches **1320C** and the cool end only 900C, so a ware's position in the kiln is its grade.",
"subject": "the longitudinal section of a wood-fired dragon kiln",
"image_prompt": "A long cutaway of an egg-shaped wood-firing kiln built up a slope ...",
"zones": [
{
"id": "z1",
"heading": "Stoke hole",
"box": [
0.03,
0.42,
0.17,
0.44
],
"objects": [
"stoker",
"split pine",
"iron door"
],
"callouts": [
"40 t of pine",
"one charge / 4 min",
"1320C"
],
"drillable": true
},
{
"id": "z2",
"heading": "Firing chamber",
"box": [
0.2,
0.38,
0.16,
0.48
],
"objects": [
"ember bed",
"grate"
],
"callouts": [
"reducing flame",
"1.6 m deep"
],
"drillable": true
}
],
"facts": [
{
"claim": "The kiln is about 18 m long",
"kind": "number",
"confidence": "medium"
}
],
"avoid": "no gas burners, no pyrometers, no metal flue",
"child_seeds": [
{
"zone_id": "z1",
"label": "The stoke hole",
"seed": "A close view of the stoke hole the moment the iron door opens."
}
]
}
task: "render" — Illustrate the planned page. (image)
The one lane that carries no task and no lane_instructions. An image run joins the app's system prompt with the run input into a single text prompt, so every extra field competes with the scene description — send the compiled instruction and the $model override, nothing else. Use /run and poll: image runs emit no deltas. One 1024px image per run, returned as base64.
Request body:
{
"instruction": "A single coherent 16:9 encyclopedia diagram plate, densely annotated, drawn as one scene: A long cutaway of an egg-shaped wood-firing kiln ..., isometric cutaway illustration viewed from a slight elevated angle of about 30 degrees, fine confident line work, muted natural colours, soft beige background #F5EFE6, matte paper feel, no harsh shadows, never photorealistic, dense diagram-style in-image text annotations: ...",
"$model": "gpt-image"
}
A successful reply — for the text and vision lanes this is the string in done.output.output, parsed:
{
"output": {
"images": [
{
"content_type": "image/png",
"b64": "iVBORw0KGgo..."
}
]
},
"status": "succeeded",
"charged_credits": 148
}
task: "annotate" — Read the labels the drawing actually contains. (vision)
Upload the plate first (step 7) and pass its id as $files. This lane exists because an image model does not draw the labels it was asked for: it drops some, garbles some, invents others and moves the rest. fragments[].xy is what the drill lane's nearby_text is built from, so a fragment placed in the wrong quadrant sends the next press to the wrong subject. fidelity.verdict of poor means the text layer is mostly noise and the page is worth drawing again.
Request body:
{
"task": "annotate",
"lane_instructions": "<FLIP_PROMPTS.annotate>",
"planned": {
"title": "A dragon kiln in cross-section",
"caption": "...",
"zones": [
{
"id": "z1",
"heading": "Stoke hole",
"box": [
0.03,
0.42,
0.17,
0.44
],
"callouts": [
"40 t of pine",
"one charge / 4 min",
"1320C"
]
}
]
},
"topic": "a Jingdezhen dragon kiln",
"path": [],
"style": {
"id": "isometric",
"name": "Isometric cutaway",
"clause": "..."
},
"lang_hint": "en",
"$files": [
"file_01J8Z..."
]
}
A successful reply — for the text and vision lanes this is the string in done.output.output, parsed:
{
"lane": "annotate",
"fragments": [
{
"text": "Stoke hole",
"xy": [
0.115,
0.395
],
"kind": "heading",
"zone_id": "z1",
"legible": true,
"corrects": ""
},
{
"text": "1320C",
"xy": [
0.108,
0.605
],
"kind": "measure",
"zone_id": "z1",
"legible": true,
"corrects": ""
},
{
"text": "Stoke hoie",
"xy": [
0.42,
0.2
],
"kind": "callout",
"zone_id": "",
"legible": false,
"corrects": "Stoke hole"
}
],
"zone_check": [
{
"zone_id": "z1",
"heading_drawn": true,
"callouts_planned": 3,
"callouts_drawn": 2,
"placed_as_planned": true,
"note": ""
}
],
"fidelity": {
"verdict": "partial",
"fragments_total": 3,
"garbled": 1,
"dropped": [
"one charge / 4 min"
],
"invented": [],
"summary": "Both zone headings were drawn; a third of the callouts did not survive and one is unreadable."
}
}
task: "pick" — Turn a press into a subject and a brief. (vision)
Pass the plate with a red circled crosshair drawn at the press point as $files — the lane's instructions name that marker as the signal to trust above all others, and the app draws it in the browser on a canvas before uploading. The other signals are ranked below it in order: nearby_text, then zone_hit, then parent_image_prompt. evidence comes back naming which one decided the answer. A press on empty ground gets the refusal shape, which is a correct answer and not an error.
Request body:
{
"task": "pick",
"lane_instructions": "<FLIP_PROMPTS.pick>",
"click_xy": [
0.272,
0.455
],
"parent_title": "A dragon kiln in cross-section",
"parent_caption": "A Jingdezhen egg-shaped wood kiln in section ...",
"parent_image_prompt": "A long cutaway of an egg-shaped wood-firing kiln ...",
"nearby_text": [
{
"text": "reducing flame",
"xy": [
0.272,
0.455
],
"dist": 0.0,
"kind": "callout"
},
{
"text": "1.6 m deep",
"xy": [
0.278,
0.505
],
"dist": 0.0503,
"kind": "measure"
}
],
"zone_hit": {
"id": "z2",
"heading": "Firing chamber",
"box": [
0.2,
0.38,
0.16,
0.48
],
"objects": [
"ember bed",
"grate"
],
"callouts": [
"reducing flame",
"1.6 m deep"
],
"drillable": true
},
"existing_labels": [
{
"label": "The stoke hole",
"anchor_xy": [
0.06,
0.86
],
"leader_xy": [
0.115,
0.63
]
}
],
"path": [],
"depth": 0,
"max_depth": 3,
"style": {
"id": "isometric",
"name": "Isometric cutaway",
"clause": "..."
},
"lang_hint": "en",
"$files": [
"file_01J8ZMARKER..."
]
}
A successful reply — for the text and vision lanes this is the string in done.output.output, parsed:
{
"lane": "pick",
"confident": true,
"label": "The ember bed",
"anchor_xy": [
0.4,
0.62
],
"leader_xy": [
0.272,
0.455
],
"evidence": "marker - the red circle sits on the bed of embers under the grate",
"next_prompt": "A close cutaway of the ember bed: new charge, burning layer, ash and the grate below it.",
"why": "The thickness of the bed is what sets the kiln's temperature and its atmosphere."
}
And the refusal shape, which the app renders as a message rather than as an error:
{
"lane": "pick",
"confident": false,
"reason": "That is blank ground with nothing drawn in it - press one of the labelled zones instead."
}
task: "read" — Write the placard the caption summarises. (text)
Three to six sections of 60–140 words, one per zone that earns one, plus a glossary, the numbers with their confidence, and two to four questions the page leaves open — each of which the app offers as a child page. Section length is measured in word-equivalents at about 1.7 CJK characters to the word, so a Chinese placard is held to the same substance rather than to the same whitespace count.
Request body:
{
"task": "read",
"lane_instructions": "<FLIP_PROMPTS.read>",
"node": {
"title": "A dragon kiln in cross-section",
"caption": "...",
"subject": "the longitudinal section of a wood-fired dragon kiln",
"zones": [
{
"id": "z1",
"heading": "Stoke hole",
"objects": [
"stoker"
],
"callouts": [
"40 t of pine"
]
}
],
"facts": [
{
"claim": "The kiln is about 18 m long",
"kind": "number",
"confidence": "medium"
}
]
},
"fragments": [
{
"text": "Stoke hole",
"kind": "heading",
"zone_id": "z1"
}
],
"topic": "a Jingdezhen dragon kiln",
"user_note": "concentrate on the firing chamber",
"path": [],
"depth": 0,
"style": {
"id": "isometric",
"name": "Isometric cutaway",
"clause": "..."
},
"lang_hint": "en"
}
A successful reply — for the text and vision lanes this is the string in done.output.output, parsed:
{
"lane": "read",
"headline": "One kiln, twenty-eight hours, twenty thousand pots",
"standfirst": "The shape of a dragon kiln is not an aesthetic choice: the taper, the slope and the chimney together pull fire from the stoke hole to the cool end.",
"sections": [
{
"heading": "Why fire climbs",
"zone_id": "z5",
"body": "There is no fan anywhere in this kiln ..."
}
],
"glossary": [
{
"term": "saggar",
"gloss": "A refractory box that shields ware from ash and direct flame."
}
],
"numbers": [
{
"value": "1320C",
"means": "peak temperature at the firing chamber",
"confidence": "high"
}
],
"open_questions": [
{
"question": "Where do the baffle bricks go to lift the cool end?",
"would_show": "Several baffle arrangements and the temperature distribution each produces."
}
],
"reading_minutes": 4
}
The seven steps
Pick a language once; every block on the page follows it, and the choice is remembered.
1. Get a token
A guest token is minted on demand and can call /me and /estimate. The metered lanes need a personal token, which comes from signing in — the easiest route is the token page, which shows the token this browser already holds and copies it as a shell export.
# The shortest path is the token page: it shows the token this browser already
# holds for the app and copies it as a shell export.
# https://flipbook-canvas.skillsafe.ai/tokens.html
#
# Or mint a guest token from the command line. A guest can call /me and
# /estimate; the metered lanes need a personal token from signing in.
curl -s -X POST https://api.skillsafe.ai/v1/app-api/guest \
-H 'Content-Type: application/json' \
-d '{"slug":"flipbook-canvas"}'
# -> {"data":{"token":"aut_...","guest_id":"gst_..."}}
export FLIPBOOK_TOKEN="aut_..." # paste it, or use the token page's shell export
# Open https://flipbook-canvas.skillsafe.ai/tokens.html and press "Copy shell export",
# or mint a guest token:
import os, json, urllib.request
def guest():
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/guest",
data=json.dumps({"slug": "flipbook-canvas"}).encode(),
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req) as r:
return json.load(r)["data"]["token"]
TOKEN = os.environ.get("FLIPBOOK_TOKEN") or guest()
// Open https://flipbook-canvas.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token:
const API = "https://api.skillsafe.ai/v1/app-api";
async function guest() {
const res = await fetch(`${API}/guest`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "flipbook-canvas" }),
});
const json = await res.json();
return json.data.token;
}
let TOKEN = "YOUR_TOKEN"; // from the token page
if (TOKEN === "YOUR_TOKEN") TOKEN = await guest();
// Open https://flipbook-canvas.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token.
package main
import (
"bytes"; "encoding/json"; "net/http"; "os"
)
const API = "https://api.skillsafe.ai/v1/app-api"
func guest() (string, error) {
body, _ := json.Marshal(map[string]string{"slug": "flipbook-canvas"})
res, err := http.Post(API+"/guest", "application/json", bytes.NewReader(body))
if err != nil { return "", err }
defer res.Body.Close()
var out struct { Data struct { Token string `json:"token"` } }
json.NewDecoder(res.Body).Decode(&out)
return out.Data.Token, nil
}
func token() string {
if t := os.Getenv("FLIPBOOK_TOKEN"); t != "" { return t }
t, _ := guest()
return t
}
// Open https://flipbook-canvas.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token.
import java.net.URI;
import java.net.http.*;
static final String API = "https://api.skillsafe.ai/v1/app-api";
static final HttpClient HTTP = HttpClient.newHttpClient();
static String guest() throws Exception {
HttpRequest req = HttpRequest.newBuilder(URI.create(API + "/guest"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"flipbook-canvas\"}"))
.build();
String body = HTTP.send(req, HttpResponse.BodyHandlers.ofString()).body();
// any JSON library; the token is at data.token
return body.split("\"token\":\"")[1].split("\"")[0];
}
static String TOKEN = System.getenv("FLIPBOOK_TOKEN"); // or paste "YOUR_TOKEN"
# Open https://flipbook-canvas.skillsafe.ai/tokens.html and press "Copy token",
# or mint a guest token.
require "net/http"
require "json"
require "uri"
API = "https://api.skillsafe.ai/v1/app-api"
def guest
uri = URI("#{API}/guest")
res = Net::HTTP.post(uri, { slug: "flipbook-canvas" }.to_json,
"Content-Type" => "application/json")
JSON.parse(res.body)["data"]["token"]
end
TOKEN = ENV.fetch("FLIPBOOK_TOKEN") { guest }
<?php
// Open https://flipbook-canvas.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token.
const API = "https://api.skillsafe.ai/v1/app-api";
function post_json(string $path, array $body, ?string $token = null): array {
$headers = ["Content-Type: application/json"];
if ($token) $headers[] = "Authorization: Bearer $token";
$ctx = stream_context_create(["http" => [
"method" => "POST",
"header" => implode("\r\n", $headers),
"content" => json_encode($body),
"ignore_errors" => true,
]]);
return json_decode(file_get_contents(API . $path, false, $ctx), true);
}
$guest = post_json("/guest", ["slug" => "flipbook-canvas"]);
$token = getenv("FLIPBOOK_TOKEN") ?: $guest["data"]["token"];
// Open https://flipbook-canvas.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token.
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
const string Api = "https://api.skillsafe.ai/v1/app-api";
var http = new HttpClient();
async Task<string> GuestAsync() {
var res = await http.PostAsJsonAsync($"{Api}/guest", new { slug = "flipbook-canvas" });
var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
return doc.RootElement.GetProperty("data").GetProperty("token").GetString()!;
}
var token = Environment.GetEnvironmentVariable("FLIPBOOK_TOKEN") ?? await GuestAsync();
http.DefaultRequestHeaders.Add("Authorization", "Bearer " + token);
2. Check who you are and what you can afford
credits is the balance in credits, where 10,000 credits is one US dollar. Compare it against the estimate before you submit anything.
curl -s https://api.skillsafe.ai/v1/app-api/me -H "Authorization: Bearer $FLIPBOOK_TOKEN"
# -> {"data":{"subject_type":"user","credits":184200,"user_id":"usr_..."}}
#
# subject_type is "guest" until you sign in. Guests can price a run but not run it,
# unless the publisher has sponsorship switched on.
def get(path):
req = urllib.request.Request(API + path,
headers={"Authorization": "Bearer " + TOKEN})
with urllib.request.urlopen(req) as r:
return json.load(r)["data"]
me = get("/me")
print(me["subject_type"], me["credits"])
async function api(path, body) {
const res = await fetch(API + path, {
method: body ? "POST" : "GET",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${TOKEN}` },
body: body ? JSON.stringify(body) : undefined,
});
const json = await res.json();
if (!res.ok) throw new Error(json.error?.message || res.status);
return json.data;
}
const me = await api("/me");
console.log(me.subject_type, me.credits);
func apiGet(path, token string) (map[string]any, error) {
req, _ := http.NewRequest("GET", API+path, nil)
req.Header.Set("Authorization", "Bearer "+token)
res, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
defer res.Body.Close()
var out struct{ Data map[string]any }
json.NewDecoder(res.Body).Decode(&out)
return out.Data, nil
}
me, _ := apiGet("/me", token())
fmt.Println(me["subject_type"], me["credits"])
HttpRequest me = HttpRequest.newBuilder(URI.create(API + "/me"))
.header("Authorization", "Bearer " + TOKEN)
.GET().build();
System.out.println(HTTP.send(me, HttpResponse.BodyHandlers.ofString()).body());
def api_get(path)
uri = URI("#{API}#{path}")
req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
JSON.parse(res.body)["data"]
end
me = api_get("/me")
puts "#{me['subject_type']} #{me['credits']}"
<?php
function get_json(string $path, string $token): array {
$ctx = stream_context_create(["http" => [
"method" => "GET",
"header" => "Authorization: Bearer $token",
"ignore_errors" => true,
]]);
return json_decode(file_get_contents(API . $path, false, $ctx), true);
}
$me = get_json("/me", $token)["data"];
echo $me["subject_type"], " ", $me["credits"], "\n";
var me = await http.GetFromJsonAsync<JsonDocument>($"{Api}/me");
var d = me!.RootElement.GetProperty("data");
Console.WriteLine($"{d.GetProperty("subject_type")} {d.GetProperty("credits")}");
3. Price the exact body you mean to run
Free, and per lane: hold_credits differs between lanes because the prompts and the output caps differ, so never show one lane's hold for another's run. hold_credits is what is reserved; the settled charged_credits is usually far lower, because the hold prices the full output cap. A run whose balance sits between min_credits and hold_credits still executes, with a reduced cap and truncated: true in the result.
# Free. No job, no charge. Price the exact body you intend to run - every lane
# has a different hold because the prompts and the output caps differ.
curl -s -X POST https://api.skillsafe.ai/v1/app-api/estimate \
-H "Authorization: Bearer $FLIPBOOK_TOKEN" \
-H 'Content-Type: application/json' \
-d @plan-body.json
# -> {"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra","markup_bps":1000,
# "hold_credits":3120,"min_credits":420,"sponsor_enabled":false}}
# The plate lane prices the image tier instead:
curl -s -X POST https://api.skillsafe.ai/v1/app-api/estimate \
-H "Authorization: Bearer $FLIPBOOK_TOKEN" -H 'Content-Type: application/json' \
-d '{"instruction":"...","$model":"gpt-image"}'
# -> hold_credits is priced per image, not per token
def post(path, body):
req = urllib.request.Request(
API + path, data=json.dumps(body).encode(),
headers={"Content-Type": "application/json",
"Authorization": "Bearer " + TOKEN})
with urllib.request.urlopen(req) as r:
return json.load(r)["data"]
est = post("/estimate", plan_body)
print(est["model"], est["model_alias"], est["hold_credits"], est["min_credits"])
assert est["hold_credits"] <= me["credits"], "top up before running"
# The plate lane is priced separately, per image.
img = post("/estimate", {"instruction": "...", "$model": "gpt-image"})
const est = await api("/estimate", planBody);
console.log(est.model, est.model_alias, est.hold_credits, est.min_credits);
if (est.hold_credits > me.credits) throw new Error("not enough credits");
// The plate lane, priced per image:
const imgEst = await api("/estimate", { instruction: "...", $model: "gpt-image" });
func apiPost(path string, body any, token string) (map[string]any, error) {
b, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", API+path, bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
res, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
defer res.Body.Close()
var out struct{ Data map[string]any }
json.NewDecoder(res.Body).Decode(&out)
return out.Data, nil
}
est, _ := apiPost("/estimate", planBody, token())
fmt.Println(est["model"], est["hold_credits"])
String body = mapper.writeValueAsString(planBody);
HttpRequest est = HttpRequest.newBuilder(URI.create(API + "/estimate"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
System.out.println(HTTP.send(est, HttpResponse.BodyHandlers.ofString()).body());
def api_post(path, body)
uri = URI("#{API}#{path}")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json",
"Authorization" => "Bearer #{TOKEN}")
req.body = body.to_json
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
JSON.parse(res.body)["data"]
end
est = api_post("/estimate", plan_body)
puts "#{est['model']} holds #{est['hold_credits']}"
<?php
$est = post_json("/estimate", $planBody, $token)["data"];
printf("%s holds %d (min %d)\n",
$est["model"], $est["hold_credits"], $est["min_credits"]);
var estRes = await http.PostAsJsonAsync($"{Api}/estimate", planBody);
var est = JsonDocument.Parse(await estRes.Content.ReadAsStringAsync())
.RootElement.GetProperty("data");
Console.WriteLine($"{est.GetProperty("model")} holds {est.GetProperty("hold_credits")}");
4. Run and poll
Use this for the plate lane. Send an Idempotency-Key on every attempt. Two things to know about it: a replay returns the original job rather than billing twice, and that holds even when the original failed — so salt the key with something that changes per page load, or a retry after a reload will replay the old failure and no new job will appear in the account's job list at all.
# /run returns a job id immediately; poll /jobs/{id} until it is terminal.
# This is the lane to use for plates: an image run streams no deltas, so
# run-stream would buy you nothing.
#
# Send an Idempotency-Key on EVERY attempt. A retry that reuses the key returns
# the original job instead of billing a second one - including when the original
# FAILED, so salt the key per page load rather than deriving it only from input.
JOB=$(curl -s -X POST https://api.skillsafe.ai/v1/app-api/run \
-H "Authorization: Bearer $FLIPBOOK_TOKEN" \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: flipbook-canvas:render:p1abc:9f3a2:l7x8k:a1' \
-d '{"instruction":"...","$model":"gpt-image"}' | python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["job_id"])')
until curl -s https://api.skillsafe.ai/v1/app-api/jobs/$JOB -H "Authorization: Bearer $FLIPBOOK_TOKEN" \
| grep -qE '"status":"(succeeded|failed)"'; do sleep 2; done
curl -s https://api.skillsafe.ai/v1/app-api/jobs/$JOB -H "Authorization: Bearer $FLIPBOOK_TOKEN" > job.json
# succeeded: data.output.images[0] = {"content_type":"image/png","b64":"..."}
# failed: data.error is a plain STRING on this platform, not an object
import base64, time
job = post("/run", render_body) # send an Idempotency-Key header in real code
job_id = job["job_id"]
while True:
j = get(f"/jobs/{job_id}")
if j["status"] in ("succeeded", "failed"):
break
time.sleep(2)
if j["status"] != "succeeded":
err = j.get("error")
raise SystemExit(err if isinstance(err, str) else err.get("message", "run failed"))
img = j["output"]["images"][0]
open("plate.png", "wb").write(base64.b64decode(img["b64"]))
print("charged", j.get("charged_credits"))
const started = await fetch(`${API}/run`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${TOKEN}`,
"Idempotency-Key": `${"flipbook-canvas"}:render:${nodeId}:${hash}:${nonce}:a1`,
},
body: JSON.stringify(renderBody),
}).then((r) => r.json());
let job;
do {
await new Promise((r) => setTimeout(r, 2000));
job = await api(`/jobs/${started.data.job_id}`);
} while (job.status !== "succeeded" && job.status !== "failed");
if (job.status !== "succeeded") {
// `error` arrives as a plain string here, not { message }
throw new Error(typeof job.error === "string" ? job.error : job.error?.message);
}
const b64 = job.output.images[0].b64;
started, _ := apiPost("/run", renderBody, token()) // set Idempotency-Key too
jobID := started["job_id"].(string)
var job map[string]any
for {
job, _ = apiGet("/jobs/"+jobID, token())
s, _ := job["status"].(string)
if s == "succeeded" || s == "failed" { break }
time.Sleep(2 * time.Second)
}
if job["status"] != "succeeded" {
log.Fatalf("run failed: %v", job["error"]) // a string, not an object
}
HttpRequest run = HttpRequest.newBuilder(URI.create(API + "/run"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", "flipbook-canvas:render:p1abc:9f3a2:l7x8k:a1")
.POST(HttpRequest.BodyPublishers.ofString(renderBody))
.build();
String started = HTTP.send(run, HttpResponse.BodyHandlers.ofString()).body();
String jobId = started.split("\"job_id\":\"")[1].split("\"")[0];
String job;
do {
Thread.sleep(2000);
HttpRequest poll = HttpRequest.newBuilder(URI.create(API + "/jobs/" + jobId))
.header("Authorization", "Bearer " + TOKEN).GET().build();
job = HTTP.send(poll, HttpResponse.BodyHandlers.ofString()).body();
} while (!job.contains("\"succeeded\"") && !job.contains("\"failed\""));
started = api_post("/run", render_body) # add an Idempotency-Key header
job_id = started["job_id"]
job = nil
loop do
job = api_get("/jobs/#{job_id}")
break if %w[succeeded failed].include?(job["status"])
sleep 2
end
abort(job["error"].to_s) unless job["status"] == "succeeded"
File.binwrite("plate.png", Base64.decode64(job["output"]["images"][0]["b64"]))
<?php
$started = post_json("/run", $renderBody, $token)["data"];
$jobId = $started["job_id"];
do {
sleep(2);
$job = get_json("/jobs/$jobId", $token)["data"];
} while (!in_array($job["status"], ["succeeded", "failed"], true));
if ($job["status"] !== "succeeded") {
// `error` is a plain string on this platform
exit(is_string($job["error"]) ? $job["error"] : "run failed");
}
file_put_contents("plate.png", base64_decode($job["output"]["images"][0]["b64"]));
var req = new HttpRequestMessage(HttpMethod.Post, $"{Api}/run") {
Content = JsonContent.Create(renderBody)
};
req.Headers.Add("Idempotency-Key", "flipbook-canvas:render:p1abc:9f3a2:l7x8k:a1");
var startedRes = await http.SendAsync(req);
var jobId = JsonDocument.Parse(await startedRes.Content.ReadAsStringAsync())
.RootElement.GetProperty("data").GetProperty("job_id").GetString();
JsonElement job;
do {
await Task.Delay(2000);
var poll = await http.GetStringAsync($"{Api}/jobs/{jobId}");
job = JsonDocument.Parse(poll).RootElement.GetProperty("data");
} while (job.GetProperty("status").GetString() is not ("succeeded" or "failed"));
5. Stream the text lanes
The four text and vision lanes stream. Frames are event: plus data:, separated by a blank line; delta carries a chunk of the JSON being written, done carries the whole reply plus charged_credits and truncated. Keep the accumulated deltas: if the stream dies mid-flight, whatever parsed is still worth rendering, and the app does exactly that.
# The text lanes stream. Each SSE frame is `event:` plus `data:`; the useful
# events are `delta` (a chunk of the JSON being written), `job`, `done` and
# `error`. `done.output.output` holds the whole reply.
curl -N -s -X POST https://api.skillsafe.ai/v1/app-api/run-stream \
-H "Authorization: Bearer $FLIPBOOK_TOKEN" \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: flipbook-canvas:plan:p1abc:9f3a2:l7x8k:a1' \
-d @plan-body.json
# event: job
# data: {"job_id":"job_..."}
# event: delta
# data: {"text":"{\"lane\":\"plan\",\"title\":\"A dragon kiln"}
# ...
# event: done
# data: {"job_id":"job_...","status":"succeeded","charged_credits":812,
# "truncated":false,"output":{"output":"{...the whole JSON object...}"}}
import urllib.request
req = urllib.request.Request(
API + "/run-stream", data=json.dumps(plan_body).encode(),
headers={"Content-Type": "application/json",
"Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "flipbook-canvas:plan:p1abc:9f3a2:l7x8k:a1"})
raw, done = "", None
with urllib.request.urlopen(req) as r:
event, data = "message", ""
for line in r:
line = line.decode().rstrip("\n")
if line.startswith("event:"):
event = line[6:].strip()
elif line.startswith("data:"):
data += line[5:].strip()
elif line == "":
if data:
payload = json.loads(data)
if event == "delta":
raw += payload.get("text", "")
elif event in ("done", "pending"):
done = payload
elif event == "error":
raise SystemExit(payload.get("message", "stream failed"))
event, data = "message", ""
plan = json.loads((done or {}).get("output", {}).get("output") or raw)
print(plan["title"], len(plan["zones"]), "zones")
// In a browser the vendored SDK does this for you:
// const done = await ss.runStream(input, { idempotencyKey, onDelta, onJob });
// Outside the browser, read the SSE stream directly:
const res = await fetch(`${API}/run-stream`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${TOKEN}`,
"Idempotency-Key": `flipbook-canvas:plan:${nodeId}:${hash}:${nonce}:a1`,
},
body: JSON.stringify(planBody),
});
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "", raw = "", done = null;
for (;;) {
const { value, done: end } = await reader.read();
if (end) break;
buf += dec.decode(value, { stream: true });
let i;
while ((i = buf.indexOf("\n\n")) >= 0) {
const frame = buf.slice(0, i); buf = buf.slice(i + 2);
let ev = "message", data = "";
frame.split("\n").forEach((l) => {
if (l.startsWith("event:")) ev = l.slice(6).trim();
else if (l.startsWith("data:")) data += l.slice(5).trim();
});
if (!data) continue;
const p = JSON.parse(data);
if (ev === "delta") raw += p.text || "";
else if (ev === "done" || ev === "pending") done = p;
else if (ev === "error") throw new Error(p.message);
}
}
const plan = JSON.parse(done?.output?.output || raw);
b, _ := json.Marshal(planBody)
req, _ := http.NewRequest("POST", API+"/run-stream", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token())
req.Header.Set("Idempotency-Key", "flipbook-canvas:plan:p1abc:9f3a2:l7x8k:a1")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 1<<20), 1<<20)
var raw strings.Builder
event := "message"
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event:"):
event = strings.TrimSpace(line[6:])
case strings.HasPrefix(line, "data:"):
var p struct {
Text string `json:"text"`
Output struct{ Output string `json:"output"` } `json:"output"`
}
json.Unmarshal([]byte(strings.TrimSpace(line[5:])), &p)
if event == "delta" { raw.WriteString(p.Text) }
if event == "done" && p.Output.Output != "" { raw.Reset(); raw.WriteString(p.Output.Output) }
}
}
fmt.Println(raw.String())
HttpRequest stream = HttpRequest.newBuilder(URI.create(API + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", "flipbook-canvas:plan:p1abc:9f3a2:l7x8k:a1")
.POST(HttpRequest.BodyPublishers.ofString(planBody))
.build();
StringBuilder raw = new StringBuilder();
HTTP.send(stream, HttpResponse.BodyHandlers.ofLines()).body().forEach(line -> {
if (line.startsWith("data:")) {
String data = line.substring(5).trim();
// parse with any JSON library: delta frames carry .text,
// the done frame carries .output.output
if (data.contains("\"text\"")) raw.append(data);
}
});
System.out.println(raw);
require "net/http"
uri = URI("#{API}/run-stream")
req = Net::HTTP::Post.new(uri,
"Content-Type" => "application/json",
"Authorization" => "Bearer #{TOKEN}",
"Idempotency-Key" => "flipbook-canvas:plan:p1abc:9f3a2:l7x8k:a1")
req.body = plan_body.to_json
raw = ""
done = nil
event = "message"
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
line = line.chomp
if line.start_with?("event:") then event = line[6..].strip
elsif line.start_with?("data:")
p = JSON.parse(line[5..].strip) rescue next
raw << (p["text"] || "") if event == "delta"
done = p if event == "done"
end
end
end
end
end
plan = JSON.parse(done&.dig("output", "output") || raw)
<?php
// SSE over a plain stream: read line by line and reassemble the frames.
$ctx = stream_context_create(["http" => [
"method" => "POST",
"header" => implode("\r\n", [
"Content-Type: application/json",
"Authorization: Bearer $token",
"Idempotency-Key: flipbook-canvas:plan:p1abc:9f3a2:l7x8k:a1",
]),
"content" => json_encode($planBody),
]]);
$fh = fopen(API . "/run-stream", "r", false, $ctx);
$raw = ""; $done = null; $event = "message";
while (($line = fgets($fh)) !== false) {
$line = rtrim($line, "\r\n");
if (str_starts_with($line, "event:")) { $event = trim(substr($line, 6)); continue; }
if (!str_starts_with($line, "data:")) continue;
$p = json_decode(trim(substr($line, 5)), true);
if ($event === "delta") $raw .= $p["text"] ?? "";
if ($event === "done") $done = $p;
}
fclose($fh);
$plan = json_decode($done["output"]["output"] ?? $raw, true);
var streamReq = new HttpRequestMessage(HttpMethod.Post, $"{Api}/run-stream") {
Content = JsonContent.Create(planBody)
};
streamReq.Headers.Add("Idempotency-Key", "flipbook-canvas:plan:p1abc:9f3a2:l7x8k:a1");
using var res = await http.SendAsync(streamReq, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
var raw = new StringBuilder();
string ev = "message";
while (await reader.ReadLineAsync() is string line) {
if (line.StartsWith("event:")) { ev = line[6..].Trim(); continue; }
if (!line.StartsWith("data:")) continue;
var p = JsonDocument.Parse(line[5..].Trim()).RootElement;
if (ev == "delta" && p.TryGetProperty("text", out var t)) raw.Append(t.GetString());
if (ev == "done" && p.TryGetProperty("output", out var o))
raw.Clear().Append(o.GetProperty("output").GetString());
}
Console.WriteLine(raw.ToString());
6. Upload an image for the vision lanes
Both vision lanes take $files. Files are R2-backed, 10 MB each, and GET /files/{id}/url returns a short-lived signed URL that works directly in an <img src>. Quota is 500 files and 20 MB per user, which is why the app re-encodes a plate to a 1280px JPEG before storing it rather than keeping the raw PNG.
# The two vision lanes need an image on the platform first. Upload it, then pass
# its id as $files. `annotate` sends the plate; `pick` sends the plate with a red
# circled crosshair drawn at the press point.
FILE=$(curl -s -X POST https://api.skillsafe.ai/v1/app-api/files \
-H "Authorization: Bearer $FLIPBOOK_TOKEN" \
-F 'file=@plate.jpg' -F 'name=plate.jpg' \
| python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["file"]["file_id"])')
# then include it in the run body
jq --arg f "$FILE" '. + {"$files": [$f]}' annotate-body.json > body.json
curl -s -X POST https://api.skillsafe.ai/v1/app-api/run-stream \
-H "Authorization: Bearer $FLIPBOOK_TOKEN" -H 'Content-Type: application/json' \
-H "Idempotency-Key: flipbook-canvas:annotate:p1abc:aa11:l7x8k:a1" -d @body.json
# A short-lived signed URL for display (works directly in <img src>):
curl -s https://api.skillsafe.ai/v1/app-api/files/$FILE/url -H "Authorization: Bearer $FLIPBOOK_TOKEN"
import mimetypes, uuid
def upload(path):
boundary = "----flip" + uuid.uuid4().hex
name = os.path.basename(path)
mime = mimetypes.guess_type(name)[0] or "application/octet-stream"
body = b"".join([
f"--{boundary}\r\n".encode(),
f'Content-Disposition: form-data; name="file"; filename="{name}"\r\n'.encode(),
f"Content-Type: {mime}\r\n\r\n".encode(),
open(path, "rb").read(), b"\r\n",
f"--{boundary}--\r\n".encode(),
])
req = urllib.request.Request(
API + "/files", data=body,
headers={"Content-Type": f"multipart/form-data; boundary={boundary}",
"Authorization": "Bearer " + TOKEN})
with urllib.request.urlopen(req) as r:
return json.load(r)["data"]["file"]["file_id"]
annotate_body["$files"] = [upload("plate.jpg")]
// In the browser, the SDK wraps this:
// const f = await ss.files.upload(new File([blob], "plate.jpg", { type: "image/jpeg" }));
// img.src = await ss.files.url(f.file_id);
const fd = new FormData();
fd.append("file", blob, "plate.jpg");
fd.append("name", "plate.jpg");
const up = await fetch(`${API}/files`, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}` }, // no Content-Type: FormData sets it
body: fd,
}).then((r) => r.json());
annotateBody.$files = [up.data.file.file_id];
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
part, _ := w.CreateFormFile("file", "plate.jpg")
f, _ := os.Open("plate.jpg")
io.Copy(part, f)
w.WriteField("name", "plate.jpg")
w.Close()
req, _ := http.NewRequest("POST", API+"/files", &buf)
req.Header.Set("Content-Type", w.FormDataContentType())
req.Header.Set("Authorization", "Bearer "+token())
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
var out struct {
Data struct{ File struct{ FileID string `json:"file_id"` } } `json:"data"`
}
json.NewDecoder(res.Body).Decode(&out)
annotateBody["$files"] = []string{out.Data.File.FileID}
// java.net.http has no multipart builder; assemble the body yourself.
String boundary = "----flip" + System.nanoTime();
byte[] image = Files.readAllBytes(Path.of("plate.jpg"));
var out = new ByteArrayOutputStream();
out.writeBytes(("--" + boundary + "\r\n").getBytes());
out.writeBytes(("Content-Disposition: form-data; name=\"file\"; filename=\"plate.jpg\"\r\n").getBytes());
out.writeBytes("Content-Type: image/jpeg\r\n\r\n".getBytes());
out.writeBytes(image);
out.writeBytes(("\r\n--" + boundary + "--\r\n").getBytes());
HttpRequest up = HttpRequest.newBuilder(URI.create(API + "/files"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "multipart/form-data; boundary=" + boundary)
.POST(HttpRequest.BodyPublishers.ofByteArray(out.toByteArray()))
.build();
String uploaded = HTTP.send(up, HttpResponse.BodyHandlers.ofString()).body();
require "net/http/post/multipart" # gem: multipart-post
uri = URI("#{API}/files")
File.open("plate.jpg") do |f|
req = Net::HTTP::Post::Multipart.new(uri.path,
"file" => UploadIO.new(f, "image/jpeg", "plate.jpg"),
"name" => "plate.jpg")
req["Authorization"] = "Bearer #{TOKEN}"
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
file_id = JSON.parse(res.body)["data"]["file"]["file_id"]
annotate_body["$files"] = [file_id]
end
<?php
$ch = curl_init(API . "/files");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer $token"],
CURLOPT_POSTFIELDS => [
"file" => new CURLFile("plate.jpg", "image/jpeg", "plate.jpg"),
"name" => "plate.jpg",
],
CURLOPT_RETURNTRANSFER => true,
]);
$uploaded = json_decode(curl_exec($ch), true);
curl_close($ch);
$annotateBody['$files'] = [$uploaded["data"]["file"]["file_id"]];
using var form = new MultipartFormDataContent();
var bytes = await File.ReadAllBytesAsync("plate.jpg");
var part = new ByteArrayContent(bytes);
part.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg");
form.Add(part, "file", "plate.jpg");
form.Add(new StringContent("plate.jpg"), "name");
var upRes = await http.PostAsync($"{Api}/files", form);
var fileId = JsonDocument.Parse(await upRes.Content.ReadAsStringAsync())
.RootElement.GetProperty("data").GetProperty("file")
.GetProperty("file_id").GetString();
7. Parse the contract, and mean it
Every lane's reply is a single JSON object and nothing else — no prose, no fences. In practice
the failures are predictable: a ```json fence, a sentence of preamble, a trailing
offer of help. Match braces from the first { to its partner while tracking string
state rather than reaching for a greedy regular expression, which breaks on a }
inside a caption and on a second object after the first.
Then validate against the lane's contract and decide what is cosmetic and what is structural. A caption three characters over its limit is worth trimming and noting. A plan with two zones instead of six is a different page from the one you asked for, and the right move is one retry carrying a note that names exactly what was wrong — a generic "reply in JSON" retry bills a second run to make the same mistake. The app's own implementation of this is /contract.js, which is worth reading before writing your own.
Errors
| status | code | what it means |
|---|---|---|
400 | VALIDATION_ERROR | The body is malformed, or a field the lane requires is missing. Check the lane's input shape above; nothing is billed. |
401 | UNAUTHORIZED | No token, an expired token, or a creator API key — a creator key is not valid on /v1/app-api/*. Mint a guest token or sign in. |
402 | INSUFFICIENT_CREDITS | The balance cannot cover min_credits. Compare against /estimate before submitting; a 402 after submit is a bug in your client, not in the user's wallet. |
403 | FORBIDDEN | The token is scoped to a different app, or the lane needs a signed-in user and the token is a guest with no sponsorship available. |
404 | NOT_FOUND | Unknown job id, file id, or collection. A collection 404 usually means it was never declared in the release. |
409 | CONFLICT | An Idempotency-Key replay whose body differs from the original. Change the key or send the original body. |
429 | RATE_LIMITED | Back off. Vector similarity is limited to 30 a minute per IP; the other data endpoints share 120 a minute. |
503 | UNAVAILABLE | The model tier is momentarily unavailable. A failed run refunds its whole hold. |
A failed job carries error as a plain string on this platform, not
as an object with a message. Read both shapes.
Storing canvases
The app declares one collection, canvases, with acl_read: owner and
acl_write: user. Records nest the document under doc —
{"record_id": "...", "doc": {...}} — so read rec.doc and never the
fields flat. Queries take an operator object per field
({"where": {"node_count": {"gte": 4}}}; a bare value is rejected) and order with
sort, an object — order_by is silently ignored and falls back to
created_at desc. POST /collections/canvases/similar searches by meaning
over title, topic, summary and trail, and
resolves with the records array directly rather than a {records} envelope.
One document is capped at 64 KB, which a deep canvas will exceed. The app sheds placards, then text layers, then thumbnails, then child seeds, then image prompts, then zone detail, then its deepest pages — and records which, so a restored canvas can say what is missing instead of pretending to be whole.
What you do not need this API for
A good deal of the app never calls the platform, and reimplementing it against the API would be paying for arithmetic. The planned SVG plate, the zone hit-test, the press marker, the spatial and semantic dedup, the free child seeds, the label collision spread, and all four exports are client-side. The modules are served from this origin and readable: canvas.js, svgcanvas.js, overlay.js, contract.js, exporter.js, prompts.js.