Drive Subtitle Studio from your own code
Everything the browser computes — the SRT and WebVTT parsing, the structural
validation, the reading-speed measurement, the terminology pass, the spoken-punctuation and
unambiguous-homophone repairs, the corrected file, the diff, the CSV and the JSON — is deterministic and
runs client-side, so the API surface here is the metered judgement pass over the cues no rule could settle,
plus the storage the app saves its passes to. Every request goes to
https://api.skillsafe.ai/v1/app-api and carries a bearer token.
Base URL, envelope and errors
Every response is a JSON envelope. Success carries data; failure carries error
with a stable code. Nothing else appears at the top level, so a client can branch on the presence
of error alone.
{ "ok": true, "data": { "...": "..." } }
{ "ok": false, "error": { "code": "insufficient_credits", "message": "...", "details": { } } }
| Code | HTTP | What it means | What to do |
|---|---|---|---|
unauthorized | 401 | No token, or the token was minted for another app. | Mint a guest token or sign in; see step 2. |
insufficient_credits | 402 | The balance is below min_credits. | Top up. /estimate is free, so check before you submit. |
validation_error | 400 | The input JSON is not the shape the app declares. | Compare against the input schema in step 4. |
rate_limited | 429 | Too many requests. Similarity search is the tightest at 30/min per IP. | Back off and retry; do not tight-loop. |
payload_too_large | 413 | The run input exceeded 1 MB of JSON. | Send fewer cues per request — the app caps the review set and says how many it left behind. |
upstream_error | 502/529 | The model provider failed or was overloaded. | Retry with the SAME Idempotency-Key so a partial charge is not repeated. |
1. A tiny client
Every call is the same three lines: a bearer token, a JSON body, and the envelope unwrapped. Read the token
from your environment or your secret store — the snippets below use a "YOUR_TOKEN" placeholder,
and the token page will show you yours and copy a shell export for you, so you never
have to open a developer console.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="YOUR_TOKEN"
call() { # call <path> <json-body>
curl -sS -X POST "$BASE$1" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "$2"
}
import json, os, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN")
def call(path, body=None, method="POST"):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(BASE + path, data=data, method=method)
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r:
env = json.load(r)
if not env.get("ok"):
raise RuntimeError(env["error"]["code"] + ": " + env["error"]["message"])
return env["data"]
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN";
async function call(path, body, method = "POST") {
const res = await fetch(BASE + path, {
method,
headers: {
"Authorization": `Bearer ${TOKEN}`,
"Content-Type": "application/json"
},
body: body === undefined ? undefined : JSON.stringify(body)
});
const env = await res.json();
if (!env.ok) throw new Error(`${env.error.code}: ${env.error.message}`);
return env.data;
}
package main
import (
"bytes"
"encoding/json"
"errors"
"net/http"
)
const base = "https://api.skillsafe.ai/v1/app-api"
const token = "YOUR_TOKEN"
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) {
b, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", base+path, bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+token)
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, errors.New(env.Error.Code + ": " + env.Error.Message)
}
return env.Data, nil
}
import java.net.URI;
import java.net.http.*;
public class TamDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String TOKEN = "YOUR_TOKEN";
static final HttpClient HTTP = HttpClient.newHttpClient();
static String call(String path, String jsonBody) throws Exception {
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(BASE + path))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody))
.build();
HttpResponse<String> res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
return res.body(); // a JSON envelope: { ok, data } or { ok, error }
}
}
require "json"
require "net/http"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN"
def call(path, body = nil, method = :post)
uri = URI(BASE + path)
klass = method == :get ? Net::HTTP::Get : Net::HTTP::Post
req = klass.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req.body = JSON.dump(body) if body
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
env = JSON.parse(res.body)
raise "#{env["error"]["code"]}: #{env["error"]["message"]}" unless env["ok"]
env["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN";
function call(string $path, ?array $body = null, string $method = "POST") {
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . TOKEN,
"Content-Type: application/json",
],
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
$env = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($env["ok"])) {
throw new RuntimeException($env["error"]["code"] . ": " . $env["error"]["message"]);
}
return $env["data"];
}
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
var BASE = "https://api.skillsafe.ai/v1/app-api";
var TOKEN = "YOUR_TOKEN";
var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", TOKEN);
async Task<JsonElement> Call(string path, object? body = null)
{
var content = new StringContent(JsonSerializer.Serialize(body ?? new { }),
Encoding.UTF8, "application/json");
var res = await http.PostAsync(BASE + path, content);
var env = JsonDocument.Parse(await res.Content.ReadAsStringAsync()).RootElement;
if (!env.GetProperty("ok").GetBoolean())
throw new Exception(env.GetProperty("error").GetProperty("code").GetString());
return env.GetProperty("data");
}
2. A token
A guest token is minted per app and needs the slug in the body — an
X-App-Slug header returns 400. A guest can browse and estimate; running the metered pass needs a
signed-in personal token, which the token page will hand you along with a ready-made
shell export. Note that acl_read: "owner" scopes stored records to the calling subject, and every
/guest call mints a new subject — reuse one token across create and query or you
will read an empty collection.
curl -sS -X POST "$BASE/guest" \
-H "Content-Type: application/json" \
-d '{"slug":"subtitle-studio"}'
# -> { "ok": true, "data": { "token": "...", "subject_type": "guest" } }
import json, urllib.request
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/guest",
data=json.dumps({"slug": "subtitle-studio"}).encode(),
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req) as r:
TOKEN = json.load(r)["data"]["token"]
const res = await fetch("https://api.skillsafe.ai/v1/app-api/guest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "subtitle-studio" })
});
const { data } = await res.json();
const TOKEN = data.token;
body := bytes.NewBufferString(`{"slug":"subtitle-studio"}`)
res, _ := http.Post(base+"/guest", "application/json", body)
defer res.Body.Close()
var env struct {
Data struct{ Token string } `json:"data"`
}
json.NewDecoder(res.Body).Decode(&env)
guestToken := env.Data.Token
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(BASE + "/guest"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"subtitle-studio\"}"))
.build();
String envelope = HTTP.send(req, HttpResponse.BodyHandlers.ofString()).body();
// envelope.data.token
uri = URI("https://api.skillsafe.ai/v1/app-api/guest")
res = Net::HTTP.post(uri, JSON.dump({ slug: "subtitle-studio" }),
"Content-Type" => "application/json")
TOKEN = JSON.parse(res.body)["data"]["token"]
<?php
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/guest");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ["Content-Type: application/json"],
CURLOPT_POSTFIELDS => json_encode(["slug" => "subtitle-studio"]),
]);
$env = json_decode(curl_exec($ch), true);
$token = $env["data"]["token"];
var guestBody = new StringContent("{\"slug\":\"subtitle-studio\"}", Encoding.UTF8, "application/json");
var guestRes = await new HttpClient().PostAsync(
"https://api.skillsafe.ai/v1/app-api/guest", guestBody);
var guestEnv = JsonDocument.Parse(await guestRes.Content.ReadAsStringAsync()).RootElement;
var token = guestEnv.GetProperty("data").GetProperty("token").GetString();
3. Who am I, and what is the balance
GET /me is free and is what the app uses for its credit preflight: compare
credits against the min_credits that /estimate returns and refuse to
submit rather than collecting a 402 afterwards.
curl -sS "$BASE/me" -H "Authorization: Bearer $TOKEN"
# -> { "ok": true, "data": { "subject_type": "user", "credits": 250000 } }
me = call("/me", method="GET")
print(me["subject_type"], me["credits"])
const me = await call("/me", undefined, "GET");
console.log(me.subject_type, me.credits);
req, _ := http.NewRequest("GET", base+"/me", nil)
req.Header.Set("Authorization", "Bearer "+token)
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
HttpRequest me = HttpRequest.newBuilder()
.uri(URI.create(BASE + "/me"))
.header("Authorization", "Bearer " + TOKEN)
.GET()
.build();
System.out.println(HTTP.send(me, HttpResponse.BodyHandlers.ofString()).body());
me = call("/me", nil, :get)
puts me["subject_type"], me["credits"]
<?php
$me = call("/me", null, "GET");
echo $me["subject_type"], " ", $me["credits"], PHP_EOL;
var meReq = new HttpRequestMessage(HttpMethod.Get, BASE + "/me"); var meRes = await http.SendAsync(meReq); Console.WriteLine(await meRes.Content.ReadAsStringAsync());
4. The input this app accepts
Taken from buildInput() in app.js, not from intent. The facts object
is the whole client-side measurement and is what makes the model accountable, and
cues_for_review is the partition: the model is instructed to return exactly one verdict per
cue_id in that array and none for anything else, so an empty array yields an empty review. If you
are driving the API without running the browser engine, send whatever of facts you can compute
— but note that facts.corrections_already_applied is what stops the model undoing work the
rules already did, and facts.terms_absent_from_file is what stops it inventing an occurrence of a
term the file never contained.
{
"title": "LangChain course - lesson 1",
"terms": "LangChain, LangGraph, OpenAI, Agent, checkpointer",
"facts": {
"format": "srt | vtt",
"cue_count": 12,
"duration_seconds": 54.3,
"declared_terms": [ "LangChain", "LangGraph", "OpenAI" ],
"terms_absent_from_file": [ "Agent" ],
"close_match_repair_enabled": true,
"structural_errors": 0,
"structural_warnings": 0,
"corrections_already_applied": [
{ "cue_id": 1, "from": "Lang Chain", "to": "LangChain",
"kind": "terminology | close-match | identifier | phonetic" }
],
"variant_clusters": [
{ "majority": "LangChain", "majority_count": 7,
"runner_up": "Lang Chain", "runner_up_count": 2 }
],
"verdict_from_rules": "clean | corrected | review | structural | unparsed",
"cues_flagged_total": 6,
"cues_sent": 6
},
"cues_for_review": [
{
"cue_id": 2,
"start": "00:00:04,700",
"end": "00:00:08,900",
"text": "the cue text AFTER the engine's own corrections - this is what you correct",
"flagged_because": [ "why no rule could settle it" ]
}
],
"cues_not_sent": "",
"current_datetime": "2026-08-08T09:00:00Z",
"retry_note": "optional - present only on the app's one reformat retry"
}
start and end are context, never output. They are
sent so the model can judge whether a cue is a fragment of a longer sentence, and they are the one thing it must
never write back. Cue timings, cue numbering and the one-to-one cue correspondence are invariants of this
pipeline, re-proved against the original file before anything can be exported.5. The output contract
Taken from normalizeModel() and reconcile() in srtkit.js. The model
returns exactly one JSON object with no fence and no prose. An entry whose cue_id is not a
number is dropped and reported, and an unknown verdict is kept verbatim and failed by
name, so a malformed review is reported rather than quietly normalised into agreement.
{
"language": "Chinese with English technical terms",
"domain": "LangChain tutorial for developers",
"summary": "what the recogniser was getting wrong, in a sentence or two",
"cue_reviews": [
{
"cue_id": 2,
"verdict": "corrected | clean",
"corrected_text": "the complete corrected text of the cue, all of its lines, text only",
"errors": [
{ "from": "Luncheon", "to": "LangChain",
"kind": "phonetic | terminology | identifier | stutter" }
],
"note": "why, in one short sentence"
}
],
"glossary_additions": [ "string" ],
"unverified": [ "string" ]
}
What the app asserts about that reply
These are counted, not sampled, and the results are rendered next to the model's own words. Anything that fails is held back: that cue keeps the engine's version of its text.
| Check | Assertion | Fails when |
|---|---|---|
cue_partition | cue_reviews is a partition of cues_for_review: every cue_id reviewed exactly once. Missing, duplicated and off-contract ids are counted and reported separately, and an id that is not a cue in the file at all is reported differently again from one that exists but was never sent. | A cue is missing, reviewed twice, or was never sent for review. |
bad_verdict | verdict is exactly corrected or clean. | Anything else — the offending string is shown verbatim. |
no_change | A cue marked corrected actually differs from the text supplied. | The corrected text is byte-identical to the input, which is a clean verdict wearing the wrong label. |
structure_in_text | corrected_text contains no --> and no bare number line. | The reply rebuilt a cue block instead of returning cue text. |
claim_unfounded | Every errors[].from appears verbatim in the raw text the model was handed — not a normalised copy of it. | The reply claims to have replaced something that was never there. |
claim_unapplied | Every errors[].to appears verbatim in that cue's corrected_text. | The reply describes a fix it did not make. |
contradicts_engine | A declared term present in the supplied text is still present, spelled identically, in the reply. | The reply restyles or drops a spelling the user supplied. The engine wins; both are shown. |
line_break_changed | The corrected cue has the same number of lines as the one supplied. | Warns — the timing cannot move, so a re-wrap changes how the cue reads on screen. |
readability_regressed | Chars-per-second is recomputed after the edit and reported. | Warns only. Reading speed is deliberately not allowed to accept or reject a correction: it is a function of text length and cue duration, so it is derivable from what is already measured and is not independent evidence about a word. |
absent_terms_excluded | Terms the engine measured as absent from the file are excluded from every check. | Never — informational by construction, so the model is not blamed for a gap the browser already found. |
invariants | The exported file is re-parsed and compared to the original: same cue count, byte-identical timings, numbering preserved, nothing merged or split. | Any of those differ. Export is blocked outright rather than warned about. |
6. Estimate first — it is free
/estimate creates no job and charges nothing. hold_credits is what will be
reserved, not the price: it prices the full output cap, and the settled
charged_credits is usually far lower. If the balance sits between min_credits and
hold_credits the run still executes with a reduced cap and comes back
"truncated": true.
curl -sS -X POST "$BASE/estimate" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
--data @input.json
# -> { "ok": true, "data": { "model": "gpt-5.6-terra", "model_alias": "gpt-terra",
# "markup_bps": 1000, "hold_credits": 2900, "min_credits": 120 } }
est = call("/estimate", payload)
print(est["model"], est["model_alias"], est["markup_bps"])
if me["credits"] < est["min_credits"]:
raise SystemExit("top up before running")
const est = await call("/estimate", payload);
if (me.credits < est.min_credits) throw new Error("top up before running");
console.log(`reserving up to ${est.hold_credits} credits on ${est.model}`);
data, err := call("/estimate", payload)
if err != nil {
log.Fatal(err)
}
var est struct {
Model string `json:"model"`
HoldCredits int `json:"hold_credits"`
MinCredits int `json:"min_credits"`
}
json.Unmarshal(data, &est)
String est = call("/estimate", inputJson);
System.out.println(est); // model, model_alias, markup_bps, hold_credits, min_credits
est = call("/estimate", payload)
abort("top up before running") if me["credits"] < est["min_credits"]
puts "reserving up to #{est["hold_credits"]} on #{est["model"]}"
<?php
$est = call("/estimate", $payload);
if ($me["credits"] < $est["min_credits"]) {
exit("top up before running\n");
}
var est = await Call("/estimate", payload);
Console.WriteLine(est.GetProperty("model").GetString());
Console.WriteLine(est.GetProperty("hold_credits").GetInt32());
7. Run it — with an Idempotency-Key
Pass a key derived from the input plus an attempt counter. The app uses
subtitle-studio:<fnv1a-of-title-terms-switch-and-subtitles>:<length>:a<attempt>, and its one automatic reformat
retry reuses a key derived from the same input, so a malformed first reply can never double-bill. Do the same:
a network blip that makes you retry must not become a second charge.
curl -sS -X POST "$BASE/run" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: subtitle-studio:741933f4:809:a1" \
--data @input.json
# -> { "ok": true, "data": { "job_id": "job_...", "status": "queued" } }
curl -sS "$BASE/jobs/job_..." -H "Authorization: Bearer $TOKEN"
# poll until status is "succeeded" or "failed"
import time
req = urllib.request.Request(BASE + "/run", data=json.dumps(payload).encode(), method="POST")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", "subtitle-studio:741933f4:809:a1")
with urllib.request.urlopen(req) as r:
job = json.load(r)["data"]
while True:
j = call("/jobs/" + job["job_id"], method="GET")
if j["status"] in ("succeeded", "failed"):
break
time.sleep(1.5)
result = json.loads(j["output"]["output"])
print(len(result["cue_reviews"]), "cue verdicts")
const job = await fetch(`${BASE}/run`, {
method: "POST",
headers: {
"Authorization": `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": "subtitle-studio:741933f4:809:a1"
},
body: JSON.stringify(payload)
}).then(r => r.json()).then(e => e.data);
let j;
do {
await new Promise(r => setTimeout(r, 1500));
j = await call(`/jobs/${job.job_id}`, undefined, "GET");
} while (j.status !== "succeeded" && j.status !== "failed");
const result = JSON.parse(j.output.output);
b, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", base+"/run", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "subtitle-studio:741933f4:809:a1")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
// then poll GET /jobs/{job_id} until status is terminal
HttpRequest run = HttpRequest.newBuilder()
.uri(URI.create(BASE + "/run"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", "subtitle-studio:741933f4:809:a1")
.POST(HttpRequest.BodyPublishers.ofString(inputJson))
.build();
String job = HTTP.send(run, HttpResponse.BodyHandlers.ofString()).body();
// then poll GET /jobs/{job_id}
uri = URI(BASE + "/run")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = "subtitle-studio:741933f4:809:a1"
req.body = JSON.dump(payload)
job = JSON.parse(Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }.body)["data"]
loop do
j = call("/jobs/#{job["job_id"]}", nil, :get)
break if %w[succeeded failed].include?(j["status"])
sleep 1.5
end
<?php
$ch = curl_init(BASE . "/run");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . TOKEN,
"Content-Type: application/json",
"Idempotency-Key: subtitle-studio:741933f4:809:a1",
],
CURLOPT_POSTFIELDS => json_encode($payload),
]);
$job = json_decode(curl_exec($ch), true)["data"];
// then poll GET /jobs/{job_id}
var runMsg = new HttpRequestMessage(HttpMethod.Post, BASE + "/run")
{
Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json")
};
runMsg.Headers.Add("Idempotency-Key", "subtitle-studio:741933f4:809:a1");
var runRes = await http.SendAsync(runMsg);
// then poll GET /jobs/{job_id}
8. Streaming, if you want the progress
The SDK exposes runStream, and the app uses it to advance its staged progress card off real
signals — each field name appearing in the delta stream moves it on. Frame names arrive on the
event: line, and the payload on data:. The same
Idempotency-Key discipline applies.
curl -N -sS -X POST "$BASE/run-stream" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: subtitle-studio:741933f4:809:a1" \
--data @input.json
# event: job
# data: {"job_id":"job_..."}
# event: delta
# data: {"text":"{\"title\":\"Checkout single-page test"}
# event: done
# data: {"status":"succeeded","charged_credits":812,"truncated":false}
req = urllib.request.Request(BASE + "/run-stream", data=json.dumps(payload).encode())
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", "subtitle-studio:741933f4:809:a1")
raw, event = "", None
with urllib.request.urlopen(req) as r:
for line in r:
line = line.decode().rstrip("\n")
if line.startswith("event:"):
event = line[6:].strip()
elif line.startswith("data:") and event == "delta":
raw += json.loads(line[5:])["text"]
result = json.loads(raw)
const res = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: {
"Authorization": `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": "subtitle-studio:741933f4:809:a1"
},
body: JSON.stringify(payload)
});
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "", raw = "", event = null;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop();
for (const line of lines) {
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:") && event === "delta") raw += JSON.parse(line.slice(5)).text;
}
}
const result = JSON.parse(raw);
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "subtitle-studio:741933f4:809:a1")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
sc := bufio.NewScanner(res.Body)
var event, raw string
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event:"):
event = strings.TrimSpace(line[6:])
case strings.HasPrefix(line, "data:") && event == "delta":
var d struct{ Text string }
json.Unmarshal([]byte(line[5:]), &d)
raw += d.Text
}
}
HttpRequest stream = HttpRequest.newBuilder()
.uri(URI.create(BASE + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", "subtitle-studio:741933f4:809:a1")
.POST(HttpRequest.BodyPublishers.ofString(inputJson))
.build();
StringBuilder raw = new StringBuilder();
HTTP.send(stream, HttpResponse.BodyHandlers.ofLines())
.body()
.filter(l -> l.startsWith("data:"))
.forEach(l -> raw.append(l.substring(5)));
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = "subtitle-studio:741933f4:809:a1"
req.body = JSON.dump(payload)
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|
event = line[6..].strip if line.start_with?("event:")
raw << JSON.parse(line[5..])["text"] if line.start_with?("data:") && event == "delta"
end
end
end
end
<?php
$raw = "";
$event = null;
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . TOKEN,
"Content-Type: application/json",
"Idempotency-Key: subtitle-studio:741933f4:809:a1",
],
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$raw, &$event) {
foreach (explode("\n", $chunk) as $line) {
if (str_starts_with($line, "event:")) {
$event = trim(substr($line, 6));
} elseif (str_starts_with($line, "data:") && $event === "delta") {
$raw .= json_decode(substr($line, 5), true)["text"] ?? "";
}
}
return strlen($chunk);
},
]);
curl_exec($ch);
var streamMsg = new HttpRequestMessage(HttpMethod.Post, BASE + "/run-stream")
{
Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json")
};
streamMsg.Headers.Add("Idempotency-Key", "subtitle-studio:741933f4:809:a1");
var streamRes = await http.SendAsync(streamMsg, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await streamRes.Content.ReadAsStreamAsync());
var raw = new StringBuilder();
string? evt = null, line;
while ((line = await reader.ReadLineAsync()) != null)
{
if (line.StartsWith("event:")) evt = line[6..].Trim();
else if (line.StartsWith("data:") && evt == "delta")
raw.Append(JsonDocument.Parse(line[5..]).RootElement.GetProperty("text").GetString());
}
9. Saving and finding past passes
The app declares one collection, corrections, with acl_read: "owner" and
acl_write: "user". Record creation is POST
/collections/corrections/records. Every where entry must be an operator object
— the bare-value shorthand is rejected — and ordering uses a sort object;
order_by is silently ignored in favour of created_at desc.
// declared fields (queryable); the full document round-trips regardless
{ "name": "corrections", "acl_read": "owner", "acl_write": "user",
"fields": [
{ "name": "title", "type": "string" },
{ "name": "domain", "type": "string" },
{ "name": "terms_summary", "type": "string" },
{ "name": "summary", "type": "string" },
{ "name": "verdict", "type": "string" },
{ "name": "cue_count", "type": "number" },
{ "name": "engine_changes", "type": "number" },
{ "name": "ai_changes", "type": "number" },
{ "name": "check_fails", "type": "number" },
{ "name": "ran_at", "type": "timestamp" }
],
"embed": ["title", "domain", "terms_summary", "summary"] }
# create
curl -sS -X POST "$BASE/collections/corrections/records" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"title":"LangChain course - lesson 1","domain":"LangChain tutorial","terms_summary":"LangChain, LangGraph, OpenAI, checkpointer","verdict":"corrected","cue_count":12,"engine_changes":10,"ai_changes":6,"check_fails":0,"ran_at":"2026-08-08T09:00:00Z","summary":"the recogniser heard LangChain as Luncheon throughout and picked the drawing homophone for session"}'
# exact filter - about a tenth the cost of a similarity query
curl -sS -X POST "$BASE/collections/corrections/query" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"where":{"verdict":{"eq":"structural"}},"sort":{"field":"ran_at","dir":"desc"},"limit":20}'
# semantic search - 30 requests/min per IP, so debounce it
curl -sS -X POST "$BASE/collections/corrections/similar" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"text":"the lesson with the checkpointer terms","limit":8}'
rec = call("/collections/corrections/records", {
"title": "LangChain course - lesson 1",
"domain": "LangChain tutorial",
"terms_summary": "LangChain, LangGraph, OpenAI, checkpointer",
"verdict": "corrected",
"cue_count": 12,
"engine_changes": 10,
"ai_changes": 6,
"check_fails": 0,
"ran_at": "2026-08-08T09:00:00Z",
"summary": "the recogniser heard LangChain as Luncheon throughout",
})
page = call("/collections/corrections/query", {
"where": {"verdict": {"eq": "structural"}},
"sort": {"field": "ran_at", "dir": "desc"},
"limit": 20,
})
hits = call("/collections/corrections/similar",
{"text": "the lesson with the checkpointer terms", "limit": 8})
// the SDK's similar() resolves to the record ARRAY; query() to { records }.
// Accept either shape rather than trusting one.
const recordsOf = (r) => (Array.isArray(r) ? r : (r && r.records) || []);
await call("/collections/corrections/records", {
title: "LangChain course - lesson 1",
domain: "LangChain tutorial",
terms_summary: "LangChain, LangGraph, OpenAI, checkpointer",
verdict: "corrected",
cue_count: 12,
engine_changes: 10,
ai_changes: 6,
check_fails: 0,
ran_at: new Date().toISOString(),
summary: "the recogniser heard LangChain as Luncheon throughout"
});
const page = await call("/collections/corrections/query", {
where: { verdict: { eq: "structural" } },
sort: { field: "ran_at", dir: "desc" },
limit: 20
});
console.log(recordsOf(page).length);
rec := map[string]any{
"title": "LangChain course - lesson 1",
"domain": "LangChain tutorial",
"terms_summary": "LangChain, LangGraph, OpenAI, checkpointer",
"verdict": "corrected",
"cue_count": 12,
"engine_changes": 10,
"ai_changes": 6,
"check_fails": 0,
"ran_at": time.Now().UTC().Format(time.RFC3339),
}
call("/collections/corrections/records", rec)
call("/collections/corrections/query", map[string]any{
"where": map[string]any{"verdict": map[string]any{"eq": "structural"}},
"sort": map[string]any{"field": "ran_at", "dir": "desc"},
"limit": 20,
})
call("/collections/corrections/records",
"{\"title\":\"LangChain course - lesson 1\"," +
"\"verdict\":\"corrected\",\"cue_count\":12,\"ai_changes\":6," +
"\"ran_at\":\"2026-08-08T09:00:00Z\"}");
call("/collections/corrections/query",
"{\"where\":{\"verdict\":{\"eq\":\"structural\"}}," +
"\"sort\":{\"field\":\"ran_at\",\"dir\":\"desc\"},\"limit\":20}");
call("/collections/corrections/records", {
title: "LangChain course - lesson 1",
domain: "LangChain tutorial",
terms_summary: "LangChain, LangGraph, OpenAI, checkpointer",
verdict: "corrected",
cue_count: 12,
engine_changes: 10,
ai_changes: 6,
check_fails: 0,
ran_at: Time.now.utc.iso8601
})
call("/collections/corrections/query", {
where: { verdict: { eq: "structural" } },
sort: { field: "ran_at", dir: "desc" },
limit: 20
})
<?php
call("/collections/corrections/records", [
"title" => "LangChain course - lesson 1",
"domain" => "LangChain tutorial",
"terms_summary" => "LangChain, LangGraph, OpenAI, checkpointer",
"verdict" => "corrected",
"cue_count" => 12,
"engine_changes" => 10,
"ai_changes" => 6,
"check_fails" => 0,
"ran_at" => gmdate("c"),
]);
call("/collections/corrections/query", [
"where" => ["verdict" => ["eq" => "structural"]],
"sort" => ["field" => "ran_at", "dir" => "desc"],
"limit" => 20,
]);
await Call("/collections/corrections/records", new {
title = "LangChain course - lesson 1",
domain = "LangChain tutorial",
terms_summary = "LangChain, LangGraph, OpenAI, checkpointer",
verdict = "corrected",
cue_count = 12,
engine_changes = 10,
ai_changes = 6,
check_fails = 0,
ran_at = DateTime.UtcNow.ToString("o")
});
await Call("/collections/corrections/query", new {
where = new { verdict = new { eq = "structural" } },
sort = new { field = "ran_at", dir = "desc" },
limit = 20
});
Rules worth knowing before you design around this
- Vectors are never backfilled. Only records written after
embedwas declared are searchable, and widening the embed set later does not re-index old rows. - Indexing is asynchronous — a similarity query fired immediately after a write can lag by seconds.
- 64 KB per document. The app spends that budget in priority order and marks a record it had to trim, so a restored pass is honest about what it can no longer recompute.
- Prefer
wheretosimilarwhenever an exact match would do: it is roughly an order of magnitude cheaper and its rate limit is four times looser.
10. What this API cannot do
The free engine does not live behind an endpoint. The SRT and WebVTT parsing, the structural findings, the
reading-speed and line-length measurement, the terminology pass over every spacing and casing variant, the
close-match repair, the spoken-punctuation join, the unambiguous-homophone table, the variant-cluster
derivation, the corrected file, the word-level diff, the changes CSV, the measurement JSON and the invariant
re-validation are all computed in srtkit.js in the browser, with no network call and nothing
charged. If you want them server-side, the honest answer is to run that file — it is plain ES5, has no
dependencies, and exposes parse, analyze, parseTerms,
renderFile, validateCorrected, diffWords,
normalizeModel, reconcile, mergeModel, summarize,
changesCsv, measurementJson and renderReport on
window.SrtKit.
It also cannot move a timestamp for you, and that is deliberate. Retiming is a different job with different inputs (a waveform, a shot list, a frame rate) and mixing it into a correction pass is how a file that was merely mis-transcribed becomes a file that is out of sync as well.