Skip to content

JSON output

For a program that runs sb (an editor plugin, a script), seven commands print JSON instead of text when given --json: sb version, status, info, doctor, add, ask and run. This page is the contract: what is printed, what will not change under you, and how a failure looks. Reading output for people? The command reference describes the text.

The rules

  • One object per line, on stdout, and nothing else. Every command prints exactly one line of JSON, except sb run, which prints one line per event (JSON Lines, below). The output is ASCII (non-ASCII characters are \uXXXX escapes), and each line is flushed as soon as it is written, so a reader sees sb run progress as it happens.
  • Human text goes to stderr. A warning (warning: git commit failed), the update notice and the Interrupted: line are on stderr and never on stdout. A reader that parses stdout needs no other filter.
  • Exit codes do not change. They are the same as without --json: 0 success, 1 an error, 130 and 143 after Ctrl-C and SIGTERM (see Stopping a command). sb doctor --json exits 1 when a check is FAIL, and still prints its object.
  • Every object carries "contract", the contract number (see below).
  • No traceback. A failure is an error object (below) with exit 1, not Python's stack trace.
sb status --json
{"contract": 1, "vault": "/Users/you/Documents/Obsidian/esbi", "queue": {"queued": 2, "processing": 0, "done": 5, "failed": 1}, "failed": [{"target": "https://example.com/", "attempts": 3, "error": "ExtractError: No readable article content found at https://example.com/"}], "retrying": []}

The contract number

contract is 1. A program should read it, and read it first: the way to ask which contract an installed sb speaks, and which version it is, is

sb version --json
{"contract": 1, "version": "0.4.1"}

The rule for changes:

  • Additive changes keep the number. A new field in an object, a new command with --json, a new event name in sb run, a new value of stopped_by, a new error code: contract stays 1. So a reader must ignore fields, events and codes it does not know, and never fail on them.
  • A breaking change bumps the number. Removing or renaming a field, changing its type or meaning, or changing the shape of an object, makes contract 2. A reader that sees a number it does not know should stop and say that this sb is newer than the reader.

sb version --json is available from the first release that has --json; a reader that gets No such option: --json from it is talking to an older sb that has no contract at all.

Errors

A failure is one object and exit 1:

{"contract": 1, "error": "config file not found: /tmp/typo.toml", "code": "config_not_found"}

error is a one-line message for a person (its wording may change); code is for the program and will not change under contract 1.

code Means Raised by
config_not_found No config file: a --config path (or ESBI_CONFIG) that does not exist, or none in the default places every command that reads the config
bad_config The config is invalid (a misspelled key, a wrong type, an unknown provider in model), or a model cannot be built from it status, info, add, ask, run
vault_not_found [paths].vault points at a folder that does not exist (a typo, an unmounted drive). Nothing is created. sb doctor and the commands that only edit the config do not raise it every command that opens the vault
bad_target An argument of sb add is not an http(s) URL or an existing .pdf, .md or (with OCR on) image file. Nothing was queued add
os_error A file or disk problem: a read-only or full vault, a missing permission, a vault's state database that cannot be used, or no home folder. error is the one line a person would read (Permission denied: /path) every command that touches files
llm_error The model could not be reached or failed while answering ask
run_in_progress Another run holds the lock. Exit 0, as without --json: skipping is not a failure run

A new code can appear later (additive): treat an unknown code as a generic failure.

The commands

sb version

sb version --json prints {"contract": 1, "version": "0.4.1"}. --check is not honoured: it prints no network answer.

Field Type Meaning
contract number The contract number
version text The installed version, as sb version prints it

sb status

Field Type Meaning
vault text Absolute path of the vault
queue object queued, processing, done, failed: how many sources are in each state (numbers, 0 when none). A source still being retried counts as queued
failed list The sources parked after their last attempt: {"target", "attempts", "error"} (text, number, text)
retrying list The queued sources that have failed before: {"target", "attempts", "error"}

sb info

Field Type Meaning
version text The installed version
config text Absolute path of the config file in use
vault text Absolute path of the vault
viewer text obsidian or none
language text The notes' language code (en, es)
models object The model per task that is configured, as provider/name text: summarize, and each of synthesize, private, ocr, embed that is set. ask is always there: it is the [llm.ask] model, else the summarize one
sends_text_out object For each task in models (same keys), true when the text of a source leaves this machine for that model (an API, claude-cli, codex-cli, a remote Ollama or LM Studio server, an Ollama -cloud model) and false when it stays (Ollama or LM Studio on this machine). A model that cannot be built from the config counts as true: a client uses this to warn, so the doubtful case errs on the safe side. It is the same rule that keeps email away from a model: a client can show it instead of guessing from the provider name. Added in 0.3.0 without changing contract (1)
nightly object installed (true or false: the launchd job is loaded; false where there is no launchd) and time (text, HH:MM, from [run].nightly_time)

sb doctor

Field Type Meaning
ok true or false true when no check is FAIL (warnings do not count). The exit code is 1 when this is false
checks list One entry per line of the text output, in the same order

Each check is {"level", "name", "text", "fix"}, all text: level is ok, WARN or FAIL; name is the label of the text output (config, version, vault, model summarize, ocr, ocr memory, network...); text the message; fix what to do, or the empty text when there is nothing to fix. A broken config is still an object, with a FAIL config check, not an error object. The checks are listed in sb doctor.

sb add

Nothing is fetched: it queues, like the text mode.

Field Type Meaning
queued number Sources newly put in the queue
already_known number Sources the queue already had
in_wiki list Links not queued because the wiki already has a note for them: {"target", "title"} (the link as given, the note's title)

If any target is invalid, nothing is queued and the output is the error object with bad_target.

sb ask

Field Type Meaning
answer text The answer, or the refusal ("I find nothing about this in the wiki.", in the notes' language)
cited list of text Titles of the pages the answer rests on, without the [[ ]]. Empty when the answer was refused. Titles only: the pages' text is never in the output
refused true or false true when no valid citation backed an answer
saved text or null With --save and a grounded answer, the path of the saved page relative to the vault (wiki/syntheses/...md); else null
unsupported list The sentences the pages do not back: [{"sentence": text, "missing": [text, ...]}], where missing are the numbers and words of the sentence found in none of the pages. In answer those sentences carry a ⚠ and the text ends with a line naming the missing words. Empty when everything is backed, when [run].check_answers = false, or when the pages are in another language than the notes. When most sentences fail the answer is refused (refused: true) and the list still says which. Added without changing contract (1): a reader that does not know it ignores it

A refused answer is a normal object (refused: true, cited: []) with exit 0. Only a failure to ask at all (the model is down, the config is wrong) is an error object, with llm_error or bad_config.

sb run

sb run --json prints JSON Lines: one object per event, in this order, each with "contract" and "event". The human progress lines (... chunk 1 of 5, + Note, the summary) are dropped from stdout; warnings stay on stderr.

{"contract": 1, "event": "started", "queued": 2}
{"contract": 1, "event": "source_started", "target": "https://en.wikipedia.org/wiki/Zettelkasten", "title": "https://en.wikipedia.org/wiki/Zettelkasten"}
{"contract": 1, "event": "step", "target": "https://en.wikipedia.org/wiki/Zettelkasten", "name": "chunk", "index": 1, "total": 3}
{"contract": 1, "event": "step", "target": "https://en.wikipedia.org/wiki/Zettelkasten", "name": "synthesis"}
{"contract": 1, "event": "source_done", "target": "https://en.wikipedia.org/wiki/Zettelkasten", "title": "Zettelkasten", "note": "wiki/sources/Zettelkasten.md"}
{"contract": 1, "event": "source_started", "target": "https://example.com/old", "title": "https://example.com/old"}
{"contract": 1, "event": "source_failed", "target": "https://example.com/old", "attempt": 1, "max_attempts": 3, "parked": false, "reason": "ExtractError: No readable article content found at https://example.com/old"}
{"contract": 1, "event": "finished", "ingested": 1, "failed": 1, "skipped": 0, "tokens": 20826, "stopped_by": null}
event Fields When
started queued (number) The queue is about to be drained; how many sources wait. First line
source_started target (text), title (text) A source is being read. title is its label (a clip's title, a file name) or else the target
step target, name (text), and for chunk and merging section also index and total (numbers) Progress inside a source. name is one of chunk (chunk index of total), merging section (section index of total, only for a source of more than [run].max_chunks chunks), synthesis, detailed summary or connections. A new name can appear later: ignore the ones you do not know
source_done target, title, note (text) The source is in the wiki. note is the note's path relative to the vault. Also sent for a source the wiki already had (it is counted as skipped in finished), with the existing note
source_failed target, attempt, max_attempts (numbers), parked (true or false), reason (text) A source failed. parked is true when it is out of attempts and waits for sb retry; false when it will be tried again on a later run
finished ingested, failed, skipped, tokens (numbers), stopped_by (text or null) Always the last line, whatever ended the run

stopped_by says why the run ended:

Value Means Exit
null The queue was drained 0
max_sources --limit or [run].max_sources_per_run was reached; the rest stays queued 0
token_budget [run].max_tokens_per_run was reached; the rest stays queued 0
usd_budget [run].max_usd_per_run was reached (an estimate from [bench.prices]); the rest stays queued, with no attempt counted 0
llm_unavailable The model could not be reached. Nothing is counted against the sources 1
interrupted Ctrl-C or SIGTERM. The source being read went back to the queue with no attempt counted 130 or 143
not_due --if-due and no scheduled batch is due (today's nightly run already happened and nothing is queued or waiting in inbox/, the latest scheduled batch stopped for an outage or an interruption, or [run].max_batches_per_day scheduled batches already ran). This is the only line: no started 0

Other cases:

  • Nothing queued still prints started (with queued: 0) and finished.
  • Another run holds the lock prints one error object with run_in_progress and exits 0: no started, no finished.
  • A config that cannot be used (including a model that cannot be built) prints one error object (config_not_found or bad_config) and exits 1, before any event.
  • A source that fails is not an error object: it is a source_failed event and the run goes on. Only the model being unreachable stops the run, and then the last line is a finished with llm_unavailable.

A reader that wants a status bar needs only started.queued and a count of source_done plus source_failed; one that wants the result reads finished. Read until finished (or an error object), then stop: the process may still be working on the lint and the index, which print nothing on stdout.