← Words to Time / API
Tokens

Drive Words to Time from your own code

How long will it take to say? Word counts, speaking time, and AI script fitting. Everything the web page does with the model is available over HTTP: post a script, get back either a rewrite that lands on your target duration or a rehearsal plan with pause points, emphasis and the risky spots marked. The natural uses are a script pipeline that re-fits every episode draft to a fixed slot, a teaching tool that returns a delivery plan for a submitted speech, and a batch job that runs a season of video scripts through the same timing gate before anyone books a studio.

A note on what costs money. The counting itself — words, characters, sentences, paragraphs, spoken-word estimate, speaking time at any words-per-minute rate, the per-paragraph timeline and the target-duration fit meter — is arithmetic that runs in your browser on the app page, and it costs nothing there. Over the API, GET /me and POST /estimate are also free: they create no job and charge no credits. Only POST /run and POST /run-stream are metered, because only those two call the model.

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

Authentication is one header: Authorization: Bearer …. That is all of it — there is no app-slug header on any endpoint, because the token is already scoped to this app and the API reads the app off the token. The single exception is POST /guest, which mints the token and therefore cannot infer anything from one: that call takes the slug in its JSON body, as {"slug": "words-to-time"}, and takes no Authorization header at all.

Error codes

codestatuswhat to do
unauthorized401The token is missing, malformed or expired. Get a new one from the token page.
payment_required402The balance is below min_credits. Call /estimate first — it is free — and top up.
forbidden403The token is valid but belongs to another app, or a guest token tried a metered run. A guest can count and estimate; fitting and pacing need a personal token.
not_found404Unknown job id, unknown collection, or the app slug does not exist.
conflict409The same Idempotency-Key was replayed with a different body. Change the key or send the original input.
validation_error422The input object is missing a required field — task or script is the usual one — or a field is the wrong type. A body that is not valid JSON at all comes back as a 400.
rate_limited429Too many requests. Back off and retry; do not tight-loop.
internal5xxA server-side failure, reported as server_error on a plain 500. Retry with the SAME Idempotency-Key so you are not billed twice.

The task field picks the lane — read this first

One endpoint, two lanes. task is the first field of every input object and it is the only field that changes what comes back. Get it wrong and you will get a well-formed answer to a question you did not ask, so it is worth being deliberate about which one you want.

taskwhat it doeswhat comes back
"fit" Rewrites the script so it lands on target_seconds at your words-per-minute rate — trimming when it runs long, expanding when it runs short — while preserving the voice, the argument and the concrete detail. A full revised_script, a target_words budget, an itemised edits list with a signed words_delta on each entry, what was kept, what the cut risks, and a delivery_note.
"pace" Leaves the words alone and plans the performance: where the time actually goes, where to breathe, what to hit, and which sentences will trip you at speed. A sections timing plan with cumulative starts_at, pauses, emphasis, per-quote risks, tips, and a marked_script with [pause] markers and *asterisks* around the emphasis words.

Both lanes echo the lane back as task in the output object, so a client that fans out over both can route the reply without tracking the request. An unknown task is not an error: the model answers as the closest lane and says so in summary. That is deliberate — a typo should still return something useful for the credits you were charged — but it means you cannot infer the lane from a 200. Read output.task, and if you are strict, assert it equals what you sent.

The two lanes take almost the same input. fit adds mode and treats target_seconds as required; pace has no mode and accepts target_seconds: 0 to mean "no target, just tell me how this reads". Everything else — script, the four measured numbers, wpm, purpose and notes — is identical, which is what makes it cheap to run both lanes over one paste.

// lane fit — the exact object the app's own form submits
{ "task": "fit", "script": "...", "words": 812, "spoken_words": 842,
  "current_seconds": 389, "wpm": 130, "target_seconds": 300,
  "mode": "auto", "purpose": "speech", "notes": "" }

// lane pace — same shape, no mode, target_seconds may be 0
{ "task": "pace", "script": "...", "words": 812, "spoken_words": 842,
  "current_seconds": 389, "wpm": 130, "target_seconds": 300,
  "purpose": "youtube", "notes": "" }

Full field tables for both lanes are in step 4, and there is one complete worked example per lane — a real input with its representative output — in step 7.

1. A tiny client

One helper that adds the two headers, unwraps data and raises on error. Everything after this step is one call through it.

# Every call is the same three things: the base URL, your bearer token,
# and a JSON body. Keep the token in a shell variable.
BASE="https://api.skillsafe.ai/v1/app-api"
SLUG="words-to-time"      # only needed to mint a guest token and to name the idempotency key
TOKEN="$SKILLSAFE_TOKEN"  # from https://words-to-time.skillsafe.ai/tokens.html

call() {                  # call <path> [json-body]
  if [ -n "$2" ]; then
    curl -sS -X POST "$BASE/$1" \
      -H "Authorization: Bearer $TOKEN" \
      -H "Content-Type: application/json" \
      -d "$2"
  else
    curl -sS "$BASE/$1" -H "Authorization: Bearer $TOKEN"
  fi
}

2. Get a token

The easiest route is the token page: it shows the token this browser already holds, with Copy token and Copy shell export buttons, and a sign-in button for a personal token. Nothing on that page needs a developer tool — it reads the same storage the app itself uses and prints the token for you.

There are two kinds of token. A guest token is minted with a single unauthenticated POST and is enough for /me and /estimate, which is all you need to price a script and to confirm your plumbing works. Running a fit or a pace is metered, so it needs a personal token from signing in — either by pressing Sign in with SkillSafe on the token page, or by sending a browser through the hosted sign-in flow and letting it come back to your own page with the token on the redirect. On the token page that whole round trip is handled for you; the SSO snippets below are for when you are embedding the flow somewhere else.

# The token page is the shortest path. It shows the token this browser holds and
# hands you a ready-made shell export:
#
#   https://words-to-time.skillsafe.ai/tokens.html
#   export SKILLSAFE_TOKEN="..."
#
# To mint a guest token from the command line instead. A guest token is enough
# for /me and /estimate; a fit or pace run needs a personal token from signing in.
#
# Note the shape: the slug goes in the BODY here, and there is no Authorization
# header, because this is the call that hands you the token. Every other endpoint
# is the mirror image - Authorization: Bearer only, and no slug anywhere.
curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"words-to-time"}'
# {"ok":true,"data":{"token":"aut_...","guest_id":"gst_...","expires_at":"2026-09-19T12:00:00Z"}}

# SSO, when you want a personal token: open the hosted sign-in in a browser with
# a redirect back to a page you control. The token comes back on the redirect.
open "https://skillsafe.ai/app-login?slug=words-to-time&redirect=https://example.com/callback"

3. Check the session and the balance — free

GET /me tells you whether the token is a guest or a person, and what the balance is. It creates no job and costs nothing. subject_type is guest or user — a guest can price a run but cannot start one — and credits is the wallet balance in credits. Compare it against min_credits from the next step before you run, so a shortfall surfaces as your own clear message rather than a 402 halfway through a batch of forty scripts.

call me
# {"ok":true,"data":{"subject_type":"user","username":"you","credits":51234}}

4. Price the run — free

The input object is exactly what the app's own form submits. The first field is always task; the rest is the script plus the four numbers the browser already measured.

Lane fit — the input

fieldtypemeaning
taskstring, requiredThe literal string "fit". Routes the run to the rewrite lane. See the lane table.
scriptstring, requiredThe script itself, as plain text with blank lines between paragraphs. Paragraph breaks are load-bearing: they are what the lane uses to locate an edit and what the free timeline counts against. At least 120 characters, and at most 24000 characters are sent — the web app clips longer pastes middle-out on a paragraph boundary and leaves an in-band marker where the removal happened. A caller doing its own clipping should do the same and say so, because an unannounced gap reads to the model as a jump cut the author intended.
wordsnumber, requiredThe plain word count of script: whitespace-separated tokens. This is the number a reader recognises.
spoken_wordsnumber, requiredThe spoken word count, which is what actually drives timing. Digits, currency, times and percentages expand when read aloud: 1,250 is about four spoken words, $40 about two, 3:30 about two, 2024 about two. On ordinary prose it equals words; on a script full of figures it can be five per cent higher, which is twenty seconds on a ten-minute read.
current_secondsnumber, requiredHow long the script runs today at wpm, in whole seconds — round(spoken_words / wpm * 60). Send it rather than making the model derive it; it is the anchor the whole rewrite is measured against.
wpmnumber, requiredWords per minute. The app's presets are 110 slow, 130 presentation (the default), 140 conversational, 150 YouTube, 150 podcast, 155 audiobook, 170 fast, and a custom slider from 80 to 220. Silent reading is shown separately at 238 wpm and is never what this field means.
target_secondsnumber, requiredThe duration to land on, in whole seconds — 300 for a five-minute slot. Required in this lane: it is the budget. The word budget the model works to is round(target_seconds / 60 * wpm), which is what comes back as target_words.
modestring"auto", "trim" or "expand". auto compares current_seconds with target_seconds and picks a direction. trim and expand force one — useful when you know the draft is padded even though it already fits, or when you want more material at the same length.
purposestringspeech, presentation, youtube, podcast, voiceover, toast, lecture or other. It changes what is treated as protected: a toast keeps the personal anecdote and loses the throat-clearing, a YouTube script keeps the hook and the call to action, a lecture keeps the worked example.
notesstring, optionalFree-form constraints, may be the empty string. This is where "do not touch the opening", "the closing quote is required by the client", "cut the second story before anything else" go. Explicit protection is respected and shows up in kept.

Lane pace — the input

fieldtypemeaning
taskstring, requiredThe literal string "pace". Routes the run to the rehearsal lane.
scriptstring, requiredIdentical to the fit lane: plain text, blank lines between paragraphs, 120 characters minimum, 24000 sent. It matters more here than in fit, because every pauses[].after, emphasis[].quote and risks[].quote must be a verbatim substring of what you sent — so if you clip, the quotes come from the clipped text, not from the original.
wordsnumber, requiredPlain word count of script.
spoken_wordsnumber, requiredSpoken word count, digits and symbols expanded, as above. This is what the section timings are built from.
current_secondsnumber, requiredCurrent run time at wpm, whole seconds. The sections plan should add up to roughly this number, and checking that it does is the cheapest sanity test on the reply.
wpmnumber, requiredSame presets and same 80–220 range as the fit lane.
target_secondsnumber, requiredSend 0 when there is no target. Zero is a real value here, not a missing one: it tells the lane to read the script on its own terms rather than against a clock, and the verdict then speaks to delivery rather than to length.
purposestringSame eight values. It sets the register of the advice — a podcast gets conversational breath points, a lecture gets comprehension pauses after the definitions, a toast gets the beat before the punchline.
notesstring, optionalFree-form, may be empty. "I run out of breath on long sentences", "this is being read off a teleprompter", "the room has a two-second echo" all change what comes back.

There is no mode field in the pace lane. Sending one is harmless — unknown keys are ignored — but it does nothing, because this lane does not rewrite.

/estimate creates no job and charges nothing. It returns the model binding — model, model_alias, markup_bps — and the reservation: hold_credits is what gets held, min_credits is the balance you must clear to start, sponsor_enabled says whether the app is covering the run, and byok says whether the run is billed against your own model key rather than credits. The hold is a reservation, not the price. It prices the full output cap, so the charged_credits you see after settlement is usually far lower — often a small fraction of the hold. Budget against hold_credits, report against charged_credits.

The two lanes do not cost the same. fit returns a full revised_script and pace returns a full marked_script, so both scale with the length of what you sent, but fit also carries the edits ledger. Estimate the lane you are about to run rather than assuming one price for the app.

# The fit lane: a 6:29 speech that has to be 5:00.
INPUT='{"task": "fit", "script": "Every school morning, my alarm goes off at 5:50. It is dark, it is cold, and I have already lost the argument with myself about getting up.\n\nThe district says the schedule cannot change. Two other districts our size changed it in a single year.\n\nSo here is what I am asking this board to do tonight: move first period from 7:20 to 8:30, and run the elementary buses first.", "words": 812, "spoken_words": 842, "current_seconds": 389, "wpm": 130, "target_seconds": 300, "mode": "auto", "purpose": "speech", "notes": "Keep the alarm-clock opening and the direct ask at the end."}'

call estimate "$INPUT"
# {"ok":true,"data":{"hold_credits":1544,"min_credits":115,"model":"gpt-5.6-terra",
#   "markup_bps":1000,"model_alias":"gpt-terra","sponsor_enabled":false,"byok":false}}
#
# The pace lane on the same paste is the same call with task swapped and no mode.
PACE_INPUT='{"task": "pace", "script": "You are wearing headphones right now, or you were an hour ago, and you have no idea what they are doing to the air.\n\nHere is the trick: the microphone on the outside listens, and the driver plays the exact opposite wave back at you.\n\nIf that was useful, there is a whole playlist on the rest of your gear. Link is on the screen.", "words": 612, "spoken_words": 631, "current_seconds": 252, "wpm": 150, "target_seconds": 0, "purpose": "youtube", "notes": ""}'

call estimate "$PACE_INPUT"
#
# estimate is FREE. It creates no job and charges nothing. hold_credits is what
# gets RESERVED; charged_credits after settlement is normally much lower.

5. Run it, then poll

POST /run returns a job_id; poll GET jobs/{job_id} until status is succeeded or failed. The lane's JSON is the string at data.output.output. The terminal job also carries charged_credits — the real price — and the truncated flag.

Always send an Idempotency-Key. It is not formally required by the endpoint, and it is required in practice: derive it from the input as the web app does, a content hash plus an attempt counter. Put the lane in the key — words-to-time:<task>:<hash>:a<attempt> — because running fit and pace over the same paste is the common case, and two lanes sharing one key is exactly the 409 you do not want to debug at three in the morning. A retried request carrying the same key returns the same job instead of billing a second run. Replaying a key with a different body is a 409 conflict, so bump the attempt suffix whenever the input actually changed — including when you only nudged wpm, because that changes the whole budget.

# Always send an Idempotency-Key derived from the input, with the lane in it.
# A retried request with the same key returns the SAME job instead of billing twice.
KEY="words-to-time:fit:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16):a1"

JOB=$(curl -sS -X POST "$BASE/run" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d "$INPUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')

# Poll until the job reaches a terminal status.
while :; do
  OUT=$(call "jobs/$JOB")
  STATUS=$(printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["status"])')
  [ "$STATUS" = "succeeded" ] && break
  [ "$STATUS" = "failed" ] && echo "$OUT" && exit 1
  sleep 2
done

# The terminal job looks like this:
# {"ok":true,"data":{"job_id":"job_...","status":"succeeded",
#   "output":{"output":"{\"task\":\"fit\",\"title\":\"Why the school day should start later\", ...}"},
#   "charged_credits":402,"truncated":false}}

printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["output"]["output"])'

6. Or stream it

POST /run-stream is the same call over server-sent events. Each delta event carries {"text": "..."}, a chunk of the lane's JSON, and the final done event carries status, charged_credits — the real price, normally a fraction of the hold — and the truncated flag.

The practical tip: the web app does not parse the partial JSON to drive its progress display, it watches for key names arriving in the accumulating text. That is worth copying, because both lanes end with one very long string field and the last thirty seconds of a run look identical to a spinner otherwise. In the fit lane the arrival of "edits" means the rewrite is done and the ledger is being written, and "revised_script" means the new script is coming through. In the pace lane "sections" is the timing plan, "pauses" and "emphasis" are the annotation pass, and "marked_script" is the final render. Substring matching on the quoted key name is enough, and it costs nothing.

# Server-sent events. Each `delta` carries a chunk of the lane JSON; the final
# `done` event carries the status, charged_credits and the truncated flag.
curl -N -X POST "$BASE/run-stream" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -H "Accept: text/event-stream" \
  -d "$INPUT"

# event: job    {"job_id":"job_..."}
# event: delta  {"text":"{\"task\":\"fit\",\"title\":\"Why the school"}
# event: delta  {"text":" day should start later\",\"verdict\":\"fits\","}
# event: done   {"status":"succeeded","charged_credits":402,"truncated":false}

7. Parse the reply

data.output.output is a string holding one JSON object — no code fence, no prose around it. The web app strips an optional fence anyway, takes everything from the first { to the last }, parses that, and then checks the reply against the script it sent. Doing the same two things — the slice and the reconciliation — is what makes a caller robust against the small variations a model produces.

Worked example — lane fit

A student speech that runs 6:29 and has to be 5:00. The script below is abridged with an ellipsis for the page; a real call sends the whole thing.

{
  "task": "fit",
  "script": "Every school morning, my alarm goes off at 5:50. It is dark, it is cold, and I have already lost the argument with myself about getting up. …\n\nThe district says the schedule cannot change. …\n\nSo here is what I am asking this board to do tonight: move first period from 7:20 to 8:30, and run the elementary buses first.",
  "words": 812,
  "spoken_words": 842,
  "current_seconds": 389,
  "wpm": 130,
  "target_seconds": 300,
  "mode": "auto",
  "purpose": "speech",
  "notes": "Keep the alarm-clock opening and the direct ask at the end."
}

And the reply, complete in its top-level fields, abridged inside the long strings:

{
  "task": "fit",
  "title": "Why the school day should start later",
  "verdict": "fits",
  "summary": "Trimmed 190 words, almost all of it the second sleep study and the throat-clearing in front of the hook. The revision runs 4:58 at 130 wpm against your 5:00 target, which leaves two seconds of headroom for the pause before the ask.",
  "target_words": 650,
  "revised_script": "Every school morning, my alarm goes off at 5:50. It is dark, it is cold, and I have already lost the argument with myself about getting up. …\n\nThe district says the schedule cannot change. Edina and Fairfax both changed it in a single year. …\n\nSo here is what I am asking this board to do tonight: move first period from 7:20 to 8:30, and run the elementary buses first.",
  "edits": [
    { "kind": "cut", "where": "paragraph 4, the second sleep study",
      "before": "A 2019 study out of the University of Minnesota tracked nine thousand students across eight high schools and found that …",
      "after": "",
      "words_delta": -64,
      "why": "Two studies make the same point; one carries it and the other costs thirty seconds." },
    { "kind": "tighten", "where": "the opening, before the alarm clock",
      "before": "I want to start by asking all of you a question, and I want you to really think about it before you answer, because it matters more than it sounds like it does",
      "after": "Let me ask you something, and think before you answer",
      "words_delta": -21,
      "why": "The hook lands faster without the run-up to it." },
    { "kind": "merge", "where": "paragraphs 6 and 7, the bus objection",
      "before": "The district says the buses cannot be re-routed. … Other districts have re-routed their buses.",
      "after": "The district says the buses cannot be re-routed. Edina and Fairfax both did it in a single year.",
      "words_delta": -58,
      "why": "The objection and the answer to it belong in one breath." },
    { "kind": "restructure", "where": "the close",
      "before": "… and that is why I care about this. So I am asking you to move the start time.",
      "after": "So I am asking you to move the start time. … and that is why I care about this.",
      "words_delta": -47,
      "why": "Putting the ask before the anecdote means the last thing the board hears is the vote you want." }
  ],
  "kept": [
    "The 5:50 alarm opening — it is the only concrete image in the speech, and you asked for it.",
    "The direct ask at the end, word for word.",
    "Both numbers from the CDC citation."
  ],
  "risks": [
    "With the Minnesota study gone, one source carries the evidence. Expect a question about it and have the number ready.",
    "The merged bus paragraph is dense now. It needs a deliberate breath in front of it or it will sound rushed."
  ],
  "delivery_note": "Take this slower than the original, not faster — the two seconds you have spare are best spent on the silence before the ask."
}

Note what reconciles. target_words is round(300 / 60 × 130) = 650. The four words_delta values sum to -190, and 812 − 190 = 622 words, which counts as 645 spoken words and lands at 4:58. Every one of those numbers is checkable against the input you sent without asking the model to be trusted about arithmetic.

Worked example — lane pace

A YouTube script with no target duration — target_seconds: 0 — where the question is not "how long" but "how does this read out loud".

{
  "task": "pace",
  "script": "You are wearing headphones right now, or you were an hour ago, and you have no idea what they are doing to the air.\n\nHere is the trick: the microphone on the outside listens, and the driver plays the exact opposite wave back at you. Peak meets trough, and the two cancel. …\n\nThat is also why it works beautifully on an aeroplane and badly in a cafe: engine hum is steady and predictable, and the person behind you laughing is neither. …\n\nIf that was useful, there is a whole playlist on the rest of your gear. Link is on the screen.",
  "words": 612,
  "spoken_words": 631,
  "current_seconds": 252,
  "wpm": 150,
  "target_seconds": 0,
  "purpose": "youtube",
  "notes": ""
}

And the reply:

{
  "task": "pace",
  "title": "How noise-cancelling headphones actually work",
  "verdict": "needs-work",
  "summary": "At 150 wpm this runs 4:12, and the shape is lopsided: the physics section takes 1:48 of it while the hook is over in ten seconds. Three sentences are long enough that you will run out of breath saying them at pace.",
  "sections": [
    { "heading": "Cold open hook", "words": 25, "seconds": 10, "starts_at": "0:00",
      "note": "Ten seconds is right for the platform, but it is the densest writing in the script. Slow it down and you lose the hook; keep it fast and land the pause after it instead." },
    { "heading": "What the problem is", "words": 95, "seconds": 38, "starts_at": "0:10",
      "note": "Comfortable pace. This is the section to steal time from if the edit runs long." },
    { "heading": "How cancellation works", "words": 270, "seconds": 108, "starts_at": "0:48",
      "note": "Nearly half the runtime in one block, and it is the part viewers rewind. Break it with a beat at the wave diagram rather than reading straight through." },
    { "heading": "Where it fails", "words": 130, "seconds": 52, "starts_at": "2:36",
      "note": "The strongest writing. It can take a slightly slower read than the rest without dragging." },
    { "heading": "Outro and call to action", "words": 110, "seconds": 44, "starts_at": "3:28",
      "note": "Forty-four seconds is long for an outro on a four-minute video. Half of it would not be missed." }
  ],
  "pauses": [
    { "after": "and you have no idea what they are doing to the air.", "kind": "beat",
      "why": "The hook needs a full beat to land before the title card; without it the next line eats the joke." },
    { "after": "Peak meets trough, and the two cancel.", "kind": "emphasis",
      "why": "This is the one sentence the whole video exists to deliver. Stop after it." },
    { "after": "engine hum is steady and predictable, and the person behind you laughing is neither.", "kind": "breath",
      "why": "That sentence is 26 words at 150 wpm. Take the breath here or you will take it in the middle of the next one." }
  ],
  "emphasis": [
    { "quote": "the exact opposite wave", "why": "The mechanism in four words — hit it and the rest of the section is easier to follow." },
    { "quote": "engine hum is steady and predictable", "why": "The reason the failure case makes sense. Lean on steady and predictable." },
    { "quote": "Link is on the screen.", "why": "The only instruction in the script. Say it flatly and clearly, not brightly." }
  ],
  "risks": [
    { "quote": "That is also why it works beautifully on an aeroplane and badly in a cafe: engine hum is steady and predictable, and the person behind you laughing is neither.",
      "issue": "One sentence, 27 words, with a colon in the middle. At 150 wpm that is eleven seconds without an obvious breath.",
      "fix": "Split at the colon. End the first sentence on cafe and start the second on Engine hum." },
    { "quote": "Here is the trick: the microphone on the outside listens, and the driver plays the exact opposite wave back at you.",
      "issue": "Two technical nouns arrive before the verb that explains them, so the listener is holding both in memory.",
      "fix": "Add a half-beat after listens. The comma is not long enough on its own." },
    { "quote": "If that was useful, there is a whole playlist on the rest of your gear.",
      "issue": "The call to action is buried in a conditional, which reads as an apology at speed.",
      "fix": "Drop If that was useful and start on There is a whole playlist." }
  ],
  "tips": [
    "Your natural pace on this material is closer to 160 than 150 — record thirty seconds and re-time it before you trust the 4:12.",
    "The physics section is where you will speed up without noticing. Put a physical marker in the script at 0:48.",
    "Read the outro at the same energy as the hook, not lower; the drop is the most common reason a good video ends flat.",
    "Two of the three risky sentences are in the same paragraph. Rehearse that paragraph on its own, out loud, twice."
  ],
  "marked_script": "You are wearing headphones right now, or you were an hour ago, and you have no idea what they are doing to the air. [pause]\n\nHere is the trick: the microphone on the outside listens, [pause] and the driver plays *the exact opposite wave* back at you. Peak meets trough, and the two cancel. [pause] …\n\nThat is also why it works beautifully on an aeroplane and badly in a cafe: *engine hum is steady and predictable*, and the person behind you laughing is neither. [pause] …\n\nIf that was useful, there is a whole playlist on the rest of your gear. *Link is on the screen.*"
}

Note what reconciles here. The five sections[].seconds sum to 252, which is exactly current_seconds; the starts_at values are the running total of the ones before them; the words per section sum to 630 against 631 spoken words. And every pauses[].after, emphasis[].quote and risks[].quote above is a literal substring of the script that was sent. That last one is the check worth writing code for.

Reconcile the reply against the script you sent

Neither lane is trusted verbatim by the web app, and neither should be by a caller. The model is good at writing and indifferent at arithmetic, so the client re-does the arithmetic and reports the difference rather than hiding it. These are the checks the app itself runs, in the order it runs them:

# Pull the lane JSON out of the envelope, then re-time the result yourself.
OUT=$(call "jobs/$JOB")
BODY=$(printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["output"]["output"])')

# fit: recount the revised script and re-time it at the wpm you sent.
printf '%s' "$BODY" | python3 - "$WPM" "$TARGET" <<'PY'
import json, sys
wpm, target = int(sys.argv[1]), int(sys.argv[2])
out = json.load(sys.stdin)
if out["task"] != "fit":
    sys.exit(f"model answered as {out['task']}, not fit")
n = len(out["revised_script"].split())
secs = round(n / wpm * 60)
print(f"{n} words, {secs // 60}:{secs % 60:02d} at {wpm} wpm, target {target}s")
print("delta", secs - target, "seconds")
print("edits ledger sums to", sum(e["words_delta"] for e in out["edits"]))
PY

The output contract

One JSON object, no fences, no prose around it. Both lanes open with task, title, verdict and summary, and diverge after that. Every key in each object, as the web app reads it:

Lane fit — the output

keytypemeaning
taskstringEchoes the lane: "fit". If it comes back as "pace", the router fell through to the other lane and summary will say so.
titlestringSixty characters or fewer, naming the script — "Why the school day should start later". Taken from the script's own subject, not invented. It is what the run is filed under in history.
verdictenumfits, close, over or under. This describes the revision, not the original — see the enum table below.
summarystringOne to three sentences: what was done and where it landed. The one field to show when you only have room for one.
target_wordsnumberThe word budget the rewrite was written to, round(target_seconds / 60 × wpm). Recompute it rather than trusting it; it is cheap and it catches a mis-parsed input.
revised_scriptstringThe full revised script as plain text with blank lines between paragraphs. Not a diff, not an excerpt — the thing you would read out loud. This is the longest field in the object and the one that gets cut first when a run truncates.
editsobject[]{kind, where, before, after, words_delta, why}, at least three entries for a real edit. kind is cut, tighten, merge, expand or restructure. where locates the edit by section or by a quote. before is a short quote from the original and after is the corresponding quote from the revision — empty when kind is cut, because nothing replaced it. words_delta is signed: negative means words were removed. why is one sentence.
keptstring[]What was deliberately preserved, and implicitly why it survived the cut. Anything you protected in notes should appear here; if it does not, the constraint was not honoured and that is worth flagging to the user.
risksstring[]What the cut costs, if anything — an argument now carried by one source, a transition that got abrupt, a joke that lost its setup. May be empty on a light trim. Plain strings in this lane; the pace lane's risks is a different shape.
delivery_notestringOne sentence on how to deliver the revised version, usually about pace: a script trimmed to the budget has no slack, so the note often says where to spend what little there is.

Lane pace — the output

keytypemeaning
taskstringEchoes the lane: "pace".
titlestringSixty characters or fewer, naming the script.
verdictenumready, needs-work or at-risk. Note the hyphen in needs-work; it is not an underscore and not a space.
summarystringOne to three sentences: the overall read, usually naming the single worst pacing problem.
sectionsobject[]{heading, words, seconds, starts_at, note}. The timing plan, in script order. heading is a short name the model gives the section, not a heading it found. words and seconds are that section's share; starts_at is a formatted string, "2:36" or "1:04:12", not a number — the cumulative clock position where the section begins. note is the pacing observation for that stretch.
pausesobject[]{after, kind, why}. after is an exact quote from the script, 120 characters or fewer, naming the point to stop at. kind is breath (you need air), beat (the line needs room to land) or emphasis (the silence is doing the work). why is one sentence.
emphasisobject[]{quote, why}. Short exact quotes — usually a phrase, not a sentence — that carry the weight, with the reason. These are the words that get asterisks in marked_script.
risksobject[]{quote, issue, fix}. The sentences that will go wrong out loud: too long for one breath, a tongue-twister, a number that is ambiguous when spoken, a clause order that only works on the page. fix is actionable and specific to that quote. Note this is objects, not strings, unlike the fit lane's risks.
tipsstring[]Three to six delivery tips grounded in this script — not general advice about breathing and posture. A tip that would apply to any script is a defect, not a feature.
marked_scriptstringThe full script with [pause] markers inserted at the points in pauses and *asterisks* around the phrases in emphasis. The words themselves are unchanged — this lane never rewrites. It is the field a presenter actually prints, and like revised_script in the other lane, it is the first thing to go when a run truncates.

The enums

fieldvaluesnotes
fit.verdict fits, close, over, under Describes where the revision landed against target_seconds, not where the original was. fits means on budget; close means near enough that a slower or faster read absorbs it; over and under mean the rewrite did not get there and the remaining distance is real work. Treat over as a result to show the user, not an error to hide — and check it against your own recount, because this is the field most worth disagreeing with.
pace.verdict ready, needs-work, at-risk ready: rehearse it and go. needs-work: the pacing is uneven or a few sentences will trip you, and the notes say where. at-risk: something structural — no breath points in a long stretch, a section that eats half the runtime, a script whose delivery cannot survive its own sentence lengths.
edits[].kind cut, tighten, merge, expand, restructure cut removes material outright and leaves after empty. tighten says the same thing in fewer words. merge folds two passages into one. expand adds material, and is the only kind with a positive words_delta. restructure moves material without changing the word count much — expect a small delta and read why for the reason.
pauses[].kind breath, beat, emphasis breath is physical — the sentence is too long to say in one. beat is comic or rhetorical timing. emphasis is the silence that makes the next line land. A renderer can style them differently; the app draws breath marks and beats with different weights.
mode (input) auto, trim, expand auto compares current_seconds with target_seconds. The two explicit values override that comparison, which is how you get a script that already fits made denser, or one that fits made fuller.
purpose (input) speech, presentation, youtube, podcast, voiceover, toast, lecture, other Sets what counts as protected material and what register the advice is written in. When in doubt other is honest and produces neutral advice; guessing wrong produces confident advice about the wrong thing.

House rules both lanes obey

8. Use it in a pipeline

The worked example: a job walks a folder of scripts, times each one against the slot it is booked for, and only spends credits on the ones that do not already fit. The counting is free, so the gate is cheap — most drafts never reach the model at all. Derive the Idempotency-Key from the script text so re-running the job on an unchanged draft replays the same result instead of re-billing, and bump the attempt suffix only when the text or the target actually changed.

Two things worth building in from the start. First, decide up front what a miss means: a script the model could not get inside fifteen seconds of its target is a script that needs a human, and the honest thing is to exit non-zero rather than ship the near miss. Second, remember the free half — running fit on a draft that is already four seconds long is spending credits to be told what arithmetic already knew.

#!/bin/sh
# wtt-gate.sh - fail the build when a script does not fit its slot.
set -eu

BASE="https://api.skillsafe.ai/v1/app-api"
SLUG="words-to-time"      # only needed to mint a guest token and to name the idempotency key
TOKEN="$SKILLSAFE_TOKEN"  # from https://words-to-time.skillsafe.ai/tokens.html
WPM=150
TARGET=300                 # the slot, in seconds

FAILED=0
for f in scripts/*.txt; do
  WORDS=$(wc -w < "$f" | tr -d ' ')
  SECS=$(( (WORDS * 60 + WPM / 2) / WPM ))

  # The counting is free. Only pay for the scripts that miss.
  if [ "$SECS" -ge $((TARGET - 15)) ] && [ "$SECS" -le $((TARGET + 15)) ]; then
    printf '%s: %ds - fits, no run needed\n' "$f" "$SECS"
    continue
  fi

  INPUT=$(python3 - "$f" "$WORDS" "$SECS" "$WPM" "$TARGET" <<'PY'
import json, sys
path, words, secs, wpm, target = sys.argv[1], *map(int, sys.argv[2:])
text = open(path).read()[:24000]
print(json.dumps({"task": "fit", "script": text, "words": words,
                  "spoken_words": words, "current_seconds": secs, "wpm": wpm,
                  "target_seconds": target, "mode": "auto",
                  "purpose": "youtube", "notes": ""}))
PY
)
  KEY="words-to-time:fit:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16):a1"
  JOB=$(curl -sS -X POST "$BASE/run" \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" -H "Idempotency-Key: $KEY" \
    -d "$INPUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')

  # ... poll jobs/$JOB, read data.output.output, recount revised_script ...
  printf '%s: %ds - ran the fit lane as job %s\n' "$f" "$SECS" "$JOB"
  FAILED=$((FAILED + 1))
done

[ "$FAILED" -eq 0 ] || { echo "wtt: $FAILED script(s) did not fit"; exit 1; }
echo "wtt: every script fits its slot"

Truncation and partial results

When the balance sits between min_credits and hold_credits, the run is not refused: it executes with a reduced output cap and comes back with truncated: true on the finished job and on the streaming done event. What you hold then is a prefix of the reply, not the reply. This lands in a specific and predictable place in both lanes, because both put their longest field last: in fit the edits ledger may be complete while revised_script is cut mid-sentence, and in pace the sections, pauses and emphasis may all be there while marked_script stops halfway through the third paragraph.

That is the worst possible failure for this app, because a script that ends mid-sentence still looks like a script. Check the flag before you treat a reply as complete, and check it before you write the result to a file someone will read out loud. The right response is a retry, not a repair: resubmit with the attempt suffix on the Idempotency-Key incremented so the new body is not a replay of the old key, and — if the script is long — send a shorter one. Repairing truncated JSON by appending closing braces produces something that parses and is not what the model meant.

A cheap belt-and-braces check that costs nothing: in the fit lane, compare the last paragraph of revised_script against the last paragraph of what you sent. A rewrite that ends somewhere structurally different from the original — no closing punctuation, a hanging conjunction — is truncated even when the flag says otherwise. The same test works on marked_script in the pace lane, where it is stronger still, because that lane never rewrites: the marked script should end on the same words the original does.