Skip to content

Configuration reference

All settings live in one TOML file, config.toml. sb init writes a first version; this page lists every key, its default, and a complete example for the common setups. For the choice between models, read Choose how the notes are written first.

Where the config file is

The first file that exists in this list is used:

  1. --config PATH, given to the command.
  2. The ESBI_CONFIG environment variable.
  3. ~/.config/esbi-cli/config.toml (where sb init writes it).
  4. A config.toml next to an editable install of the project (so a uv tool install --editable copy works from any folder).
  5. ~/.config/secondbrain/config.toml, the place used before the project was renamed (the old SECONDBRAIN_CONFIG variable also still works, after ESBI_CONFIG).

A config.toml in the current folder is never read: a cloned repository could otherwise point the model or the mailbox at someone else's server.

sb info prints which file is used. A file you name on purpose must exist: if the --config path (or the file ESBI_CONFIG points at) is missing, the command stops with error: config file not found: PATH and exit code 1. Only the default places (3 to 5) are tried one after the other without a message.

sb info
config=~/.config/esbi-cli/config.toml
vault=~/Documents/Obsidian/esbi
viewer=none
language=en
email=disabled

Edit the file with any editor. There is nothing to reload: the next command reads it again. Two things need a second step: a changed nightly_time needs sb schedule install again, and a changed [llm.embed] model rebuilds the page index on the next sb ask or sb run.

The file is checked whenever it is read, in every section. A misspelled key, an unknown section, or a value of the wrong type stops the command with one error: line (exit code 1) that names the section, the key and what is valid, and sb doctor shows it as FAIL config:

error: [llm.summarize] has an unknown key 'num_ctxx' (did you mean 'num_ctx'?). Valid keys: model, base_url, api_key_env, num_ctx, temperature, timeout_seconds, fallback, max_tokens
error: [run].max_chunks must be a whole number, got 'ten'

Keys written by early versions that nothing ever read are accepted and ignored: daily_cost_cap_usd in [run], and the [llm.link] and [llm.lint] sections.

A complete file

This is the starter file (config.example.toml) with every key at its default. Lines starting with # are optional sections.

[paths]
vault = "~/Documents/Obsidian/esbi"
# legacy_vault = "~/Notes/old-links"

[notes]
language = "en"
viewer = "obsidian"

[run]
max_source_chars = 4000
chunk_chars = 8000
max_chunks = 16
max_calls_per_source = 60
ocr_max_pages = 10
find_connections = true
rewrite_questions = false
check_answers = true
nightly_time = "03:00"
max_sources_per_run = 20
max_batches_per_day = 6
max_consolidations_per_run = 0
max_tokens_per_run = 300000
# max_usd_per_run = 2.0
flag_contradictions = false

[llm.summarize]
model = "ollama/llama3.2:latest"
num_ctx = 8192

# [llm.synthesize]
# [llm.private]
# [llm.ocr]
# [llm.ask]
# [llm.embed]

[bench]
models = ["ollama/llama3.2:latest"]
cases = 3

[bench.prices]

# [update]
# check = true

# [network]
# use_environment_proxy = false

[email]
enabled = false
imap_host = "imap.gmail.com"
mailbox = "esbi-cli"
# user = "you@gmail.com"
# follow_links = true
# follow_links_max = 3

[paths]

Key Default Meaning
vault required The vault folder. ~ is expanded. Without it every command fails with config.toml needs [paths].vault. The folder must exist: if it does not (a typo, an unmounted drive), every command that opens the vault stops with error: vault not found: PATH (--json code vault_not_found) and creates nothing. sb init makes it, and sb doctor, sb init and the commands that only edit the config (sb ocr ..., sb email set-password) work without it
legacy_vault none An older folder with Links/ and PDFs/, read only by sb import-legacy

[notes]

Key Default Meaning
language "en" The language the notes are written in: "en" (English) or "es" (Spanish). It sets the instruction to the model, the section headings (Executive summary, Resumen ejecutivo...), the daily index, index.md, the lint and benchmark reports and the answers and refusals of sb ask. Quotes stay in the source's language. Any other value is an error that lists the supported ones. A missing key means "en". See Language of the notes
viewer "obsidian" "obsidian" opens notes with obsidian:// links; "none" makes sb today print a path and stops sb doctor asking you to open the folder in Obsidian. Anything else is an error

Language of the notes

[notes].language is the one setting that decides the language of everything the program writes into the wiki. The command line itself (messages, --help, errors) is always English.

Value Language Example heading
"en" English (the default) ## Executive summary
"es" Spanish ## Resumen ejecutivo

What follows the setting: the notes' section headings and labels (## From [[Source]] on concept pages), the daily index and the Home.md block, index.md, the lint report, the sb bench report, and what sb ask answers and refuses with. The instruction to the model says which language to write in; if the model answers in the wrong language, the worker asks once more and then keeps the answer with a warning.

Changing the setting is safe: the worker never rewrites a note by itself. Notes already written keep their language, and every reader (privacy filters, sb ask, read ticks, sb reingest) recognises the headings of both languages, so a vault can hold both. To rebuild the old notes in the new language, run sb reingest --all (see Manage the queue). Your SCHEMA.md is never overwritten; its Language paragraph says which language the notes are in, and the setting wins if they disagree.

sb init --language en|es writes the setting and the vault's SCHEMA.md and index.md in that language. To add a language, see Add a language.

[run]

Key Default Meaning
max_source_chars 4000 A source up to this many characters is read by the model in one go; a longer one is read in chunks (see How a note is made)
chunk_chars 8000 Size of each chunk of a long source
max_chunks 16 How many notes the plan and the summary are written from. A source with more chunks than this is read in full: every chunk gets its notes, then the notes are merged into sections of 5 (level by level) until at most max_chunks remain. Sampling is gone. A source with at most this many chunks is read exactly as before. See Long sources
max_calls_per_source 60 A hard cap on the model calls for one source, retries included; at least 12 (a smaller value is an error when the config is read). Calls are kept back for merging what was read and for the plan, the summary and the connections, so when the cap stops the reading early the note says which part was not read (a warning callout at the top, and a warning in the run). A source of 40 chunks takes about 55 calls; raise it for books (a source needing more than about 45 chunks is read only as far as the cap allows)
ocr_max_pages 10 A scanned PDF is read (OCR) up to this many pages; the run says when it stopped short
find_connections true One extra model call per source that relates the new note to pages you already have, with the reason. false skips it (faster, no "Connections to your wiki" section)
rewrite_questions false sb ask first asks the model for search words in the notes' language and in English (one small extra call). Finds notes whose words differ from the question's. Measured on 16 questions: right page in the top 6 for 88% without, 94% with
check_answers true sb ask checks each sentence of an answer against the pages the model was given and cited: a ⚠ after a sentence whose numbers, proper names or key words are not in them, a footer naming what was not found, and a refusal when most sentences fail. No model call. It compares vocabulary, not meaning, and was measured only on Spanish with llama3.2; set false to switch it off (for another language or model you do not trust it with, or to see the unchecked answer). See Ask your wiki
nightly_time "03:00" When the nightly job runs, HH:MM in 24 hours. Re-run sb schedule install after changing it. A bad value is rejected when the config is read
max_sources_per_run 20 Sources per run (sb run --limit overrides it)
max_batches_per_day 6 How many scheduled batches the job runs in a day: a whole number of at least 1 (anything else is an error when the config is read). It counts the scheduled runs (sb run --if-due) since the last nightly_time, the nightly run included, so with the defaults the night's run and up to five hourly batches drain the queue and then stop until the next night. The hourly tick runs the next batch whenever sources are queued, whatever ended the last one (max_sources_per_run, max_tokens_per_run, max_usd_per_run, or an empty queue that filled again), so this cap is what bounds a day's work and spend. A run that stopped for a model outage or an interruption does not count, and neither does a manual sb run. Before this key, a large backlog was drained one batch per hour all day with no limit. See Run it every night
max_consolidations_per_run 0 How many concept and entity pages one run may give a consolidated ## Summary by itself (after the sources, for the pages it just wrote that reached three sources, or two more than at their last summary). 0 is off, and that is the default on purpose: a 3B model such as llama3.2 writes summaries that read well but sometimes state relations the sections do not say, so read a few first with sb consolidate (manual, never capped) and turn this on (for example 5) together with a stronger [llm.synthesize] model. See Consolidated summaries
max_tokens_per_run 300000 A run stops once the models have used this many tokens; what is left stays queued
max_usd_per_run unset (no cap) A number of USD, more than 0 (0 or a negative value is an error when the config is read). A run stops once its estimated cost reaches it; what is left stays queued, with no attempt counted, and the run is recorded as usd_budget (also the stopped_by of sb run --json). The estimate is the tokens each model used times its price in [bench.prices] (USD per million tokens, the table sb bench uses). Only models that send text out count: a model on this machine costs 0, and so do claude-cli/ and codex-cli/, which are flat-rate subscriptions. A model with no price counts as 0, so with no [bench.prices] entry the cap never stops anything. With a fallback its tokens are priced at the dearer of the two models. The check happens between sources, so the source being read when the cap is reached finishes first
flag_contradictions false When true, the model may flag contradictions between a new source and existing pages: they become notes in wiki/review/ and a [!warning] on the page. Off because small models flag tenuous ones; turn it on with a stronger model

[llm.<task>]

One section per task. Each task can use a different model.

Section Used for If absent
[llm.summarize] Required. Takes notes on each chunk of a long source; writes the whole note when there is no synthesize Every command that needs a model fails, saying there is no [llm.summarize] section
[llm.synthesize] Writes the core note, the abstract and the connections (a few calls per source). Use a stronger model here: reading is cheap, synthesis is where quality is made summarize does it
[llm.ask] sb ask, question rewriting, sb eval --rewrite/--answers summarize
[llm.private] The only model that reads email. Must run on this machine Emails are refused while a cloud model is set
[llm.ocr] Reads images and scanned PDFs. Must be an Ollama vision model on this machine. Optional: sb ocr enable and sb ocr disable write it for you Images are not read; scanned PDFs are rejected
[llm.embed] Embeddings for search by meaning (hybrid search). Only ollama/<model> Search is by keyword only

Any [llm.<task>] section accepts these keys:

Key Default Meaning
model required <provider>/<name>, see the providers below
base_url the provider's usual address Where the server is. For Ollama and LM Studio, an address that is not this machine is reported by sb doctor as sending text out, and email is kept off it
api_key_env OPENAI_API_KEY / ANTHROPIC_API_KEY The name of the environment variable that holds the key. Keys are never written in the file
num_ctx 8192 The context window, in tokens. Ollama only
temperature 0.2 Sampling temperature. Not used by claude-cli or codex-cli
timeout_seconds 300 Seconds to wait for one answer. A timeout fails that chunk or source; the run goes on. The old name timeout is a deprecated alias: it still works, and timeout_seconds wins when both are set. The one-line notice (notice: [llm.summarize] timeout is now timeout_seconds, on stderr) is printed once a day per config file, not on every command (the time is kept in notices.json in the cache folder, ~/.cache/esbi-cli or $XDG_CACHE_HOME/esbi-cli; if that folder cannot be written you only get the notice more often). sb doctor always prints it
max_tokens none A cap on the length of an answer. Ollama only. Set it on the model that reads chunks: small models sometimes loop on one input, and the cap turns a 5-minute timeout into seconds
enabled true Only in [llm.ocr] (any other section rejects it as an unknown key). false switches reading images and scanned PDFs off and keeps the model choice, so turning it back on needs no typing: sb ocr disable and sb ocr enable do the same from the command line
fallback none A second <provider>/<name> used when the main model cannot be reached (a spent usage window, a lost login, a server down). A timeout does not trigger it. sb doctor checks it too

Providers

Provider Talks to Notes
ollama/<name> Ollama's API, default http://localhost:11434 Nothing leaves your machine unless base_url points elsewhere or the name ends in -cloud (or :cloud)
lmstudio/<identifier> LM Studio's local server, default http://localhost:1234/v1 No key. The identifier is what lms ls shows
openai/<name> Any OpenAI-compatible endpoint Set base_url for OpenRouter and similar. Key from OPENAI_API_KEY or api_key_env. Text goes to that provider
anthropic/<name> Anthropic's Messages API Key from ANTHROPIC_API_KEY or api_key_env. Text goes to Anthropic
claude-cli/<model> The official claude command, paid by your Claude Pro or Max subscription claude-cli/default (your account's model) or claude-cli/sonnet. No key in any file; log in once with claude auth login. max_tokens, num_ctx and temperature do not apply
codex-cli/<model> The official codex command, paid by your ChatGPT plan codex-cli/default (the model your Codex is set to) or a model name Codex knows. No key in any file; log in once with codex login. A fallback works as with the other providers. max_tokens, num_ctx and temperature do not apply. See Using a ChatGPT plan

A model that sends text away (openai, anthropic, claude-cli, codex-cli, and a remote Ollama or LM Studio) is never shown email. The rules are in Safety and privacy.

Using a subscription

Log in once with claude auth login (Claude Code must be installed), then point a task at it. Three things to know. (1) Every call counts against your plan's usage windows (a session limit and a weekly one). When you hit one, the call fails with the tool's message, the run stops like any other model outage, nothing is lost, and the next run resumes. (2) The tool is started with its tools, settings, plugins and MCP servers switched off: otherwise one call carried about 450,000 tokens of your own setup (about 700 with them off). (3) How Anthropic counts non-interactive use against a plan has changed before; check your plan's terms.

Using a ChatGPT plan

Install OpenAI's codex tool, log in once with codex login, then point a task at codex-cli/default (or codex-cli/<model>). sb doctor checks that codex is installed and logged in. Four things to know. (1) Every call counts against your plan's usage windows; when you hit one the call fails with the tool's message, the run stops like any other model outage, nothing is lost, and the next run resumes (a local fallback keeps it going meanwhile). (2) The call runs read-only, in an empty scratch folder, with the tool's own tools, rules and your Codex settings switched off: the model gets the prompt and nothing else of yours. (3) The text of each source goes to OpenAI, so the worker treats it like any model that sends text out: email is never shown to it (keep a local [llm.private]). (4) It was measured once, against codex-cli 0.146.0 on a ChatGPT plan: the strict schema the worker sends was accepted, a call takes 9 to 12 seconds, and every call carries about 8,500 tokens of the tool's own overhead. That suits the nightly run, a few calls per source; it is a poor fit for a model that makes many small calls, so keep the reading model ([llm.summarize]) local and put only the writing one ([llm.synthesize]) here. It has not been exercised more widely than that.

[llm.synthesize]
model = "codex-cli/default"
fallback = "ollama/llama3.2:latest"
timeout_seconds = 600

[email]

Key Default Meaning
enabled false Fetch mail during sb run
imap_host "imap.gmail.com" The IMAP server
mailbox "esbi-cli" The mailbox name to read; for Gmail, the label
user none The mailbox address. The app password is not here: it lives in the macOS Keychain under the service esbi-cli-imap (off macOS, which is not supported, the system keyring: see Email where there is no keyring)
follow_links false Queue the http(s) links found in the text of a mail, so the pages they lead to become notes too. See Links in a mail
follow_links_max 3 At most this many links are queued per mail, from 1 to 10 (anything else is an error when the config is read)

sb setup email or sb email configure writes this block for you, and keeps follow_links and follow_links_max if they were already there.

With follow_links = true, the links in the text of each newly saved mail are queued, never fetched while the mail is captured. The nightly run then reads them like any queued source, with its limits, and treats what it finds as email: only [llm.private] (a model on this machine) writes the note, the note has kind: email, and a cloud model is never shown it, exactly like the mail it came from. Tracking redirects, unsubscribe, preferences, view-in-browser and account-action links (sign in, confirm, reset password, activate...), and pictures, are skipped; a link already queued or already a note is not queued again. Only links that show as text in the mail are found: a link hidden behind a button or anchor text in an HTML-only mail is not.

It is off by default because opening a link is a visit: the site that sent the mail, or whoever put the link there, sees a request from your machine (through your IP address, and at the time the nightly run reaches it). Fetching still goes through the same public-address guard as any link (see Safety and privacy). Turn it on only for mail you trust to link to pages you would open anyway.

The queue shows these as ordinary sources; the capture line says how many: Mail: 3 saved, 0 duplicates, 0 failed, 2 images saved, 5 links queued.

[network]

Key Default Meaning
use_environment_proxy false Fetching a link (and the update check) ignores HTTP_PROXY, HTTPS_PROXY, ALL_PROXY and NO_PROXY. true honours them, for a network whose only way out is a proxy

Why off: the check that a link points at a public address describes where the request goes only when this machine connects itself. Behind a proxy it is the proxy that resolves the name and connects, so the check no longer says where the request ended up, and a proxy inside your network can be made to reach internal hosts through a link in an email. With the setting on, the address checks are the proxy's, not esbi-cli's, and sb doctor shows WARN network for as long as it is set. Nothing but this setting turns the proxy on: it is read from the config of the command that is running, and every command starts with it off.

[bench]

Used by sb bench, and prices is also the price list for [run].max_usd_per_run; nothing here changes which model the worker uses.

Key Default Meaning
models none The candidate models, ["ollama/qwen3:4b", ...]
cases 3 Cases per task
prices none The [bench.prices] table: USD per million tokens (input and output blended) per model, for the cost estimate. Local models cost 0. It is also what [run].max_usd_per_run multiplies a run's tokens by
[bench.prices]
"anthropic/claude-sonnet-5-5" = 9.0

[update]

Key Default Meaning
check true Once a day, ask GitHub whether a newer release exists, and print one line after a command run in a terminal when there is one. false turns the check off. sb update and sb version --check always ask, because you asked them to

What is sent: one anonymous HTTPS GET to api.github.com (the releases API), whose request carries a User-Agent naming esbi-cli and its version. No vault data, no identifier, nothing from the config. Nothing is installed by itself. The nightly job (sb run) never checks. See Update esbi-cli.

The setting is read from the config the command was run with (--config, ESBI_CONFIG or the default places), since 0.2.1. To be sure the check is off whatever the config, set ESBI_NO_UPDATE_CHECK=1.

Environment variables

Variable Used for
ESBI_CONFIG The config file, when --config is not given
ESBI_NO_UPDATE_CHECK 1 turns off the automatic update check, whatever [update].check says
ANTHROPIC_API_KEY The key for anthropic/... models (or the variable named by api_key_env)
OPENAI_API_KEY The key for openai/... models (or the variable named by api_key_env)
HTTP_PROXY, HTTPS_PROXY, ALL_PROXY, NO_PROXY Ignored when fetching web pages, unless [network] use_environment_proxy = true; see [network]
UV_PROJECT_ENVIRONMENT Only for development from a checkout: keeps the virtual environment out of an iCloud-synced folder

Setups

Each block is the whole [llm.*] part of the file for that setup. Everything else stays as it is.

All local (Ollama)

[llm.summarize]
model = "ollama/llama3.2:latest"
timeout_seconds = 300
num_ctx = 8192
max_tokens = 1200

This is what sb init --model local writes. Nothing leaves your machine. Needs ollama pull llama3.2.

All local (LM Studio)

[llm.summarize]
model = "lmstudio/qwen2.5-7b-instruct"
timeout_seconds = 300

Use the identifier lms ls shows. sb init --model local --runtime lmstudio --local-model NAME writes it. Add base_url = "http://localhost:1234/v1" only if the server runs on another port.

Subscription, with a local fallback

[llm.summarize]
model = "claude-cli/default"
fallback = "ollama/llama3.2:latest"
timeout_seconds = 300

[llm.synthesize]
model = "claude-cli/default"
fallback = "ollama/llama3.2:latest"
timeout_seconds = 600

[llm.ask]
model = "claude-cli/default"
fallback = "ollama/llama3.2:latest"
timeout_seconds = 600

[llm.private]
model = "ollama/llama3.2:latest"

This is what sb init --model subscription writes. A good split is a local reading model (many small calls) and only the writing model on the subscription (a few calls per source): a 3-chunk source took about one minute that way, against several locally.

API key

[llm.summarize]
model = "anthropic/claude-sonnet-5-5"
fallback = "ollama/llama3.2:latest"
timeout_seconds = 300

[llm.ask]
model = "anthropic/claude-sonnet-5-5"
fallback = "ollama/llama3.2:latest"
timeout_seconds = 600

[llm.private]
model = "ollama/llama3.2:latest"

Set the key in the environment (export ANTHROPIC_API_KEY=... in your shell profile; for the nightly job the variable must be visible to launchd, which does not read your shell profile, so prefer the subscription or a local model for unattended runs). For an OpenAI-compatible service use model = "openai/gpt-4o-mini", and base_url = "https://openrouter.ai/api/v1" with api_key_env = "OPENROUTER_API_KEY" for OpenRouter.

Ollama on another machine

[llm.summarize]
model = "ollama/qwen3:8b"
base_url = "http://192.168.1.50:11434"
timeout_seconds = 900

The notes' text goes to that machine, so email is kept off it (and [llm.private] must stay on this one). sb init --base-url writes the same and warns; sb doctor keeps a WARN server summarize line.

Different models for different jobs

[llm.summarize]            # reads each chunk: many cheap calls
model = "ollama/llama3.2:latest"
max_tokens = 1200

[llm.synthesize]           # writes the note: a few calls
model = "claude-cli/default"
fallback = "ollama/llama3.2:latest"

[llm.ask]
model = "ollama/qwen3:4b"

Images and scanned PDFs (OCR)

[llm.ocr]
model = "ollama/qwen3-vl:2b-instruct"
timeout_seconds = 600

sb init --ocr or sb ocr enable writes it (and sb ocr enable --pull downloads the model). Run ollama pull qwen3-vl:2b-instruct once (1.9 GB). The model must run on this machine; another provider, another host, a -cloud model or a fallback is refused. To stop reading images without losing the model choice, set enabled = false in the same section (or run sb ocr disable):

[llm.ocr]
model = "ollama/qwen3-vl:2b-instruct"
enabled = false

qwen3-vl:2b-instruct is the recommended model and fits an 8 GB Mac. deepseek-ocr:3b (6.7 GB) is a choice for a machine with 16 GB of memory or more; on 8 GB it was measured unusable (about 100 times slower). See Choosing the OCR model.

Search by meaning (embeddings)

[llm.embed]
model = "ollama/nomic-embed-text"
timeout_seconds = 60

Run ollama pull nomic-embed-text once (about 270 MB). Pages are embedded once, by section, in .esbi/index.sqlite3, and again only when they change. With the section absent, or the embedder unreachable, search is exactly the keyword search. Email is never sent to an embedder that is not on this machine. Details and measurements: Hybrid retrieval.

Without Obsidian

[notes]
viewer = "none"

See Use it without Obsidian.