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
| code | status | what to do |
|---|---|---|
unauthorized | 401 | The token is missing, malformed or expired. Get a new one from the token page. |
payment_required | 402 | The balance is below min_credits. Call /estimate first — it is free — and top up. |
forbidden | 403 | The 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_found | 404 | Unknown job id, unknown collection, or the app slug does not exist. |
conflict | 409 | The same Idempotency-Key was replayed with a different body. Change the key or send the original input. |
validation_error | 422 | The 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_limited | 429 | Too many requests. Back off and retry; do not tight-loop. |
internal | 5xx | A 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.
task | what it does | what 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
}
import json, os, urllib.error, urllib.request
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 = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://words-to-time.skillsafe.ai/tokens.html
def call(path, body=None):
"""Returns the unwrapped `data`, or raises with the API error code."""
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(f"{BASE}/{path}", data=data, method="POST" if body is not None else "GET")
req.add_header("Authorization", f"Bearer {TOKEN}")
if body is not None:
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req) as r:
payload = json.load(r)
except urllib.error.HTTPError as e:
payload = json.load(e)
if not payload.get("ok"):
err = payload.get("error", {})
raise RuntimeError(f"{err.get('code')}: {err.get('message')}")
return payload["data"]
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "words-to-time"; // only for minting a guest token and naming the idempotency key
const TOKEN = "YOUR_TOKEN"; // from https://words-to-time.skillsafe.ai/tokens.html
async function call(path, body) {
const res = await fetch(`${BASE}/${path}`, {
method: body ? "POST" : "GET",
headers: {
Authorization: `Bearer ${TOKEN}`,
...(body ? { "Content-Type": "application/json" } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const payload = await res.json();
if (!payload.ok) throw new Error(`${payload.error.code}: ${payload.error.message}`);
return payload.data;
}
package main
import (
"bufio"
"bytes"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
const (
base = "https://api.skillsafe.ai/v1/app-api"
slug = "words-to-time" // only for minting a guest token and naming the idempotency key
)
var token = os.Getenv("SKILLSAFE_TOKEN") // from https://words-to-time.skillsafe.ai/tokens.html
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func call(path string, body any) (json.RawMessage, error) {
method := http.MethodGet
var rdr io.Reader
if body != nil {
method = http.MethodPost
b, _ := json.Marshal(body)
rdr = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, base+"/"+path, rdr)
req.Header.Set("Authorization", "Bearer "+token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var env envelope
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return nil, err
}
if !env.OK {
return nil, fmt.Errorf("%s: %s", env.Error.Code, env.Error.Message)
}
return env.Data, nil
}
import java.net.URI;
import java.net.http.*;
public class WordsToTime {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String SLUG = "words-to-time"; // guest minting + idempotency keys only
static final String TOKEN = System.getenv().getOrDefault("SKILLSAFE_TOKEN", "YOUR_TOKEN");
static final HttpClient HTTP = HttpClient.newHttpClient();
static String call(String path, String jsonBody) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + "/" + path))
.header("Authorization", "Bearer " + TOKEN);
if (jsonBody != null) {
b.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
} else {
b.GET();
}
HttpResponse<String> res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
// The envelope is always {"ok":true,"data":...} or {"ok":false,"error":...}.
return res.body();
}
}
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "words-to-time" # only for minting a guest token and naming the idempotency key
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://words-to-time.skillsafe.ai/tokens.html
def call(path, body = nil)
uri = URI("#{BASE}/#{path}")
req = body ? Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
if body
req["Content-Type"] = "application/json"
req.body = JSON.generate(body)
end
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
payload = JSON.parse(res.body)
raise "#{payload['error']['code']}: #{payload['error']['message']}" unless payload["ok"]
payload["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "words-to-time"; // guest minting + idempotency keys only
define("TOKEN", getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN"); // from /tokens.html
function call(string $path, ?array $body = null) {
$ch = curl_init(BASE . "/" . $path);
$headers = ["Authorization: Bearer " . TOKEN];
if ($body !== null) {
$headers[] = "Content-Type: application/json";
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$payload = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($payload["ok"])) {
throw new RuntimeException($payload["error"]["code"] . ": " . $payload["error"]["message"]);
}
return $payload["data"];
}
using System.Net.Http.Json;
using System.Text.Json;
static class WordsToTime
{
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Slug = "words-to-time"; // guest minting + idempotency keys only
static readonly string Token =
Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN";
static readonly HttpClient Http = new();
public static async Task<JsonElement> Call(string path, object? body = null)
{
var req = new HttpRequestMessage(body is null ? HttpMethod.Get : HttpMethod.Post, $"{Base}/{path}");
req.Headers.Add("Authorization", $"Bearer {Token}");
if (body is not null) req.Content = JsonContent.Create(body);
var res = await Http.SendAsync(req);
var payload = await res.Content.ReadFromJsonAsync<JsonElement>();
if (!payload.GetProperty("ok").GetBoolean())
{
var e = payload.GetProperty("error");
throw new Exception($"{e.GetProperty("code")}: {e.GetProperty("message")}");
}
return payload.GetProperty("data");
}
}
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"
# Open https://words-to-time.skillsafe.ai/tokens.html and press "Copy token",
# or mint a guest token here. A guest token can call /me and /estimate but
# cannot run a metered fit or pace.
import json, urllib.parse, urllib.request
# The slug goes in the body of this one call, and it takes no Authorization
# header. Every other endpoint is the reverse: Bearer token only, no slug.
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/guest",
data=json.dumps({"slug": "words-to-time"}).encode(), method="POST")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r:
guest = json.load(r)["data"]
TOKEN = guest["token"] # "aut_..."; guest also carries guest_id and expires_at
# For a personal token, send a browser through the hosted sign-in and read the
# token off the redirect you land on. There is no headless password flow.
sso = "https://skillsafe.ai/app-login?" + urllib.parse.urlencode(
{"slug": "words-to-time", "redirect": "https://example.com/callback"})
print("open this in a browser:", sso)
// Open https://words-to-time.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot run a metered fit or pace.
// The slug goes in the body of this one call, and it takes no Authorization
// header. Every other endpoint is the reverse: Bearer token only, no slug.
const res = await fetch("https://api.skillsafe.ai/v1/app-api/guest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "words-to-time" }),
});
const guest = (await res.json()).data; // { token, guest_id, expires_at }
const TOKEN = guest.token; // "aut_..."
// In a browser, the bundled SDK does the whole SSO round trip for you: it opens
// the hosted sign-in, captures the token off the redirect and stores it under
// localStorage["skillsafe_app_token:words-to-time"].
//
// const ss = SkillSafe.init({ slug: "words-to-time" });
// ss.captureRedirectToken(); // run this on the page you return to
// await ss.loginPopup(); // or ss.login() for a full-page redirect
// console.log(ss.token);
// Open https://words-to-time.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot run a metered fit or pace.
// The slug goes in the body of this one call, and it takes no Authorization
// header. Every other endpoint is the reverse: Bearer token only, no slug.
guestBody, _ := json.Marshal(map[string]string{"slug": "words-to-time"})
guestReq, _ := http.NewRequest(http.MethodPost,
"https://api.skillsafe.ai/v1/app-api/guest", bytes.NewReader(guestBody))
guestReq.Header.Set("Content-Type", "application/json")
guestRes, err := http.DefaultClient.Do(guestReq)
if err != nil {
panic(err)
}
defer guestRes.Body.Close()
var guest struct {
Data struct {
Token string `json:"token"` // "aut_..."
GuestID string `json:"guest_id"` // "gst_..."
ExpiresAt string `json:"expires_at"`
} `json:"data"`
}
_ = json.NewDecoder(guestRes.Body).Decode(&guest)
fmt.Println(guest.Data.Token)
// A personal token needs a browser: send the user to
// https://skillsafe.ai/app-login?slug=words-to-time&redirect=YOUR_CALLBACK
// and read the token off the redirect.
// Open https://words-to-time.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot run a metered fit or pace.
var http = HttpClient.newHttpClient();
// The slug goes in the body of this one call, and it takes no Authorization
// header. Every other endpoint is the reverse: Bearer token only, no slug.
var guestReq = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/guest"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\": \"words-to-time\"}"))
.build();
HttpResponse<String> guest = http.send(guestReq, HttpResponse.BodyHandlers.ofString());
System.out.println(guest.body());
// {"ok":true,"data":{"token":"aut_...","guest_id":"gst_...","expires_at":"..."}}
// For a personal token, open
// https://skillsafe.ai/app-login?slug=words-to-time&redirect=YOUR_CALLBACK
// in a browser and capture the token from the redirect.
# Open https://words-to-time.skillsafe.ai/tokens.html and press "Copy token",
# or mint a guest token here. A guest token can call /me and /estimate but
# cannot run a metered fit or pace.
require "json"
require "net/http"
require "uri"
# The slug goes in the body of this one call, and it takes no Authorization
# header. Every other endpoint is the reverse: Bearer token only, no slug.
uri = URI("https://api.skillsafe.ai/v1/app-api/guest")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req.body = JSON.generate("slug" => "words-to-time")
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
guest = JSON.parse(res.body)["data"] # { "token", "guest_id", "expires_at" }
TOKEN = guest["token"] # "aut_..."
# A personal token comes from the hosted sign-in in a browser:
sso = URI("https://skillsafe.ai/app-login")
sso.query = URI.encode_www_form(slug: "words-to-time", redirect: "https://example.com/callback")
puts "open this in a browser: #{sso}"
<?php
// Open https://words-to-time.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot run a metered fit or pace.
// The slug goes in the body of this one call, and it takes no Authorization
// header. Every other endpoint is the reverse: Bearer token only, no slug.
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/guest");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(["slug" => "words-to-time"]));
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json"]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$guest = json_decode(curl_exec($ch), true)["data"];
curl_close($ch);
echo $guest["token"]; // "aut_..."; $guest also has guest_id and expires_at
// A personal token comes from the hosted sign-in in a browser:
echo "https://skillsafe.ai/app-login?" . http_build_query([
"slug" => "words-to-time",
"redirect" => "https://example.com/callback",
]);
// Open https://words-to-time.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot run a metered fit or pace.
using var http = new HttpClient();
// The slug goes in the body of this one call, and it takes no Authorization
// header. Every other endpoint is the reverse: Bearer token only, no slug.
var guestReq = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/guest");
guestReq.Content = new StringContent("{\"slug\":\"words-to-time\"}", Encoding.UTF8, "application/json");
var guestRes = await http.SendAsync(guestReq);
var guest = (await guestRes.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("data");
Console.WriteLine(guest.GetProperty("token").GetString()); // "aut_..."
Console.WriteLine(guest.GetProperty("guest_id").GetString()); // "gst_..."
// For a personal token, open this in a browser and capture the redirect:
// https://skillsafe.ai/app-login?slug=words-to-time&redirect=YOUR_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}}
me = call("me")
print(me["subject_type"], me.get("credits"))
if me["subject_type"] != "user":
print("guest token: /estimate works, /run will 403")
const me = await call("me");
console.log(me.subject_type, me.credits);
if (me.subject_type !== "user") console.warn("guest token: /run will 403");
raw, err := call("me", nil)
if err != nil {
panic(err)
}
var me struct {
SubjectType string `json:"subject_type"`
Credits int `json:"credits"`
}
_ = json.Unmarshal(raw, &me)
fmt.Println(me.SubjectType, me.Credits)
System.out.println(call("me", null));
// {"ok":true,"data":{"subject_type":"user","username":"you","credits":51234}}
me = call("me")
puts "#{me['subject_type']} #{me['credits']}"
<?php
$me = call("me");
echo $me["subject_type"], " ", $me["credits"], PHP_EOL;
var me = await WordsToTime.Call("me");
Console.WriteLine(me.GetProperty("subject_type").GetString());
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
| field | type | meaning |
|---|---|---|
task | string, required | The literal string "fit". Routes the run to the rewrite lane. See the lane table. |
script | string, required | The 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. |
words | number, required | The plain word count of script: whitespace-separated tokens. This is the number a reader recognises. |
spoken_words | number, required | The 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_seconds | number, required | How 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. |
wpm | number, required | Words 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_seconds | number, required | The 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. |
mode | string | "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. |
purpose | string | speech, 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. |
notes | string, optional | Free-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
| field | type | meaning |
|---|---|---|
task | string, required | The literal string "pace". Routes the run to the rehearsal lane. |
script | string, required | Identical 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. |
words | number, required | Plain word count of script. |
spoken_words | number, required | Spoken word count, digits and symbols expanded, as above. This is what the section timings are built from. |
current_seconds | number, required | Current 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. |
wpm | number, required | Same presets and same 80–220 range as the fit lane. |
target_seconds | number, required | Send 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. |
purpose | string | Same 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. |
notes | string, optional | Free-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.
# The fit lane: a 6:29 speech that has to be 5:00.
FIT_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\n"
"The district says the schedule cannot change. Two other districts our size changed it "
"in a single year.\n\n"
"So 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.",
}
# The pace lane on the same paste: same shape, no mode, target_seconds may be 0.
PACE_INPUT = {
"task": "pace",
"script": FIT_INPUT["script"],
"words": 812,
"spoken_words": 842,
"current_seconds": 389,
"wpm": 130,
"target_seconds": 300,
"purpose": "speech",
"notes": "",
}
est = call("estimate", FIT_INPUT)
print(est["model"], est["model_alias"], est["markup_bps"])
print(est["hold_credits"], est["min_credits"], est["sponsor_enabled"])
# estimate is free: no job is created and nothing is charged. The hold is a
# reservation against the full output cap, not the price of the run.
// The fit lane: a 6:29 speech that has to be 5:00.
const 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\n" +
"The district says the schedule cannot change. Two other districts our size changed it " +
"in a single year.\n\n" +
"So 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.";
const FIT_INPUT = {
task: "fit",
script: SCRIPT,
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.",
};
// The pace lane on the same paste: same shape, no mode, target_seconds may be 0.
const PACE_INPUT = {
task: "pace",
script: SCRIPT,
words: 812,
spoken_words: 842,
current_seconds: 389,
wpm: 130,
target_seconds: 300,
purpose: "speech",
notes: "",
};
const est = await call("estimate", FIT_INPUT);
console.log(est.model, est.model_alias, est.markup_bps);
console.log(est.hold_credits, est.min_credits, est.sponsor_enabled);
// estimate is free: no job is created and nothing is charged. hold_credits is a
// reservation against the output cap; charged_credits is normally far lower.
// The fit lane: a 6:29 speech that has to be 5:00.
const 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\n" +
"The district says the schedule cannot change. Two other districts our size changed " +
"it in a single year.\n\n" +
"So 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."
input := map[string]any{
"task": "fit",
"script": script,
"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.",
}
// The pace lane is the same object with task swapped and mode dropped.
paceInput := map[string]any{
"task": "pace",
"script": script,
"words": 812,
"spoken_words": 842,
"current_seconds": 389,
"wpm": 130,
"target_seconds": 300,
"purpose": "speech",
"notes": "",
}
_ = paceInput
raw, err := call("estimate", input)
if err != nil {
panic(err)
}
fmt.Println(string(raw)) // estimate is free - no job, no charge; the hold is a reservation
// The fit lane: a 6:29 speech that has to be 5:00.
String 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."
}
""";
// The pace lane: identical apart from "task" and the missing "mode".
String paceInput = input
.replace("\"task\": \"fit\"", "\"task\": \"pace\"")
.replace(" \"mode\": \"auto\",\n", "");
System.out.println(call("estimate", input));
// estimate is free: no job is created and nothing is charged.
// The data object carries model, model_alias, markup_bps, hold_credits,
// min_credits and sponsor_enabled. hold_credits is a reservation against the
// full output cap, so the settled charge is normally far lower.
# The fit lane: a 6:29 speech that has to be 5:00.
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\n" \
"The district says the schedule cannot change. Two other districts our size " \
"changed it in a single year.\n\n" \
"So 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."
input = {
"task" => "fit",
"script" => SCRIPT,
"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."
}
# The pace lane on the same paste.
pace_input = input.merge("task" => "pace", "notes" => "").tap { |h| h.delete("mode") }
est = call("estimate", input)
puts "#{est['model']} hold=#{est['hold_credits']} min=#{est['min_credits']}"
# estimate is free: no job is created and nothing is charged.
<?php
// The fit lane: a 6:29 speech that has to be 5:00.
$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\n"
. "The district says the schedule cannot change. Two other districts our size "
. "changed it in a single year.\n\n"
. "So 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.";
$input = [
"task" => "fit",
"script" => $script,
"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.",
];
// The pace lane on the same paste.
$paceInput = $input;
$paceInput["task"] = "pace";
$paceInput["notes"] = "";
unset($paceInput["mode"]);
$est = call("estimate", $input);
echo $est["model"], " ", $est["hold_credits"], " ", $est["min_credits"], PHP_EOL;
// estimate is free: no job is created and nothing is charged.
// The fit lane: a 6:29 speech that has to be 5:00.
const string 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\n" +
"The district says the schedule cannot change. Two other districts our size changed it " +
"in a single year.\n\n" +
"So 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.";
var input = new
{
task = "fit",
script,
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."
};
// The pace lane on the same paste: no mode, and target_seconds may be 0.
var paceInput = new
{
task = "pace",
script,
words = 812,
spoken_words = 842,
current_seconds = 389,
wpm = 130,
target_seconds = 300,
purpose = "speech",
notes = ""
};
var est = await WordsToTime.Call("estimate", input);
Console.WriteLine(est.GetProperty("hold_credits").GetInt32());
Console.WriteLine(est.GetProperty("sponsor_enabled").GetBoolean());
// estimate is free: no job is created and nothing is charged. The hold is a
// reservation against the output cap, not the price of the run.
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"])'
import hashlib, time
# 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.
digest = hashlib.sha256(json.dumps(FIT_INPUT, sort_keys=True).encode()).hexdigest()[:16]
key = f"words-to-time:{FIT_INPUT['task']}:{digest}:a1"
req = urllib.request.Request(f"{BASE}/run", data=json.dumps(FIT_INPUT).encode(), method="POST")
req.add_header("Authorization", f"Bearer {TOKEN}")
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key)
with urllib.request.urlopen(req) as r:
job_id = json.load(r)["data"]["job_id"]
while True:
job = call(f"jobs/{job_id}")
if job["status"] == "succeeded":
break
if job["status"] == "failed":
raise RuntimeError(job.get("error"))
time.sleep(2)
out = json.loads(job["output"]["output"])
print(out["task"], out["verdict"], out["title"])
if out["task"] == "fit":
print(out["target_words"], "word budget,", len(out["edits"]), "edits")
else:
print(len(out["sections"]), "sections,", len(out["pauses"]), "pauses")
print("charged", job.get("charged_credits"), "truncated", job.get("truncated"))
import { createHash } from "node:crypto";
// 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.
const digest = createHash("sha256").update(JSON.stringify(FIT_INPUT)).digest("hex").slice(0, 16);
const key = `words-to-time:${FIT_INPUT.task}:${digest}:a1`;
const started = await fetch(`${BASE}/run`, {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": key,
},
body: JSON.stringify(FIT_INPUT),
}).then((r) => r.json());
let job = started.data;
while (job.status !== "succeeded" && job.status !== "failed") {
await new Promise((r) => setTimeout(r, 2000));
job = await call(`jobs/${job.job_id}`);
}
if (job.status === "failed") throw new Error(JSON.stringify(job.error));
const out = JSON.parse(job.output.output);
console.log(out.task, out.verdict, out.title, job.charged_credits);
console.log(out.task === "fit" ? `${out.edits.length} edits` : `${out.sections.length} sections`);
// 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.
body, _ := json.Marshal(input)
sum := sha256.Sum256(body)
key := fmt.Sprintf("words-to-time:fit:%x:a1", sum[:8])
req, _ := http.NewRequest(http.MethodPost, base+"/run", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
var started struct {
Data struct {
JobID string `json:"job_id"`
} `json:"data"`
}
_ = json.NewDecoder(res.Body).Decode(&started)
for {
raw, err := call("jobs/"+started.Data.JobID, nil)
if err != nil {
panic(err)
}
var job struct {
Status string `json:"status"`
Output struct {
Output string `json:"output"`
} `json:"output"`
ChargedCredits int `json:"charged_credits"`
Truncated bool `json:"truncated"`
}
_ = json.Unmarshal(raw, &job)
if job.Status == "succeeded" {
fmt.Println(job.Output.Output) // the lane JSON, as a string
fmt.Println(job.ChargedCredits, job.Truncated)
break
}
if job.Status == "failed" {
panic("run failed")
}
time.Sleep(2 * time.Second)
}
// 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.
var digest = java.security.MessageDigest.getInstance("SHA-256")
.digest(input.getBytes(java.nio.charset.StandardCharsets.UTF_8));
var key = "words-to-time:fit:"
+ java.util.HexFormat.of().formatHex(digest).substring(0, 16) + ":a1";
var start = HttpRequest.newBuilder(URI.create(BASE + "/run"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString(input))
.build();
String started = HTTP.send(start, HttpResponse.BodyHandlers.ofString()).body();
// Parse job_id out of `started`, then poll GET jobs/{job_id} every two seconds
// until status is "succeeded" or "failed". The lane JSON is data.output.output,
// and the terminal job also carries charged_credits and truncated.
System.out.println(started);
require "digest"
# 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.
digest = Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]
key = "words-to-time:#{input['task']}:#{digest}:a1"
uri = URI("#{BASE}/run")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = key
req.body = JSON.generate(input)
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
job_id = JSON.parse(res.body)["data"]["job_id"]
loop do
job = call("jobs/#{job_id}")
if job["status"] == "succeeded"
out = JSON.parse(job["output"]["output"])
puts "#{out['task']} #{out['verdict']} #{out['title']}"
puts "charged=#{job['charged_credits']} truncated=#{job['truncated']}"
break
end
raise "run failed" if job["status"] == "failed"
sleep 2
end
<?php
// 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.
$digest = substr(hash("sha256", json_encode($input)), 0, 16);
$key = "words-to-time:{$input['task']}:{$digest}:a1";
$ch = curl_init(BASE . "/run");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($input));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer " . TOKEN,
"Content-Type: application/json",
"Idempotency-Key: " . $key,
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$jobId = json_decode(curl_exec($ch), true)["data"]["job_id"];
curl_close($ch);
while (true) {
$job = call("jobs/" . $jobId);
if ($job["status"] === "succeeded") {
$out = json_decode($job["output"]["output"], true);
echo $out["task"], " ", $out["verdict"], " ", $out["title"], PHP_EOL;
echo "charged=", $job["charged_credits"], " truncated=", var_export($job["truncated"], true), PHP_EOL;
break;
}
if ($job["status"] === "failed") { throw new RuntimeException("run failed"); }
sleep(2);
}
using System.Security.Cryptography;
using System.Text;
// 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.
var json = JsonSerializer.Serialize(input);
var digest = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(json)))[..16].ToLowerInvariant();
var key = $"words-to-time:{input.task}:{digest}:a1";
var run = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run");
run.Headers.Add("Authorization", "Bearer YOUR_TOKEN");
run.Headers.Add("Idempotency-Key", key);
run.Content = JsonContent.Create(input);
// POST it, read data.job_id, then poll GET jobs/{job_id} every two seconds until
// status is "succeeded" or "failed". The lane JSON is data.output.output, and the
// terminal job also carries charged_credits and truncated.
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}
# Server-sent events: the reply arrives in chunks, so a UI can show progress.
req = urllib.request.Request(f"{BASE}/run-stream", data=json.dumps(FIT_INPUT).encode(), method="POST")
req.add_header("Authorization", f"Bearer {TOKEN}")
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key)
req.add_header("Accept", "text/event-stream")
raw = ""
done = {}
event = None
stage = "reading the script"
with urllib.request.urlopen(req) as stream:
for line in stream:
line = line.decode().rstrip("\n")
if line.startswith("event: "):
event = line[7:]
elif line.startswith("data: ") and event == "delta":
raw += json.loads(line[6:]).get("text", "")
# The arrival of a key name is the progress signal the web app uses.
if '"revised_script"' in raw:
stage = "writing the revised script"
elif '"marked_script"' in raw:
stage = "marking up the script"
elif '"edits"' in raw:
stage = "listing the edits"
elif '"sections"' in raw:
stage = "building the timing plan"
elif line.startswith("data: ") and event == "done":
done = json.loads(line[6:])
out = json.loads(raw[raw.index("{"):raw.rindex("}") + 1])
print(stage, out["task"], out["verdict"], done.get("charged_credits"))
// Server-sent events: the reply arrives in chunks, so a UI can show progress.
const res = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": key,
Accept: "text/event-stream",
},
body: JSON.stringify(FIT_INPUT),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let raw = "";
let done = {};
let event = null;
let stage = "reading the script";
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
buffer += decoder.decode(chunk.value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop();
for (const line of lines) {
if (line.startsWith("event: ")) event = line.slice(7);
else if (line.startsWith("data: ") && event === "delta") {
raw += JSON.parse(line.slice(6)).text ?? "";
// The arrival of a key name is the progress signal the web app uses.
if (raw.includes('"revised_script"')) stage = "writing the revised script";
else if (raw.includes('"marked_script"')) stage = "marking up the script";
else if (raw.includes('"edits"')) stage = "listing the edits";
else if (raw.includes('"sections"')) stage = "building the timing plan";
} else if (line.startsWith("data: ") && event === "done") {
done = JSON.parse(line.slice(6));
}
}
}
const out = JSON.parse(raw.slice(raw.indexOf("{"), raw.lastIndexOf("}") + 1));
console.log(stage, out.task, out.verdict, done.charged_credits);
// Server-sent events: the reply arrives in chunks, so a UI can show progress.
req, _ = http.NewRequest(http.MethodPost, base+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
req.Header.Set("Accept", "text/event-stream")
res, _ = http.DefaultClient.Do(req)
defer res.Body.Close()
var raw strings.Builder
var event string
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event: "):
event = strings.TrimPrefix(line, "event: ")
case strings.HasPrefix(line, "data: ") && event == "delta":
var d struct {
Text string `json:"text"`
}
_ = json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &d)
raw.WriteString(d.Text)
// "edits" and "revised_script" (fit), or "sections" and "marked_script"
// (pace), are the progress signals worth watching for.
case strings.HasPrefix(line, "data: ") && event == "done":
fmt.Println(strings.TrimPrefix(line, "data: ")) // status, charged_credits, truncated
}
}
fmt.Println(raw.String())
// Server-sent events: the reply arrives in chunks, so a UI can show progress.
var stream = HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.header("Accept", "text/event-stream")
.POST(HttpRequest.BodyPublishers.ofString(input))
.build();
StringBuilder raw = new StringBuilder();
String[] event = { null };
HTTP.send(stream, HttpResponse.BodyHandlers.ofLines()).body().forEach(line -> {
if (line.startsWith("event: ")) event[0] = line.substring(7);
else if (line.startsWith("data: ") && "delta".equals(event[0])) {
raw.append(line.substring(6)); // each data line is {"text":"..."} - decode and append .text
}
});
System.out.println(raw);
// Watch the accumulating text for "edits" and "revised_script" in the fit lane,
// or "sections" and "marked_script" in the pace lane, to advance a progress
// display. The final `done` event carries status, charged_credits and truncated.
# Server-sent events: the reply arrives in chunks, so a UI can show progress.
uri = URI("#{BASE}/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = key
req["Accept"] = "text/event-stream"
req.body = JSON.generate(input)
raw = +""
event = nil
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
line = line.chomp
if line.start_with?("event: ") then event = line[7..]
elsif line.start_with?("data: ") && event == "delta"
raw << (JSON.parse(line[6..])["text"] || "")
# "revised_script" (fit) and "marked_script" (pace) mean the long tail
# of the reply has started; that is the progress signal worth showing.
end
end
end
end
end
out = JSON.parse(raw[raw.index("{")..raw.rindex("}")])
puts "#{out['task']} #{out['verdict']} #{out['title']}"
<?php
// Server-sent events: the reply arrives in chunks, so a UI can show progress.
$raw = "";
$event = null;
$ch = curl_init(BASE . "/run-stream");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($input));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer " . TOKEN,
"Content-Type: application/json",
"Idempotency-Key: " . $key,
"Accept: text/event-stream",
]);
curl_setopt($ch, CURLOPT_WRITEFUNCTION, function ($ch, $chunk) use (&$raw, &$event) {
foreach (explode("\n", $chunk) as $line) {
if (str_starts_with($line, "event: ")) {
$event = substr($line, 7);
} elseif (str_starts_with($line, "data: ") && $event === "delta") {
$raw .= json_decode(substr($line, 6), true)["text"] ?? "";
}
}
return strlen($chunk);
});
curl_exec($ch);
curl_close($ch);
$out = json_decode(substr($raw, strpos($raw, "{")), true);
echo $out["task"], " ", $out["verdict"], PHP_EOL;
// Server-sent events: the reply arrives in chunks, so a UI can show progress.
var stream = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run-stream");
stream.Headers.Add("Authorization", "Bearer YOUR_TOKEN");
stream.Headers.Add("Idempotency-Key", key);
stream.Headers.Add("Accept", "text/event-stream");
stream.Content = JsonContent.Create(input);
using var res = await Http.SendAsync(stream, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
var raw = new StringBuilder();
string? evt = null;
while (await reader.ReadLineAsync() is { } line)
{
if (line.StartsWith("event: ")) evt = line[7..];
else if (line.StartsWith("data: ") && evt == "delta")
{
var d = JsonSerializer.Deserialize<JsonElement>(line[6..]);
if (d.TryGetProperty("text", out var t)) raw.Append(t.GetString());
// Watch raw for "edits"/"revised_script" (fit) or
// "sections"/"marked_script" (pace) to advance a progress display.
}
}
Console.WriteLine(raw.ToString());
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:
- Both lanes.
output.taskequals thetaskyou sent. If it does not, the model answered as the closest lane — the reply is still usable, andsummarywill say so, but do not feed it to a renderer expecting the other shape. - fit. Recount
revised_scriptthe same way you countedscript, recompute the duration at thewpmyou sent, and compare againsttarget_seconds. Landing within about fifteen seconds is a hit — the app badges it revised script lands at 4:58 of your 5:00 target. Missing by more is worth surfacing loudly: still 0:40 over — the model missed the budget. It is a real outcome, not a bug, and a caller that silently reports the model's own claim will ship a script that runs long. - fit. Sum
edits[].words_deltaand compare it against the actual change in word count. A large disagreement means the ledger is decorative rather than accurate, which matters if you are showing an editor what changed. - fit.
target_wordsshould beround(target_seconds / 60 × wpm). Recompute it; do not display the model's number without checking it. - pace. Sum
sections[].wordsand compare againstspoken_words, and sumsections[].secondsagainstcurrent_seconds. Check that eachstarts_atis the running total of the precedingseconds. - pace. Every
pauses[].after,emphasis[].quoteandrisks[].quotemust be a substring of the script after whitespace normalization — collapse runs of whitespace to a single space on both sides before comparing, because line wrapping is the usual cause of a near miss. Count the misses and say so: 2 of 7 quotes were not found verbatim. Do not silently drop them; a missing quote is the signal that the annotation drifted. - pace. If you sent a clipped script, the quotes are checked against the clipped text — that is what the model saw. Reconciling against the unclipped original will produce phantom misses.
# 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
import re
def words_of(text):
"""Plain word count. The app also computes a spoken count that expands
digits and symbols; for reconciliation the plain count is enough."""
return len(text.split())
def duration(spoken, wpm):
return round(spoken / wpm * 60)
def fmt(seconds):
h, rem = divmod(int(seconds), 3600)
m, s = divmod(rem, 60)
return f"{h}:{m:02d}:{s:02d}" if h else f"{m}:{s:02d}"
def norm(text):
return re.sub(r"\s+", " ", text).strip()
body = job["output"]["output"]
body = body[body.index("{"):body.rindex("}") + 1]
out = json.loads(body)
assert out["task"] == FIT_INPUT["task"], f"model answered as {out['task']}"
if out["task"] == "fit":
n = words_of(out["revised_script"])
secs = duration(n, FIT_INPUT["wpm"])
target = FIT_INPUT["target_seconds"]
print(f"revised script lands at {fmt(secs)} of your {fmt(target)} target")
if abs(secs - target) > 15:
print(f"MISSED: still {fmt(abs(secs - target))} {'over' if secs > target else 'under'}")
ledger = sum(e["words_delta"] for e in out["edits"])
print("ledger", ledger, "actual", n - FIT_INPUT["words"])
print("target_words claimed", out["target_words"],
"computed", round(target / 60 * FIT_INPUT["wpm"]))
else:
haystack = norm(PACE_INPUT["script"])
quotes = ([p["after"] for p in out["pauses"]]
+ [e["quote"] for e in out["emphasis"]]
+ [r["quote"] for r in out["risks"]])
missing = [q for q in quotes if norm(q) not in haystack]
if missing:
print(f"{len(missing)} of {len(quotes)} quotes were not found verbatim")
print("sections sum", sum(s["seconds"] for s in out["sections"]),
"vs", PACE_INPUT["current_seconds"])
const wordsOf = (t) => t.trim().split(/\s+/).filter(Boolean).length;
const duration = (spoken, wpm) => Math.round((spoken / wpm) * 60);
const fmt = (s) => {
s = Math.round(s);
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), r = s % 60;
return h ? `${h}:${String(m).padStart(2, "0")}:${String(r).padStart(2, "0")}`
: `${m}:${String(r).padStart(2, "0")}`;
};
const norm = (t) => t.replace(/\s+/g, " ").trim();
let body = job.output.output;
body = body.slice(body.indexOf("{"), body.lastIndexOf("}") + 1);
const out = JSON.parse(body);
if (out.task !== FIT_INPUT.task) console.warn(`model answered as ${out.task}`);
if (out.task === "fit") {
const n = wordsOf(out.revised_script);
const secs = duration(n, FIT_INPUT.wpm);
const target = FIT_INPUT.target_seconds;
console.log(`revised script lands at ${fmt(secs)} of your ${fmt(target)} target`);
if (Math.abs(secs - target) > 15) {
console.warn(`MISSED: still ${fmt(Math.abs(secs - target))} ${secs > target ? "over" : "under"}`);
}
const ledger = out.edits.reduce((a, e) => a + e.words_delta, 0);
console.log("ledger", ledger, "actual", n - FIT_INPUT.words);
console.log("target_words", out.target_words, "computed", Math.round((target / 60) * FIT_INPUT.wpm));
} else {
const haystack = norm(PACE_INPUT.script);
const quotes = [
...out.pauses.map((p) => p.after),
...out.emphasis.map((e) => e.quote),
...out.risks.map((r) => r.quote),
];
const missing = quotes.filter((q) => !haystack.includes(norm(q)));
if (missing.length) console.warn(`${missing.length} of ${quotes.length} quotes were not found verbatim`);
const total = out.sections.reduce((a, s) => a + s.seconds, 0);
console.log("sections sum", total, "vs", PACE_INPUT.current_seconds);
}
type fitOut struct {
Task string `json:"task"`
Title string `json:"title"`
Verdict string `json:"verdict"`
Summary string `json:"summary"`
TargetWords int `json:"target_words"`
RevisedScript string `json:"revised_script"`
Edits []struct {
Kind string `json:"kind"`
Where string `json:"where"`
Before string `json:"before"`
After string `json:"after"`
WordsDelta int `json:"words_delta"`
Why string `json:"why"`
} `json:"edits"`
Kept []string `json:"kept"`
Risks []string `json:"risks"`
DeliveryNote string `json:"delivery_note"`
}
func wordsOf(s string) int { return len(strings.Fields(s)) }
func fmtSecs(sec int) string {
if sec >= 3600 {
return fmt.Sprintf("%d:%02d:%02d", sec/3600, (sec%3600)/60, sec%60)
}
return fmt.Sprintf("%d:%02d", sec/60, sec%60)
}
raw := job.Output.Output
raw = raw[strings.Index(raw, "{") : strings.LastIndex(raw, "}")+1]
var out fitOut
_ = json.Unmarshal([]byte(raw), &out)
if out.Task != "fit" {
fmt.Println("model answered as", out.Task)
}
n := wordsOf(out.RevisedScript)
secs := int(float64(n)/130.0*60.0 + 0.5)
fmt.Printf("revised script lands at %s of your %s target\n", fmtSecs(secs), fmtSecs(300))
ledger := 0
for _, e := range out.Edits {
ledger += e.WordsDelta
}
fmt.Println("ledger", ledger, "actual", n-812)
// Slice from the first { to the last }, parse, then re-time it yourself.
String body = raw.substring(raw.indexOf('{'), raw.lastIndexOf('}') + 1);
// ... parse `body` with your JSON library of choice into `out` ...
// fit: recount the revised script at the wpm you sent.
int n = out.revisedScript.trim().split("\\s+").length;
int secs = Math.round(n / 130f * 60f);
System.out.printf("revised script lands at %d:%02d of your 5:00 target%n", secs / 60, secs % 60);
if (Math.abs(secs - 300) > 15) {
System.out.printf("MISSED by %ds%n", Math.abs(secs - 300));
}
// pace: every quote must survive whitespace normalization as a substring.
String haystack = script.replaceAll("\\s+", " ").strip();
for (String q : quotes) { // pauses[].after + emphasis[].quote + risks[].quote
if (!haystack.contains(q.replaceAll("\\s+", " ").strip())) {
System.out.println("not found verbatim: " + q);
}
}
def words_of(text) = text.split(/\s+/).reject(&:empty?).length
def duration(spoken, wpm) = (spoken.to_f / wpm * 60).round
def fmt(sec)
sec = sec.round
sec >= 3600 ? format("%d:%02d:%02d", sec / 3600, (sec % 3600) / 60, sec % 60)
: format("%d:%02d", sec / 60, sec % 60)
end
def norm(text) = text.gsub(/\s+/, " ").strip
body = job["output"]["output"]
body = body[body.index("{")..body.rindex("}")]
out = JSON.parse(body)
warn "model answered as #{out['task']}" unless out["task"] == input["task"]
if out["task"] == "fit"
n = words_of(out["revised_script"])
secs = duration(n, input["wpm"])
puts "revised script lands at #{fmt(secs)} of your #{fmt(input['target_seconds'])} target"
warn "MISSED by #{fmt((secs - input['target_seconds']).abs)}" if (secs - input["target_seconds"]).abs > 15
puts "ledger #{out['edits'].sum { |e| e['words_delta'] }} actual #{n - input['words']}"
else
haystack = norm(input["script"])
quotes = out["pauses"].map { _1["after"] } +
out["emphasis"].map { _1["quote"] } +
out["risks"].map { _1["quote"] }
missing = quotes.reject { haystack.include?(norm(_1)) }
warn "#{missing.length} of #{quotes.length} quotes were not found verbatim" unless missing.empty?
puts "sections sum #{out['sections'].sum { _1['seconds'] }} vs #{input['current_seconds']}"
end
<?php
function words_of(string $t): int {
return count(preg_split('/\s+/', trim($t), -1, PREG_SPLIT_NO_EMPTY));
}
function duration(int $spoken, int $wpm): int { return (int) round($spoken / $wpm * 60); }
function fmt_secs(int $s): string {
return $s >= 3600
? sprintf("%d:%02d:%02d", intdiv($s, 3600), intdiv($s % 3600, 60), $s % 60)
: sprintf("%d:%02d", intdiv($s, 60), $s % 60);
}
function norm(string $t): string { return trim(preg_replace('/\s+/', ' ', $t)); }
$body = $job["output"]["output"];
$body = substr($body, strpos($body, "{"), strrpos($body, "}") - strpos($body, "{") + 1);
$out = json_decode($body, true);
if ($out["task"] === "fit") {
$n = words_of($out["revised_script"]);
$secs = duration($n, $input["wpm"]);
echo "revised script lands at ", fmt_secs($secs),
" of your ", fmt_secs($input["target_seconds"]), " target", PHP_EOL;
$ledger = array_sum(array_column($out["edits"], "words_delta"));
echo "ledger ", $ledger, " actual ", $n - $input["words"], PHP_EOL;
} else {
$haystack = norm($input["script"]);
$quotes = array_merge(
array_column($out["pauses"], "after"),
array_column($out["emphasis"], "quote"),
array_column($out["risks"], "quote"),
);
$missing = array_filter($quotes, fn($q) => !str_contains($haystack, norm($q)));
if ($missing) {
echo count($missing), " of ", count($quotes), " quotes were not found verbatim", PHP_EOL;
}
}
using System.Text.RegularExpressions;
static int WordsOf(string t) =>
t.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length;
static int Duration(int spoken, int wpm) => (int)Math.Round(spoken / (double)wpm * 60);
static string Fmt(int s) => s >= 3600
? $"{s / 3600}:{(s % 3600) / 60:00}:{s % 60:00}"
: $"{s / 60}:{s % 60:00}";
static string Norm(string t) => Regex.Replace(t, @"\s+", " ").Trim();
var body = raw[raw.IndexOf('{')..(raw.LastIndexOf('}') + 1)];
var outDoc = JsonSerializer.Deserialize<JsonElement>(body);
if (outDoc.GetProperty("task").GetString() == "fit")
{
var n = WordsOf(outDoc.GetProperty("revised_script").GetString()!);
var secs = Duration(n, 130);
Console.WriteLine($"revised script lands at {Fmt(secs)} of your {Fmt(300)} target");
if (Math.Abs(secs - 300) > 15) Console.Error.WriteLine($"MISSED by {Fmt(Math.Abs(secs - 300))}");
var ledger = outDoc.GetProperty("edits").EnumerateArray()
.Sum(e => e.GetProperty("words_delta").GetInt32());
Console.WriteLine($"ledger {ledger} actual {n - 812}");
}
else
{
var haystack = Norm(script);
var quotes = outDoc.GetProperty("pauses").EnumerateArray().Select(p => p.GetProperty("after").GetString()!)
.Concat(outDoc.GetProperty("emphasis").EnumerateArray().Select(e => e.GetProperty("quote").GetString()!))
.Concat(outDoc.GetProperty("risks").EnumerateArray().Select(r => r.GetProperty("quote").GetString()!))
.ToList();
var missing = quotes.Count(q => !haystack.Contains(Norm(q)));
if (missing > 0) Console.Error.WriteLine($"{missing} of {quotes.Count} quotes were not found verbatim");
}
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
| key | type | meaning |
|---|---|---|
task | string | Echoes the lane: "fit". If it comes back as "pace", the router fell through to the other lane and summary will say so. |
title | string | Sixty 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. |
verdict | enum | fits, close, over or under. This describes the revision, not the original — see the enum table below. |
summary | string | One to three sentences: what was done and where it landed. The one field to show when you only have room for one. |
target_words | number | The 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_script | string | The 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. |
edits | object[] | {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. |
kept | string[] | 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. |
risks | string[] | 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_note | string | One 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
| key | type | meaning |
|---|---|---|
task | string | Echoes the lane: "pace". |
title | string | Sixty characters or fewer, naming the script. |
verdict | enum | ready, needs-work or at-risk. Note the hyphen in needs-work; it is not an underscore and not a space. |
summary | string | One to three sentences: the overall read, usually naming the single worst pacing problem. |
sections | object[] | {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. |
pauses | object[] | {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. |
emphasis | object[] | {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. |
risks | object[] | {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. |
tips | string[] | 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_script | string | The 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
| field | values | notes |
|---|---|---|
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
- The reply is JSON and nothing else. No code fence, no preamble, no trailing commentary. Slice from the first
{to the last}anyway — it costs one line and survives the day a model decides to be helpful. taskis honoured. An unknowntaskis answered as the closest lane, with the substitution stated insummaryrather than performed silently.- Nothing is invented. Neither lane adds facts, statistics, quotations or anecdotes that are not in the pasted script. In the
fitlane anexpandedit develops what is already there — it does not go and find new material. - Quotes are verbatim. Every quoted fragment in
pauses,emphasisandrisksis a substring of the script as sent. This is the one rule worth asserting in code, because it is the one that degrades most visibly when a run goes long.
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"
#!/usr/bin/env python3
"""wtt_gate.py - fail the build when a script does not fit its slot."""
import pathlib, sys
WPM, TARGET, TOLERANCE = 150, 300, 15
failed = []
for path in sorted(pathlib.Path("scripts").glob("*.txt")):
text = path.read_text()[:24000]
n = len(text.split())
secs = round(n / WPM * 60)
# The counting is free. Only spend credits on the scripts that miss.
if abs(secs - TARGET) <= TOLERANCE:
print(f"{path}: {fmt(secs)} - fits, no run needed")
continue
body = dict(FIT_INPUT, script=text, words=n, spoken_words=n,
current_seconds=secs, wpm=WPM, target_seconds=TARGET,
purpose="youtube", notes="")
job = run_and_wait(body) # /run + poll, from step 5
out = json.loads(job["output"]["output"])
revised = len(out["revised_script"].split())
landed = round(revised / WPM * 60)
ok = abs(landed - TARGET) <= TOLERANCE
print(f"{path}: {fmt(secs)} -> {fmt(landed)} ({out['verdict']}) "
f"charged={job.get('charged_credits')}")
if not ok:
failed.append((path, landed))
if failed:
for path, landed in failed:
print(f"MISS {path}: landed at {fmt(landed)}, wanted {fmt(TARGET)}", file=sys.stderr)
sys.exit(1)
print("wtt: every script fits its slot")
#!/usr/bin/env node
// wtt-gate.mjs - fail the build when a script does not fit its slot.
import { readdir, readFile } from "node:fs/promises";
const WPM = 150, TARGET = 300, TOLERANCE = 15;
const failed = [];
for (const name of (await readdir("scripts")).filter((f) => f.endsWith(".txt"))) {
const text = (await readFile(`scripts/${name}`, "utf8")).slice(0, 24000);
const n = wordsOf(text);
const secs = duration(n, WPM);
// The counting is free. Only spend credits on the scripts that miss.
if (Math.abs(secs - TARGET) <= TOLERANCE) {
console.log(`${name}: ${fmt(secs)} - fits, no run needed`);
continue;
}
const body = {
task: "fit", script: text, words: n, spoken_words: n,
current_seconds: secs, wpm: WPM, target_seconds: TARGET,
mode: "auto", purpose: "youtube", notes: "",
};
const job = await runAndWait(body); // /run + poll, from step 5
const out = JSON.parse(job.output.output);
const landed = duration(wordsOf(out.revised_script), WPM);
console.log(`${name}: ${fmt(secs)} -> ${fmt(landed)} (${out.verdict}) charged=${job.charged_credits}`);
if (Math.abs(landed - TARGET) > TOLERANCE) failed.push([name, landed]);
}
if (failed.length) {
for (const [name, landed] of failed) {
console.error(`MISS ${name}: landed at ${fmt(landed)}, wanted ${fmt(TARGET)}`);
}
process.exitCode = 1;
} else {
console.log("wtt: every script fits its slot");
}
// wtt-gate - fail the build when a script does not fit its slot.
const (
wpmGate = 150
target = 300
tolerance = 15
)
entries, _ := os.ReadDir("scripts")
failed := 0
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".txt") {
continue
}
b, _ := os.ReadFile("scripts/" + e.Name())
text := string(b)
if len(text) > 24000 {
text = text[:24000]
}
n := wordsOf(text)
secs := int(float64(n)/wpmGate*60 + 0.5)
// The counting is free. Only spend credits on the scripts that miss.
if secs >= target-tolerance && secs <= target+tolerance {
fmt.Printf("%s: %s - fits, no run needed\n", e.Name(), fmtSecs(secs))
continue
}
body := map[string]any{
"task": "fit", "script": text, "words": n, "spoken_words": n,
"current_seconds": secs, "wpm": wpmGate, "target_seconds": target,
"mode": "auto", "purpose": "youtube", "notes": "",
}
out := runAndWait(body) // /run + poll, from step 5
landed := int(float64(wordsOf(out.RevisedScript))/wpmGate*60 + 0.5)
fmt.Printf("%s: %s -> %s (%s)\n", e.Name(), fmtSecs(secs), fmtSecs(landed), out.Verdict)
if landed < target-tolerance || landed > target+tolerance {
failed++
}
}
if failed > 0 {
fmt.Fprintf(os.Stderr, "wtt: %d script(s) did not fit\n", failed)
os.Exit(1)
}
fmt.Println("wtt: every script fits its slot")
// WttGate - fail the build when a script does not fit its slot.
int wpm = 150, target = 300, tolerance = 15;
int failed = 0;
for (var path : java.nio.file.Files.newDirectoryStream(
java.nio.file.Path.of("scripts"), "*.txt")) {
var text = java.nio.file.Files.readString(path);
if (text.length() > 24000) text = text.substring(0, 24000);
int n = text.trim().split("\\s+").length;
int secs = Math.round(n / (float) wpm * 60);
// The counting is free. Only spend credits on the scripts that miss.
if (Math.abs(secs - target) <= tolerance) {
System.out.printf("%s: %d:%02d - fits, no run needed%n", path, secs / 60, secs % 60);
continue;
}
// Build the fit input, POST /run with an Idempotency-Key derived from `text`,
// poll jobs/{job_id}, then recount revised_script at the same wpm.
String jsonBody = fitInput(text, n, secs, wpm, target);
String result = call("run", jsonBody);
System.out.println(result);
failed++;
}
if (failed > 0) {
System.err.printf("wtt: %d script(s) did not fit%n", failed);
System.exit(1);
}
System.out.println("wtt: every script fits its slot");
#!/usr/bin/env ruby
# wtt_gate.rb - fail the build when a script does not fit its slot.
WPM, TARGET, TOLERANCE = 150, 300, 15
failed = []
Dir.glob("scripts/*.txt").sort.each do |path|
text = File.read(path)[0, 24_000]
n = words_of(text)
secs = duration(n, WPM)
# The counting is free. Only spend credits on the scripts that miss.
if (secs - TARGET).abs <= TOLERANCE
puts "#{path}: #{fmt(secs)} - fits, no run needed"
next
end
body = {
"task" => "fit", "script" => text, "words" => n, "spoken_words" => n,
"current_seconds" => secs, "wpm" => WPM, "target_seconds" => TARGET,
"mode" => "auto", "purpose" => "youtube", "notes" => ""
}
job = run_and_wait(body) # /run + poll, from step 5
out = JSON.parse(job["output"]["output"])
landed = duration(words_of(out["revised_script"]), WPM)
puts "#{path}: #{fmt(secs)} -> #{fmt(landed)} (#{out['verdict']})"
failed << [path, landed] if (landed - TARGET).abs > TOLERANCE
end
unless failed.empty?
failed.each { |path, landed| warn "MISS #{path}: landed at #{fmt(landed)}" }
exit 1
end
puts "wtt: every script fits its slot"
<?php
// wtt_gate.php - fail the build when a script does not fit its slot.
const WPM = 150, TARGET = 300, TOLERANCE = 15;
$failed = [];
foreach (glob("scripts/*.txt") as $path) {
$text = substr(file_get_contents($path), 0, 24000);
$n = words_of($text);
$secs = duration($n, WPM);
// The counting is free. Only spend credits on the scripts that miss.
if (abs($secs - TARGET) <= TOLERANCE) {
echo "$path: ", fmt_secs($secs), " - fits, no run needed", PHP_EOL;
continue;
}
$body = [
"task" => "fit", "script" => $text, "words" => $n, "spoken_words" => $n,
"current_seconds" => $secs, "wpm" => WPM, "target_seconds" => TARGET,
"mode" => "auto", "purpose" => "youtube", "notes" => "",
];
$job = run_and_wait($body); // /run + poll, from step 5
$out = json_decode($job["output"]["output"], true);
$landed = duration(words_of($out["revised_script"]), WPM);
echo "$path: ", fmt_secs($secs), " -> ", fmt_secs($landed), " (", $out["verdict"], ")", PHP_EOL;
if (abs($landed - TARGET) > TOLERANCE) { $failed[] = $path; }
}
if ($failed) {
fwrite(STDERR, "wtt: " . count($failed) . " script(s) did not fit" . PHP_EOL);
exit(1);
}
echo "wtt: every script fits its slot", PHP_EOL;
// WttGate - fail the build when a script does not fit its slot.
const int Wpm = 150, Target = 300, Tolerance = 15;
var failed = new List<string>();
foreach (var path in Directory.EnumerateFiles("scripts", "*.txt").OrderBy(p => p))
{
var text = File.ReadAllText(path);
if (text.Length > 24000) text = text[..24000];
var n = WordsOf(text);
var secs = Duration(n, Wpm);
// The counting is free. Only spend credits on the scripts that miss.
if (Math.Abs(secs - Target) <= Tolerance)
{
Console.WriteLine($"{path}: {Fmt(secs)} - fits, no run needed");
continue;
}
var body = new
{
task = "fit", script = text, words = n, spoken_words = n,
current_seconds = secs, wpm = Wpm, target_seconds = Target,
mode = "auto", purpose = "youtube", notes = ""
};
var job = await RunAndWait(body); // /run + poll, from step 5
var outDoc = JsonSerializer.Deserialize<JsonElement>(job.GetProperty("output").GetProperty("output").GetString()!);
var landed = Duration(WordsOf(outDoc.GetProperty("revised_script").GetString()!), Wpm);
Console.WriteLine($"{path}: {Fmt(secs)} -> {Fmt(landed)} ({outDoc.GetProperty("verdict").GetString()})");
if (Math.Abs(landed - Target) > Tolerance) failed.Add(path);
}
if (failed.Count > 0)
{
Console.Error.WriteLine($"wtt: {failed.Count} script(s) did not fit");
Environment.Exit(1);
}
Console.WriteLine("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.