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\uXXXXescapes), and each line is flushed as soon as it is written, so a reader seessb runprogress as it happens. - Human text goes to stderr. A warning (
warning: git commit failed), the update notice and theInterrupted: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:0success,1an error,130and143after Ctrl-C andSIGTERM(see Stopping a command).sb doctor --jsonexits1when a check isFAIL, 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 neweventname insb run, a new value ofstopped_by, a new errorcode:contractstays1. 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
contract2. A reader that sees a number it does not know should stop and say that thissbis 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(withqueued: 0) andfinished. - Another run holds the lock prints one error object with
run_in_progressand exits0: nostarted, nofinished. - A config that cannot be used (including a model that cannot be built) prints one error object (
config_not_foundorbad_config) and exits1, before any event. - A source that fails is not an error object: it is a
source_failedevent and the run goes on. Only the model being unreachable stops the run, and then the last line is afinishedwithllm_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.