← Picture Book Desk / API
Tokens

Drive Picture Book Desk from your own code

Everything the web page does is available over HTTP: send the manuscript page by page, say which age band it is for, attach the reading-level facts you computed in your own process, and get the same reading teacher's judgement back — or ask for a revision and get the pages rewritten to the band. The natural use is a slush pile read overnight, or a pipeline that re-levels every draft in a series whenever an editor changes the target age.

One thing to be clear about before the first call: the model never computes the numbers. Words, sentences and syllables per page, the revised Spache grade, Flesch-Kincaid grade, Flesch reading ease, New Dale-Chall, Dolch sight-word coverage, the rhyme scheme and syllables per line of every page, refrains, page-turn cues and the phonics spelling patterns are all computed by the caller and sent as facts. The model's job is judgement over those facts and over the words on the page — whether the book fits the children it is for, whether the rhyme and the rhythm hold up read aloud, which words a child would stumble on, what to change. See computing the facts yourself; the engine the web page uses ships as three plain scripts (/wordlists.js, /dict.js, /leveler.js) you can load in node.

And one thing to be clear about the grades themselves: a readability grade measures independent decoding — the level at which a child could read the text alone. A picture book is normally read aloud to a child two to four years younger than the grade implies, which is why the desk's age bands sit well below the grades they allow.

Base URL and the envelope

Every endpoint lives under https://api.skillsafe.ai/v1/app-api and every response uses the same envelope, so one helper covers the whole API:

{ "ok": true,  "data":  { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "status": 402, "details": { ... } } }

The token is minted for this app (the guest endpoint takes {"slug":"picture-book-desk"} in its body), so no slug header is needed afterwards — no app-slug header exists on this API at all. Send your token as Authorization: Bearer … on every call.

The run body is the input object: post {"task": "level", …} directly. Wrapping it as {"input": {…}} returns 200 and quietly hides every field from the model, so never do that.

StatusCodeMeaning
400validation_errorThe body is not a JSON object, or a declared required field (task, brief, manuscript, target, facts) 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: running is metered and needs a personal token. Sign in to run.
404not_foundUnknown job id.
429rate_limitedBack off and retry.
5xxserver_errorTransient. Retry with the same Idempotency-Key so a retry never double-bills.

1. Get a token

A guest token is free and enough for /me and /estimate. Running a lane is metered, so it needs a personal token — sign in on the token page and copy it from there. A guest token that calls /run gets 403.

curl -s -X POST https://api.skillsafe.ai/v1/app-api/guest -H "Content-Type: application/json" -d '{"slug":"picture-book-desk"}'

2. A tiny client

One helper, one envelope. The samples below reuse it.

curl -s -X GET https://api.skillsafe.ai/v1/app-api/me \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json"

3. Check the session and the balance

GET /me returns subject_type (user or guest), subject_id and credits — and nothing else, so a signed-in caller is exactly subject_type === "user". A real user's first run should not 402: compare credits with the estimate's hold_credits before running.

curl -s -X GET https://api.skillsafe.ai/v1/app-api/me \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json"

4. Price the run — free

POST /estimate with the exact run body returns model (gpt-5.6-terra), model_alias (gpt-terra), markup_bps (1000), hold_credits and min_credits. hold_credits is a reservation placed against the balance while the job runs, not the price: the unused part is refunded and the real cost comes back as charged_credits on the finished job. No job is created and nothing is charged by estimating. The body must be a JSON object with scalar string fields only — no nested objects, which is why facts travels as a JSON string.

curl -s -X POST https://api.skillsafe.ai/v1/app-api/estimate \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"task": "level", "brief": "A bedtime book for my niece, who is four. I want to know whether it fits ages 3-5 and whether the rhyme holds up read aloud.", "manuscript": "Page 1\nThe sun slid down behind the hill.\nSmall Mole came home to bed.\nThe sky went pink. The wind went still.\nShe put a cap upon her head.\n\nPage 2\nHer mother sang a sleepy song.\nThe moon came up so round.\nThe night was soft. The day was long.\nThere was no other sound.\n\nPage 3\nSmall Mole shut both her sleepy eyes.\nThe stars kept watch above.\nSleep well, small Mole, the night wind sighs.\nSleep well, my little love.", "target": "3-5", "facts": "<JSON string from Leveler.analyze(manuscript, {target}) - see below>"}'

The fields both lanes take

FieldTypeMeaning
taskstring, requiredThe lane, and the first field to decide. level judges the manuscript you already have: whether it fits the children it is for, the read-aloud craft, the words a child would stumble on, the phonics it happens to teach. revise rewrites the pages to the band in target, keeping the story, the characters, the voice and — where the pages rhyme — the rhyme. Any other value is rejected as unknown; the reply always names the lane it answered in lane, and the two contracts are never blended.
briefstring, requiredWho the book is for and what you want checked or changed, in your own words. In the revise lane this is where the level lane's flagged words and your own constraints go (“keep the refrain”, “keep the ABAB rhyme”, “the mole is called Small Mole”).
manuscriptstring, requiredThe text of the book, pages separated by blank lines, dashes or Page N markers. Line breaks inside a page are kept and are what the rhyme scheme and the syllables-per-line are measured on. A long manuscript may be clipped in the middle with a marker; the facts are always computed from the whole text, never from the clipped copy.
targetstring, requiredThe age band: 2-3 (board book), 3-5 (picture book), 5-7 (early reader) or 7-9 (early chapter). It selects the desk's word-count and grade ceilings that the flags are raised against, and it is the band the revise lane writes to.
factsstring, requiredA JSON string (not an object), produced by Leveler.analyze(manuscript, {target}). Everything the model is allowed to quote as a number comes from here. See computing the facts yourself.
retry_notestringOptional, and normally absent. The page sends it only on the automatic reformat retry, when the first reply did not parse as one JSON object.

Every field is a scalar string. Anything not in this table is rejected as unknown, and a missing task, brief, manuscript, target or facts is a 400 validation_error.

The desk's age bands

The bands are the desk's own trade guidance, not a rule of the trade, and the flags in facts.flags are raised against them. facts.summary.target_label names the band the manuscript was measured against.

targetBandWordsRevised Spache gradeDolch sight words
2-3Board book0–1501.9 or under—
3-5Picture book100–7002.9 or under—
5-7Early reader200–12003.5 or under50% or more
7-9Early chapter800–60004.5 or under—

Read those grades with the caveat at the top of this page in mind: a Spache grade of 2.9 is the level at which a child could decode the text alone, and the book is being read aloud to a three-year-old. The grade ceiling is a ceiling on the sentences and the vocabulary, not a claim about who the book is for.

Computing the facts yourself

Load the three engine scripts in node with a stub window, in this order, and call the same function the page calls. Everything the model is allowed to quote comes out of this one call:

global.window = {};
const vm = require("vm"), fs = require("fs");
for (const f of ["wordlists.js", "dict.js", "leveler.js"]) vm.runInThisContext(fs.readFileSync(f, "utf8"));
const Leveler = window.Leveler;

const manuscript = [
  "Page 1",
  "The sun slid down behind the hill.",
  "Small Mole came home to bed.",
  "The sky went pink. The wind went still.",
  "She put a cap upon her head.",
  "",
  "Page 2",
  "Her mother sang a sleepy song.",
  "The moon came up so round.",
  "The night was soft. The day was long.",
  "There was no other sound.",
  "",
  "Page 3",
  "Small Mole shut both her sleepy eyes.",
  "The stars kept watch above.",
  "Sleep well, small Mole, the night wind sighs.",
  "Sleep well, my little love."
].join("\n");

const facts = Leveler.analyze(manuscript, { target: "3-5" });   // target: 2-3 | 3-5 | 5-7 | 7-9

// facts.summary            pages, words, unique_words, ttr, sentences, syllables,
//                          asl (words per sentence), asw (syllables per word),
//                          words_per_page_mean / _max / _min, longest_sentence_words,
//                          longest_word, longest_word_syllables,
//                          spache_grade, spache_pdw, spache_unfamiliar, reading_age,
//                          fk_grade, flesch_ease,
//                          dale_chall, dale_chall_pdw, dale_chall_unfamiliar, dale_chall_band,
//                          dolch_pct, dolch_tokens, dolch_noun_pct, dolch_by_level,
//                          non_dolch_types, multisyllable_types, multisyllable_pct,
//                          rhymed_pages, rhyme_pct, meter_cv_mean, dialogue_lines,
//                          question_pages, cue_pages, refrains, target, target_label
// facts.pages[]            page, lines, words, sentences, syllables, longest_sentence_words,
//                          line_syllables[], rhyme_scheme (AABB; ? for a last word the
//                          dictionary does not know), rhymed_lines, rhymes, dialogue_lines,
//                          ends_with_question, ends_with_cue, first_line
// facts.phonics            keyed by cvc, digraph, blend, silent_e, vowel_team, r_controlled,
//                          each {label, types, examples[]}
// facts.spache_unfamiliar_words / dale_chall_unfamiliar_words / multisyllable_words /
//   non_dolch_examples     word lists, up to 40 each
// facts.refrain_lines[]    {line, pages[]}
// facts.flags[]            {id, text} with ids too_long, too_short, level_high, long_sentences,
//                          low_sight_words, hard_words, rhyme_uneven, meter_uneven,
//                          many_pages, few_pages
// facts.warnings[]         engine warnings the model must address in notes_on_input
// facts.errors[]           what could not be read at all
// facts.parse              {pages, mode}

JSON.stringify(facts)   // send this string as the facts field

Send JSON.stringify(facts) as facts — a string, not an object. The grades are the published formulas: the revised Spache (1974) for the primary grade, Flesch-Kincaid (Kincaid et al. 1975) and Flesch reading ease (1948) alongside it, and New Dale-Chall (Chall & Dale 1995) with its band. Sight-word coverage is the Dolch list (1936). facts.summary.reading_age is the Spache grade plus five — the age at which a child typically reads that grade alone, which is not the age the book is for. Syllables and rhymes come from a pronunciation dictionary, so a last word the dictionary does not know makes that line a ? in the page's rhyme_scheme rather than a guess.

The level request body, in full

Every field app.js sends, with facts abbreviated:

{
  "task": "level",
  "brief": "A bedtime book for my niece, who is four. I want to know whether it fits ages 3-5 and whether the rhyme holds up read aloud.",
  "manuscript": "Page 1\nThe sun slid down behind the hill.\nSmall Mole came home to bed.\nThe sky went pink. The wind went still.\nShe put a cap upon her head.\n\nPage 2\nHer mother sang a sleepy song.\nThe moon came up so round.\nThe night was soft. The day was long.\nThere was no other sound.\n\nPage 3\nSmall Mole shut both her sleepy eyes.\nThe stars kept watch above.\nSleep well, small Mole, the night wind sighs.\nSleep well, my little love.",
  "target": "3-5",
  "facts": "<JSON string from Leveler.analyze(manuscript, {target}) - see below>"
}

Worked example: the level lane

Three pages of a rhyming bedtime book about a small mole, aimed at ages 3–5, and the question “does this fit?”. Price it first; the same body goes to /run.

curl -s -X POST https://api.skillsafe.ai/v1/app-api/estimate \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"task": "level", "brief": "A bedtime book for my niece, who is four. I want to know whether it fits ages 3-5 and whether the rhyme holds up read aloud.", "manuscript": "Page 1\nThe sun slid down behind the hill.\nSmall Mole came home to bed.\nThe sky went pink. The wind went still.\nShe put a cap upon her head.\n\nPage 2\nHer mother sang a sleepy song.\nThe moon came up so round.\nThe night was soft. The day was long.\nThere was no other sound.\n\nPage 3\nSmall Mole shut both her sleepy eyes.\nThe stars kept watch above.\nSleep well, small Mole, the night wind sighs.\nSleep well, my little love.", "target": "3-5", "facts": "<JSON string from Leveler.analyze(manuscript, {target}) - see below>"}'

The revise request body, in full

The same manuscript and the same band, but the brief now says what to change and what to keep, and the level lane's flagged words travel in it:

{
  "task": "revise",
  "brief": "Bring it to ages 3-5 and keep the ABAB rhyme, the name Small Mole and the last line. The level lane flagged sighs as the hardest word.",
  "manuscript": "Page 1\nThe sun slid down behind the hill.\nSmall Mole came home to bed.\nThe sky went pink. The wind went still.\nShe put a cap upon her head.\n\nPage 2\nHer mother sang a sleepy song.\nThe moon came up so round.\nThe night was soft. The day was long.\nThere was no other sound.\n\nPage 3\nSmall Mole shut both her sleepy eyes.\nThe stars kept watch above.\nSleep well, small Mole, the night wind sighs.\nSleep well, my little love.",
  "target": "3-5",
  "facts": "<JSON string from Leveler.analyze(manuscript, {target}) - see below>"
}

Worked example: the revise lane

Ask for the same three pages brought to the band with the rhyme kept. The reply is the whole book back, page by page — an unchanged page is repeated verbatim so you always hold a complete manuscript — plus what changed and what was deliberately kept. The model never states a word count or a grade for its own revision: re-run Leveler.analyze on the returned pages and compute the before and after yourself.

curl -s -X POST https://api.skillsafe.ai/v1/app-api/estimate \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"task": "revise", "brief": "Bring it to ages 3-5 and keep the ABAB rhyme, the name Small Mole and the last line. The level lane flagged sighs as the hardest word.", "manuscript": "Page 1\nThe sun slid down behind the hill.\nSmall Mole came home to bed.\nThe sky went pink. The wind went still.\nShe put a cap upon her head.\n\nPage 2\nHer mother sang a sleepy song.\nThe moon came up so round.\nThe night was soft. The day was long.\nThere was no other sound.\n\nPage 3\nSmall Mole shut both her sleepy eyes.\nThe stars kept watch above.\nSleep well, small Mole, the night wind sighs.\nSleep well, my little love.", "target": "3-5", "facts": "<JSON string from Leveler.analyze(manuscript, {target}) - see below>"}'

5. Run it, then poll

POST /run returns {job_id}; GET /jobs/{job_id} until status is succeeded or failed. Send an Idempotency-Key header derived from the manuscript, the lane, the target and the facts so a retry never double-bills. The reply's output.output is the JSON text described in the contract below; charged_credits is the actual cost, and the unused part of the hold is released.

curl -s -X POST https://api.skillsafe.ai/v1/app-api/run \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: picture-book-desk-level-0001" \
  -d '{"task": "level", "brief": "A bedtime book for my niece, who is four. I want to know whether it fits ages 3-5 and whether the rhyme holds up read aloud.", "manuscript": "Page 1\nThe sun slid down behind the hill.\nSmall Mole came home to bed.\nThe sky went pink. The wind went still.\nShe put a cap upon her head.\n\nPage 2\nHer mother sang a sleepy song.\nThe moon came up so round.\nThe night was soft. The day was long.\nThere was no other sound.\n\nPage 3\nSmall Mole shut both her sleepy eyes.\nThe stars kept watch above.\nSleep well, small Mole, the night wind sighs.\nSleep well, my little love.", "target": "3-5", "facts": "<JSON string from Leveler.analyze(manuscript, {target}) - see below>"}'
curl -s -X GET https://api.skillsafe.ai/v1/app-api/jobs/JOB_ID \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json"

6. Or stream it

POST /run-stream is server-sent events: job, then tick heartbeats, then done with the full output. Browsers receive ticks rather than text deltas, so build progress on elapsed time and parse the output from done. The revise lane is the long one — it writes every page back — so it is the one worth streaming.

curl -N -s -X POST https://api.skillsafe.ai/v1/app-api/run-stream \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Accept: text/event-stream" \
  -H "Content-Type: application/json" -d '{"task": "revise", "brief": "Bring it to ages 3-5 and keep the ABAB rhyme, the name Small Mole and the last line. The level lane flagged sighs as the hardest word.", "manuscript": "Page 1\nThe sun slid down behind the hill.\nSmall Mole came home to bed.\nThe sky went pink. The wind went still.\nShe put a cap upon her head.\n\nPage 2\nHer mother sang a sleepy song.\nThe moon came up so round.\nThe night was soft. The day was long.\nThere was no other sound.\n\nPage 3\nSmall Mole shut both her sleepy eyes.\nThe stars kept watch above.\nSleep well, small Mole, the night wind sighs.\nSleep well, my little love.", "target": "3-5", "facts": "<JSON string from Leveler.analyze(manuscript, {target}) - see below>"}'
# events: job (the job id), tick (heartbeat), done (the full output). Browsers receive ticks, not deltas.

The output contract

One JSON object, no prose around it and no code fences. Common keys on every reply, whichever lane answered:

KeyTypeMeaning
lanelevel | reviseThe lane actually answered. If task was missing or unrecognised the closer lane is chosen from the input and named here.
titlestringUnder eighty characters, naming the situation — the manuscript, its size, its grade and its band.
headlinestringOne sentence: the verdict and the one thing that decides it.
verdictstringOne of the lane's enum below.
summarystringThree to five sentences, read instead of the rest.
notes_on_input[]string[]Anything unreadable, contradictory or missing in the input, including every entry of facts.warnings.
risks[]string[]What could go wrong from here, one per string, grounded in the manuscript.
next_steps[]string[]Ordered and concrete; each one starts with a verb.
LaneVerdictBody
level fits · too_hard · too_easy · too_long · mixed · insufficient_text reading (three to five short paragraphs, every number quoted from facts: what the manuscript is, the level, the sentences and words, the read-aloud craft, the brief answered) · fit {words: under|in|over, level: under|in|over|unclear, why} · page_notes[] {page, note} · craft {rhyme, rhythm, refrain, page_turns} · vocabulary[] {word, why, alternative} · phonics_focus[] {pattern: cvc|digraph|blend|silent_e|vowel_team|r_controlled|sight_words|rhyming_family, words[], note} · suggestions[] {change, why, page}
revise light_touch · revised · restructured · declined rationale (three or four short paragraphs: where the manuscript sits, what changed and what was kept, the craft decisions, what to read aloud) · pages[] {page, text} · changes[] {page, what, why} · kept[] · read_aloud_note

In the level lane every entry in facts.flags is addressed somewhere — in fit.why, a page_notes row or a suggestions row — and a flag the model disagrees with is answered with the line that justifies the disagreement, not ignored. page_notes lists only pages that need something, so a manuscript that fits may carry one or two rows. vocabulary holds at most eight words, hardest first, and a character name is never a vocabulary problem. phonics_focus carries two to four rows for a 5-7 or 7-9 band and one to two for the younger bands, drawing on facts.phonics first. Every word in a vocabulary entry and in a phonics_focus row appears in manuscript case-insensitively — that is checkable, and worth checking.

In the revise lane pages covers pages 1..N of the manuscript in order, so the array is the whole book: an unchanged page is repeated verbatim, a merged page keeps the lower number and a changes row says so. Character names are kept. A page that rhymes in the manuscript rhymes with the same scheme in the revision unless a changes row says why not. Line breaks inside a page are \n.

The model states no word count, grade, age or percentage for its own revision, and it should not: those are computed from the text it just wrote. Run Leveler.analyze(revisedText, {target}) over the joined pages yourself and you get the after to set against the before — which is exactly what the web page shows. The ban covers only figures that follow from the new text; the original manuscript's facts and the desk's bands are quoted freely.

Reconcile the rest. Every figure the model writes in either lane is meant to be a verbatim copy out of facts, so read them back: pull the numbers out of reading, fit.why, craft and each suggestions row, compare them with the engine, and surface the disagreements rather than the prose. The web page does this on every run and shows the author what did not match. A sensible pipeline also checks the two membership rules above and, in the revise lane, that pages really does cover every page.

insufficient_text and declined are real verdicts, not failures. Fewer than twenty words, a non-empty facts.errors, or material in the manuscript that is not suitable for the stated age band all produce one of them: the numbers stay on the page, the unsuitable passage is not reproduced, and headline says why.

Derived from two agent skills by jamesrochabrun: @jamesrochabrun/kids-book-writer for the age-appropriate language, rhyme and read-aloud craft that both lanes follow, and @jamesrochabrun/reading-teacher for the reading-level and phonics judgement in the level lane. Reading ease follows Flesch (1948); the grade level follows Kincaid et al. (1975); the primary grade is the revised Spache (1974); the New Dale-Chall score follows Chall and Dale (1995); sight-word coverage uses the Dolch list (1936). A readability grade measures independent decoding, and a picture book is normally read aloud to a child two to four years younger. Not affiliated with the skills' author.