Bento Deck Studio — API & tutorial Open the app

Turn notes into a Bento deck from your own code

Everything this app does goes through the SkillSafe App API — plain JSON over HTTPS with optional streaming. This tutorial covers both agent tasks the app exposes — generate_deck and revise_deck — and how to drop the returned bento/slides document into a single-file Bento deck you can open in any browser, with examples in cURL, Python, JavaScript, Go, Java, Ruby, PHP and C#.

Basics

Base URL: https://api.skillsafe.ai/v1/app-api. Every request sends Authorization: Bearer <token> and JSON bodies with Content-Type: application/json. Responses are wrapped in an envelope: {"data": …} on success, {"error": {"code", "message"}} on failure. Runs execute the app's agent (model gpt-5.6-terra) and are billed in SkillSafe credits to the calling token, with a worst-case hold up front and the actual cost settled when the job finishes.

StatusMeaning
401Missing or expired token — create a new session.
402Not enough credits — top up at skillsafe.ai/account/credits.
403The token isn't allowed to do this.
404Unknown job or record id.
5xxTransient platform error — retry with backoff.

Browsers enforce CORS for this API, so run these examples from a server, script or terminal — not from another website's frontend. The app's own decks history collection is an internal store, not a public API surface — read your results from the job output instead.

Step 0 — A tiny client

Every task below is one or two HTTP calls, so start with a small helper that adds the auth header, sends JSON and unwraps the data envelope. The later steps reuse this helper.

export API="https://api.skillsafe.ai/v1/app-api"
export TOKEN="YOUR_TOKEN"      # see step 1

# every call looks like:
#   curl -s "$API/…" -H "Authorization: Bearer $TOKEN" [-d '{json}']
# jq is used below to pull fields out of the {"data": …} envelope
import json, requests

API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN"  # see step 1

def api(method, path, body=None, **headers):
    res = requests.request(method, API + path, json=body,
                           headers={"Authorization": f"Bearer {TOKEN}", **headers})
    payload = res.json()
    if not res.ok:
        raise RuntimeError(payload.get("error", {}).get("message", res.reason))
    return payload["data"]
// Node 18+ (built-in fetch)
const API = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // see step 1 — read it from your environment in real code

async function api(method, path, body, extraHeaders = {}) {
  const res = await fetch(API + path, {
    method,
    headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", ...extraHeaders },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const json = await res.json();
  if (!res.ok) throw new Error(json.error?.message ?? res.statusText);
  return json.data;
}
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
)

const API = "https://api.skillsafe.ai/v1/app-api"

var token = os.Getenv("SKILLSAFE_TOKEN") // see step 1

func call(method, path string, body, out any) error {
	var buf bytes.Buffer
	if body != nil {
		json.NewEncoder(&buf).Encode(body)
	}
	req, _ := http.NewRequest(method, API+path, &buf)
	req.Header.Set("Authorization", "Bearer "+token)
	req.Header.Set("Content-Type", "application/json")
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return err
	}
	defer res.Body.Close()
	var env struct {
		Data  json.RawMessage `json:"data"`
		Error *struct{ Message string `json:"message"` } `json:"error"`
	}
	json.NewDecoder(res.Body).Decode(&env)
	if res.StatusCode >= 400 {
		return fmt.Errorf("api %s %s: %s", method, path, env.Error.Message)
	}
	if out == nil {
		return nil
	}
	return json.Unmarshal(env.Data, out)
}
// Java 17+, no dependencies. Pair with your JSON library (Jackson, Gson…)
// to read fields out of the returned envelope.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class SkillSafe {
    static final String API = "https://api.skillsafe.ai/v1/app-api";
    static final String TOKEN = System.getenv("SKILLSAFE_TOKEN"); // see step 1
    static final HttpClient HTTP = HttpClient.newHttpClient();

    static String api(String method, String path, String jsonBody) throws Exception {
        var req = HttpRequest.newBuilder(URI.create(API + path))
            .header("Authorization", "Bearer " + TOKEN)
            .header("Content-Type", "application/json")
            .method(method, jsonBody == null
                ? HttpRequest.BodyPublishers.noBody()
                : HttpRequest.BodyPublishers.ofString(jsonBody))
            .build();
        var res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
        if (res.statusCode() >= 400) throw new RuntimeException(res.body());
        return res.body(); // envelope: {"data": …}
    }
}
require "net/http"
require "json"

API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN") # see step 1

def api(method, path, body = nil)
  uri = URI(API + path)
  req = Net::HTTP.const_get(method.capitalize).new(uri)
  req["Authorization"] = "Bearer #{TOKEN}"
  req["Content-Type"] = "application/json"
  req.body = body.to_json if body
  res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
  payload = JSON.parse(res.body)
  raise (payload.dig("error", "message") || res.message) unless res.is_a?(Net::HTTPSuccess)
  payload["data"]
end
<?php
const API = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN"); // see step 1

function api(string $method, string $path, ?array $body = null): mixed {
    global $TOKEN;
    $ch = curl_init(API . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST  => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER     => [
            "Authorization: Bearer $TOKEN",
            "Content-Type: application/json",
        ],
        CURLOPT_POSTFIELDS     => $body === null ? null : json_encode($body),
    ]);
    $payload = json_decode(curl_exec($ch), true);
    $status  = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);
    if ($status >= 400) {
        throw new Exception($payload["error"]["message"] ?? "HTTP $status");
    }
    return $payload["data"];
}
// .NET 8+
using System.Net.Http.Json;
using System.Text.Json;

static class SkillSafe
{
    const string Api = "https://api.skillsafe.ai/v1/app-api";
    static readonly HttpClient Http = new();

    static SkillSafe() =>
        Http.DefaultRequestHeaders.Authorization =
            new("Bearer", Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN")); // see step 1

    public static async Task<JsonElement> ApiAsync(HttpMethod method, string path, object? body = null)
    {
        var req = new HttpRequestMessage(method, Api + path);
        if (body != null) req.Content = JsonContent.Create(body);
        var res = await Http.SendAsync(req);
        var json = await res.Content.ReadFromJsonAsync<JsonElement>();
        if (!res.IsSuccessStatusCode)
            throw new Exception(json.GetProperty("error").GetProperty("message").GetString());
        return json.GetProperty("data");
    }
}

Step 1 — Get a token

POST /guest

For scripted use, the simplest reliable path is your personal token: open the token page, sign in, and hit "Copy shell export" — it puts export SKILLSAFE_TOKEN="…" on your clipboard, which every example below reads. Treat the token like a password — it can spend your credits. For fully headless scripts, POST /guest (below) mints a guest token with no browser involved; guests can always call /me and /estimate, but whether a guest can afford an actual run depends on the app's daily sponsorship budget, so don't build on it.

curl -s -X POST "$API/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"bento-slides"}' | jq -r '.data.token'
token = api("POST", "/guest", {"slug": "bento-slides"})["token"]
const { token } = await api("POST", "/guest", { slug: "bento-slides" });
var guest struct{ Token string `json:"token"` }
err := call("POST", "/guest", map[string]string{"slug": "bento-slides"}, &guest)
String envelope = api("POST", "/guest", """
    {"slug":"bento-slides"}""");
// token is at data.token in the returned JSON
token = api("POST", "/guest", { slug: "bento-slides" })["token"]
$token = api("POST", "/guest", ["slug" => "bento-slides"])["token"];
var guest = await SkillSafe.ApiAsync(HttpMethod.Post, "/guest",
    new { slug = "bento-slides" });
var token = guest.GetProperty("token").GetString();

Step 2 — Check who you are and your balance

GET /me

Returns subject_type ("user" or "guest"), subject_id and your credits balance. Check this before an expensive run.

curl -s "$API/me" -H "Authorization: Bearer $TOKEN" | jq '.data'
me = api("GET", "/me")
print(me["subject_type"], me["credits"])
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
var me struct {
	SubjectType string `json:"subject_type"`
	Credits     int64  `json:"credits"`
}
err := call("GET", "/me", nil, &me)
String envelope = api("GET", "/me", null);
// data.subject_type, data.credits
me = api("GET", "/me")
puts "#{me["subject_type"]}: #{me["credits"]} credits"
$me = api("GET", "/me");
echo "{$me['subject_type']}: {$me['credits']} credits\n";
var me = await SkillSafe.ApiAsync(HttpMethod.Get, "/me");
Console.WriteLine($"{me.GetProperty("subject_type")}: {me.GetProperty("credits")} credits");

Step 3 — Estimate the cost

POST /estimate

Send the same input you would send to a run; the response's hold_credits is the worst-case cost and min_credits the floor. Nothing is charged and no job is created. The response also reports the resolved model and whether sponsorship is active. Longer notes and a higher slide_target both push the estimate up.

curl -s -X POST "$API/estimate" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"task":"generate_deck","notes":"…","slide_target":8}' | jq '.data'
est = api("POST", "/estimate", {"task": "generate_deck", "notes": notes,
                                "slide_target": 8})
print("worst case:", est["hold_credits"], "credits on", est["model"])
const est = await api("POST", "/estimate", {
  task: "generate_deck", notes, slide_target: 8,
});
console.log("worst case:", est.hold_credits, "credits on", est.model);
var est struct {
	HoldCredits int64  `json:"hold_credits"`
	Model       string `json:"model"`
}
err := call("POST", "/estimate", map[string]any{
	"task": "generate_deck", "notes": notes, "slide_target": 8,
}, &est)
String envelope = api("POST", "/estimate", """
    {"task":"generate_deck","notes": %s,"slide_target":8}
    """.formatted(toJsonString(notes)));
// worst-case cost is at data.hold_credits
est = api("POST", "/estimate", { task: "generate_deck", notes: notes,
                                 slide_target: 8 })
puts "worst case: #{est["hold_credits"]} credits on #{est["model"]}"
$est = api("POST", "/estimate", [
    "task" => "generate_deck",
    "notes" => $notes,
    "slide_target" => 8,
]);
echo "worst case: {$est['hold_credits']} credits on {$est['model']}\n";
var est = await SkillSafe.ApiAsync(HttpMethod.Post, "/estimate", new {
    task = "generate_deck", notes, slide_target = 8 });
Console.WriteLine($"worst case: {est.GetProperty("hold_credits")} credits");

Step 4 — Task generate_deck: run it and wait

POST /run
GET /jobs/{job_id}

The main task. /run places a credit hold and returns a job_id; poll /jobs/{job_id} every 1–2 seconds until status is succeeded or failed. Always send an Idempotency-Key header so a network retry can't start a second, double-charged run. The agent replies with one JSON document, delivered as a string at output.output — parse it.

{
  "task": "generate_deck",
  "title": "Q3 Platform Review",              // optional; derived from notes if absent
  "notes": "…the source material…",           // required
  "audience": "the exec team",                // optional
  "tone": "confident, plain",                  // optional
  "slide_target": 8,                           // optional, 3–16
  "accent": "#E8442E",                         // optional CSS hex
  "prescan": {                                 // optional, advisory
    "sections": 5,
    "numbers": ["4.2M", "18%"],
    "chars": 1207,
    "clipped": false
  }
}
Input fieldTypeNotes
taskstring, optionalOmit or "generate_deck" for this task.
notesstring, requiredThe source material: meeting notes, an outline, a report, bullet fragments. May carry a [... trimmed ...] marker where long input was clipped.
titlestring, optionalDeck title; the agent derives one from the notes when absent.
audience, tonestring, optionalGuide voice and density. Default: professional, concise.
slide_targetinteger, optional3–16. Hit ±1; without it the material decides (typically 5–10).
accentstring, optionalCSS hex colour used as the deck's single accent, e.g. "#E8442E".
prescanobject, optionalAdvisory facts counted before the run: {sections, numbers, chars, clipped}. Every counted section should show up in the deck, and 3+ comparable numbers nudge the agent into charting them.
$modelstring, optionalPer-run model override (allowlisted models only).
# input.json: {"task":"generate_deck","notes":"…","slide_target":8,"accent":"#E8442E"}
JOB_ID=$(curl -s -X POST "$API/run" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: deck-$(date +%s)" \
  -d @input.json | jq -r '.data.job_id')

while :; do
  JOB=$(curl -s "$API/jobs/$JOB_ID" -H "Authorization: Bearer $TOKEN")
  STATUS=$(echo "$JOB" | jq -r '.data.status')
  [ "$STATUS" = "succeeded" ] || [ "$STATUS" = "failed" ] && break
  sleep 2
done

# the deck document is a JSON *string* at data.output.output — keep it for step 6
echo "$JOB" | jq -r '.data.output.output' > doc.json
jq '{title, slides: (.slides | length), types: [.slides[].elements[].type] | unique}' doc.json
import time

job_id = api("POST", "/run", {
    "task": "generate_deck",
    "notes": notes,
    "slide_target": 8,
    "accent": "#E8442E",
}, **{"Idempotency-Key": "deck-001"})["job_id"]

while True:
    job = api("GET", f"/jobs/{job_id}")
    if job["status"] in ("succeeded", "failed"):
        break
    time.sleep(1.5)

if job["status"] == "failed":
    raise RuntimeError(job.get("error", "run failed"))

raw = job["output"]
if isinstance(raw, dict) and "output" in raw:
    raw = raw["output"]
doc = json.loads(raw) if isinstance(raw, str) else raw

if "error" in doc:                      # thin input
    raise SystemExit(doc["detail"])
print(doc["title"], "—", len(doc["slides"]), "slides")
const { job_id } = await api("POST", "/run", {
  task: "generate_deck",
  notes,
  slide_target: 8,
  accent: "#E8442E",
}, { "Idempotency-Key": crypto.randomUUID() });

let job;
do {
  await new Promise((r) => setTimeout(r, 1500));
  job = await api("GET", `/jobs/${job_id}`);
} while (job.status !== "succeeded" && job.status !== "failed");

if (job.status === "failed") throw new Error(job.error ?? "run failed");

const raw = job.output?.output ?? job.output;
const doc = typeof raw === "string" ? JSON.parse(raw) : raw;
if (doc.error) throw new Error(doc.detail); // thin input
console.log(doc.title, doc.slides.length, "slides");
var started struct{ JobID string `json:"job_id"` }
err := call("POST", "/run", map[string]any{
	"task": "generate_deck", "notes": notes, "slide_target": 8,
}, &started)
if err != nil {
	log.Fatal(err)
}

var job struct {
	Status string `json:"status"`
	Error  string `json:"error"`
	Output struct {
		Output string `json:"output"`
	} `json:"output"`
}
for {
	if err := call("GET", "/jobs/"+started.JobID, nil, &job); err != nil {
		log.Fatal(err)
	}
	if job.Status == "succeeded" || job.Status == "failed" {
		break
	}
	time.Sleep(1500 * time.Millisecond)
}
var doc map[string]any
json.Unmarshal([]byte(job.Output.Output), &doc)
if msg, thin := doc["error"]; thin {
	log.Fatalf("%v: %v", msg, doc["detail"])
}
fmt.Println(doc["title"], len(doc["slides"].([]any)), "slides")
String envelope = api("POST", "/run", """
    {"task":"generate_deck","notes": %s,"slide_target":8}
    """.formatted(toJsonString(notes)));
String jobId = /* data.job_id via your JSON library */;

while (true) {
    String job = api("GET", "/jobs/" + jobId, null);
    String status = /* data.status */;
    if (status.equals("succeeded") || status.equals("failed")) break;
    Thread.sleep(1500);
}
// the deck is the JSON *string* at data.output.output — parse it again with your
// JSON library; a {"error":"not_enough_material"} object means the notes were thin
started = api("POST", "/run", { task: "generate_deck", notes: notes,
                                slide_target: 8, accent: "#E8442E" })

job = nil
loop do
  job = api("GET", "/jobs/#{started["job_id"]}")
  break if %w[succeeded failed].include?(job["status"])
  sleep 1.5
end
raise (job["error"] || "run failed") if job["status"] == "failed"

raw = job["output"].is_a?(Hash) ? job["output"].fetch("output", job["output"]) : job["output"]
doc = raw.is_a?(String) ? JSON.parse(raw) : raw
abort doc["detail"] if doc["error"] # thin input
puts "#{doc["title"]} — #{doc["slides"].size} slides"
$started = api("POST", "/run", [
    "task" => "generate_deck",
    "notes" => $notes,
    "slide_target" => 8,
]);

do {
    sleep(2);
    $job = api("GET", "/jobs/" . $started["job_id"]);
} while (!in_array($job["status"], ["succeeded", "failed"]));

if ($job["status"] === "failed") {
    throw new Exception($job["error"] ?? "run failed");
}

$raw = is_array($job["output"]) ? ($job["output"]["output"] ?? $job["output"]) : $job["output"];
$doc = is_string($raw) ? json_decode($raw, true) : $raw;
if (isset($doc["error"])) {
    throw new Exception($doc["detail"]); // thin input
}
echo $doc["title"] . " — " . count($doc["slides"]) . " slides\n";
var started = await SkillSafe.ApiAsync(HttpMethod.Post, "/run", new {
    task = "generate_deck", notes, slide_target = 8, accent = "#E8442E" });
var jobId = started.GetProperty("job_id").GetString();

JsonElement job;
while (true)
{
    job = await SkillSafe.ApiAsync(HttpMethod.Get, $"/jobs/{jobId}");
    var status = job.GetProperty("status").GetString();
    if (status is "succeeded" or "failed") break;
    await Task.Delay(1500);
}
var doc = JsonDocument.Parse(
    job.GetProperty("output").GetProperty("output").GetString()!).RootElement;
if (doc.TryGetProperty("error", out _))
    throw new Exception(doc.GetProperty("detail").GetString()); // thin input
Console.WriteLine($"{doc.GetProperty("title")}: {doc.GetProperty("slides").GetArrayLength()} slides");

The document the agent returns has this shape:

{
  "format": "bento/slides", "version": 1, "title": "Q3 Platform Review",
  "size": { "width": 1280, "height": 720 },
  "theme": { "background": "#101418", "color": "#F2F0EA",
             "accent": "#E8442E", "fontFamily": "system-ui, sans-serif" },
  "slides": [
    { "id": "s1", "background": "#101418", "transition": "none",
      "notes": "Open on the headline number, then frame the quarter.",
      "elements": [
        { "id": "cover-title", "type": "text", "x": 96, "y": 220, "w": 1088, "h": 120,
          "rotation": 0, "opacity": 1, "role": "title", "html": "Q3 Platform Review",
          "fontSize": 72, "fontWeight": 700, "color": "#F2F0EA", "align": "left",
          "valign": "middle", "lineHeight": 1.15, "fx": { "enter": "fade-up" } }
      ] }
  ]
}
FieldWhat it is
format, versionAlways "bento/slides" and 1 — check them before you trust the rest.
sizeThe canvas: {width: 1280, height: 720}. All element coordinates are in these units.
theme{background, color, accent, fontFamily} — one accent colour for the whole deck; accent echoes your input when you sent one.
slides[]In order, ids s1…sN. Each carries background, transition ("none" or "morph" when it shares element ids with the slide before it), notes — the speaker's talk track, always non-empty — and elements[].
elements[]Type text, shape, chart or table only — no images or media, since the agent has no binary assets. Every element carries id, type, x, y, w, h, rotation, opacity, plus its per-type fields (html/fontSize…, shape/fill…, preset/option, columns/rows/style).
errorOn thin input the whole reply is {"error": "not_enough_material", "detail": "…"} instead — check for it before reading slides.

Charts are ECharts-shaped pure JSON under option: bar and line series[].data are plain numbers, only pie takes {name, value} pairs. Repeated element ids across consecutive slides with "transition": "morph" are intentional — that's what makes shared blocks glide instead of pop.

Step 5 — The same run, streamed

POST /run-stream

Identical input to /run, but the response is text/event-stream, so you can show progress while a long deck generates. Events:

EventData
job{job_id} — the run was accepted.
delta{text} — the next chunk of agent output.
done / pendingFinal payload: {job_id, status, charged_credits, output}. Authoritative — deltas can drop the tail, so always read the document from here.
error{code, message, job_id}.
curl -sN -X POST "$API/run-stream" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d @input.json
# event: job    data: {"job_id":"job_…"}
# event: delta  data: {"text":"{\"format\":\"bento/slides\""}
# …
# event: done   data: {"job_id":"…","status":"succeeded","charged_credits":608,
#                      "output":{"output":"…the full deck JSON…"}}
res = requests.post(API + "/run-stream", json=payload, stream=True,
                    headers={"Authorization": f"Bearer {TOKEN}"})
event, done = None, None
for line in res.iter_lines(decode_unicode=True):
    if line.startswith("event:"):
        event = line[6:].strip()
    elif line.startswith("data:"):
        data = json.loads(line[5:])
        if event == "delta":
            print(data.get("text", ""), end="", flush=True)
        elif event in ("done", "pending"):
            done = data
        elif event == "error":
            raise RuntimeError(data.get("message"))

doc = json.loads(done["output"]["output"])
const res = await fetch(API + "/run-stream", {
  method: "POST",
  headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
  body: JSON.stringify(payload),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "", out = "", event = "message", done;
for (;;) {
  const chunk = await reader.read();
  if (chunk.done) break;
  buf += decoder.decode(chunk.value, { stream: true });
  let i;
  while ((i = buf.indexOf("\n")) >= 0) {
    const line = buf.slice(0, i); buf = buf.slice(i + 1);
    if (line.startsWith("event:")) event = line.slice(6).trim();
    else if (line.startsWith("data:")) {
      const data = JSON.parse(line.slice(5));
      if (event === "delta") out += data.text ?? "";
      else if (event === "done" || event === "pending") done = data;
      else if (event === "error") throw new Error(data.message);
    }
  }
}
const doc = JSON.parse(done.output.output);
req, _ := http.NewRequest("POST", API+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
	log.Fatal(err)
}
defer res.Body.Close()

sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 0, 1<<20), 1<<20)
event, done := "", []byte(nil)
for sc.Scan() {
	line := sc.Text()
	if strings.HasPrefix(line, "event:") {
		event = strings.TrimSpace(line[6:])
	} else if strings.HasPrefix(line, "data:") {
		data := strings.TrimSpace(line[5:])
		if event == "delta" {
			// unmarshal {"text": …} and append
		} else if event == "done" || event == "pending" {
			done = []byte(data)
		}
	}
}
// unmarshal done → .output.output (a JSON string) → your deck struct or map
var req = HttpRequest.newBuilder(URI.create(API + "/run-stream"))
    .header("Authorization", "Bearer " + TOKEN)
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString(payloadJson))
    .build();
var lines = HTTP.send(req, HttpResponse.BodyHandlers.ofLines()).body();

final String[] event = {""};
StringBuilder doneData = new StringBuilder();
lines.forEach(line -> {
    if (line.startsWith("event:")) event[0] = line.substring(6).trim();
    else if (line.startsWith("data:")) {
        if (event[0].equals("delta")) { /* parse {"text"} and append */ }
        else if (event[0].equals("done")) doneData.append(line.substring(5).trim());
    }
});
// parse doneData → output.output (a JSON string) → the deck document
uri = URI(API + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req.body = payload.to_json

event, done, buf = nil, nil, ""
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
  http.request(req) do |res|
    res.read_body do |chunk|
      buf << chunk
      while (i = buf.index("\n"))
        line = buf.slice!(0..i).chomp
        if line.start_with?("event:") then event = line[6..].strip
        elsif line.start_with?("data:")
          data = JSON.parse(line[5..])
          print data["text"] if event == "delta"
          done = data if %w[done pending].include?(event)
        end
      end
    end
  end
end
doc = JSON.parse(done["output"]["output"])
$event = ""; $done = null; $buf = "";
$ch = curl_init(API . "/run-stream");
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => ["Authorization: Bearer $TOKEN", "Content-Type: application/json"],
    CURLOPT_POSTFIELDS => json_encode($payload),
    CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$event, &$done, &$buf) {
        $buf .= $chunk;
        while (($i = strpos($buf, "\n")) !== false) {
            $line = rtrim(substr($buf, 0, $i)); $buf = substr($buf, $i + 1);
            if (str_starts_with($line, "event:")) $event = trim(substr($line, 6));
            elseif (str_starts_with($line, "data:")) {
                $data = json_decode(substr($line, 5), true);
                if ($event === "delta") echo $data["text"] ?? "";
                if ($event === "done" || $event === "pending") $done = $data;
            }
        }
        return strlen($chunk);
    },
]);
curl_exec($ch);
curl_close($ch);
$doc = json_decode($done["output"]["output"], true);
var req = new HttpRequestMessage(HttpMethod.Post, Api + "/run-stream")
    { Content = JsonContent.Create(payload) };
var res = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());

string? line; string ev = ""; JsonElement doneEl = default;
while ((line = await reader.ReadLineAsync()) != null)
{
    if (line.StartsWith("event:")) ev = line[6..].Trim();
    else if (line.StartsWith("data:"))
    {
        var data = JsonDocument.Parse(line[5..]).RootElement.Clone();
        if (ev == "delta") Console.Write(
            data.TryGetProperty("text", out var t) ? t.GetString() : "");
        else if (ev is "done" or "pending") doneEl = data;
    }
}
var doc = JsonDocument.Parse(
    doneEl.GetProperty("output").GetProperty("output").GetString()!).RootElement;

Step 6 — Make it a deck you can open

The document is only half of a Bento file. The other half is the shell: a self-contained HTML page carrying the open-source Bento runtime and one empty document block. Download it from https://bento-slides.skillsafe.ai/bento-template.txt, splice your JSON into the empty <script type="application/bento+json" id="bento-doc"></script> block, and save the result as Deck.bento.html — double-clicking that file opens a working, animated deck with no install and no server.

One rule when writing the JSON into the block: escape every < as \u003c, otherwise a </script sequence anywhere in your text would end the block early and break the file. Several JSON encoders do this for you — Go's encoding/json and PHP's JSON_HEX_TAG escape it by default. Leave the base64 blocks near the end of the shell alone: that's the compressed runtime, not content.

# doc.json came out of step 4; the shell is the same for every deck
curl -s -o shell.html "https://bento-slides.skillsafe.ai/bento-template.txt"

# escape "<" and drop the JSON into the empty document block
sed 's|<|\\u003c|g' doc.json > safe.json
perl -0777 -pe 'BEGIN { local $/; open my $f, "<", "safe.json"; our $doc = <$f> }
                s/(id="bento-doc">)(?=<\/script>)/$1$doc/' shell.html > Deck.bento.html

open Deck.bento.html    # macOS; xdg-open on Linux
import urllib.request

# doc is the parsed deck from step 4
shell = urllib.request.urlopen(
    "https://bento-slides.skillsafe.ai/bento-template.txt").read().decode("utf-8")

doc_json = json.dumps(doc, ensure_ascii=False).replace("<", "\\u003c")
OPEN = '<script type="application/bento+json" id="bento-doc">'
CLOSE = "</script>"

filled = shell.replace(OPEN + CLOSE, OPEN + doc_json + CLOSE, 1)
if filled == shell:
    raise RuntimeError("document block not found — is the shell up to date?")
with open("Deck.bento.html", "w", encoding="utf-8") as f:
    f.write(filled)
print("wrote Deck.bento.html —", len(doc["slides"]), "slides")
import { writeFile } from "node:fs/promises";

// doc is the parsed deck from step 4
const shell = await (await fetch(
  "https://bento-slides.skillsafe.ai/bento-template.txt")).text();

const docJson = JSON.stringify(doc).replaceAll("<", "\\u003c");
const OPEN = '<script type="application/bento+json" id="bento-doc">';
const CLOSE = "</script>";

const filled = shell.replace(OPEN + CLOSE, OPEN + docJson + CLOSE);
if (filled === shell) throw new Error("document block not found in the shell");
await writeFile("Deck.bento.html", filled);
console.log("wrote Deck.bento.html —", doc.slides.length, "slides");
// doc is the parsed deck from step 4
shellRes, err := http.Get("https://bento-slides.skillsafe.ai/bento-template.txt")
if err != nil {
	log.Fatal(err)
}
defer shellRes.Body.Close()
shell, _ := io.ReadAll(shellRes.Body)

docJSON, _ := json.Marshal(doc) // encoding/json escapes "<" as \u003c already
const openTag = `<script type="application/bento+json" id="bento-doc">`
const closeTag = `</script>`

filled := strings.Replace(string(shell),
	openTag+closeTag, openTag+string(docJSON)+closeTag, 1)
if filled == string(shell) {
	log.Fatal("document block not found in the shell")
}
if err := os.WriteFile("Deck.bento.html", []byte(filled), 0o644); err != nil {
	log.Fatal(err)
}
// deckJson is the JSON string from data.output.output
String shell = HTTP.send(
    HttpRequest.newBuilder(URI.create(
        "https://bento-slides.skillsafe.ai/bento-template.txt")).build(),
    HttpResponse.BodyHandlers.ofString()).body();

String docJson = deckJson.replace("<", "\\u003c");
String openTag = "<script type=\"application/bento+json\" id=\"bento-doc\">";
String closeTag = "</script>";

String filled = shell.replace(openTag + closeTag, openTag + docJson + closeTag);
if (filled.equals(shell)) throw new RuntimeException("document block not found");
java.nio.file.Files.writeString(
    java.nio.file.Path.of("Deck.bento.html"), filled);
require "open-uri"

# doc is the parsed deck from step 4
shell = URI.parse("https://bento-slides.skillsafe.ai/bento-template.txt").read

doc_json = JSON.generate(doc).gsub("<") { "\\u003c" }
OPEN  = '<script type="application/bento+json" id="bento-doc">'
CLOSE = "</script>"

filled = shell.sub(OPEN + CLOSE) { OPEN + doc_json + CLOSE }
raise "document block not found in the shell" if filled == shell
File.write("Deck.bento.html", filled)
puts "wrote Deck.bento.html — #{doc["slides"].size} slides"
// $doc is the decoded deck from step 4
$shell = file_get_contents("https://bento-slides.skillsafe.ai/bento-template.txt");

// JSON_HEX_TAG escapes "<" as \u003c for us
$docJson = json_encode($doc, JSON_HEX_TAG | JSON_UNESCAPED_UNICODE);
$open  = '<script type="application/bento+json" id="bento-doc">';
$close = '</script>';

$filled = str_replace($open . $close, $open . $docJson . $close, $shell, $hits);
if ($hits === 0) {
    throw new Exception("document block not found in the shell");
}
file_put_contents("Deck.bento.html", $filled);
echo "wrote Deck.bento.html — " . count($doc["slides"]) . " slides\n";
// doc is the JsonElement deck from step 4
var shell = await Http.GetStringAsync(
    "https://bento-slides.skillsafe.ai/bento-template.txt");

var docJson = doc.GetRawText().Replace("<", "\\u003c");
const string openTag = "<script type=\"application/bento+json\" id=\"bento-doc\">";
const string closeTag = "</script>";

var filled = shell.Replace(openTag + closeTag, openTag + docJson + closeTag);
if (filled == shell) throw new Exception("document block not found in the shell");
await File.WriteAllTextAsync("Deck.bento.html", filled);
Console.WriteLine($"wrote Deck.bento.html — {doc.GetProperty("slides").GetArrayLength()} slides");

Prefer not to touch files? The app's Download .bento.html button does this splice for you and hands back the finished file. In a deck that's already open, window.bento exposes { doc, serialize(), loadDoc(json) }, so you can swap the document live from the browser console.

Step 7 — Task revise_deck: change an existing deck

Revisions go through the same two endpoints (/run or /run-stream) — only the JSON body changes. Send the document you already have back as doc plus feedback in plain language; the agent applies it while preserving untouched slides and existing element ids, so morph transitions keep working. The reply is again one full bento/slides document as a string at output.output — feed it straight back into step 6.

{
  "task": "revise_deck",
  "doc": { …the existing bento/slides document… },
  "feedback": "Cut slide 4. Turn the pricing bullets into a table and make the
               revenue numbers a bar chart."
}
// → a complete bento/slides document, same shape as step 4
# revise.json: {"task":"revise_deck","doc":{…},"feedback":"…"}
jq -n --slurpfile doc doc.json \
  '{task:"revise_deck", doc:$doc[0], feedback:"Cut slide 4; chart the revenue numbers."}' \
  > revise.json

curl -s -X POST "$API/run" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: revise-$(date +%s)" \
  -d @revise.json | jq -r '.data.job_id'
# …then poll /jobs/{job_id} exactly as in step 4, and rebuild the file as in step 6
def run_task(payload, key):
    job_id = api("POST", "/run", payload, **{"Idempotency-Key": key})["job_id"]
    while True:
        job = api("GET", f"/jobs/{job_id}")
        if job["status"] in ("succeeded", "failed"):
            break
        time.sleep(1.5)
    if job["status"] == "failed":
        raise RuntimeError(job.get("error", "run failed"))
    return json.loads(job["output"]["output"])

doc = run_task({
    "task": "revise_deck",
    "doc": doc,
    "feedback": "Cut slide 4; make the revenue numbers a bar chart.",
}, "revise-001")
print(doc["title"], "—", len(doc["slides"]), "slides")
async function runTask(payload) {
  const { job_id } = await api("POST", "/run", payload,
    { "Idempotency-Key": crypto.randomUUID() });
  let job;
  do {
    await new Promise((r) => setTimeout(r, 1500));
    job = await api("GET", `/jobs/${job_id}`);
  } while (job.status !== "succeeded" && job.status !== "failed");
  if (job.status === "failed") throw new Error(job.error ?? "run failed");
  return JSON.parse(job.output.output);
}

const revised = await runTask({
  task: "revise_deck",
  doc,
  feedback: "Cut slide 4; make the revenue numbers a bar chart.",
});
// reuse the step-4 poll loop; only the body changes
payload := map[string]any{
	"task":     "revise_deck",
	"doc":      doc,
	"feedback": "Cut slide 4; make the revenue numbers a bar chart.",
}
var started struct{ JobID string `json:"job_id"` }
if err := call("POST", "/run", payload, &started); err != nil {
	log.Fatal(err)
}
// …poll /jobs/{job_id} as in step 4, then unmarshal output.output
// reuse the step-4 poll loop; only the body changes
String payload = """
    {"task":"revise_deck","doc": %s,
     "feedback":"Cut slide 4; make the revenue numbers a bar chart."}
    """.formatted(deckJson);
String envelope = api("POST", "/run", payload);
// …poll /jobs/{job_id} as in step 4, then parse output.output
def run_task(payload)
  started = api("POST", "/run", payload)
  job = nil
  loop do
    job = api("GET", "/jobs/#{started["job_id"]}")
    break if %w[succeeded failed].include?(job["status"])
    sleep 1.5
  end
  raise (job["error"] || "run failed") if job["status"] == "failed"
  JSON.parse(job["output"]["output"])
end

doc = run_task({ task: "revise_deck", doc: doc,
                 feedback: "Cut slide 4; chart the revenue numbers." })
function run_task(array $payload): array {
    $started = api("POST", "/run", $payload);
    do {
        sleep(2);
        $job = api("GET", "/jobs/" . $started["job_id"]);
    } while (!in_array($job["status"], ["succeeded", "failed"]));
    if ($job["status"] === "failed") {
        throw new Exception($job["error"] ?? "run failed");
    }
    return json_decode($job["output"]["output"], true);
}

$doc = run_task([
    "task" => "revise_deck",
    "doc" => $doc,
    "feedback" => "Cut slide 4; chart the revenue numbers.",
]);
// reuse the step-4 poll loop; only the body changes
var payload = new {
    task = "revise_deck", doc,
    feedback = "Cut slide 4; make the revenue numbers a bar chart." };
var started = await SkillSafe.ApiAsync(HttpMethod.Post, "/run", payload);
// …poll /jobs/{job_id} as in step 4, then parse output.output

The agent works only from what you send it: it distils and restructures your notes but never invents figures, quotes or commitments that aren't in them. If the material is too thin for even three slides, you get {"error": "not_enough_material", "detail": "…"} back rather than a padded deck — that's the product, not a limitation.