# The JSON protocol

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

Page: https://scribiz.com/docs/cli/json-protocol

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.

```bash
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`.

| `type` | When | Fields |
| --- | --- | --- |
| `hello` | First line, always | `protocol` (always 1 today), `engine` and `cli` (versions), `pid`, `command`, `capabilities`, `mediaPrep` |
| `start` | The run begins | `input`, `mode` |
| `plan` | After the source is resolved, and again if the plan changes | `tiers`, `reason`, `estimate` (`minutes`, `usdLow`, `usdHigh`, `durationSeconds`, `pricedAt`, `breakdown`) |
| `stage` | A step starts, ends, is skipped or fails | `stage`, `status` (`start`, `done`, `skipped`, `failed`), `detail`, `ms` |
| `progress` | A step reports how far it is | `stage`, `fraction` (0 to 1, or `null`), `done`, `total`, `unit`, `etaSeconds` |
| `chunk` | One piece of audio changes state | `index`, `total`, `status`, `attempt`, `startSeconds`, `durationSeconds`, `words` |
| `retry` | A request failed and will be tried again | `stage`, `attempt`, `maxAttempts`, `delayMs`, `code`, `message` |
| `warning` | Something is approximate or incomplete | `code`, `message`, `data` |
| `partial` | A growing transcript is available | `coveredSeconds`, `words` |
| `usage` | A model call finished | `delta`, `total` |
| `cache` | A stored layer was looked up | `layer` (`meta`, `captions`, `transcript`, `visual`, `synthesis`), `hit` |
| `done` | The work finished | `summary` (`words`, `durationSeconds`, `usd`, `ms`) |
| `heartbeat` | Every 5 seconds from `hello` to the last line | `t` (milliseconds since `hello`) |
| `device_code` | `scribiz login --json`, once the code is issued | `userCode`, `verificationUri`, `verificationUriComplete`, `expiresIn` (seconds), `interval` (seconds) |
| `item` | Before each input of a batch | `index`, `input`, `total` |
| `summary` | Last line of a batch | `total`, `succeeded`, `failed`, `stopped` |
| `result` | The work is ready | `kind`, then the fields for that kind |
| `error` | The run failed | `error` |

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](https://scribiz.com/docs/api/context-object.md#warnings).

`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.

| `kind` | Command | Fields |
| --- | --- | --- |
| `context` | `scribiz <input>`, `scribiz context <input>` | `context`, or `path` and `bytes` |
| `ask` | `scribiz ask` | `answer`, `citations` |
| `login` | `scribiz login` | `saved`, then `configPath` when it was saved, or `key` with `--no-config`. Also `keyId` and `user` (`email`) when known |
| `whoami` | `scribiz whoami` | `me` |
| `doctor`, `jit` | `scribiz doctor` | `report`, or `jit` |
| `format` | `scribiz format` | `format` and `text`, or `format`, `path` and `bytes` |
| `setup`, `logout` | `scribiz setup`, `logout` | `configPath` and `verified`, or `removed` (a list: `Scribiz login`, `Gemini key`) |

A `context` result has two shapes.

```json title="With --out-file"
{"type":"result","bytes":5009,"kind":"context","path":"/Users/you/result.json"}
```

```json title="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":"you@example.com"}}
```

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:

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

`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](https://scribiz.com/docs/api/errors.md#error-codes).

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"}}
```

| `op` | `args` | `result` |
| --- | --- | --- |
| `probe` | `path` | `durationSeconds`, `hasAudio`, `hasVideo`, and `width`, `height`, `fps` when known |
| `extractWav` | `path`, `out` | `durationSeconds`. Write the whole audio as 16 kHz mono 16-bit PCM WAV to `out`, all tracks mixed. |
| `encodeChunk` | `wav`, `startSeconds`, `durationSeconds`, `out` | `path`, `mime`. Optional. Encode that slice into a compact file such as AAC. Answer `UNSUPPORTED` and the CLI uploads WAV slices. |
| `makeVisualProxy` | `path`, `out`, `maxShortSide`, `fps`, `includeAudio` | `durationSeconds`, `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

```ts title="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.
