← Thirty-Six
API
Tokens

Drive Thirty-Six from your own code

The metered task is available over HTTP. A natural use is a camera-shop app, a photo-walk organiser or a chat assistant that already knows the outing: your code sends the roll and the shot list, and gets back the critic's plan - a verdict (ready, rebalance or wrong-film), the frames for every group, a keeper / maybe / cut mark, a composition note and a note on the light.

The split first: the page does the exposure arithmetic; the model judges and writes; the page checks it. The facts (each group's scene brightness from the classic exposure-value table, the setting the camera can reach, how many stops over or under, flash reach, the frame budget, the film speed that would save the most frames) come from rollkit.js, which runs in node as well as in the browser. Its checkPlan checks a reply before anything is applied: every group planned once, frames that add up to the roll, cut groups at zero, citations that exist, and a verdict that agrees with the measured facts. The light levels, film latitudes and camera specs are typical values - estimates for planning, not meter readings.

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":"thirty-six"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object, sent bare: never wrap it as {"input": {…}}.

StatusCodeMeaning
400validation_errorThe body is not a JSON object, or task is missing.
401unauthorizedNo token, or a stale one. Mint a guest token or sign in again.
402insufficient_creditsThe balance is under min_credits. Price with /estimate first.
403forbiddenA guest token tried to run: the plan task is metered and needs a personal token.
404not_foundUnknown job id.
429rate_limitedBack off and retry.
5xxserver_errorTransient. Retry with the same Idempotency-Key so a retry never double-bills.

The task field comes first

Every run sends task. There is one task, plan; a missing or unknown task is treated as plan.

taskDoesReply
planJudges the roll against the outing and re-allocates its frames.verdict (ready - fits the roll and nothing kept is lost, at most 3 frames move; rebalance - over or short of the roll, or a lost group the person can fix or cut; wrong-film - more than half the frames are risky or lost and another film speed saves most of them), verdict_why, title, groups [{id, frames, mark, composition, exposure, cite}] with mark one of keeper, maybe, cut (cut means 0 frames) and frames that add up to frames, swaps [{what, why, cite}], film_advice, tips, caveats.

Cites are the fact ids in facts plus N-outing (the outing note). The declared input schema carries scalars only, so object-valued fields travel JSON-encoded as strings (RollKit.toWire does this). Only task is required by the schema.

Input fields

FieldTypeNotes
taskstringplan.
outingstringWhere, when, who and what the roll is for; past 1,500 characters the middle is cut around a marker.
isonumberFilm speed: 50, 100, 200, 400, 800, 1600 or 3200.
film_typestringcolor (colour negative), bw (black and white negative) or slide.
framesnumberExposures on the roll: 12, 24, 27, 36 or 72.
camerastringThe camera class, e.g. Single-use camera.
camera_specstringAperture range, shutter range, slowest hand-held speed, flash guide number.
shotsstringOne group per line: G1 | 10 | Costume close-ups at the party | Dim party or bar | Close, about 1.5 m | People, moving a little.
factsstring (JSON)[{id, text}] from RollKit.facts: F-film, F-camera, F-budget, F-G1 … one per group, F-tally, F-best-iso, or F-empty.
retry_notestringOnly on a reformat retry.

The reply

The job's output.output is a string holding one JSON object. Parse it with RollKit.parseReply, then normalize(reply), then checkPlan(reply, roll), which returns {alloc, sum, notes, changed, analysis} - the frames per group the page would apply, their total, and the notes (ok, warn, bad). applyPlan(roll, reply) returns the roll with those counts.

Worked examples

Replies produced against the app's prompt for the sample rolls (made up). Between them they cover all three verdicts. The page replays each of them free from its sample buttons; the full replies ship in example-result.js. Long fields are folded.

task: plan - Autumn leaf walk - ready

Input:

{
 "task": "plan",
 "outing": "A Saturday afternoon walk with my sister through the park when the maples turn, a stop at the lakeside cafe, and back home as the sun goes down. I want it to feel warm and a bit nostalgic, like old family snapshots.",
 "iso": 200,
 "film_type": "color",
 "frames": 24,
 "camera": "Point-and-shoot compact",
 "camera_spec": "f/3.5-f/16, 1/2 s to 1/500 s, hand-held to 1/30 s, flash GN 10, automatic exposure",
 "shots": "G1 | 6 | Trail under the maples | Hazy sun, soft shadows | Landscape or skyline | Still subject\nG2 | 6 | Portraits in the leaves | Open shade or heavy overcast | Close, about 1.5 m | People, moving a little\nG3 | 4 | Lake from the overlook | Cloudy bright | Landscape or skyline | Still subject\nG4 | 4 | Coffee cup by the cafe window | Indoors by a bright window | Close, about 1.5 m | Still subject\nG5 | 4 | Walking home into the low sun | Golden hour, low sun | Across a table, about 3 m | People, moving a little",
 "facts": "[{\"id\": \"F-film\", \"text\": \"ISO 200 colour negative, 24 exposures; this page assumes it holds detail from 1 stop under to 3.5 stops over (estimate)\"}, {\"id\": \"F-camera\", \"text\": \"Point-and-shoot compact: f/3.5 to f/16, 1/2 s to 1/500 s, slowest hand-held 1/30 s, sets exposure itself; flash guide number 10 at ISO 100, so it reaches about 4 m on this film (typical specs, estimates)\"}, {\"id\": \"F-budget\", \"text\": \"Shots planned: 24 frames; the roll holds 24 - exactly full\"}, \"... 7 more facts\"]"
}

Reply:

{
 "task": "plan",
 "verdict": "ready",
 "verdict_why": "Every group on this roll exposes on its own with no flash needed, none are risky or lost, and the planned frames add up to exactly the roll's length, so the plan can be shot as written.",
 "title": "Maple Walk With Sister",
 "groups": [
  {
   "id": "G1",
   "frames": 6,
   "mark": "maybe",
   "composition": "Let the trail lead the eye straight back into the trees, and put a cluster of bright turned leaves against the darker trunks so the color stands out. Tip the camera down a little so the path fills the bottom third and keep the flat sky out of the top of the frame.",
   "exposure": "The hazy sun gives soft, even light the camera reads correctly on its own - no flash, just hold the camera level and still.",
   "cite": [
    "F-G1"
   ]
  },
  {
   "id": "G2",
   "frames": 6,
   "mark": "keeper",
   "composition": "Get close enough that your sister's face and shoulders fill the frame, with a soft backdrop of leaves behind her rather than centered in open space. Have her turn toward the open part of the frame so there's room for her to look into.",
   "exposure": "The open shade is soft and even and the camera handles it on its own - no flash, just brace a little since people are moving.",
   "cite": [
    "F-G2"
   ]
  },
  "... 3 more"
 ],
 "swaps": [],
 "film_advice": "Keep this film - every shot on the roll exposes within its range as planned, and no other film speed would save more frames with this camera.",
 "tips": [
  "Wind on fully right after each shot so you don't miss the next one.",
  "Brace the camera against something solid for the café window shot - that one uses a slow shutter speed.",
  "... 2 more"
 ],
 "caveats": [
  "These exposures are rule-of-thumb estimates for typical light; an unusually dim café window could need the flash after all."
 ]
}

task: plan - Halloween party, single-use camera - rebalance

Input:

{
 "task": "plan",
 "outing": "Our Halloween house party. Everyone dresses up, we do a costume parade down the block in the late afternoon, then it is music and snacks inside with the lights low. I bought one single-use camera with 27 shots and I want the whole night on it, especially one photo of all twelve of us.",
 "iso": 400,
 "film_type": "color",
 "frames": 27,
 "camera": "Single-use camera",
 "camera_spec": "fixed f/11, 1/125 s, flash GN 14",
 "shots": "G1 | 10 | Costume close-ups at the party | Dim party or bar | Close, about 1.5 m | People, moving a little\nG2 | 8 | Porch and carved pumpkins before dark | Hazy sun, soft shadows | Across a table, about 3 m | Still subject\nG3 | 8 | Whole group across the living room | Dim party or bar | Across a room, about 6 m | People, moving a little\nG4 | 6 | Costume parade down the street | Cloudy bright | Across a table, about 3 m | People, moving a little",
 "facts": "[{\"id\": \"F-film\", \"text\": \"ISO 400 colour negative, 27 exposures; this page assumes it holds detail from 1 stop under to 3.5 stops over (estimate)\"}, {\"id\": \"F-camera\", \"text\": \"Single-use camera: fixed f/11 and 1/125 s; flash guide number 14 at ISO 100, so it reaches about 2.5 m on this film (typical specs, estimates)\"}, {\"id\": \"F-budget\", \"text\": \"Shots planned: 32 frames; the roll holds 27 - 5 frames over\"}, \"... 6 more facts\"]"
}

Reply:

{
 "task": "plan",
 "verdict": "rebalance",
 "verdict_why": "You've planned 32 frames for a 27-exposure roll, 5 over, and the whole-group photo is lost at its planned distance because the flash can't reach that far. Nothing else would save more frames with this film, so the fix is trimming and moving people closer, not a different film speed.",
 "title": "Halloween House Party Roll",
 "groups": [
  {
   "id": "G1",
   "frames": 10,
   "mark": "keeper",
   "composition": "Let a costume detail - a mask, face paint, a prop - fill most of the frame, since that's where the eye should land first. Back the person against a plain wall or dark doorway so the costume stands out from the party clutter behind them.",
   "exposure": "Turn the flash on - at about 1.5m you're well inside its 2.5m reach, so it should expose fine in the dim light.",
   "cite": [
    "F-G1"
   ]
  },
  {
   "id": "G2",
   "frames": 2,
   "mark": "maybe",
   "composition": "Use a carved pumpkin's face as the eye-catcher in the foreground, set against the softer, out-of-focus porch behind it. Get down near pumpkin height so the carving looks back at the camera instead of down at it.",
   "exposure": "No flash needed - the hazy afternoon light is within the film's latitude even though it reads a touch bright.",
   "cite": [
    "F-G2"
   ]
  },
  "... 2 more"
 ],
 "swaps": [
  {
   "what": "Move the whole-group photo in close, within about the flash's reach, instead of shooting it across the room",
   "why": "At the planned distance the flash can't reach the group at all, so it's lost; this is the one shot the night is especially about, so it has to be fixable",
   "cite": [
    "F-G3",
    "F-camera",
    "N-outing"
   ]
  },
  {
   "what": "Cut the porch and pumpkin shots from 8 frames to 2",
   "why": "The roll is over by 5 frames as planned, and this group isn't part of what the person said the night is about",
   "cite": [
    "F-G2",
    "F-budget",
    "N-outing"
   ]
  },
  "... 1 more"
 ],
 "film_advice": "Keep this ISO 400 colour negative film - no other film speed would save more frames with this camera and this shot list.",
 "tips": [
  "Wind the film on fully after every shot - this camera won't fire right if it isn't advanced.",
  "Switch the flash on and give it a few seconds to charge before every shot indoors or after dark.",
  "... 2 more"
 ],
 "caveats": [
  "The flash's reach and the exposure calls here are rule-of-thumb estimates for a typical single-use camera, so real results may vary a little.",
  "Packing all twelve people within the flash's reach in a living room will be tight - you may need two close rows."
 ]
}

task: plan - Night city on slow film - wrong-film

Input:

{
 "task": "plan",
 "outing": "A night out in the city: sunset drinks on a rooftop, a walk down the main street under the neon, a small club gig, then a late diner. I loaded the ISO 100 roll I had left from summer. I shoot hand-held and do not own a flash.",
 "iso": 100,
 "film_type": "color",
 "frames": 36,
 "camera": "Manual SLR or rangefinder",
 "camera_spec": "f/2-f/16, 1 s to 1/1000 s, hand-held to 1/30 s, no flash, manual exposure",
 "shots": "G1 | 12 | Neon signs and people on the main street | Lit street at night | Across a table, about 3 m | People, moving a little\nG2 | 12 | The band on stage | Lit stage or stadium at night | Across a room, about 6 m | Action or dancing\nG3 | 6 | Late diner booth after the show | Home lamps at night | Close, about 1.5 m | Still subject\nG4 | 6 | Rooftop at sunset before we go out | Golden hour, low sun | Landscape or skyline | Still subject",
 "facts": "[{\"id\": \"F-film\", \"text\": \"ISO 100 colour negative, 36 exposures; this page assumes it holds detail from 1 stop under to 3.5 stops over (estimate)\"}, {\"id\": \"F-camera\", \"text\": \"Manual SLR or rangefinder: f/2 to f/16, 1 s to 1/1000 s, slowest hand-held 1/30 s, set by hand; no flash (typical specs, estimates)\"}, {\"id\": \"F-budget\", \"text\": \"Shots planned: 36 frames; the roll holds 36 - exactly full\"}, \"... 6 more facts\"]"
}

Reply:

{
 "task": "plan",
 "verdict": "wrong-film",
 "verdict_why": "More than half of the planned frames are risky or lost on this film speed once it is dark out, because the street and stage scenes are too dim for it without a flash. A faster film would turn nearly all of those same frames into clean exposures, so the film itself is the problem here, not the shot list.",
 "title": "City Night Out",
 "groups": [
  {
   "id": "G1",
   "frames": 12,
   "mark": "keeper",
   "composition": "Let the neon signs supply the light and colour, and let the people read as dark, simple shapes against them - figure against a glowing ground. Keep the frame tight so a sign's glow sits just over a shoulder, not behind a face, and have them pause a beat so their outline stays sharp while the street can blur a touch around them.",
   "exposure": "On this film the street light is well below what the film can hold, so the frame would come out too dark to see; the faster film is the fix, pulling this one back into a shot that actually prints.",
   "cite": [
    "F-G1"
   ]
  },
  {
   "id": "G2",
   "frames": 12,
   "mark": "keeper",
   "composition": "Put the performer under the single brightest stage light and let the dark room swallow everything else - that is the figure against the ground here. Keep mic stands and gear out of the edges, move in or zoom with your feet until they fill a third of the frame, and catch them on the brief pause at the top of a move to cut down blur.",
   "exposure": "At this speed the stage shot is under the film's range enough to look thin and muddy in the shadows, though it may still come out; the faster film gives this one a clean negative instead of a gamble.",
   "cite": [
    "F-G2"
   ]
  },
  "... 2 more"
 ],
 "swaps": [
  {
   "what": "reload with a faster, ISO 400 colour negative film for the rest of the night",
   "why": "the same shot list on this faster film turns almost every street and stage frame into a clean exposure instead of a dark or thin one",
   "cite": [
    "F-best-iso"
   ]
  }
 ],
 "film_advice": "This ISO 100 roll is the problem tonight, not the plan. An ISO 400 colour negative film suits this camera and this much after-dark shooting far better, and would save nearly all the shots that are currently dark or thin.",
 "tips": [
  "This camera has no flash, so get physically close to whatever light there is rather than relying on it to reach you.",
  "Brace against a wall, railing or table for anything shot at a slow hand-held speed so the frame does not blur.",
  "... 2 more"
 ],
 "caveats": [
  "If this roll gets shot on the ISO 100 film anyway, expect the street walk to come out too dark to print and the stage shots to look thin and grainy."
 ]
}

Build the input yourself

Load the script in node (global.window = {}; require("./rollkit.js")), then:

const K = RollKit;
const roll = K.sanitize({ iso: 400, filmType: "color", frames: 27, camera: K.presetCamera("disposable"),
  groups: K.parseShots("10 | Costume close-ups | party | near | people\n8 | Whole group | party | far | people").groups });
const { input } = K.buildInput(roll, "Our Halloween house party");
const wire = K.toWire(input);        // POST this to /estimate and /run
// later: const n = K.normalize(K.parseReply(job.output.output));
// const { alloc, notes } = K.checkPlan(n, roll);  K.applyPlan(roll, n) -> the roll with the new counts

Step 1 — a tiny client helper

One function that sends JSON and unwraps the envelope. Paste a personal token from /tokens.html, or leave it empty and mint a guest token in step 2.

BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="paste-your-token-here"   # from https://thirty-six.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"}
}

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":"thirty-six"}' | jq -r '.data.token')

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}' 

Step 4 — POST /estimate

Free. The body is the run input itself, the bare object (never wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model_alias reads gpt-terra.

# input.json: the run input object - {"task": "plan", "outing": "...", "iso": 400, "film_type": "color", "frames": 27, "camera": "Single-use camera", "camera_spec": "fixed f/11, 1/125 s, flash GN 14", "shots": "G1 | 10 | Costume close-ups | ...\n...", "facts": "[...]"}
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}' 

Step 5 — POST /run, then poll the job

Send an Idempotency-Key built from the task and the input: a retry with the same key never bills twice. The page uses thirty-six:plan:<inputHash>:a<attempt>, and its one reformat retry (with retry_note added) is attempt 2 - a new key and a second, named run. The job's output.output is the reply text; truncated: true means the balance cut it short.

KEY="thirty-six:plan:$(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}' 

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: thirty-six:plan:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json