← Flipbook Canvas / API
Your token

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.

Base URL

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.

taskmodalitywhat it doesextra fields
plantextDivide a subject into an annotated page
renderimageDraw the plateinstruction, $model: gpt-image, and no task
annotatevisionTranscribe the drawn labels$files: [plate]
pickvisionName what was pressed$files: [marker]
readtextWrite 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

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.

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

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

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...}"}}

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"

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

statuscodewhat it means
400VALIDATION_ERRORThe body is malformed, or a field the lane requires is missing. Check the lane's input shape above; nothing is billed.
401UNAUTHORIZEDNo 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.
402INSUFFICIENT_CREDITSThe 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.
403FORBIDDENThe 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.
404NOT_FOUNDUnknown job id, file id, or collection. A collection 404 usually means it was never declared in the release.
409CONFLICTAn Idempotency-Key replay whose body differs from the original. Change the key or send the original body.
429RATE_LIMITEDBack off. Vector similarity is limited to 30 a minute per IP; the other data endpoints share 120 a minute.
503UNAVAILABLEThe 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.