Skip to content
Try it free

CLI

The JSON protocol

The newline-delimited JSON the CLI prints with --json, so you can run it from your own tool.

View as Markdown
On this page

With --json, the CLI prints one JSON object per line on standard output and nothing else. Logs go to standard error. This is the contract a host application uses to run the CLI. It is versioned, and it is the way to put Scribiz inside your own tool.

Terminal
scribiz speech.mp3 --json --out-file result.json

Rules:

  • Every line is one complete JSON object in UTF-8, ended by \n. Parse line by line. The CLI escapes U+2028, U+2029 and U+0085 inside strings, so any line splitter agrees where a line ends.
  • Standard output is flushed after every line.
  • Every object has a string type. Key order inside an object is not defined.
  • Ignore message types and fields you do not know. That is how the protocol grows without breaking you.
  • --json implies --no-input. The CLI never prompts. A missing credential is an error line and exit code 3.
  • Secrets arrive through the environment (SCRIBIZ_API_KEY, GEMINI_API_KEY, or SCRIBIZ_CONFIG_DIR to point at a saved credential), never as arguments.
  • If your side closes the pipe, the CLI stops and exits 0 without a stack trace.
  • The process is one job. It starts, does the work, writes one last line (result or error) and exits.

A whole run

Each run starts with hello and ends with result or error. This is a real run on speech.mp3, a 12 second recording, with the repeated progress lines and the file path shortened:

NDJSON
{"capabilities":["captions","audio","visual","ask","format","login","cancel","out-file"],"cli":"0.1.1","engine":"0.1.0","mediaPrep":["ffmpeg"],"pid":14185,"protocol":1,"type":"hello","command":"transcribe"}{"input":"/Users/you/speech.mp3","mode":"auto","type":"start","runId":"a5912da2","t":0}{"stage":"resolve","status":"start","type":"stage","runId":"a5912da2","t":0}{"detail":"local:8ee9f0a250ac5a75f42640c01fdbfc97","ms":38,"stage":"resolve","status":"done","type":"stage","runId":"a5912da2","t":38}{"estimate":{"breakdown":{"audio":{"usdHigh":0.001396,"usdLow":0.001032},"synthesis":{"usdHigh":0.008546,"usdLow":0.001708}},"durationSeconds":12.120726,"minutes":0.2,"pricedAt":"2026-10-04","tiers":["audio","synthesis"],"usdHigh":0.009942,"usdLow":0.00274},"reason":"Listening to the audio.","tiers":["audio","synthesis"],"type":"plan","runId":"a5912da2","t":40}{"stage":"extract","status":"start","type":"stage","runId":"a5912da2","t":42}{"fraction":1,"stage":"extract","type":"progress","runId":"a5912da2","t":103}{"ms":63,"stage":"extract","status":"done","type":"stage","runId":"a5912da2","t":105}{"detail":"1 chunk, 9 s of speech","ms":15,"stage":"chunk","status":"done","type":"stage","runId":"a5912da2","t":120}{"attempt":1,"durationSeconds":12.121,"index":0,"startSeconds":0,"status":"uploading","total":1,"type":"chunk","runId":"a5912da2","t":167}{"attempt":1,"durationSeconds":12.121,"index":0,"startSeconds":0,"status":"transcribing","total":1,"type":"chunk","runId":"a5912da2","t":1739}{"delta":{"audioSeconds":12.16,"model":"gemini-3.5-transcribe","tokens":{"audioIn":304,"in":305,"out":40,"thought":0,"videoIn":0},"usd":0.00109,"videoSeconds":0,"stage":"transcribe"},"total":{"audioSeconds":12.16,"tokens":{"audioIn":304,"in":305,"out":40,"thought":0,"videoIn":0},"usdEstimate":0.00109,"videoSeconds":0},"type":"usage","runId":"a5912da2","t":3470}{"coveredSeconds":12.121,"type":"partial","words":31,"runId":"a5912da2","t":3472}{"detail":"0 of 1 chunks need repair","ms":1,"stage":"verify","status":"done","type":"stage","runId":"a5912da2","t":3473}{"t":5002,"type":"heartbeat"}{"ms":2615,"stage":"synthesize","status":"done","type":"stage","runId":"a5912da2","t":6094}{"summary":{"durationSeconds":12.120726,"ms":6095,"usd":0.0032702499999999997,"words":31},"type":"done","runId":"a5912da2","t":6095}{"type":"result","bytes":5009,"kind":"context","path":"/Users/you/result.json"}

A bare scribiz <input> --json builds the whole Context, so a run ends with the summary step, as above.

Lines you will see

Every event from the engine carries runId (a short id for the run) and t (milliseconds since the run started). The lines the CLI adds itself (hello, device_code, heartbeat, item, summary, result and the media requests) do not carry runId.

typeWhenFields
helloFirst line, alwaysprotocol (always 1 today), engine and cli (versions), pid, command, capabilities, mediaPrep
startThe run beginsinput, mode
planAfter the source is resolved, and again if the plan changestiers, reason, estimate (minutes, usdLow, usdHigh, durationSeconds, pricedAt, breakdown)
stageA step starts, ends, is skipped or failsstage, status (start, done, skipped, failed), detail, ms
progressA step reports how far it isstage, fraction (0 to 1, or null), done, total, unit, etaSeconds
chunkOne piece of audio changes stateindex, total, status, attempt, startSeconds, durationSeconds, words
retryA request failed and will be tried againstage, attempt, maxAttempts, delayMs, code, message
warningSomething is approximate or incompletecode, message, data
partialA growing transcript is availablecoveredSeconds, words
usageA model call finisheddelta, total
cacheA stored layer was looked uplayer (meta, captions, transcript, visual, synthesis), hit
doneThe work finishedsummary (words, durationSeconds, usd, ms)
heartbeatEvery 5 seconds from hello to the last linet (milliseconds since hello)
device_codescribiz login --json, once the code is issueduserCode, verificationUri, verificationUriComplete, expiresIn (seconds), interval (seconds)
itemBefore each input of a batchindex, input, total
summaryLast line of a batchtotal, succeeded, failed, stopped
resultThe work is readykind, then the fields for that kind
errorThe run failederror

A run has exactly one start, then exactly one done or one error. The stages are resolve, download, extract, chunk, upload, transcribe, verify, repair, proofread, visual-prep, visual, synthesize and render. chunk statuses are queued, uploading, transcribing, verifying, retry, repairing, done and failed. In plan, tiers lists which of captions, audio, visual and synthesis will run, and it is empty when everything is stored. Warning codes are listed in The Context object.

progress, chunk and partial are for display. Do not derive results from them. The result is the Context.

A heartbeat tells you the process is alive while a long step runs. Three missed heartbeats, about 15 seconds with no line of any kind, is a reasonable point to call a run stalled.

The result

result is the last line of a successful run. Its kind says what it holds.

kindCommandFields
contextscribiz <input>, scribiz context <input>context, or path and bytes
askscribiz askanswer, citations
loginscribiz loginsaved, then configPath when it was saved, or key with --no-config. Also keyId and user (email) when known
whoamiscribiz whoamime
doctor, jitscribiz doctorreport, or jit
formatscribiz formatformat and text, or format, path and bytes
setup, logoutscribiz setup, logoutconfigPath and verified, or removed (a list: Scribiz login, Gemini key)

A context result has two shapes.

With --out-file
{"type":"result","bytes":5009,"kind":"context","path":"/Users/you/result.json"}
Without it, up to 256 KB
{"type":"result","kind":"context","context":{"schema":1,"id":"local:8ee9f0a250ac5a75f42640c01fdbfc97"}}

With --out-file, the CLI writes the Context there (mode 0600, atomically) and prints the path and size. Without it, a single input's Context is inline when it is at most 262,144 bytes. A larger one is written to a file under the system temp folder and announced by path, and the host deletes it. A two hour Context with word times is about 1 MB, so always pass --out-file when the input might be long.

A batch is a folder, or more than one input. Each result carries index. In a batch each Context is always written to a file: <name>.json in the --out-file folder, or, without --out-file, next to its source file (in the current folder for a link). The result line carries path and bytes, never context. Pass --out-file with a folder to keep the files out of your media folder.

The file holds the same Context as scribiz context --format json, with word times when the transcript has them. scribiz format and scribiz ask read it.

Signing in

scribiz login --json waits for a person to confirm a code. It prints hello, then device_code, then waits. Show userCode and open verificationUriComplete: the person confirms the code at https://scribiz.com/login/device while signed in to Scribiz. The CLI never opens a browser itself in this mode, and it never prompts.

NDJSON
{"capabilities":["captions","audio","visual","ask","format","login","cancel","out-file"],"cli":"0.1.1","engine":"0.1.0","mediaPrep":["ffmpeg"],"pid":14185,"protocol":1,"type":"hello","command":"login"}{"expiresIn":600,"interval":5,"type":"device_code","userCode":"ABCD1234","verificationUri":"https://scribiz.com/login/device","verificationUriComplete":"https://scribiz.com/login/device?user_code=ABCD1234"}{"type":"result","kind":"login","saved":true,"configPath":"/Users/you/.scribiz/config.json","keyId":"key_T09PW9orWbQL","user":{"email":"[email protected]"}}

The code lasts expiresIn seconds. A code that expires or is denied ends the run with an error line (AUTH, exit code 3). With --no-config nothing is saved: the result carries the key instead of configPath, so your tool can keep it. Treat it like any secret.

Errors

JSON
{"type":"error","error":{"code":"AUTH","message":"No credential found","retryable":false,"scope":"account","stage":"resolve","hint":"Sign in with `scribiz login` (a free account), or use your own Gemini key with `scribiz setup`. In a script, set SCRIBIZ_API_KEY or GEMINI_API_KEY."}}

An error line is the last line of a failed run. The engine's own error event is that line, so a run never prints two. Errors the CLI raises itself, such as a bad argument or a missing credential, have no runId or t. The process then exits with a code that matches the error:

CodeMeaning
0Done
1Another failure
2Wrong usage. The error code is INPUT_UNSUPPORTED.
3Credentials or minutes
4The source
5The service
6A tool is missing
130Canceled

error.retryable says whether trying again can help. error.scope is account for credential and minute problems, which stop a whole queue, or job for a problem with this one input. error.status and error.retryAfterMs carry the service's HTTP status and wait when there was one. Treat message and hint as untrusted text: they can hold part of a video's title or an upstream message. Every error code is described in Errors and limits.

In a batch, an error ends that item, not the process. The last line is a summary, with stopped: true when an account problem ended the remaining items. The exit code is the first failure's.

Cancel

Send SIGINT, SIGTERM or SIGHUP, or write a command to the CLI's standard input:

JSON
{"cmd":"cancel"}

The CLI kills its child processes, deletes uploaded files and its run folder, prints an error with the code CANCELLED, and exits with 130.

JSON
{"type":"error","error":{"code":"CANCELLED","message":"Cancelled","retryable":false,"scope":"job","stage":"upload","hint":"The run was cancelled."},"runId":"dfc3f1f6","t":1465}

A second signal, or a cancel that has not finished after 10 seconds, exits at once with 130 without cleaning up. Closing standard input does not cancel a run, except when you answer media requests (see below), where it means you are gone.

Let the host prepare media

An app that already knows how to read media, such as one built on AVFoundation, can do the media work and leave everything else to the CLI. Start it with --media-prep stdio (or stdio,ffmpeg to fall back to ffmpeg). Both need --json. The CLI then writes request lines and waits for responses on standard input.

JSON
{"type":"request","id":1,"op":"probe","args":{"path":"/Users/you/talk.mov"}}

Answer with the same id. Several requests can be in flight at once, and answers can come in any order:

JSON
{"type":"response","id":1,"ok":true,"result":{"durationSeconds":252.4,"hasAudio":true,"hasVideo":true}}

or fail it:

JSON
{"type":"response","id":1,"ok":false,"error":{"code":"UNSUPPORTED","message":"Cannot open this container"}}
opargsresult
probepathdurationSeconds, hasAudio, hasVideo, and width, height, fps when known
extractWavpath, outdurationSeconds. Write the whole audio as 16 kHz mono 16-bit PCM WAV to out, all tracks mixed.
encodeChunkwav, startSeconds, durationSeconds, outpath, mime. Optional. Encode that slice into a compact file such as AAC. Answer UNSUPPORTED and the CLI uploads WAV slices.
makeVisualProxypath, out, maxShortSide, fps, includeAudiodurationSeconds, bytes. A low-resolution H.264 MP4. The original video is never uploaded.

While a long request runs, send {"type":"request-progress","id":2,"fraction":0.4}. It also restarts the request's timer. A request that goes quiet fails the run with TIMEOUT: after 60 seconds for probe, 120 for encodeChunk, and 10 minutes for extractWav and makeVisualProxy. The CLI then sends {"type":"request-cancel","id":2}, and you should stop that work and not answer.

The error codes of a response are UNSUPPORTED and FAILED. UNSUPPORTED means "not my job". With stdio,ffmpeg, it makes the CLI do that step with ffmpeg instead, and without ffmpeg the run stops with INPUT_UNSUPPORTED and a hint to install it. FAILED never falls back: for probe, extractWav and makeVisualProxy the run fails with INPUT_CORRUPT.

Whatever prepares the media, the results are identical: silence detection, cutting, merging and speaker mapping always run in the CLI on the audio.

Read it from code

TypeScript (Node or Bun)
import { spawn } from 'node:child_process'import { createInterface } from 'node:readline'const child = spawn('scribiz', ['https://youtu.be/jNQXAC9IVRw', '--json', '--out-file', 'result.json'], {  stdio: ['pipe', 'pipe', 'inherit'],})for await (const line of createInterface({ input: child.stdout })) {  const msg = JSON.parse(line)  if (msg.type === 'hello' && msg.protocol !== 1) {    child.stdin.write('{"cmd":"cancel"}\n')    throw new Error(`Unsupported protocol ${msg.protocol}`)  }  if (msg.type === 'progress') {    console.log(msg.stage, msg.fraction)  }  if (msg.type === 'result') {    console.log('Context written to', msg.path)  }  if (msg.type === 'error') {    console.error(msg.error.code, msg.error.message)  }}// To cancel: child.stdin.write('{"cmd":"cancel"}\n')

Schema

scribiz --json-schema prints one JSON document with a JSON Schema (draft 2020-12) for the Context, the progress events, the error object and every message on this page. Generate your types from it instead of writing them by hand, and decode a message by its type.

Checked against the Scribiz build on 2026-10-05.

Loading the index