Drive Balloon Page from your own code
Everything the page asks the model is available over HTTP. A natural use is a storyboard kept as data - a lesson plan, a safety briefing, a family story written as one beat per line - where a script lays the beats onto panels, asks for a scripted page, and then tightens only the panels whose words will not fit the art.
One thing to be clear about first: the model never sees geometry. The caller lays out the page and sends, per panel, its id in reading order, the grid columns and rows it spans, its shot, its beat and a word budget computed from its printed size. The model returns words and pacing; the caller checks every panel id, every budget and every split or merge against its own layout before placing anything. The page's layout engine ships as plain scripts you can load in node — see the end of the examples.
Base URL and the envelope
Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response uses one envelope:
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "status": 402, "details": { ... } } }
The token is minted for this app (the guest call takes {"slug":"balloon-page"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object: never wrap it as {"input": {…}} — that returns 200, warns unknown field 'input', and hides every field from the model.
Errors:
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The body is not a JSON object. |
| 401 | unauthorized | No token, or a stale one. Mint a guest token or sign in again. |
| 402 | insufficient_credits | The balance is under min_credits. Price with /estimate first. |
| 403 | forbidden | A guest token tried to run: scripting is metered and needs a personal token. |
| 404 | not_found | Unknown job id. |
| 429 | rate_limited | Back off and retry. |
| 5xx | server_error | Transient. Retry with the same Idempotency-Key so a retry never double-bills. |
The task field comes first
One system prompt serves two tasks. task picks the contract; a missing or unknown task makes the model pick the closest one and name it in the reply's task.
| task | When | Extra fields |
|---|---|---|
script | Write the page from its beats: per panel a shot, an action line, a caption and up to three lines of dialogue, plus pacing suggestions (split, merge, add a beat panel). | Optional unplaced_beats. |
tighten | Rewrite only the named panels so their words fit the panel's budget, keeping the speakers and the information. | targets (required for this task), optional why. |
Every run body is a JSON object of string fields (the declared input schema; task, title, audience, cast, reading and pages are required, and /estimate warns when one is missing).
| Field | What it holds |
|---|---|
task | "script" or "tighten". |
title, audience, tone | The comic's title, who it is for ("kids 5-8", "adults" ...) and an optional tone. |
cast | "Mia (girl, 7); Grandpa Joe (her grandfather)" - the only speakers allowed. |
reading | "left-to-right" or "right-to-left". |
pages | JSON string: [{"page":1,"layout":"2x2","format":"page|strip","grid":"2 cols x 2 rows","panels":[{"id":"1.1","cols":"1","rows":"1-2","shot":"wide","beat":"...","budget_words":30,"action"?,"figures"?:["Mia (wave, happy)"],"balloons"?:[{"kind","speaker","text"}]}]}]. Panel ids are page.position in reading order (recursive XY-cut). A beat is cut to 220 characters, marked [...]; past 60 panels whole pages are left out. |
unplaced_beats | Optional JSON string: beats with no panel yet. |
targets | tighten only: "1.2, 1.3". |
why | tighten only, optional: what the person wants. |
clipped | Optional: what was shortened or left out. |
The reply text is one JSON object:
// task "script"
{ "task": "script", "title": "...", "logline": "...",
"panels": [{ "id": "1.1", "shot": "establishing|wide|medium|close|extreme|over", "action": "...", "caption": "...",
"dialogue": [{ "speaker": "Mia", "kind": "speech|thought|shout|whisper", "text": "..." }], "sfx": "", "note": "" }],
"pacing": [{ "type": "split", "panel": "1.2", "direction": "vertical|horizontal", "why": "..." },
{ "type": "merge", "panels": ["1.3", "1.4"], "why": "..." },
{ "type": "add_beat", "after": "1.2", "beat": "...", "why": "..." }],
"notes": ["..."] }
// task "tighten"
{ "task": "tighten", "panels": [{ "id": "1.2", "caption": "", "dialogue": [...], "cut": "what changed and why" }], "notes": [] }
Check before you place: a panel id that was not sent is ignored, caption plus dialogue words over budget_words mark the panel "needs a bigger panel", a speaker outside cast is flagged, and a merge of two panels that do not share a full edge (same rows and touching columns, or same columns and touching rows) is invalid.
Worked examples
These are the bundled examples on the page: the exact run body the page builds and a reply produced against the shipped prompt (the page replays the same replies for free).
script: a kids' story over two pages (splash + strip, then 2 x 2)
Input:
{
"task": "script",
"title": "The Runaway Kite",
"audience": "kids 5-8",
"cast": "Mia (girl, 7, brave and a bit bossy); Grandpa Joe (her grandfather, slow on his feet, full of jokes); Biscuit (their scruffy dog, only barks)",
"reading": "left-to-right",
"pages": "(a JSON string - decoded below)",
"tone": "warm, funny, a little breathless"
}
pages, decoded:
[
{
"page": 1,
"layout": "splash-strip",
"format": "page",
"grid": "3 cols x 3 rows",
"panels": [
{
"id": "1.1",
"cols": "1-3",
"rows": "1-2",
"shot": "establishing",
"beat": "A windy hill in the park: Mia and Grandpa Joe fly a red kite while Biscuit watches.",
"budget_words": 40,
"figures": [
"Mia (point, happy)",
"Grandpa Joe (stand, happy)"
]
},
{
"id": "1.2",
"cols": "1",
"rows": "3",
"shot": "medium",
"beat": "A big gust snaps the string.",
"budget_words": 13
},
{
"id": "1.3",
"cols": "2",
"rows": "3",
"shot": "medium",
"beat": "The kite sails away over the trees and Mia gasps.",
"budget_words": 13
},
{
"id": "1.4",
"cols": "3",
"rows": "3",
"shot": "medium",
"beat": "Mia runs after it with Biscuit barking beside her.",
"budget_words": 13
}
]
},
{
"page": 2,
"layout": "2x2",
"format": "page",
"grid": "2 cols x 2 rows",
"panels": [
{
"id": "2.1",
"cols": "1",
"rows": "1",
"shot": "medium",
"beat": "Grandpa puffs along behind, holding on to his hat.",
"budget_words": 30
},
{
"id": "2.2",
"cols": "2",
"rows": "1",
"shot": "medium",
"beat": "The kite is stuck high in an old oak tree.",
"budget_words": 30
},
{
"id": "2.3",
"cols": "1",
"rows": "2",
"shot": "wide",
"beat": "Biscuit jumps and tugs the trailing string down with his teeth.",
"budget_words": 30,
"figures": [
"Biscuit (cheer, happy)"
]
},
{
"id": "2.4",
"cols": "2",
"rows": "2",
"shot": "medium",
"beat": "Mia hugs Biscuit; Grandpa says the dog is the real kite champion.",
"budget_words": 30
}
]
}
]
Reply:
{
"task": "script",
"title": "The Runaway Kite",
"logline": "When a gust steals Mia's red kite, she, Grandpa Joe and Biscuit chase it to an old oak, and the dog saves the day.",
"panels": [
{
"id": "1.1",
"shot": "establishing",
"action": "A green hill under a windy sky; Mia, left, points at a red kite, Grandpa Joe holds the string, right, Biscuit watches.",
"caption": "A windy day on the hill in the park.",
"dialogue": [
{
"speaker": "Mia",
"kind": "speech",
"text": "Higher, Grandpa! Let it go higher!"
},
{
"speaker": "Grandpa Joe",
"kind": "speech",
"text": "Any higher and it will need a ticket to the moon!"
}
],
"sfx": "WHOOSH",
"note": "Big open splash: let the sky and the kite breathe."
},
{
"id": "1.2",
"shot": "extreme",
"action": "Close on Grandpa's hand as the kite string snaps in a gust, the loose end whipping away.",
"caption": "",
"dialogue": [
{
"speaker": "Grandpa Joe",
"kind": "shout",
"text": "Uh-oh!"
}
],
"sfx": "SNAP",
"note": "Almost wordless so the snap lands fast."
},
{
"id": "1.3",
"shot": "wide",
"action": "The red kite sails off over the treetops; Mia, small in the corner, gasps with both hands on her cheeks.",
"caption": "",
"dialogue": [
{
"speaker": "Mia",
"kind": "shout",
"text": "Our kite!"
}
],
"sfx": "",
"note": ""
},
{
"id": "1.4",
"shot": "medium",
"action": "Mia runs right, after the kite, arms pumping; Biscuit bounds beside her, ears flying, barking.",
"caption": "",
"dialogue": [
{
"speaker": "Mia",
"kind": "shout",
"text": "Come back, kite!"
},
{
"speaker": "Biscuit",
"kind": "shout",
"text": "Woof! Woof!"
}
],
"sfx": "",
"note": "Motion to the right pulls the reader into the page turn."
},
{
"id": "2.1",
"shot": "medium",
"action": "Grandpa Joe puffs along behind, red-cheeked, one hand holding his hat on his head.",
"caption": "",
"dialogue": [
{
"speaker": "Grandpa Joe",
"kind": "speech",
"text": "Slow down! These old legs only have one speed!"
}
],
"sfx": "PUFF PUFF",
"note": ""
},
{
"id": "2.2",
"shot": "over",
"action": "Over Mia's shoulder: the red kite hangs tangled high in an old oak tree, its string dangling down.",
"caption": "",
"dialogue": [
{
"speaker": "Mia",
"kind": "speech",
"text": "There it is! Way up in the old oak."
},
{
"speaker": "Grandpa Joe",
"kind": "speech",
"text": "Well, it found a cosy spot for a nap."
}
],
"sfx": "",
"note": "Show the dangling string clearly; it pays off next panel."
},
{
"id": "2.3",
"shot": "wide",
"action": "Biscuit leaps up, grabs the trailing string in his teeth and tugs; the kite slides down from the branches.",
"caption": "",
"dialogue": [
{
"speaker": "Biscuit",
"kind": "shout",
"text": "Woof!"
},
{
"speaker": "Mia",
"kind": "shout",
"text": "Biscuit, you got it!"
}
],
"sfx": "TUG",
"note": ""
},
{
"id": "2.4",
"shot": "medium",
"action": "Mia, left, hugs a happy Biscuit with the kite beside them; Grandpa Joe, right, laughs and tips his hat.",
"caption": "",
"dialogue": [
{
"speaker": "Mia",
"kind": "speech",
"text": "Good boy, Biscuit!"
},
{
"speaker": "Grandpa Joe",
"kind": "speech",
"text": "Forget me. This dog is the real kite champion!"
},
{
"speaker": "Biscuit",
"kind": "speech",
"text": "Woof!"
}
],
"sfx": "",
"note": "Warm close on the family; end on the joke."
}
],
"pacing": [
{
"type": "split",
"panel": "2.3",
"direction": "vertical",
"why": "The beat holds two moments: Biscuit's leap, then the tug that brings the kite down. Side by side they read as a mini action beat."
},
{
"type": "add_beat",
"after": "2.2",
"beat": "Mia stretches on tiptoe, reaching for the string, but it is just too high.",
"direction": "vertical",
"why": "A small try-and-fail
... (shortened here; the page shows it whole)
script: a community-garden ladder safety briefing, with one beat waiting for a panel
Input (unplaced_beats holds the beat with no panel):
{
"task": "script",
"title": "Ladder Safety at the Community Garden",
"audience": "adults",
"cast": "Rosa (volunteer lead, calm, has seen every accident); Dev (new volunteer, keen, in a hurry)",
"reading": "left-to-right",
"pages": "(a JSON string - decoded below)",
"tone": "friendly but exact - the rules must be right",
"unplaced_beats": "(a JSON string - decoded below)"
}
pages, decoded:
[
{
"page": 1,
"layout": "six",
"format": "page",
"grid": "2 cols x 3 rows",
"panels": [
{
"id": "1.1",
"cols": "1",
"rows": "1",
"shot": "wide",
"beat": "Dev grabs the old wooden ladder to clear leaves from the shed gutter.",
"budget_words": 20,
"figures": [
"Dev (walk, happy)"
]
},
{
"id": "1.2",
"cols": "2",
"rows": "1",
"shot": "medium",
"beat": "Rosa stops him: check the ladder first - feet, rungs and the spreader locks.",
"budget_words": 20,
"figures": [
"Rosa (point, neutral)",
"Dev (stand, surprised)"
]
},
{
"id": "1.3",
"cols": "1",
"rows": "2",
"shot": "medium",
"beat": "Set it on firm, level ground: one foot out from the wall for every four feet up.",
"budget_words": 20
},
{
"id": "1.4",
"cols": "2",
"rows": "2",
"shot": "medium",
"beat": "Climb facing the ladder and keep three points of contact - two feet and a hand.",
"budget_words": 20
},
{
"id": "1.5",
"cols": "1",
"rows": "3",
"shot": "medium",
"beat": "Never stand on the top two rungs; climb down and move the ladder instead of leaning out.",
"budget_words": 20
},
{
"id": "1.6",
"cols": "2",
"rows": "3",
"shot": "medium",
"beat": "Dev clears the gutter while Rosa holds the base; thumbs up from both.",
"budget_words": 20
}
]
}
]
unplaced_beats, decoded:
[
"Tools go up in a bucket on a rope, never in your hands."
]
Reply:
{
"task": "script",
"title": "Ladder Safety at the Community Garden",
"logline": "A keen new volunteer learns from Rosa how to check, set, climb and use a ladder before clearing the shed gutter.",
"panels": [
{
"id": "1.1",
"shot": "wide",
"action": "The community garden shed; Dev strides toward it, the old wooden ladder over his shoulder, eyes on the leaf-choked gutter.",
"caption": "",
"dialogue": [
{
"speaker": "Dev",
"kind": "speech",
"text": "Gutter's full of leaves. I'll be up and down in no time."
}
],
"sfx": "",
"note": ""
},
{
"id": "1.2",
"shot": "over",
"action": "Over Dev's shoulder at Rosa, who points at the ladder; Dev on the left, Rosa on the right.",
"caption": "",
"dialogue": [
{
"speaker": "Rosa",
"kind": "speech",
"text": "Stop. Check the ladder first."
},
{
"speaker": "Rosa",
"kind": "speech",
"text": "Feet, rungs and the spreader locks."
},
{
"speaker": "Dev",
"kind": "speech",
"text": "It looked fine..."
}
],
"sfx": "",
"note": "Rosa's first balloon is the brake on the page; keep it short."
},
{
"id": "1.3",
"shot": "wide",
"action": "The ladder stands on firm, level ground against the shed, its base set out from the wall; Dev left, Rosa right.",
"caption": "",
"dialogue": [
{
"speaker": "Rosa",
"kind": "speech",
"text": "Firm, level ground. One foot out from the wall for every four feet up."
},
{
"speaker": "Dev",
"kind": "speech",
"text": "Got it."
}
],
"sfx": "",
"note": "Wide so the reader sees the ladder's angle to the wall."
},
{
"id": "1.4",
"shot": "medium",
"action": "Dev climbs facing the ladder, two feet and one hand on it; Rosa below on the right, watching.",
"caption": "",
"dialogue": [
{
"speaker": "Rosa",
"kind": "speech",
"text": "Climb facing the ladder. Keep three points of contact - two feet and a hand."
},
{
"speaker": "Dev",
"kind": "speech",
"text": "Facing in. Got it."
}
],
"sfx": "",
"note": ""
},
{
"id": "1.5",
"shot": "close",
"action": "Rosa's face, firm, looking up; Dev's boot pauses below the top two rungs.",
"caption": "",
"dialogue": [
{
"speaker": "Rosa",
"kind": "speech",
"text": "Never stand on the top two rungs."
},
{
"speaker": "Rosa",
"kind": "speech",
"text": "Can't reach? Climb down and move the ladder. Don't lean out."
}
],
"sfx": "",
"note": "The tightest shot on the page for the strictest rule."
},
{
"id": "1.6",
"shot": "wide",
"action": "Dev clears the gutter from the ladder while Rosa holds the base on the right; both give a thumbs up.",
"caption": "",
"dialogue": [
{
"speaker": "Dev",
"kind": "speech",
"text": "Gutter's clear!"
},
{
"speaker": "Rosa",
"kind": "speech",
"text": "And nobody fell. That's the job."
}
],
"sfx": "",
"note": "Pull back wide to close the page on the finished job."
}
],
"pacing": [
{
"type": "add_beat",
"after": "1.4",
"beat": "Tools go up in a bucket on a rope, never in your hands.",
"direction": "vertical",
"why": "The unplaced rule belongs with climbing: hands stay on the ladder, so tools ride up in the bucket."
}
],
"notes": [
"Rules arrive one per panel in the order a climb happens: check, set, climb, stay low, finish.",
"Keep Dev on the left and Rosa on the right throughout so the conversation never flips.",
"Every safety line uses the words of the briefing; only Dev's reactions are added."
]
}
script: a family anecdote as a 4-panel strip, with lines the person wrote that do not fit
Input:
{
"task": "script",
"title": "Grandma's First Video Call",
"audience": "all ages",
"cast": "Grandma Lin (81, sharp, suspicious of phones); Ben (her grandson, 15, patient)",
"reading": "left-to-right",
"pages": "(a JSON string - decoded below)",
"tone": "gentle family comedy"
}
pages, decoded:
[
{
"page": 1,
"layout": "strip-4",
"format": "strip",
"grid": "4 cols x 1 rows",
"panels": [
{
"id": "1.1",
"cols": "1",
"rows": "1",
"shot": "medium",
"beat": "Ben props a phone against the teapot for Grandma's first video call.",
"budget_words": 18,
"figures": [
"Ben (kneel, happy)",
"Grandma Lin (sit, thinking)"
]
},
{
"id": "1.2",
"cols": "2",
"rows": "1",
"shot": "close",
"beat": "Grandma sees only her own nose filling the screen.",
"budget_words": 18,
"figures": [
"Grandma Lin (stand, surprised)"
],
"balloons": [
{
"kind": "speech",
"text": "Why is my nose on the television? Where is everybody? Is this thing broken already?",
"speaker": "Grandma Lin"
}
]
},
{
"id": "1.3",
"cols": "3",
"rows": "1",
"shot": "medium",
"beat": "She shouts at the phone as if the family were across the street.",
"budget_words": 18,
"figures": [
"Grandma Lin (cheer, angry)",
"Ben (stand, tired)"
],
"balloons": [
{
"kind": "shout",
"text": "HELLO? CAN EVERYBODY HEAR ME? I AM TALKING INTO THE TELEPHONE NOW. BENJAMIN SAYS I DO NOT HAVE TO SHOUT BUT I THINK I DO!",
"speaker": "Grandma Lin"
},
{
"kind": "whisper",
"text": "Grandma, they can hear you perfectly fine, you really do not need to shout at the phone like that.",
"speaker": "Ben"
}
]
},
{
"id": "1.4",
"cols": "4",
"rows": "1",
"shot": "wide",
"beat": "The whole family on screen waves back and Grandma beams.",
"budget_words": 18,
"figures": [
"Grandma Lin (sit, love)"
]
}
]
}
]
Reply:
{
"task": "script",
"title": "Grandma's First Video Call",
"logline": "Grandma Lin's first video call starts with her own nose and a lot of shouting, then ends with the whole family waving back.",
"panels": [
{
"id": "1.1",
"shot": "medium",
"action": "Ben, kneeling at right, props a phone against the teapot; Grandma Lin sits at left, eyeing it.",
"caption": "Grandma's first video call.",
"dialogue": [
{
"speaker": "Ben",
"kind": "speech",
"text": "Just look at the screen, Grandma."
},
{
"speaker": "Grandma Lin",
"kind": "speech",
"text": "Hmph. At the teapot?"
}
],
"sfx": "",
"note": ""
},
{
"id": "1.2",
"shot": "close",
"action": "The phone screen fills with Grandma's own nose; behind it, her startled eyes go wide.",
"caption": "",
"dialogue": [
{
"speaker": "Grandma Lin",
"kind": "speech",
"text": "Why is my nose on the television?"
},
{
"speaker": "Grandma Lin",
"kind": "speech",
"text": "Is it broken already?"
}
],
"sfx": "",
"note": ""
},
{
"id": "1.3",
"shot": "medium",
"action": "Grandma at left leans in and bellows at the phone; Ben at right sags, palm to forehead.",
"caption": "",
"dialogue": [
{
"speaker": "Grandma Lin",
"kind": "shout",
"text": "HELLO? CAN EVERYBODY HEAR ME?"
},
{
"speaker": "Ben",
"kind": "whisper",
"text": "They hear you fine, Grandma."
}
],
"sfx": "",
"note": "Let the shout balloon dominate; keep Ben's whisper small."
},
{
"id": "1.4",
"shot": "wide",
"action": "The screen fills with the whole family waving back; Grandma, seated at left, beams at them.",
"caption": "",
"dialogue": [
{
"speaker": "Grandma Lin",
"kind": "speech",
"text": "There you all are!"
}
],
"sfx": "",
"note": "Few words; the waving family carries the payoff."
}
],
"pacing": [
{
"type": "split",
"panel": "1.3",
"direction": "vertical",
"why": "The shout and Ben's tired whisper are two moments; side by side, the whisper lands as its own beat."
},
{
"type": "merge",
"panels": [
"1.1",
"1.2"
],
"why": "Optional: if the strip runs tight, setup and the nose reveal can share one wider panel, freeing room for the finale."
}
],
"notes": [
"Grandma stays left and Ben right in every shared panel.",
"Shots step medium, close, medium, wide so the ending opens up.",
"The last panel is the payoff; keep it the quietest."
]
}
tighten: the same strip, only panels 1.2 and 1.3
Input (targets names the panels; the reply must not touch any other):
{
"task": "tighten",
"title": "Grandma's First Video Call",
"audience": "all ages",
"cast": "Grandma Lin (81, sharp, suspicious of phones); Ben (her grandson, 15, patient)",
"reading": "left-to-right",
"pages": "(a JSON string - decoded below)",
"tone": "gentle family comedy",
"targets": "1.2, 1.3",
"why": "Grandma's lines will not fit - keep the joke, lose the words."
}
pages, decoded:
[
{
"page": 1,
"layout": "strip-4",
"format": "strip",
"grid": "4 cols x 1 rows",
"panels": [
{
"id": "1.1",
"cols": "1",
"rows": "1",
"shot": "medium",
"beat": "Ben props a phone against the teapot for Grandma's first video call.",
"budget_words": 18,
"figures": [
"Ben (kneel, happy)",
"Grandma Lin (sit, thinking)"
]
},
{
"id": "1.2",
"cols": "2",
"rows": "1",
"shot": "close",
"beat": "Grandma sees only her own nose filling the screen.",
"budget_words": 18,
"figures": [
"Grandma Lin (stand, surprised)"
],
"balloons": [
{
"kind": "speech",
"text": "Why is my nose on the television? Where is everybody? Is this thing broken already?",
"speaker": "Grandma Lin"
}
]
},
{
"id": "1.3",
"cols": "3",
"rows": "1",
"shot": "medium",
"beat": "She shouts at the phone as if the family were across the street.",
"budget_words": 18,
"figures": [
"Grandma Lin (cheer, angry)",
"Ben (stand, tired)"
],
"balloons": [
{
"kind": "shout",
"text": "HELLO? CAN EVERYBODY HEAR ME? I AM TALKING INTO THE TELEPHONE NOW. BENJAMIN SAYS I DO NOT HAVE TO SHOUT BUT I THINK I DO!",
"speaker": "Grandma Lin"
},
{
"kind": "whisper",
"text": "Grandma, they can hear you perfectly fine, you really do not need to shout at the phone like that.",
"speaker": "Ben"
}
]
},
{
"id": "1.4",
"cols": "4",
"rows": "1",
"shot": "wide",
"beat": "The whole family on screen waves back and Grandma beams.",
"budget_words": 18,
"figures": [
"Grandma Lin (sit, love)"
]
}
]
}
]
Reply:
{
"task": "tighten",
"panels": [
{
"id": "1.2",
"caption": "",
"dialogue": [
{
"speaker": "Grandma Lin",
"kind": "speech",
"text": "Why is my nose on the television? Where did everybody go?"
}
],
"cut": "dropped 'is this broken already'; her face sells the suspicion"
},
{
"id": "1.3",
"caption": "",
"dialogue": [
{
"speaker": "Grandma Lin",
"kind": "shout",
"text": "HELLO? CAN EVERYBODY HEAR ME? I AM ON THE TELEPHONE!"
},
{
"speaker": "Ben",
"kind": "whisper",
"text": "They can hear you. Everyone can."
}
],
"cut": "cut Grandma's argument about shouting and Ben's long plea; the whisper against the shout carries the joke"
}
],
"notes": [
"Grandma speaks in full, formal sentences; Ben stays short and dry, so the voices stay apart."
]
}
The page lays out and checks; the API does not
Over the API you get the script as JSON. The layout, the reading order and the word budgets are yours to compute, and the page's own code is plain scripts you can load in node after global.window = {}: pagekit.js (PageKit.pageFromPreset(key), PageKit.readingOrder(page), PageKit.check(comic) for budgets and balloon fit, PageKit.buildInput(comic, {task, targets}) for this exact run body) and script.js (Script.normalize(reply, {task, map}), Script.reconcile(result, comic, {map, targets}), Script.applyScript(comic, result, recon) and Script.toMarkdown(comic) for the comic script).
Step 1 — a tiny client helper
One function that sends JSON and unwraps the envelope.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="paste-your-token-here" # from https://balloon-page.skillsafe.ai/tokens.html
api() { # api METHOD PATH [JSON-BODY]
curl -s -X "$1" "$BASE$2" -H "Content-Type: application/json" \
${TOKEN:+-H "Authorization: Bearer $TOKEN"} ${3:+--data "$3"}
}
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "paste-your-token-here" # from https://balloon-page.skillsafe.ai/tokens.html
def api(method, path, body=None, headers=None):
req = urllib.request.Request(BASE + path, method=method,
data=None if body is None else json.dumps(body).encode(),
headers={"Content-Type": "application/json", **({"Authorization": "Bearer " + TOKEN} if TOKEN else {}), **(headers or {})})
with urllib.request.urlopen(req) as r:
env = json.load(r)
if env.get("error"):
raise RuntimeError(env["error"])
return env["data"]
// balloon-page.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://balloon-page.skillsafe.ai/tokens.html
async function api(method, path, body, headers = {}) {
const r = await fetch(BASE + path, { method, headers: { "Content-Type": "application/json",
...(TOKEN ? { Authorization: "Bearer " + TOKEN } : {}), ...headers },
body: body === undefined ? undefined : JSON.stringify(body) });
const env = await r.json();
if (env.error) throw new Error(env.error.code + ": " + env.error.message);
return env.data;
}
package main
import ("bytes"; "encoding/json"; "fmt"; "net/http")
const base = "https://api.skillsafe.ai/v1/app-api"
var token = "paste-your-token-here" // from https://balloon-page.skillsafe.ai/tokens.html
func api(method, path string, body any, headers map[string]string) (map[string]any, error) {
var buf bytes.Buffer
if body != nil { json.NewEncoder(&buf).Encode(body) }
req, _ := http.NewRequest(method, base+path, &buf)
req.Header.Set("Content-Type", "application/json")
if token != "" { req.Header.Set("Authorization", "Bearer "+token) }
for k, v := range headers { req.Header.Set(k, v) }
res, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
defer res.Body.Close()
var env struct { Data map[string]any; Error map[string]any }
json.NewDecoder(res.Body).Decode(&env)
if env.Error != nil { return nil, fmt.Errorf("%v", env.Error) }
return env.Data, nil
}
// BalloonPage.java -- Java 17+, with com.fasterxml.jackson.core:jackson-databind
import com.fasterxml.jackson.databind.*;
import java.net.URI; import java.net.http.*; import java.util.*;
public class BalloonPage {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://balloon-page.skillsafe.ai/tokens.html
static final ObjectMapper M = new ObjectMapper();
static final HttpClient C = HttpClient.newHttpClient();
static JsonNode api(String method, String path, Object body, Map<String, String> headers) throws Exception {
var b = HttpRequest.newBuilder(URI.create(BASE + path)).header("Content-Type", "application/json");
if (!token.isEmpty()) b.header("Authorization", "Bearer " + token);
if (headers != null) headers.forEach(b::header);
b.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(M.writeValueAsString(body)));
JsonNode env = M.readTree(C.send(b.build(), HttpResponse.BodyHandlers.ofString()).body());
if (env.hasNonNull("error")) throw new RuntimeException(env.get("error").toString());
return env.get("data");
}
}
require "json"
require "net/http"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "paste-your-token-here" # from https://balloon-page.skillsafe.ai/tokens.html
def api(method, path, body = nil, headers = {})
uri = URI(BASE + path)
req = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri,
{ "Content-Type" => "application/json" }.merge(TOKEN.empty? ? {} : { "Authorization" => "Bearer #{TOKEN}" }).merge(headers))
req.body = JSON.generate(body) unless body.nil?
env = JSON.parse(Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }.body)
raise env["error"].to_s if env["error"]
env["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = "paste-your-token-here"; // from https://balloon-page.skillsafe.ai/tokens.html
function api(string $method, string $path, $body = null, array $headers = []) {
global $TOKEN;
$h = ["Content-Type: application/json"];
if ($TOKEN) $h[] = "Authorization: Bearer $TOKEN";
foreach ($headers as $k => $v) $h[] = "$k: $v";
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $h, CURLOPT_RETURNTRANSFER => true]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
$env = json_decode(curl_exec($ch), true);
if (!empty($env["error"])) throw new Exception(json_encode($env["error"]));
return $env["data"];
}
// Program.cs -- .NET 6+ console app
using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Nodes;
static class Api {
public const string Base = "https://api.skillsafe.ai/v1/app-api";
public static string Token = "paste-your-token-here"; // from https://balloon-page.skillsafe.ai/tokens.html
static readonly HttpClient C = new();
public static async Task<JsonNode?> Call(string method, string path, object? body = null, Dictionary<string, string>? headers = null) {
var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
if (Token != "") req.Headers.Add("Authorization", "Bearer " + Token);
if (headers != null) foreach (var h in headers) req.Headers.Add(h.Key, h.Value);
if (body != null) req.Content = JsonContent.Create(body);
var env = JsonNode.Parse(await (await C.SendAsync(req)).Content.ReadAsStringAsync())!;
if (env["error"] is JsonNode e && e.GetValueKind() != JsonValueKind.Null) throw new Exception(e.ToJsonString());
return env["data"];
}
}
Step 2 — a token
A guest token is enough for /me and /estimate. A run is metered, so it needs a personal token: sign in on /tokens.html and copy it from there.
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN=$(curl -s -X POST "$BASE/guest" -H "Content-Type: application/json" -d '{"slug":"balloon-page"}' | jq -r '.data.token')
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = ""
TOKEN = api("POST", "/guest", {"slug": "balloon-page"})["token"]
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = "";
TOKEN = (await api("POST", "/guest", { slug: "balloon-page" })).token;
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = ""
g, err := api("POST", "/guest", map[string]string{"slug": "balloon-page"}, nil)
if err != nil { panic(err) }
token = g["token"].(string)
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = "";
token = api("POST", "/guest", Map.of("slug", "balloon-page"), null).get("token").asText();
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN.replace("")
TOKEN.replace(api("POST", "/guest", { slug: "balloon-page" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "balloon-page"])["token"];
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
Api.Token = "";
Api.Token = (await Api.Call("POST", "/guest", new { slug = "balloon-page" }))!["token"]!.GetValue<string>();
Step 3 — GET /me
Who the token is (subject_type: user or guest) and the credit balance.
api GET /me | jq '.data | {subject_type, credits}'
me = api("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
me, _ := api("GET", "/me", nil, nil)
fmt.Println(me["subject_type"], me["credits"])
JsonNode me = api("GET", "/me", null, null);
System.out.println(me.get("subject_type") + " " + me.get("credits"));
me = api("GET", "/me")
puts [me["subject_type"], me["credits"]].inspect
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"] ?? "", "\n";
var me = await Api.Call("GET", "/me");
Console.WriteLine($"{me!["subject_type"]} {me["credits"]}");
Step 4 — POST /estimate
Free. The body is the run input itself (not wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model reads gpt-5.6-terra and model_alias gpt-terra.
# input.json: the run input object - {"task": "script", "title": "...", "audience": "...", "cast": "...", "reading": "left-to-right", "pages": "[...]"}
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}'
inp = json.load(open("input.json")) # {"task": "script", "title": "...", "audience": "...", "cast": "...", "reading": "left-to-right", "pages": "[...]"}
est = api("POST", "/estimate", inp)
print(est["hold_credits"], est["min_credits"], est["model"], est.get("warnings"))
import { readFileSync } from "node:fs";
const input = JSON.parse(readFileSync("input.json", "utf8")); // {task, title, audience, cast, reading, pages}
const est = await api("POST", "/estimate", input);
console.log(est.hold_credits, est.min_credits, est.model, est.warnings);
raw, _ := os.ReadFile("input.json") // add "os" to the imports
var input map[string]any
json.Unmarshal(raw, &input)
est, _ := api("POST", "/estimate", input, nil)
fmt.Println(est["hold_credits"], est["min_credits"], est["model"])
Map<String, Object> input = M.readValue(new java.io.File("input.json"), Map.class);
JsonNode est = api("POST", "/estimate", input, null);
System.out.println(est.get("hold_credits") + " " + est.get("min_credits") + " " + est.get("model"));
input = JSON.parse(File.read("input.json"))
est = api("POST", "/estimate", input)
puts [est["hold_credits"], est["min_credits"], est["model"]].inspect
$input = json_decode(file_get_contents("input.json"), true);
$est = api("POST", "/estimate", $input);
echo $est["hold_credits"], " ", $est["min_credits"], " ", $est["model"], "\n";
var input = JsonNode.Parse(File.ReadAllText("input.json"))!;
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"{est!["hold_credits"]} {est["min_credits"]} {est["model"]}");
Step 5 — POST /run, then poll the job
Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses balloon-page:<task>:<inputHash>:a<attempt>. The job's output.output is the reply text; truncated: true means the balance cut it short.
KEY="balloon-page:script:$(shasum -a 256 input.json | cut -c1-16):a1"
JOB=$(curl -s -X POST "$BASE/run" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: $KEY" --data @input.json | jq -r '.data.job_id')
while :; do
J=$(api GET "/jobs/$JOB"); S=$(echo "$J" | jq -r '.data.status')
case "$S" in succeeded|failed|cancelled) break;; esac; sleep 2
done
echo "$J" | jq -r '.data.output.output' > reply.json # one JSON object, the reply contract
echo "$J" | jq '.data | {charged_credits, truncated}'
import hashlib, time
key = "balloon-page:" + inp["task"] + ":" + hashlib.sha256(json.dumps(inp).encode()).hexdigest()[:16] + ":a1"
job = api("POST", "/run", inp, {"Idempotency-Key": key})
while True:
j = api("GET", "/jobs/" + job["job_id"])
if j["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(2)
reply = json.loads(j["output"]["output"]) # the lane's JSON object
print(j["charged_credits"], j.get("truncated"), reply.get("logline"))
import { createHash } from "node:crypto";
const key = `balloon-page:${input.task}:${createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16)}:a1`;
const job = await api("POST", "/run", input, { "Idempotency-Key": key });
let j;
do { await new Promise((r) => setTimeout(r, 2000)); j = await api("GET", "/jobs/" + job.job_id); }
while (!["succeeded", "failed", "cancelled"].includes(j.status));
const reply = JSON.parse(j.output.output);
console.log(j.charged_credits, j.truncated, reply.logline);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "balloon-page:" + input["task"].(string) + ":" + hex.EncodeToString(sum[:8]) + ":a1"
job, _ := api("POST", "/run", input, map[string]string{"Idempotency-Key": key})
var j map[string]any
for {
time.Sleep(2 * time.Second)
j, _ = api("GET", "/jobs/"+job["job_id"].(string), nil, nil)
if s := j["status"]; s == "succeeded" || s == "failed" || s == "cancelled" { break }
}
fmt.Println(j["charged_credits"], j["truncated"], j["output"].(map[string]any)["output"])
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "balloon-page:" + input.get("task") + ":" + HexFormat.of().formatHex(digest, 0, 8) + ":a1";
JsonNode job = api("POST", "/run", input, Map.of("Idempotency-Key", key)), j;
do { Thread.sleep(2000); j = api("GET", "/jobs/" + job.get("job_id").asText(), null, null); }
while (!Set.of("succeeded", "failed", "cancelled").contains(j.get("status").asText()));
JsonNode reply = M.readTree(j.get("output").get("output").asText());
System.out.println(j.get("charged_credits") + " " + reply);
require "digest"
key = "balloon-page:#{input["task"]}:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1"
job = api("POST", "/run", input, { "Idempotency-Key" => key })
j = nil
loop do
sleep 2
j = api("GET", "/jobs/#{job["job_id"]}")
break if %w[succeeded failed cancelled].include?(j["status"])
end
reply = JSON.parse(j["output"]["output"])
puts [j["charged_credits"], j["truncated"], reply["logline"]].inspect
$key = "balloon-page:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$job = api("POST", "/run", $input, ["Idempotency-Key" => $key]);
do { sleep(2); $j = api("GET", "/jobs/" . $job["job_id"]); }
while (!in_array($j["status"], ["succeeded", "failed", "cancelled"]));
$reply = json_decode($j["output"]["output"], true);
echo $j["charged_credits"], " ", $reply["logline"], "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"balloon-page:{input["task"]}:{Convert.ToHexString(digest, 0, 8).ToLowerInvariant()}:a1";
var job = await Api.Call("POST", "/run", input, new() { ["Idempotency-Key"] = key });
JsonNode j;
do { await Task.Delay(2000); j = (await Api.Call("GET", "/jobs/" + job!["job_id"]))!; }
while (!new[] { "succeeded", "failed", "cancelled" }.Contains(j["status"]!.GetValue<string>()));
var reply = JsonNode.Parse(j["output"]!["output"]!.GetValue<string>());
Console.WriteLine($"{j["charged_credits"]} {reply}");
Step 6 — POST /run-stream
The same run as Server-Sent Events, with the same input.json. Browsers receive progress ticks rather than text deltas; the final done event carries the reply.
# Server-Sent Events: tick/delta events while the job runs, then a final event with the job
curl -N -s -X POST "$BASE/run-stream" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: balloon-page:script:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json
rev = json.load(open("input.json")) # the same run input as input.json: {"task": "script", "title": "...", "audience": "...", "cast": "...", "reading": "left-to-right", "pages": "[...]"}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "balloon-page:script:" + hashlib.sha256(json.dumps(rev).encode()).hexdigest()[:16] + ":a1"})
with urllib.request.urlopen(req) as r:
for line in r:
line = line.decode().strip()
if line.startswith("data:"):
print(line[5:].strip()[:200])
const rev = JSON.parse(readFileSync("input.json", "utf8"));
const r = await fetch(BASE + "/run-stream", { method: "POST", body: JSON.stringify(rev), headers: {
"Content-Type": "application/json", Authorization: "Bearer " + TOKEN,
"Idempotency-Key": `balloon-page:script:${createHash("sha256").update(JSON.stringify(rev)).digest("hex").slice(0, 16)}:a1` } });
const dec = new TextDecoder();
for await (const chunk of r.body) for (const line of dec.decode(chunk).split("\n"))
if (line.startsWith("data:")) console.log(line.slice(5).trim().slice(0, 200));
rraw, _ := os.ReadFile("input.json") // add "bufio", "strings" to the imports
rs := sha256.Sum256(rraw)
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(rraw))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", "balloon-page:script:"+hex.EncodeToString(rs[:8])+":a1")
res, _ := http.DefaultClient.Do(req)
sc := bufio.NewScanner(res.Body)
for sc.Scan() { if l := sc.Text(); strings.HasPrefix(l, "data:") { fmt.Println(l[5:]) } }
String rev = java.nio.file.Files.readString(java.nio.file.Path.of("input.json"));
var rd = java.security.MessageDigest.getInstance("SHA-256").digest(rev.getBytes());
var sreq = HttpRequest.newBuilder(URI.create(BASE + "/run-stream")).header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token).header("Idempotency-Key", "balloon-page:script:" + HexFormat.of().formatHex(rd, 0, 8) + ":a1")
.POST(HttpRequest.BodyPublishers.ofString(rev)).build();
C.send(sreq, HttpResponse.BodyHandlers.ofLines()).body().filter(l -> l.startsWith("data:")).forEach(System.out::println);
rev = File.read("input.json")
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{TOKEN}",
"Idempotency-Key" => "balloon-page:script:#{Digest::SHA256.hexdigest(rev)[0, 16]}:a1")
req.body = rev
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |h|
h.request(req) { |res| res.read_body { |chunk| chunk.each_line { |l| puts l if l.start_with?("data:") } } }
end
$rev = file_get_contents("input.json");
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $rev, CURLOPT_HTTPHEADER => [
"Content-Type: application/json", "Authorization: Bearer $TOKEN",
"Idempotency-Key: balloon-page:script:" . substr(hash("sha256", $rev), 0, 16) . ":a1"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) { foreach (explode("\n", $chunk) as $l) if (str_starts_with($l, "data:")) echo $l, "\n"; return strlen($chunk); }]);
curl_exec($ch);
var rev = File.ReadAllText("input.json");
var rd = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(rev));
var sreq = new HttpRequestMessage(HttpMethod.Post, Api.Base + "/run-stream") { Content = new StringContent(rev, System.Text.Encoding.UTF8, "application/json") };
sreq.Headers.Add("Authorization", "Bearer " + Api.Token);
sreq.Headers.Add("Idempotency-Key", $"balloon-page:script:{Convert.ToHexString(rd, 0, 8).ToLowerInvariant()}:a1");
using var http = new HttpClient();
using var res = await http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var rdr = new StreamReader(await res.Content.ReadAsStreamAsync());
for (string? l; (l = await rdr.ReadLineAsync()) != null;) if (l.StartsWith("data:")) Console.WriteLine(l);