Skip to content

Configuration

Lambo reads a lambo.toml file. It looks for the file in this order: the --config path, then the LAMBO_CONFIG environment variable, then ./lambo.toml. An environment variable always wins over the file for any key it sets.

The file has [store], [embedder] and [daemon] sections, an optional [serve] section for multi-session serving, an optional [web] section for the read-only window’s views, an optional [fusion] section for recall fusion, an optional [selection] section for redundancy-aware selection, plus a small number of top-level process settings. It chooses which compiled backends this process runs, the cadence the background work runs at, and the canonization policy the serving verbs run them under.

# Top-level keys must come BEFORE the first [table] header, or TOML scopes them
# into that table and the unknown-key check refuses the file.
promotion_policy = "Solo" # optional; the default remains Swarm
[store]
kind = "sqlite"
path = "./lambo.db"
[embedder]
kind = "bge_m3"
dim = 1024
url = "http://127.0.0.1:8080"

Recall defaults, flush timing, canonization thresholds, and scoring weights are not lambo.toml keys. They are library settings you pass when you build a Memory in Rust, so putting one in the file stops the process rather than tuning anything. Six settings are exceptions, and none of them is a threshold: promotion_policy and canonization_supersede below, and the four cadence keys in Daemon cadence. promotion_policy is a selector — it sets no number. But do not read that as “the numbers stay the same”: Solo replaces the ladder rather than re-tuning it, and the store-evidence gates stop deciding anything. See Library API for what each policy actually measures. [fusion] is one more: it chooses how recall combines its legs, and its numbers are the fusion’s own, not canonization’s. See Recall fusion. [selection] is another, a switch with no number of its own: see Redundancy-aware selection.

$ lambo --config lambo.toml provision
lambo provision: failed to resolve store: config: lambo.toml: unknown field `default_top_k`, expected one of `store`, `embedder`, `daemon`, `promotion_policy`, `serve` (line 1, column 1)

The refusal names the line and column but never quotes the line itself, so a misspelled key next to a secret (a DSN with a password in it, say) does not print the secret into your logs. A value under the wrong key is not quoted back either: an unknown [store] kind or [embedder] kind lists the accepted kinds with (value not shown), a wrong-typed value reads invalid type: string (value not shown), expected ... (numbers are hidden the same way), and an unknown key is named only while it looks like a key (letters, digits, _ and -, up to 40 characters), so a DSN pasted as a quoted key is not printed. An unknown promotion_policy is quoted only when it is a short word, so a typo stays visible.

promotion_policy is an optional selector for how canonization promotes. Omit it to preserve the existing Swarm behavior. Set it to Solo for a single writer whose recurring evidence should be eligible for promotion.

Accepted values are Swarm and Solo, matched after trimming and without regard to case, so solo and Solo are the same thing — the same leniency store.kind and embedder.kind already have. Anything else fails at startup and names both what you wrote and the valid set. There is no silent fallback to the default.

There is no lambo serve --promotion-policy flag. Set it in the file or in LAMBO_PROMOTION_POLICY; no serve flag in Lambo duplicates a lambo.toml key, and every backend and policy selector is chosen the same way.

Solo does not lower the Canonical bar, and since 0.6.0 it cannot skip it either. A concept becomes a Solo Candidate by a recurrence score over its own supporting evidence (the days it was derived or depended on; edits to it do not count) rather than by Swarm’s survival and dependence floors, so under Solo you do not need to lower [daemon] gc_interval to get Candidates. Every later hop needs the score’s band and the store evidence Swarm needs: dependents from at least three other interactions with enough spread for Venerable, then more than five dependents and a clear re-promotion cooldown for Canonical. On lambo serve-web’s inspect panel the four-gate breakdown is still replaced by a line naming the policy, with the re-promotion cooldown shown under both policies; the panel’s admission explanation lists every gate the live policy reads, with one reason per gate that fails. See Library API for the score itself.

lambo demo is out of scope, but it is not unaffected. It runs a scripted swarm demonstration on its own compiled-in config, so it ignores the value: promotion_policy = "Solo" changes nothing about what the demo does. It still validates it, because the file is parsed before any command decides what to keep from it — which is also why an unparseable policy stops even the four commands that never resolve an embedder (provision, saints, inspect, stats), unlike a degenerate [daemon] cadence.

$ LAMBO_PROMOTION_POLICY=Bogus lambo --config lambo.toml demo
lambo demo: failed to build backends: config: LAMBO_PROMOTION_POLICY: unknown promotion policy "Bogus" (expected Swarm | Solo)

[daemon] is the same bargain with a narrower scope: the demo discards the cadence you set and still refuses a degenerate one, but only the commands that resolve an embedder refuse it at all.

canonization_supersede turns on evidence-driven supersession (0.6.0). It is a top-level boolean, like promotion_policy a selector rather than a threshold, and it defaults to false, so a file without it behaves exactly as before:

canonization_supersede = true # optional; top-level, before the first [table]

With it on, each canonization cycle first demotes a Canonical concept to ordinary memory when a concept that contradicts it (written with against) has since earned its own structural support: dependents from at least three other interactions, newer than the Canonical concept’s latest eligible evidence, with Stage 2’s spread. A contradiction with no newer support never demotes anything, and the contradicting concept is not promoted by it: it climbs on its own evidence. The bar is deliberately uneven: the contradicting side must clear Stage 2 again with newer support, while one new piece of support for the Canonical concept, from anyone but the agent that declared the contradiction, puts it ahead again.

The demoted concept enters the usual re-promotion cooldown, and a concept inside its cooldown cannot supersede another. With the setting on, Stage 3 also holds at Venerable any concept a contradicting concept would supersede at once: a superseded one climbing back, and also one that was never Canonical, since promoting it would only demote it on the next cycle. It waits there until it earns support newer than the contradicting side’s. When two concepts contradict each other and each qualifies against the other, only the one with the newest counted support may supersede or hold the other, so they cannot hold each other forever. lambo inspect shows it (held: contradicted by <id> with newer evidence) and names a concept’s latest demotion and why; recall marks a superseded concept with a ⤓ Superseded at ... by ... line. No new numbers: it reuses Stage 2’s bars, the age floor and the cooldown. A value that is not true or false stops the process at startup, as does the key under a table.

[fusion] (#173) chooses how recall combines its three query legs (keyword, vector, and the concepts of the three most recent interactions) into a hit’s query relevance, the w_query half of its score. It is optional, and a file without it ranks exactly as before.

[fusion]
kind = "rrf" # optional; the default is "max"
k = 3.0 # optional; RRF only
KeyTypeDefaultNotes
kindstring"max""max" takes the highest raw leg score. "rrf" is reciprocal rank fusion.
knumber3.0RRF’s constant. Larger values flatten the gap between ranks.
keyword_weightnumber1.0The keyword leg’s RRF weight.
vector_weightnumber1.0The vector leg’s RRF weight.
recent_weightnumber0.3The recent leg’s RRF weight.

max. The legs score on different scales: keyword matching is BM25, which is unbounded and routinely above 1; vector similarity is a cosine, at most 1; a recent concept scores a flat 0.35 (0.63 under the calibrated EmbeddingGemma 2 contract, see [embedder] calibration). The highest of the three wins, so in practice a keyword match outranks any vector match.

rrf. Each leg ranks its own members (score descending, the usual tie-break by canonical key), the recent leg by recency (the newest interaction’s concepts share rank 1). A hit’s relevance is Σ weight / (k + rank) over the legs it is in, divided by the most the legs that ran could give (rank 1 in each; the keyword leg counts whenever the query has text, even when nothing matched), so it lies between 0 and 1, and on a text query a recent concept alone does not score as a perfect match. The recent leg’s score is still recorded, but no longer ranks anything.

Why max stays the default. Measured on the evaluation set (fixtures/eval/) through the whole recall pipeline, k chosen on the dev split and held_out used only to confirm (evidence/issue-173-fusion/):

nDCG@10 devhit@1 devnDCG@10 held-outhit@1 held-out
BGE-M3, max0.8480.7280.8650.785
BGE-M3, rrf, k = 30.8930.7840.8870.785
EmbeddingGemma 2, max0.8480.7280.8650.785
EmbeddingGemma 2, rrf, k = 30.8890.7680.8810.760

RRF ranks better overall, but not everywhere, and the gain is below the +0.05 nDCG@10 that #173 set as the bar for a new default. On the held-out split exact-identifier queries drop (nDCG@10 0.948 to 0.899 under BGE-M3, 0.881 under EmbeddingGemma 2) and contrast queries drop (0.897 to 0.859 and 0.872). Telling a query with no answer from one with an answer by its top score does not get consistently better or worse: the chance that an answerable query’s top score beats a no-answer query’s falls on dev (0.775 to 0.682 under BGE-M3) and rises on held_out (0.673 to 0.717). Neither kind separates them well enough to gate on. The literature’s k = 60 is much worse here (nDCG@10 0.698 on dev): it puts a leg’s first and tenth hit within about 0.06 of each other, so the daemon’s share of the score decides the order.

k and the weights must be finite and at least 0, and "rrf" needs at least one weight above 0; anything else, or an unknown key, stops the process at startup. With serve --ledger, every recall hit found by a query leg records its rank in each leg and both fused values (fusion on the hit), whichever kind ranked, so one can be compared against the other on live traffic.

[selection] (#181) decides whether recall skips a hit that repeats one it already took. A reader sees the first two or three hits, so two copies of one fact can take two of those slots: a semantic match writes its own node beside the one it matched, and a store written before embedding coverage can hold the same text twice (an Entity and an Observation). It is optional and off by default, and a file without it, or with it off, recalls exactly as before.

[selection]
skip_redundant = true # optional; the default is false
similar_signals = false # optional; the default is false
KeyTypeDefaultNotes
skip_redundantbooleanfalseSkip a member redundant with a hit already taken; the next distinct member fills its slot. On its own, only identical text counts as redundant.
similar_signalsbooleanfalseAlso count a Semantic edge, or vectors at or above the merge threshold, as redundant. Needs skip_redundant = true.

What counts as redundant, checked cheapest first:

  • the same text, once spacing, Unicode composition and invisible characters are set aside, whatever the two concept types (nothing else is erased, so a different case, identifier, number, path, punctuation or word order is different text: src/Foo.rs is not src/foo.rs). This is the only signal by default.

With similar_signals = true, two more:

  • a Semantic edge between the two;
  • vectors at or above the session’s merge threshold (semantic_match_threshold, or the calibrated or labelled-pairs threshold that replaces it, see [embedder] calibration), when both carry a vector. A concept without a vector is never judged by this one. lambo recall, serve-web and lambo session verify use the same threshold as a serve: with [embedder] related_pairs, the threshold a writer derived from that file and cached in the store (matched by the file’s SHA-256); while no writer has measured the current file (or the canary is off, or the file cannot be read), the contract’s calibrated threshold, or the configured one, as a writer falls back.

Never redundant: a pair one of which contradicts the other (against, in either direction), or one of which is suspected of replacing the other (#198, see suspected_supersession_line); two texts that differ in an identifier (a snake_case, CamelCase or all-caps word, a path, version or file name, or seven or more hex digits; case counts) or in a digit of any script, that negate a different number of times (not, never, don't, avoid, unless, non-, …), where one has a word and the other its opposite (enable and disable, safe and unsafe), or whose shared words come in another order (warn replaces verify and verify replaces warn). Never skipped: a hit forced into the result (hot-list conditions, a contradicted Canonical or Venerable concept and its contradicting concept, a kept reservation holder), and a hit that carries a warning line of its own (a hot-list or contradiction line, a stale citation, a supersession or possibly-superseded line, a reservation), because the copy kept may not carry it. Canonical concepts still come first.

Only the first min(20, 4 × top_k) hits are checked, each against at most 20 hits already taken; past that, hits fill the remaining slots as before. A dependency question answered by graph traversal lists the anchor’s dependents as before; the setting does not filter it. When nothing in that window is redundant, the result is byte-identical to the rule being off. top_k and max_tokens then apply as usual.

What the reader sees. The kept hit names what was skipped in its label, after its id: (score 0.42, id 1a2b3c4d, also 5e6f7a8b), at most three ids and then +N more. Each id is an inspect focus. These items count against max_tokens, so under a tight budget a later hit can fall out of the rendered context. The skipped members, each with the signal that found it (same_content, semantic_edge, cosine), are skipped_redundant on the hit in lambo_recall’s structuredContent, the web portal’s /api/recall and the --ledger recall line; that key is absent on every other hit. In a layered recall, what a merged layer copy skipped is also_skipped_redundant on the hit (each item names its session).

Layered recall applies each session’s own setting inside that session’s recall, where the concepts’ edges are; across layers, the existing deduplication still keeps the nearest tier’s copy, and the hits a merged copy’s session skipped for it follow it (also <id> in <session>). lambo session verify replays the setting and counts a skipped member as deduplicated, not lost.

Why it is off by default. On the evaluation set under BGE-M3 (evidence/issue-181/), with copies and rewordings injected, identical-text skipping raised the distinct memories in a 500-token context at top_k = 3 from 1.44 to 2.05 (dev) and 1.45 to 2.03 (held-out), and changed nothing without redundancy, with declared contradictions, or with related but different facts. Turn it on where duplicates are common.

Why similar_signals is off by default. Against identical-text skipping alone, the Semantic-edge and cosine signals added 3 answer facets over the 8 cells (split, top_k, budget) with injected copies, and lost 42 over the 8 cells with labelled related-but-different facts. Every wrong skip came from five such pairs that the write path had merged with a Semantic edge (two names swapped, a default moved, an exception inverted, a value given in words), which the text guard of the time did not see. The stricter guard now rejects all five on that set, and the signals then add nothing there, but they stay off until measured on real stores, where sibling concepts of one write sit closer than these did. Declaring a contradiction with against protects a pair mainly because the write path then refuses to merge it (no Semantic edge), not through recall’s Contradicts veto, which then only stops the cosine signal. Turn it on only where near-copies are common and related facts are declared contradictory.

skip_redundant and similar_signals must be true or false, and similar_signals = true needs skip_redundant = true; anything else, or an unknown key, stops the process at startup. It is a table of its own, not a key under [recall], which configures the recall tier.

[store] selects where the graph is persisted.

KeyTypeDefaultNotes
kindstringmemorymemory, sqlite, cockroach, or postgres.
pathstringnoneThe SQLite file, used when kind is sqlite.
dsnstringnoneThe CockroachDB or PostgreSQL connection string, used when kind is cockroach or postgres. Prefer the environment variable so the secret stays out of the file.

kind also accepts a few spellings: mem and ram for memory, crdb for cockroach, pg for postgres, and sqlite3 for sqlite. "postgres" and "pg" are PostgreSQL, not CockroachDB.

The in-memory store is scoped to one process. Many agents can share it through a single lambo serve, because they all talk to that one process. Nothing outside that process can read it, and it does not survive a restart. Use sqlite, cockroach, or postgres when a separate command line process has to see the same session.

The kind you choose must be compiled into your binary. If it is not, Lambo fails at startup and names the feature to rebuild with. See Installation.

Where deduplication looks. With hybrid matching, the writer that holds a session decides whether a new concept repeats an existing one. On sqlite it compares the new concept against its own in-memory graph. On cockroach and postgres it asks the database for the concepts already flushed and compares against its own in-memory copy of the ones it has not flushed yet, so a fact derived twice in quick succession merges even before the first copy is flushed, and the cost of a derive does not grow with the session. Comparing against every concept in memory instead took about 98 ms per new concept at 100,000 concepts of 1,024 dimensions; the unflushed part costs about 0.8 ms per new concept with 1,000 concepts unflushed, plus the database’s indexed search. Recall on cockroach and postgres still ranks vectors in the database. The writer scores candidates by exact cosine similarity and the database by its own distance, so the two can disagree on a pair whose similarity sits right at semantic_match_threshold. On CockroachDB the approximate index can miss a flushed concept, which is then not merged. On every store, a near concept is never merged into when its text names other identifiers (digits, versions, paths: release v0.5.0 and release v0.6.0 stay apart) or when the new concept names it in against; the receipt counts each refused merge.

[recall] adds an Elasticsearch index beside the store that serves the vector part of recall. It is optional, and it is not a store kind: the [store] you chose stays the source of truth, and leases, fencing, canonization and graph queries never touch Elasticsearch. Each flush the store commits is copied to the index, and recall asks the index for the nearest concepts instead of scanning vectors in the store.

[recall]
kind = "elastic"
url = "https://my-cluster.es.example.com"
api_key = { env = "LAMBO_ES_API_KEY" }
index_prefix = "lambo"
KeyTypeDefaultNotes
kindstringnoneelastic. Required.
urlstringnoneThe cluster’s base URL, http:// or https://. A URL that carries a user name, a password, a query string or a fragment is refused.
api_keytablenone{ env = "NAME" }: the environment variable that holds the API key. The key itself is never accepted in the file. Omit it for a cluster with security off.
index_prefixstringlamboLowercase letters, digits, -, _ and .. Vectors go to <prefix>-v-<hash>, one index per embedding model, and a small <prefix>-meta index keeps one sync marker per session. Must not contain -v- or end in -v, so that <prefix>-v-* can never match another deployment’s indices on a shared cluster.
refreshstring"false"When a write becomes searchable: "false" leaves it to the index refresh interval (1 second by default), "wait_for" waits on every write and is for tests.
timeout_msinteger5000Per-request timeout for writes and searches. Must be greater than zero. Refreshes, deletes by query and counts, whose work grows with the session, get 300 seconds.

[recall] is a top-level table, not part of [store]. It needs the recall-elastic feature; a binary built without it refuses the section and names the feature rather than ignoring it. Lambo creates each index with its mapping the first time it writes to it.

Staleness. A concept becomes searchable in the index up to one refresh interval after its flush, so the vector part of recall can miss a concept written in the last second or so. The keyword and recent parts of recall still find it. This is an accepted difference from a store that ranks vectors itself. Deduplication when a writer derives new concepts does not use the index: it compares against the writer’s own in-memory graph, so a fact derived twice in quick succession still merges.

Ranking. Lambo creates each vector index with plain float HNSW (no quantization), asks it for 16 more neighbours than recall needs, and re-scores them with exact cosine from the stored vectors. Scores and order therefore match the store’s exact scan for the concepts the index returns. The index only chooses which concepts are considered, and an approximate HNSW search can occasionally miss a near neighbour that an exact scan would find. This is the second accepted difference. Because every neighbour comes back with its stored vector, a query’s response grows with the number of concepts asked for: at 1024 dimensions about 0.7 MB for 50, and at most about 20 MB at the largest limit (2048), where Lambo asks for no extra neighbours.

When the index is unavailable. A failed copy never fails the flush, because the store already has the data. The session is marked stale, and until the index is repaired recall ranks vectors in the store instead (SQLite, PostgreSQL and CockroachDB do this themselves; the in-memory store has no vector search, so recall uses only its keyword and recent parts). A failed query does the same for that one recall, and after three failed or slow (over 5 seconds) queries in a row recall stops asking the index for 30 seconds before trying again. A flush waits at most 15 seconds for its copy. The writer holding the session repairs its index in the background when it next opens the session and after a later flush, one repair at a time per session, and lambo recall-index backfill repairs it when no writer is running. Writes made without a session lease (seeding) are not copied: the next writer to open the session, or a backfill, copies them. See Command line.

Erasing a session also removes it from the index, and reports success only once a search of the index finds nothing of the session. See Command line.

The adapter has manual integration checks against a disposable local cluster, using synthetic data and the fixture embedder. They stay ignored in ordinary test runs and are not added to CI. Start the approved single-node cluster:

Terminal window
docker run -d --name lambo-es-live -p 127.0.0.1:59200:9200 \
-e discovery.type=single-node -e xpack.security.enabled=false \
-e 'ES_JAVA_OPTS=-Xms512m -Xmx512m' \
docker.elastic.co/elasticsearch/elasticsearch:8.15.3

Security is disabled only for this throwaway cluster, bound to localhost. Wait until curl --fail http://127.0.0.1:59200 succeeds, then run:

Terminal window
XDG_RUNTIME_DIR=$(mktemp -d /tmp/lbX.XXXX) \
LAMBO_ELASTIC_URL=http://127.0.0.1:59200 \
cargo test --features recall-elastic,store-sqlite,fixtures,embed-fixture \
--lib elastic_live -- --ignored --nocapture

Omit -- --ignored --nocapture to confirm the tests are reported as ignored. An explicitly requested live run fails if the URL is absent. Each test owns a UUID index prefix and removes only those indices, including on assertion failure. The outage check uses an owned loopback endpoint that times out; restart/resume reopens the client and SQLite primary over persisted state. It does not restart the Elasticsearch server. Always remove the container afterward, including after a failed run; retain the image for reuse:

Terminal window
docker rm -f lambo-es-live

[embedder] selects how text is turned into vectors.

KeyTypeDefaultNotes
kindstringbge_m3bge_m3 (an OpenAI-compatible embeddings API: local llama-server or a hosted endpoint), candle (in-process BGE-M3), gemini (Vertex AI), embeddinggemma2 (EmbeddingGemma 2 text and images through llama-server, see EmbeddingGemma 2), or fixture (tests). openai is an alias of bge_m3. bedrock parses but is not implemented, so it fails at startup.
diminteger1024The width you expect the embedder to emit. For embeddinggemma2 it must be 768, 512, 256 or 128, so set it: the default 1024 is refused.
urlstringnoneThe llama-server base URL, used when kind is bge_m3 or embeddinggemma2. Also accepted as llama_url.
modelstringnoneThe model id sent to llama-server. Empty means the server’s own default. For embeddinggemma2 it names the weights artifact and defaults to ggml-org/embeddinggemma-2-GGUF@bfcd2987/Q8_0. Also accepted as llama_model.
api_key_envstringnoneThe name of the environment variable that holds a bearer token, for a hosted OpenAI-compatible endpoint. Used when kind is bge_m3 or embeddinggemma2; any other kind refuses it. See Hosted endpoints.
imagesbooltrueWhether embeddinggemma2 embeds images. false makes it text-only: it never sends an image, and lambo_derive_image takes only client vectors. Any other kind refuses the key.
keep_warm_secsintegerautoSeconds between lambo serve keep-warm touches. Omitted means auto: every 10 seconds for the in-process candle embedder on a Metal device, off for everything else. 0 turns it off; any other value applies to every kind. For gemini, each touch is a billed Vertex AI embedding request and keeps nothing local warm, so leave it off there. On a battery-powered laptop the default wakes the GPU every 10 seconds (about 0.2% GPU duty); set keep_warm_secs = 0 to opt out.
accept_client_vectorsboolfalseAccept a vector a client computed: an image’s (lambo_derive_image with vector, lambo derive-image --vector-json) or a query’s (lambo_recall with query_vector, lambo recall --query-vector-json). Off by default: such a call is refused and the refusal names this key. Lambo checks that the vector’s declared contract (kind, model, dim) is exactly the session’s and that the vector has the right width and a real direction, but it cannot check that the vector came from that model, so turning this on trusts the client’s label. One setting for the whole process: every session a lambo serve holds gets the same value.
canarystring"warn"The embedder canary (#176). A writer attach embeds 20 fixed phrases and compares each with the vector this store pinned for the same embedding contract (kind, model, dim), kept in store_meta. It catches a change behind the same name: a re-quantised model, another llama.cpp build, a provider’s model revision. A phrase below cosine 0.99 is a drift. "verify" refuses the writer attach on a drift (fail closed; --allow-embedding-mismatch attaches anyway), "warn" logs it and attaches, "off" skips the check. A store with no pin gets one at the next writer attach, with an INFO line. Under "warn", a check that passed less than 10 minutes ago is trusted without embedding again, so one-shot CLI writers cost one store read each; "verify" embeds at every writer attach. lambo re-embed pins again after its rewrite. The pin is per store and contract, so after re-embedding one session under an unchanged contract name, re-embed the store’s other sessions under that name too: the canary no longer flags them. An embedder that cannot be reached leaves the check unverified, never refused.
related_pairspathunsetA JSON array of labelled related-but-distinct pairs, [{"a": "...", "b": "..."}], to derive semantic_match_threshold from (#87): the p99 cosine of the pairs under this embedder. It applies only with at least 100 pairs and only when it sits above the random-pair floor the canary records (unrelated texts score 0.5 to 0.62 on BGE-M3 in lambo’s framing, so a threshold there would merge distinct concepts). Otherwise, or when unset, the writer falls back to the contract’s calibrated threshold when it has one and calibrated_merge_threshold is on (EmbeddingGemma 2 at 768 dims: 0.93), else to semantic_match_threshold. The result is cached in the canary pin against the file’s SHA-256 and measured again when the file changes. If the canaries embed but the pairs do not (a slow or flaky embedder, or a file that overruns the canary’s 60 s budget), the same fallback applies and the file is retried with a backoff: 1 minute after the first failure, doubling with each further failure up to 1 hour, so a failing file does not cost every writer attach a full re-embed. A file whose contents changed is measured at the next attach. Needs canary on.
calibrationstring"builtin"Per-contract recall calibration (#87). Lambo’s score constants were set on BGE-M3’s cosine scale; another embedder puts the same texts higher or lower. "builtin" gives a contract that lambo has measured its own recent-leg score and layer floor: today only EmbeddingGemma 2 at 768 dims (ggml-org/embeddinggemma-2-GGUF@bfcd2987/Q8_0, prompt profile lambo-eg2-v2), whose recent leg scores 0.63 instead of 0.35, so it keeps the rank it has under BGE-M3: below every true hit on the evaluation set, and above about half of the irrelevant concepts rather than none. It applies to a text recall whose vector leg runs; a recall by an image or a client vector keeps 0.35, since the set is text only. For the layer floor see calibrated_layer_floor. "off" keeps 0.35 and the 0.50 layer floor for every contract. Any other contract (BGE-M3, another EG2 dimension, quantisation or prompt profile) is unaffected. The numbers and the method are in evidence/issue-87-calibration/.
calibrated_merge_thresholdbooltrueMerge at the measured contract’s own threshold instead of semantic_match_threshold (#87). Under the EG2 contract above, a writer merges at 0.93 rather than 0.85: 0.85 puts about 78% of the evaluation set’s related-but-distinct pairs over the threshold by cosine (BGE-M3: 35–44%), and every one that the identifier guard lets through, while 0.93 matches BGE-M3’s rate by cosine (about 43–46%) and keeps about 83–88% of paraphrases over it. An existing EG2 store therefore merges less from this version on; concepts already merged stay merged. false restores semantic_match_threshold, and a writer attach under that contract then logs a warning. A usable related_pairs derivation still wins. No effect for a contract without a measured row (BGE-M3 included).
calibrated_layer_floorbooltrueA layer whose [[serve.layer]] min_similarity is unset takes the measured contract’s own floor instead of 0.50 (#87). Under the EG2 contract above, that floor is 0.72. 0.50 is BGE-M3’s floor, and under EG2 it admits 99.9% of irrelevant concepts on the held-out split (BGE-M3 at 0.50: 3.2%). 0.72 admits 3.1% and keeps 92.5% of true hits: about 7.5% of true hits, the lowest-scoring ones, no longer merge from a layer. It is resolved at recall time from the calling session’s embedding contract, which its layers must share. A min_similarity you set always applies, 0.50 included. Earlier versions of this page’s example set min_similarity = 0.50, so remove that line to get the calibrated floor. lambo serve logs a warning when a layer’s floor is below its contract’s calibrated one. false, or calibration = "off", keeps 0.50. No effect for a contract without a measured row (BGE-M3 included), or for a session without layers.
suspected_supersession_linestring"validated"When recall shows the ⚠ Possibly superseded by "…" (id …), not declared line for a suspected supersession (#198). "validated" shows it only under an embedding contract whose trigger precision was measured at 0.90 or better on the labelled pairs: today BGE-M3 (candle with BAAI/bge-m3 weights, or bge_m3 with a model that names BGE-M3, such as bge-m3-FP16, at 1024 dims; 0.96 held-out, evidence/issue-198-trigger/). A bge_m3 with no model uses the server’s default model, which Lambo cannot see, so it counts as BGE-M3: if that server serves another model, set this to "off". "on" shows it for every contract, "off" for none. The record (a SuspectedSupersedes edge) is written either way, and the older hit’s possibly_superseded_by field is set either way; under EmbeddingGemma 2, which is not yet measured, that field is all the default shows. Not part of the embedding contract.

kind also accepts bge, bge-m3 and openai for bge_m3, vertex for gemini, titan for bedrock, and fake for fixture. An alias changes nothing but the spelling: the kind is still reported and stamped as bge_m3.

A store that persists vectors declares its own width, and Lambo refuses to start if dim disagrees with it.

While lambo serve sits idle, the operating system can compress or swap out model weights that nothing is touching, and the next call pays to bring them back. On Apple silicon the candle embedder’s weights live in unified memory, so this is where it shows. Keep-warm embeds one short fixed probe on an interval and discards the vector. It writes nothing to the store, the graph or the call ledger, and it is not part of the embedding contract, so you can change it without re-embedding. The first touch comes one interval after startup, never during it.

The gemini embedder and a Cloud SQL IAM login (LAMBO_POSTGRES_IAM=1 on the postgres store) authenticate as one Google identity. The embedder’s gemini_credentials key (or LAMBO_GEMINI_CREDENTIALS) names its file directly; otherwise both use the first of these that answers:

  1. GCP_LAMBO_CREDENTIALS, the path of a service-account key or an authorized-user credentials file.
  2. GOOGLE_APPLICATION_CREDENTIALS, the same.
  3. The file gcloud auth application-default login writes: $CLOUDSDK_CONFIG/application_default_credentials.json when CLOUDSDK_CONFIG is set, otherwise ~/.config/gcloud/application_default_credentials.json (%APPDATA%\gcloud\application_default_credentials.json on Windows).

An empty variable counts as unset. A variable naming a missing file is an error that names the path; it never falls through to the gcloud file. The gcloud file is used only if it exists and is a file, so after gcloud auth application-default login no variable is needed.

The bge_m3 kind speaks the OpenAI embeddings API (POST <url>/v1/embeddings), so it also reaches a hosted OpenAI-compatible endpoint when you give it a token. Cloudflare Workers AI serves BGE-M3 at 1024 dimensions:

[embedder]
kind = "openai" # the same adapter as bge_m3
dim = 1024
url = "https://api.cloudflare.com/client/v4/accounts/<account-id>/ai"
model = "@cf/baai/bge-m3"
api_key_env = "CLOUDFLARE_API_TOKEN"

api_key_env names a variable; the token itself never goes in lambo.toml. Lambo reads the variable when it resolves the embedder and sends Authorization: Bearer <token> with every embed request. Without api_key_env no Authorization header is sent.

  • If the variable is unset or empty, startup fails and names the variable. Lambo never falls back to sending no token.
  • The token is sent only over https, or over plain http to a loopback host (localhost, 127.0.0.0/8, ::1) such as a local server. Plain http to any other host is refused at startup, naming the host, so a token never crosses a network in clear text. Without api_key_env, http URLs work as before.
  • The value must look like a variable name: upper case letters, digits and _, not starting with a digit, at most 64 bytes. A value that does not, or that looks like a token, is refused without being shown. So is an inline api_key = "...".
  • It may not name LAMBO_AUTH_TOKEN or any [[serve.credential]] token_env, from the file or from LAMBO_EMBED_API_KEY_ENV. Those variables hold lambo serve’s own credentials, and one variable may not hold both a serve credential and the embedder’s token.
  • It may not name a variable lambo reads its own credentials from either: LAMBO_COCKROACH_DSN, LAMBO_POSTGRES_DSN, DATABASE_URL, GCP_LAMBO_CREDENTIALS, GOOGLE_APPLICATION_CREDENTIALS or LAMBO_GEMINI_CREDENTIALS. The same rule applies to [[serve.credential]] token_env.
  • Redirects are never followed. A 3xx from the endpoint fails the write as a configuration error naming the status (not the redirect target): point url at the endpoint itself. A url with a query or fragment is refused, because the embeddings path is appended to it.
  • Plain http to a loopback host ignores HTTP_PROXY, HTTPS_PROXY and ALL_PROXY, so a token meant for a local server is never handed to a proxy. https URLs still use them: a proxy only tunnels the TLS connection.
  • When an error body from the endpoint is quoted into an error, any run of 8 or more consecutive characters of the token is replaced, whether raw, JSON-escaped or percent-encoded. Shorter fragments, case-changed copies and other encodings are not detected. Quoted bodies are cut at 8 KiB. Logs and errors show the URL as scheme, host, port and path only.
  • A 401 or 403 from the endpoint fails the write as a configuration error, the same as a wrong URL.

The same model behind a different server is a different embedding contract, because the two can differ numerically. Set model to the hosted id (as above) so a hosted session’s contract differs from a local one, and start a fresh session or run lambo re-embed rather than mixing the two.

kind = "embeddinggemma2" embeds text and images into one space with Google’s EmbeddingGemma 2, served by llama.cpp’s llama-server. It needs llama.cpp build b11452 or later; older builds refuse the model with unknown model architecture: 'gemma-embedding2'. Download the Q8_0 GGUF and its vision projector from ggml-org/embeddinggemma-2-GGUF and start the server like this:

Terminal window
llama-server --host 127.0.0.1 --port 8191 \
-m embeddinggemma-2-Q8_0.gguf \
--mmproj mmproj-embeddinggemma-2-Q8_0.gguf \
--embeddings --pooling mean \
--image-min-tokens 280 --image-max-tokens 280 \
--ctx-size 8192 --batch-size 8192 --ubatch-size 8192
[embedder]
kind = "embeddinggemma2"
url = "http://127.0.0.1:8191"
dim = 768
  • Lambo adds the model card’s task prefixes itself: a stored concept is embedded as title: none | text: ..., a recall query as task: search result | query: ..., and an image with no prefix. A server, or a client computing vectors, that uses other prefixes is in a different space.
  • dim can be 768, 512, 256 or 128. Lambo takes the first dim of the 768 values and re-normalizes them.
  • The session’s contract records the weights and the prompt profile, for example ggml-org/embeddinggemma-2-GGUF@bfcd2987/Q8_0;prompts=lambo-eg2-v2, because llama-server ignores the model name in a request. Set model to the artifact your server actually runs. A different artifact or profile is a different contract: start a fresh session or run lambo re-embed.
  • The profile also fixes the form an image is sent in. At a fixed budget llama.cpp still puts an image larger than about 768 px a side on a coarser patch grid than a smaller one, and resamples every image from whatever resolution it was given, so the same picture sent at 512 and at 1024 px embedded up to about 0.01 apart (cosine). Lambo therefore decodes every PNG, JPEG and WebP and sends it as a lossless PNG whose longer side is exactly 768 px: larger images are scaled down (Lanczos3), smaller ones scaled up (Catmull-Rom), aspect ratio kept. Measured on b11517 for pictures submitted at 128 to 3000 px: a flat image embeds bit-identically at every size; a patterned one does not, because a picture scaled to 768 px is not the same pixels as one drawn at 768 px. Renders at 768 px and above agree to a cosine of at least 0.9991 (checkerboard) and 0.9995 (smooth gradient); a 128 px checkerboard is 0.982 from the 768 px one, since enlarging cannot restore detail that was never sent. What Lambo stores about the image (its id, hash and type) describes the bytes you sent; only the request to the server carries the converted form. Images are decoded only up to 4096 px a side, two at a time.
  • A client that computes image vectors itself is in the same space only if it sends the server the same canonical form: decode without applying EXIF orientation or colour profiles (a palette PNG becomes RGB or RGBA, a CMYK JPEG becomes RGB, alpha is kept straight, 16-bit samples are kept); resize in one step so the longer side L becomes 768 and the shorter side s becomes max(1, floor((s * 768 + floor(L / 2)) / L)) (integer arithmetic, halves rounded up), with Lanczos3 when shrinking and Catmull-Rom when enlarging, on the stored channel values; skip the resize when L is already 768; encode as PNG with no ancillary chunks. The deflate settings do not matter, only the pixels. Lambo uses the Rust image crate 0.25 for this; another resampler gives close but not bit-identical pixels. If an update of that crate ever changes the canonical pixels for the same input, Lambo’s tests fail and the change ships as a new profile name, never under lambo-eg2-v2.
  • The profile fixes the image budget at 280 tokens. The two image flags set it, and the batch flags let a whole image fit: at the server’s default ubatch of 512 the budget is silently cut to 256 tokens, a different space. The budget bounds an image’s area, so an image’s token count still depends on its size and shape (on b11517, 293 for a square up to 768 px, 260 for a larger one, anywhere from 236 to 540 for other shapes). Because no single image’s count shows the budget, Lambo checks it with a reference image of its own: before the first image it embeds for a server that passed the /props check, and again every 60 seconds, it embeds a 1x1 picture and refuses image embeds, naming the flags, unless the server reports the 293 tokens b11517 reports at the 280 budget (within 8). A response to it without a token count refuses image embeds too, since llama-server always sends one. A server that does not answer /props gets no reference check, so its image budget, like the --pooling mean setting of any server (which /props does not report), rests on your word; the log says so once.
  • Before its first embed, Lambo asks the server’s /props which file it loaded, its quantization and whether it has a vision projector. It refuses to embed with a file whose name does not mention EmbeddingGemma 2 or a quantization other than the one model names (llama.cpp’s own names are matched to the file names’ tokens, so Q4_K - Medium is Q4_K_M and all F32 is F32), and refuses images when the server has no vision projector. A server that does not answer /props (a hosted endpoint, Ollama) is used without the check, and the log says so once. If /props cannot be reached or answers with a server error, the log says so once and Lambo asks again after a wait that doubles from 1 second up to 30 seconds, not on every embed. Until a server has passed the check once, text and images embedded in that window (at most about 30 seconds per failed check, typically at startup) go to it unchecked, with no reference image check, the same as a server without /props; they are stamped with the configured contract, so if the server could be the wrong one, keep Lambo’s writes stopped until the log shows the check passed. The answer is asked again every 60 seconds and right after an embed request gets no answer at all (connection refused or reset) or a 503 “Loading model”, so a server restarted with another model or quantization is refused before Lambo writes more vectors under the old contract. Any other 503 (a busy server) is retried without asking again. If the server refuses Lambo’s reference image itself, the error names the reference image check as a server or configuration problem, not a fault in your image. Once a server has passed the check, embeds wait (and are retried) while /props cannot be reached, and a server that stops answering /props is refused: restart Lambo if you replaced it on purpose.
  • Some of these checks rest on facts measured on llama.cpp b11517 and may change with the build: the 293-token count of the reference image, and the text of the HTTP 500 for an input larger than the physical batch (“increase the physical batch size”), which Lambo settles as a failed input naming --ubatch-size. After upgrading llama.cpp, re-measure them (evidence/issue-22-eg2/ records how): a build that frames images differently refuses every image with a message naming b11517 and the flags (set images = false meanwhile), and a build that rewords the 500 makes an oversized input a retried server error again.
  • An image sent to a server started without --mmproj fails as a configuration error naming --mmproj, not as a retryable server error. Without --mmproj, set images = false.

Every image is resized to a 768 px longer side before it is embedded. That makes its vector largely, but not fully, independent of the size you send:

  • A flat image (one colour) gives a bit-identical vector at any size.
  • Real images sent at 768 px or larger agree to a cosine of at least 0.999.
  • Small images with sharp detail can drift by up to about 2% (a cosine of 0.98 for a checkerboard sent at 128 px), because enlarging cannot restore detail that was never sent.

If bit-identical vectors matter, for example when you compare vectors directly or deduplicate on them, always send the same rendition of an image, such as the original file.

[daemon] is optional and sets cadence only — how often the background work runs, never what a concept must clear to be promoted. Omit the whole section for the defaults.

KeyDefaultWhat it sets
gc_interval10000Mutations between GC sweeps.
gc_max_interval_secs86400Seconds after the last GC sweep at which a session that is still taking writes sweeps anyway.
gc_idle_floor100Session mutations since the last sweep below which the time bound does not fire.
canonization_eval_interval_secs60Seconds between canonization evaluation passes.

A degenerate value is refused rather than quietly clamped — gc_interval = 0 is a mutation counter and not a period, so it stops the process:

$ lambo --config lambo.toml demo
lambo demo: failed to build backends: config: gc_interval must be >= 1 (a mutation counter), got 0

The check runs wherever the process resolves an embedder, because that is the resolution path the cadence is applied on. Four commands do not resolve one — lambo provision, lambo saints, lambo inspect and lambo stats open the store alone — so those four accept a degenerate cadence without complaint. Every other command refuses it.

That boundary is not “commands that build a session”. lambo recall builds none — it is a lease-free reader — and is refused anyway, because the refusal is about the file, not about a lease:

$ lambo --config lambo.toml recall --session s --query x
lambo recall: failed to build backends: config: gc_interval must be >= 1 (a mutation counter), got 0
$ lambo --config lambo.toml saints --session s
0 canonical memories in session 's'

A bad promotion_policy has the wider scope of the two, and the difference is worth knowing: an unparseable policy is refused while the file is being read, so it stops every command, those four included. A degenerate cadence is refused later, when the backends are built. See Promotion policy.

Under the default Swarm policy gc_interval is the one worth understanding. Stage 1 requires gc_survived >= 3, so at the default a concept needs roughly 30000 mutations before it can be promoted at all, which an ordinary session of a few dozen concepts never reaches — this is why lambo demo sets it to 1 internally. Under Solo it does not gate Candidates; see Promotion policy. lambo.example.toml carries the same notes inline.

GC also sweeps on time. When gc_max_interval_secs has passed since a session’s last sweep and the session took at least gc_idle_floor mutations since then, it sweeps even though it is far below gc_interval; whichever trigger comes first wins. An idle session never sweeps on time alone (bar the one catch-up below), so gc_survived keeps measuring survival under eviction pressure rather than age. The time and watermark of the last sweep are stored with the session, so a writer restart does not reset the clock and does not by itself cause a sweep: a restarted writer sweeps only if one was already due. A session that took at least gc_idle_floor mutations since its last sweep and whose writer was down for a week sweeps once on its first cycle back, not seven times. A session that has never swept starts its clock when a writer first attaches to it, rather than sweeping. That is a delay, not an exemption: a never-swept session counts every lifetime mutation toward gc_idle_floor, so one that already has at least that many sweeps once about gc_max_interval_secs after the clock started, even if no write has arrived since. This is a deliberate catch-up for stores that grew before sweeps ran on time. Both keys must be positive; zero is refused the same way gc_interval = 0 is. A gc_idle_floor at or above gc_interval is accepted but makes the time bound unreachable (the mutation trigger always gets there first), so the file is accepted with a warning at startup. Neither changes what a sweep collects.

[serve] configures one lambo serve process that holds several sessions. It is optional: omit it and lambo serve behaves exactly as it always has.

This section is the key reference. For how pinned and on-demand sessions, credentials, bounds and the admin routes fit together, read Several sessions in one serve first.

An HTTP lambo serve reads sessions, default_session and max_attached: it pins every session in sessions beside its --session values, serves default_session at /mcp, and refuses to start when the pinned sessions together exceed max_attached or default_session is not one of them (the default session must be pinned). See Several sessions in one serve. A stdio serve owns one session and ignores sessions and max_attached: its --session, or, without one, the session [[serve.projects]] or default_session chooses (below).

An HTTP lambo serve also enforces [[serve.credential]]; see Credentials. A stdio serve authenticates nobody (its client owns the process), so it ignores the credentials and never reads their variables.

An HTTP lambo serve enforces attach_concurrency, idle_detach_secs and per_session_rps too. per_session_rps gives every session its own request bucket; the other two bound the sessions a serve attaches on demand, which it does once a credential reaches past the pinned sessions (a session_prefix, or an unpinned name). A stdio serve owns one session, so none of the three applies to it. An HTTP serve never reads [[serve.projects]], which is a stdio serve’s only: when an HTTP serve’s table sets it, lambo serve logs one warning at startup ([serve] sets keys this serve ignores) that names the key and no value from it. A malformed table is still refused when the file is read, so it stops every command, like an unknown key.

[serve]
sessions = ["lambo", "rustydocs", "general"] # pinned: attached at startup
default_session = "general"
max_attached = 16
attach_concurrency = 2
idle_detach_secs = 900
per_session_rps = 50
[[serve.projects]]
path = "~/Documents/work/lambo"
session = "lambo"
[[serve.credential]]
name = "agents"
token_env = "LAMBO_AGENTS_TOKEN"
sessions = ["lambo", "rustydocs", "general"]
[[serve.credential]]
name = "dresscode"
token_env = "LAMBO_DRESSCODE_TOKEN"
session_prefix = "dc-u-"
create = true
erase = true
KeyDefaultWhat it sets
sessionsnonePinned sessions, attached at startup and never idle-detached. Served by an HTTP lambo serve, beside its --session values.
default_sessionnoneThe session /mcp serves, and the one a stdio serve without --session uses when no [[serve.projects]] entry covers its working directory.
max_attached16Cap on attached sessions, pinned plus on-demand. At least 1, and at least the number of pinned sessions. At the cap, an on-demand attach detaches the least recently used idle on-demand session that the asking credential’s share allows, or is refused with 503 and Retry-After when there is none. A credential reaching past the pinned sessions with no place left beside them is warned of at startup.
attach_concurrency2Sessions that may attach at once, on demand or retried in the background. At least 1. Always 1 on SQLite, whose one connection serializes attaches with every flush.
idle_detach_secs900Seconds an on-demand session may go without a tool call before it is detached (its tail flushed, its lease released). At least 1. Pinned sessions are never idle-detached.
per_session_rps--rate-limit-rpsRequest rate for each attached session (burst twice that), drawn after the credential’s; a request over it gets 429. 0 turns the per-session limit off. A serve of one session that attaches nothing on demand applies it only when it is set.
[[serve.projects]]nonepath and session: a stdio serve started without --session under path uses session. path must be absolute or start with ~/; * is supported in its final component. An HTTP serve does not read it.
[[serve.credential]]noneA bearer credential and the sessions it may reach (below).
layersnoneThe shared sessions every pinned session that is not itself a layer also reads on lambo_recall, as an ordered list, nearest tier first. At most one in this release. See Layers.
[serve.session_layers]nonePer-session overrides of layers, each the same list form; [] opts a session out.
[[serve.layer]]nonePer-layer ranking: session, weight (default 0.5) and min_similarity (default 0.50, or the embedding contract’s calibrated floor: 0.72 under EmbeddingGemma 2 at 768 dims; see [embedder] calibrated_layer_floor).

Every session named in [serve] must be addressable by URL: 1 to 128 bytes of A-Z, a-z, 0-9, ., _, : and -, not starting with .. A name outside that rule is refused here, but you can still serve it on its own with lambo serve --session <name>, which keeps its looser rule.

A credential never holds its token. token_env names the environment variable that does, and an HTTP lambo serve reads it at startup: an unset, empty or non-UTF-8 variable refuses the start (exit 2, before any model is loaded), naming the credential and the variable and never a value. An inline token = "..." is refused, and the refusal does not quote it. A credential reaches the sessions in sessions (exact names, or "*" for every pinned session plus every session under any credential’s prefix) and those under session_prefix (ids that start with the prefix and are longer than it). It may carry create (attach a session that does not exist yet), erase (erase a session in its reach with POST /admin/s/<session>/erase) and admin (list the sessions in its reach with GET /admin/sessions). A request to either route without the flag, or for a session outside the credential’s reach, gets the empty 404. The names default and local are reserved, and token_env may not be LAMBO_AUTH_TOKEN. token_env must be a conventional variable name ([A-Z_][A-Z0-9_]*, at most 64 bytes) and must not look like a token (an AWS key id, or a 20-plus character run of letters and digits with no _), because it is the key a token is most likely to be pasted into by mistake. A value that fails is refused without being quoted.

Each [[serve.credential]] entry takes these keys:

KeyRequiredWhat it sets
nameYesA name for logs, refusals and the ledger’s credential field: 1 to 128 bytes of [A-Za-z0-9._:-], not starting with .. default and local are reserved.
token_envYesThe environment variable holding the token, read at startup by an HTTP serve. Not LAMBO_AUTH_TOKEN.
sessionssessions, session_prefix or bothExact session names, or "*" for every pinned session plus every session under any credential’s prefix. A name that is not pinned is attached on demand.
session_prefixEvery session whose id starts with this and is longer. Attached on demand.
createNo (false)May attach a session that does not exist yet.
eraseNo (false)May erase a session in its reach with POST /admin/s/<session>/erase.
adminNo (false)May list the sessions in its reach with GET /admin/sessions.

Every HTTP request to lambo serve is made as one credential, and that credential decides which sessions the request may reach. A request presents its token as Authorization: Bearer <token>; only a loopback serve with no credential configured needs none. The credentials a serve has:

ConfiguredCredentials
nothing, on loopbackOne implicit credential, local: every request is accepted without a token and reaches every pinned session. This is how a loopback serve has always behaved.
nothing, beyond loopbackNone: lambo serve refuses to start.
--auth-token or LAMBO_AUTH_TOKENdefault: every pinned session.
[[serve.credential]] entriesEach entry, with its own sessions, session_prefix and flags, beside default when a legacy token is set too.

Once any credential is configured, the implicit local credential is gone, so a loopback serve with a [[serve.credential]] requires a token on every request. Neither default nor local carries create, erase or admin. A configured token equal to the legacy one, or two credentials with the same token, refuse the start without quoting either token.

The presented token is compared with every configured token in constant time, so response timing does not say which credential a guess nearly matched. A request without a valid token gets 401, whatever it asked for. A request with a valid token for a session outside that credential’s reach gets the same empty 404 as an unknown session or an unrouted path, whether that session exists, is held by another writer, is being detached or failed, and the serve consults no store to answer it. Only inside its reach can a caller see a 503, or the erased answer (MCP error -32003 to a POST of one JSON-RPC request with an id, initialize included, and 410 to anything else). /mcp serves the default session to a credential that reaches it.

A session inside a credential’s session_prefix, or named in its sessions but not pinned, is attached on demand by the first request for it: always with create = true, and without it only once the session exists (a writer has used it); otherwise the request gets the 404. See Several sessions in one serve. lambo serve logs a warning at startup for a credential without create that reaches sessions it does not pin, and for LAMBO_AUTH_TOKEN (or --auth-token) set beside [[serve.credential]]: that token stays the default credential, reaching every pinned session, until you unset it.

A token must be printable ASCII with no leading or trailing whitespace, and at most 4096 bytes. A token that breaks this (a trailing newline from an env file, say) refuses the start with exit 2, naming the credential and never quoting the token. A presented token over 4096 bytes, or a request carrying two Authorization headers, gets the ordinary 401.

Further rules once credentials are in play:

  • Host. A loopback serve with no credential refuses a request whose Host is not localhost, 127.0.0.1 or ::1, as protection against DNS rebinding. A serve that requires a token accepts any Host, because a rebound page cannot present a token it does not know, and a serve bound beyond loopback is reached under its own address or name.
  • Fair shares. --rate-limit-rps applies per credential: each has its own bucket, so one credential’s flood never rate-limits another. --max-sessions caps the MCP sessions of the whole process, and each credential may hold at most an even share of it, --max-sessions divided by the number of credentials, rounded down and at least 1. While --max-sessions is at least the number of credentials, the shares fit inside the cap, so no credential can lock the others (the operator’s included) out. With more credentials than that, each share is 1, and a credential can be refused at the process-wide cap while it holds no session. With one credential the share is the whole cap.
  • MCP sessions belong to their opener. An MCP session id answers only the credential whose initialize opened it. Another credential naming it, even one that reaches the same session, gets exactly what an unknown or expired id gets.
  • Attribution. With --ledger, each call line made through a configured credential carries credential, that credential’s name. Calls as default or local carry no such field.
  • What a token does not guard. The credential count is not secret: response timing scales with it. A stdio lambo serve --session <name> run by the same OS user forwards to the HTTP serve’s per-session Unix socket (a directory only that user can enter) and reaches that session whatever the credentials say. Credentials guard the network; the OS user is the boundary on the machine.

A layer is a shared session, such as general, whose memories a project session’s recall reads too. With layers = ["general"], a lambo_recall to lambo reads lambo and general in one call and merges the two answers under one token budget. lambo_recall and lambo_inspect take scope to read one of the two alone. Writes, lambo_reserve and lambo_saints act on the addressed session unless they too pass scope: an agent writes a ruling that applies in every project with scope = "general", which the layer’s own write queue applies (writes to different sessions have no ordering between them, a write into a layer refuses relative file paths, as does any write to the layer’s own address, and no reference or edge crosses sessions; see the MCP reference’s Writing to a layer). A scoped call draws on the layer’s own per_session_rps. No new credential capability is involved: a credential that reaches a layered session reaches its layers, which the start check enforces. Layers are an HTTP serve’s. A stdio serve that holds its session itself serves it unlayered and logs one warning when the session would be layered; a stdio serve that forwards to an HTTP lambo serve holding the session (the usual case on a machine running one) reaches that session through the holder, layers included, and warns of nothing.

[serve]
sessions = ["lambo", "rustydocs", "lambo-paper", "general"]
layers = ["general"] # ordered, nearest tier first
[serve.session_layers]
lambo-paper = [] # opt one session out
[[serve.layer]]
session = "general"
weight = 0.5 # measured on BGE-M3 (#157)
# min_similarity = 0.50 # absolute vector relevance; unset: 0.50, or
# the contract's calibrated floor (EG2: 0.72)

layers applies to every pinned session that is not itself a layer; [serve.session_layers] overrides it per session. Every shape is a list, but this release supports one layer per session, and a longer list is refused (lists 2 layers; at most 1 is supported). On-demand sessions are never layered.

A merged recall interleaves the two answers by rank: the layer’s hit at position r ranks as weight / r, against 1 / r for the session’s own. At the default 0.5 the layer’s first hit sits beside the session’s second. An ordinary layer hit is admitted only with vector cosine at or above its min_similarity (default 0.50, or the contract’s calibrated floor, below); keyword matches and warnings do not bypass this absolute cutoff. A dependent in an anchored layer traversal answer also passes. These defaults were measured on BGE-M3 in issue #157. An unset floor is calibrated per embedding contract (#87, evidence/issue-87-calibration/). Under EmbeddingGemma 2 (768 dims, Q8_0, lambo-eg2-v2) nearly every irrelevant concept scores above 0.50, so such a layer’s floor is 0.72: the value that admits the same share of irrelevant concepts as 0.50 does under BGE-M3. On the held-out split it keeps 92.5% of true hits. A min_similarity you set always applies, and [embedder] calibrated_layer_floor = false keeps 0.50. A layer with no vector evidence contributes no ordinary hits, including during embedding or vector-search failure; the caller’s own keyword recall and anchored dependency answers still work. See lambo_recall for what a layered answer looks like.

Refused when the file is read (every command): a name outside the session charset or listed twice, more than one layer per session, a session that lists itself, a layer that has layers of its own (no transitive layering), and a [[serve.layer]] entry that names no configured layer or sets weight to anything but a finite positive number or min_similarity outside [0, 1]. Unknown keys in these tables are refused like any other.

Refused when an HTTP lambo serve starts (exit 2), before any lease is taken:

  • a layer, or a session with a non-empty [serve.session_layers] list, that the serve does not pin (by [serve] sessions or --session). Pinned sessions hold their lease for the serve’s life, so a recall never attaches a layer on demand and a layer is never evicted;
  • a credential that reaches a layered session but not each of its layers, naming the credential and both sessions. The implicit local and legacy default credentials reach every pinned session, so a serve using only those needs nothing more.

Once the pinned sessions are attached, a layer whose embedding contract differs from its session’s refuses the start too (a model left unset in one and set to "" in the other counts as different, and the message says so).

While the serve runs, a layer can stop serving: another writer took its lease, it is being detached, or its attach failed. Losing general’s lease detaches general alone. Until it is attached again, every layered recall answers from its own session with one warning, recall: layer general is unavailable; results are from lambo only, and a scope naming the layer is refused: retryably while it is held elsewhere or detaching, and as permanent once its attach has failed for good or it was erased (an erased layer is also warned of at start).

An HTTP client chooses its session by URL, not by its working directory or a project header. A useful split keeps operator-wide rulings in general, project work in its own session, and work outside a mapped project in general-usage:

[serve]
sessions = ["lambo", "rustydocs", "general", "general-usage"]
default_session = "general-usage"
layers = ["general"]
[[serve.layer]]
session = "general"
weight = 0.5

Start the HTTP serve without a --session override if /mcp should use this default_session. Register .../mcp/s/lambo in the Lambo project’s client and .../mcp/s/rustydocs in RustyDocs; register .../mcp as the client’s fallback. general is itself a layer, so it does not inherit layers. The project sessions and general-usage read it. A per-session empty list can opt a consumer out, as in the preceding example.

This configuration does not copy memories or move existing sessions. Each project’s text recall reads its own session plus general; its writes go to the addressed project unless a call passes scope = "general" (see Writing to a layer). See the read examples and the operator’s cut-over runbook.

A stdio serve given --session uses it and reads none of this. Without --session it picks, in order:

  1. The [[serve.projects]] entry whose path contains the serve’s working directory. When several do, the longest path wins. Paths are compared whole directory by whole directory, so ~/work/lambo covers ~/work/lambo/src but not ~/work/lambo-wt.
  2. default_session.
  3. Nothing: the serve refuses with the usual missing --session error and exit code 2, before it builds any backend.

A leading ~ in path expands to $HOME (~user is refused). Stars in the value of $HOME remain literal; only the configured path supplies pattern syntax. The working directory and every entry are resolved to real paths first, following symlinks, . and .., so a project entered through a symlink uses the entry for the directory the link points to. An entry whose directory does not exist never matches. If the working directory itself cannot be resolved (for example, it was deleted), the map is skipped and default_session applies. Two entries that resolve to the same directory but name different sessions are refused rather than picked by file order, as a configuration error (exit code 1). When HOME is unset or is not an absolute path, every ~ entry is skipped and the rest of the map still applies; the serve logs one warning saying so. The serve logs which entry it used, by its configured path. Neither that line, the warning, nor any refusal quotes the working directory or $HOME.

A * in the final component of path matches zero or more filename bytes, so ~/work/lambo-wt-* covers sibling worktrees and their descendants. The whole filename must match. Multiple stars are allowed; ? and brackets are ordinary characters, backslashes are path separators on Windows and ordinary characters elsewhere, and there is no escape for a literal star. Stars in parent components are refused, including */.. or */.; a trailing slash is allowed. Pattern matching is byte-exact and case-sensitive, including Unicode form, even on filesystems where literal path lookup ignores case or Unicode normalization. Non-UTF8 child names can match *.

The literal parent is resolved first, then its immediate children are scanned and matching directory roots are resolved, following symlinks even when their target is outside the parent. Each matching root covers its descendants by whole components. The deepest canonical root wins; at equal depth a literal entry wins over a pattern. Equal-depth patterns naming different sessions are refused, regardless of how many literal characters their patterns contain; agreeing ties are allowed. Missing, non-directory or unreadable parents skip that pattern, as does a failed directory listing. Startup scans every immediate child once per pattern, retaining only the best match, so a large parent directory adds startup work. No recursive directory scan is performed.

The map is read from the lambo.toml the serve loads: --config, then LAMBO_CONFIG, then ./lambo.toml in the working directory. So a single global map used by every project needs the client to pass --config or set LAMBO_CONFIG; otherwise each serve reads its project’s own ./lambo.toml, if any, whose default_session or [[serve.projects]] then chooses the session, just as it already chooses the store. See Serve a session.

[web] sets which sessions lambo serve-web serves, which Host names it answers without a credential, who may read which session, and how it reads each session. It is optional: omit it and the defaults below apply.

The window reads the store through a view: one load of a session that every request and every open tab shares until it is older than view_ttl_ms. The next request after that reloads it, and requests that arrive while a load is running wait for that one load instead of starting their own. A failed load is not kept: the requests waiting on it get the error, and the next request tries again. So the store sees about one load per session per view_ttl_ms however many tabs are open, and the page shows durable state up to view_ttl_ms older than the store. The writer-published flush lag is still read on every poll.

[web]
sessions = ["lambo", "rustydocs"]
allowed_hosts = ["lambo.example.com"]
view_ttl_ms = 1500
max_loaded_sessions = 4
load_concurrency = 2
recall_concurrency = 4
list_sessions = false
inherit_serve_credentials = false
KeyDefaultWhat it sets
sessionsnoneSessions to serve, after any --session flags. The first one overall is the default, served at /; each is served at /s/<session>/. With more than one in total, each name must be 1 to 128 bytes of [A-Za-z0-9._:-], not starting with ..
allowed_hostsnoneHost values answered while no credential is configured, beside localhost, 127.0.0.1 and [::1], after any --allowed-host flags. A name, or name:port for that port only.
view_ttl_ms1500Milliseconds a view is reused, the page’s poll interval. 0 reloads on every request, still one load for requests that arrive together. At most 60000.
max_loaded_sessions4Views kept in memory at once. The least recently used is dropped beyond it. At least 1.
load_concurrency2Session loads that may run at once. 1 to 1024. Always 1 on SQLite.
recall_concurrency4Recalls that may run at once. A recall that waits 2 seconds for a slot gets 503 with Retry-After: 1. 1 to 1024.
list_sessionsfalseAnswer GET /api/sessions with the served sessions the presenting credential may list. Off, the route does not exist. With no credential configured it names every served session to anyone who reaches the port, and startup warns about it. The page’s session picker offers these names, plus “Other session…” for a name typed in; without the listing it asks for a name instead.
inherit_serve_credentialsfalseAlso accept every [[serve.credential]] as a read credential with the same scope as on lambo serve, so its "*" is serve’s, not every served session (see below). Its create, erase and admin are dropped: the window only reads. With no [[serve.credential]] nothing is imported, and startup says so; with no other credential either, the window stays unauthenticated on loopback.

An unknown key, an out-of-range value, an empty or repeated session name and a malformed host are refused when the file is read, so they stop every command, like any other invalid key. A numeric refusal names the key and its bound, never the value. At startup lambo serve-web prints the sessions and bounds it runs with. It checks the store’s schema at startup and loads each session on the first request that needs it. See Watch a session in a browser.

[[web.credential]] gives a bearer token its own read scope on lambo serve-web. It has the grammar of [[serve.credential]] without the write capabilities, and its tokens are separate: an agent’s write token is not a window token unless you set inherit_serve_credentials.

[web]
sessions = ["lambo", "rustydocs", "general"]
[[web.credential]]
name = "lambo-viewers"
token_env = "LAMBO_WEB_LAMBO_TOKEN"
sessions = ["lambo"]
KeyWhat it sets
nameA name for logs and refusals. default and local are reserved.
token_envThe environment variable holding the token. The token itself never goes in the file. Not LAMBO_AUTH_TOKEN.
sessionsExact session names, or "*" for every served session.
session_prefixEvery served session whose name starts with this and is longer. A session that is not served is never read, whatever its name.

Each credential reads only the served sessions in its scope, beside the default credential from --auth-token or LAMBO_AUTH_TOKEN, which reads them all. Once any credential is configured, every request needs a bearer, on loopback too, and the Host check is off. create, erase and admin are refused by name, as is an inline token, a credential without token_env or scope, and a name or token_env used twice. lambo serve-web reads each variable at startup and exits 2, naming the credential and never a value, when one is unset, holds a token no request could present, or holds the same token as another credential or as LAMBO_AUTH_TOKEN. [embedder] api_key_env may not name a token_env.

An inherited [[serve.credential]] reads what it reaches on lambo serve, within the served sessions: its exact names, its prefix, and for "*" the [serve] sessions of the same lambo.toml plus every session under any [[serve.credential]] prefix. That is what "*" means to lambo serve, which attaches those sessions on demand. It never means every session [web] serves: with [serve] sessions = ["lambo"] and [web] sessions = ["lambo", "hr-private"], an inherited "*" reads lambo and gets the 404 for hr-private. A session that lambo serve pins only with a --session flag is not in the file, so an inherited "*" does not reach it; name it in [serve] sessions or give the window its own [[web.credential]]. With list_sessions, an inherited "*" lists the served sessions in [serve] sessions, never one it reaches only through a prefix.

A browser cannot send the header itself; see Watch a session in a browser.

Every variable below overrides the matching file key. An empty value counts as unset, so an empty placeholder in a .env file leaves the file value alone.

VariableOverrides
LAMBO_CONFIGThe path to lambo.toml.
LAMBO_STOREstore.kind
LAMBO_SQLITE_PATHstore.path
LAMBO_COCKROACH_DSNstore.dsn, for kind = "cockroach".
LAMBO_POSTGRES_DSNstore.dsn, for kind = "postgres".
DATABASE_URLstore.dsn for either wire-Postgres kind, when that kind’s own variable is unset. Never consulted for memory or sqlite.
LAMBO_EMBEDDERembedder.kind
LAMBO_EMBED_DIMembedder.dim
LAMBO_LLAMA_EMBED_URLembedder.url
LAMBO_LLAMA_MODELembedder.model
LAMBO_EMBED_API_KEY_ENVembedder.api_key_env. Like the key, it holds a variable name, not the token.
LAMBO_EMBED_KEEP_WARM_SECSembedder.keep_warm_secs
LAMBO_ACCEPT_CLIENT_VECTORSembedder.accept_client_vectors (true or false)
LAMBO_EMBED_CANARYembedder.canary (verify, warn or off)
LAMBO_PROMOTION_POLICYpromotion_policy (Swarm or Solo, any case)

Keep connection strings in the environment rather than in lambo.toml.

Cargo features decide which stores and embedders exist in your binary, and lambo.toml picks among them at runtime. Asking for a kind that was not compiled in is a startup error, not a fallback.

FeatureWhat it adds
store-memoryThe in-memory store.
store-sqliteThe SQLite store.
store-cockroachThe CockroachDB store.
store-postgresThe PostgreSQL + pgvector store. Fail-closed until B2 lands schema init.
recall-elasticThe Elasticsearch recall tier ([recall]). Released binaries include it.
embed-fixtureThe deterministic fixture embedder.
embed-bgeBGE-M3 embeddings through llama-server.
embed-eg2EmbeddingGemma 2 text and image embeddings through llama-server (kind = "embeddinggemma2"). Builds on embed-bge. Released binaries include it.
embed-bedrockReserved for Amazon Titan embeddings through Bedrock. Not implemented yet.
fixturesCommitted fixture graphs, which also enables store-memory.
demoA convenience profile covering the memory and CockroachDB stores, both local embedders, and the fixtures.

The default build enables store-memory, embed-bge, embed-fixture, and fixtures.

The HTTP transport binds to 127.0.0.1 on port 7700 and serves Streamable HTTP. Change the address with --bind and --port on lambo serve.

On loopback the HTTP transport is unauthenticated by default, so a local client needs no token. Binding anywhere else fails closed: lambo serve refuses to start unless you configure a bearer token, either with --auth-token or the LAMBO_AUTH_TOKEN environment variable (which takes precedence over the flag), or with a [[serve.credential]]. Configuring any credential also ends the unauthenticated loopback default. When a token is configured, every request must send Authorization: Bearer <token>. A missing or wrong token is refused with 401. A LAMBO_AUTH_TOKEN that is set but empty, or not valid UTF-8, is a usage error, not a silent allowance. The token is ignored on --transport stdio.

The HTTP transport enforces a sustained request rate for each credential, 50 requests per second by default with a burst allowance of twice that, so one credential’s flood never rate-limits another. A request over the limit is refused with 429 and Retry-After: 1. Adjust it with --rate-limit-rps; 0 turns it off. With several sessions, each session also has its own bucket, [serve] per_session_rps; see Multi-session serving.

The HTTP transport mints one MCP session per initialize and refuses to exceed a ceiling of 32 concurrently live MCP sessions by default, counted across every Lambo session the process holds. With several credentials, each may hold at most its share: the ceiling divided by the number of credentials, rounded down, at least 1. At the cap, or at a credential’s share, a new initialize is refused with 503, Retry-After: 5 and a message that tells you to close an idle session or raise --max-sessions. An MCP session answers only the credential that opened it.

A request whose declared Content-Length exceeds 4 MiB is refused with 413 before its body is parsed, and so is a body that turns out longer as it arrives (a chunked one included). The whole body must arrive within 30 s of the serve starting to read it, or the request is refused with 408; a body still arriving holds no slot of the session cap.

lambo serve-web opens a read-only browser window onto one or more sessions on port 7710 and never takes the writer lease, so it runs safely beside the lambo serve writer. It shares the same auth posture: loopback is unauthenticated by default, and a non-loopback bind requires LAMBO_AUTH_TOKEN, --auth-token or a [[web.credential]] and fails closed without one; without a credential it answers only loopback Host names and [web] allowed_hosts. It reads through a short-lived per-session view bounded by [web]. See Command line.

Before you serve against SQLite or CockroachDB, create the schema.

Terminal window
lambo --config lambo.toml provision

provision reads the store choice from your config and does the right thing for each kind. It is idempotent, so running it again is safe, and it does not build the embedder, so it works on a machine with no embedding service. It still refuses a store kind your binary was not built with.

Store kindWhat provision does
memoryNothing. It reports that the in-memory store needs no schema.
sqliteCreates the schema in the file at store.path.
cockroachRuns scripts/provision.sh, which also reconciles the vector index.

You can also run the script directly.

Terminal window
./scripts/provision.sh

Set LAMBO_COCKROACH_DSN in a .env file first, and run ./scripts/provision.sh --check to verify the schema without applying it.

See Installation and Library API.

Config::canonization_coverage_window is a library setting, defaulting to fourteen days. Stage 2 divides eligible dependent origin time spread by min(session extent, this cap), with the existing three-origin and 0.3 coverage bars. The session extent is its about-time extent, from its earliest to its latest interaction by event time, else creation time. When that extent exceeds the cap, 4.2 days of spread pass at the default; a session whose extent is fourteen days or less keeps its previous coverage. This cap does not expire older evidence. Set a duration of at least one day, in whole milliseconds, no larger than i64::MAX milliseconds, and validate the Config before use. It is not a lambo.toml key or an environment override. The file’s daemon cadences and promotion policy retain their existing roles. See Library API for the store accessor contract and custom-window evaluation and inspect helpers.