Skip to content

MCP tools

Lambo is agentic graph memory. It runs as an MCP server that exposes seven tools your agent can call to store what it learns, recall relevant prior memory, and coordinate edits. A running lambo serve --session <id> process is the single writer for that session, and your agents connect to it as MCP clients.

Lambo serves over stdio by default, and over HTTP when you ask for it.

| stdio | lambo serve --session demo | The default. Works with any MCP client through the standard mcpServers configuration. | | HTTP | lambo serve --session demo --transport http | Streamable HTTP on port 7700, bound to 127.0.0.1. Change them with --port and --bind. |

On loopback the HTTP transport is unauthenticated by default. Binding anywhere else fails closed and requires a credential: a bearer token (--auth-token or LAMBO_AUTH_TOKEN) or a [[serve.credential]]. It also applies a rate limit per credential, a ceiling on concurrently live MCP sessions, and a request-body limit. See Configuration for the knobs. One HTTP serve can also hold several sessions, each at its own URL, /mcp/s/<session>; /mcp serves the default one. See Several sessions in one serve.

Starting a second lambo serve for the same session no longer fails. The first process to start holds the session and becomes the hub; later ones connect to it over a local socket and forward every tool call, so each client gets full read, full write, and read-your-writes on request against one graph rather than one client getting memory and the rest getting none. “On request” because writes are applied in the background and acknowledged with a receipt (see lambo_derive); waiting on that receipt works through the hop exactly as it does in the hub. There is nothing to configure — point each client at lambo serve --session <id> the ordinary way.

Two things are worth knowing. Writes still happen in the holder, so lambo_stats reports the holder’s process; and if the holder dies, forwarded calls return a clear error saying nothing was read or written and that memory returns once a lambo serve holds the session again — your agent should carry on rather than block on it. Forwarding is local-only and applies to the stdio transport; a second --transport http serve on a held session still refuses to start, and says which process holds it.

The socket lives in a private per-user directory: $XDG_RUNTIME_DIR/lambo if that variable is set, otherwise /tmp/lambo-<uid>. Those are the only two, and TMPDIR is deliberately not among them — some clients pass it through to the servers they spawn and some scrub it, so keying a shared address on it made two clients on one machine disagree about where the socket was. The directory must be mode 700 and owned by you; lambo serve creates it that way and refuses both to bind into and to forward into one that is not, since a socket in a directory other accounts can reach would let any local account issue writes against your session. If you see that refusal, remove the directory or point XDG_RUNTIME_DIR at one you own. A very long XDG_RUNTIME_DIR can also leave no room for a socket address (the limit is 104 bytes for the whole path); a serve on such a machine still serves its own client normally and logs the reason, and only the forwarding stops working.

Clients that disagree about the directory anyway still work. The socket’s file name is derived from the session and the store, so a serve that finds a holder listening somewhere else forwards to wherever the holder actually published, and refuses only when the name differs — which means a different session or a different store, not a different environment.

The socket file itself is disposable. Deleting it while a holder is running makes that holder unreachable to other clients until it restarts — the holder keeps working for its own client and no data is at risk. Deleting one left behind by a crashed process is always safe; so is leaving it, because the next holder clears it under the session lease.

HTTP for a machine with several independent clients

Section titled “HTTP for a machine with several independent clients”

Stdio is the default transport, and it is the right one for a single orchestrator and its subagents — that is one connection to one server and works through the client’s mcpServers entry with nothing else to do. The moment a machine runs more than one independent client — OMP and Claude Code and Pi, say, each a distinct product — reach for HTTP instead. Run one lambo serve --session <id> --transport http and point every client’s mcpServers at that one URL, so a single process owns the session as its writer rather than each client spawning its own stdio serve that then has to proxy to the holder. The reason for the switch is single-writer, not subagents: the plurality that makes stdio the wrong default is independent clients on one machine, not the number of agents under a single orchestrator.

Moving a client from command to url is a migration across every config layer that names the server, and a stale entry anywhere rejects it. A client reads the project .mcp.json, the user or global scope, and the per-client files — Pi’s ~/.pi/agent/mcp.json, Cursor’s ~/.cursor/mcp.json, an OMP workspace .mcp.json, Codex’s ~/.codex/config.toml — and a layer that still holds command beside the new url is rejected even when the endpoint itself responds. Migrate by copying the registration into each layer that names the server; never by editing one file and assuming the others pick it up.

Every tool takes an agent_id, and every tool rejects any argument it does not define, so a typo fails rather than being ignored.

You manage ordering. A read sees a write only after that write’s tool call has returned. If you want a recall to see something you just derived, call lambo_derive first, wait for it to return, and then call lambo_recall.

Any string you send is limited to 16 KB. Lambo also refuses two classes of character. In both cases it names the offending codepoint and never echoes the raw byte back to you.

  • Control characters. Tab and newline are the only ones you may send. Every other control character is refused, so a stray U+0000 or an ANSI escape sequence cannot reach stored content and corrupt whatever renders it later.
  • Invisible formatting characters. Lambo refuses bidi overrides such as U+202E, the zero-width family such as U+200B, blank-rendering fillers such as U+3164, the byte order mark, and tag characters. These paint nothing on screen, so a concept carrying one reads as innocuous to you while reading differently to a model.

Invisible characters that real writing needs stay allowed. The zero-width joiner and non-joiner, the variation selectors, and the combining grapheme joiner all pass, so Persian, Indic, and emoji text goes through unchanged.

{"content": [{"type": "text", "text": "concept.content contains a disallowed invisible formatting character (U+202E); bidi overrides, zero-width characters, blank fillers, the BOM and tag characters are refused because they are invisible in review but not to the model (zero-width joiner and non-joiner are allowed, and are stripped from canonical keys)"}],
"isError": true}

Send the id of the agent making the call. Lambo honours it: your work is recorded in the graph under exactly that id, and your soft locks are held under it. One lambo serve process serves any number of agents, so several clients can share one server without any of them losing its identity. The process’s own --agent is what it holds the session lease and writes its heartbeats under; it is not applied to your calls.

The id is caller-asserted and unverified. Nothing authenticates it: over stdio the client owns the process, and over HTTP one bearer token authenticates the server rather than each agent. So identity here is a cooperative declaration, which is the same trust level Lambo’s soft locks already have — they are advisory and live in RAM. In practice:

  • Use one stable id for an agent’s whole life, never one per call, per task or per topic: your model or client id, such as claude-opus-5-5, gpt-5.6 or codex-agent. Ids such as b1-review or implement-m12 scatter one agent’s work across many names in the ledger.
  • Send the id on one line, at most 256 characters. agent_id refuses newlines, tabs, any other control character, the Unicode line and paragraph separators U+2028/U+2029, and length over 256 even though other strings accept some of them, because your id is rendered into other agents’ context blocks as the holder of your locks: a line break there would let you write lines into someone else’s context, and a very long id could push the block it annotates out of a budgeted context entirely.
  • Distinct ids contend honestly: one agent reserving a node another holds gets a conflict that names the holder and the expiry, so it can wait or work elsewhere.
  • Two callers sending the same id share one lock and can release each other’s. That is by design, and worth knowing before you let two clients default to the same name. Concurrent agents that must keep out of each other’s edits need distinct stable ids, for example the model id plus a stable instance suffix (claude-opus-5-5-worker-2).
  • Every write ack (lambo_derive, lambo_derive_image, lambo_record_action) names the id the write is recorded under, in its text and as agent_id in its structured content, so an agent sending a per-task id sees it at the point of use.

Errors come back as normal tool results with isError set to true and a short reason. Lambo never puts internal detail such as endpoints, connection strings, or driver messages into an error, and logs the full detail server-side instead.

What went wrongWhat you see
A missing, unknown, or malformed argumentThe parameter error naming the field, before the tool runs.
A value outside its rangeThe bound, such as top_k must be in 1..=100.
A string over the size capThe field, the cap, and the size given.
A string carrying a control or invisible characterThe field, the offending codepoint, and which class of character was refused.
A focus that matches several conceptsThe candidate list with node ids, so you can name one exactly.
An agent_id that is not a single lineThat the id must be one line with no tabs, control or line-separator characters, and the codepoint found.
An agent_id over 256 charactersThe cap and the length given.
A lock another agent holdsWho holds the node and until when, and that nothing was reserved or released.
The server losing its single-writer leaseThe class conflict and a note that the detail was logged. The holder and expiry are shown only for a lock another agent holds; the lease’s own state is not model-facing.
A store or embedding failureThe class of failure, such as store error, and a note that the detail was logged.
A vector read refused because the session’s embedding contract changed mid-query, or the vector width differsstore error, and that you should re-check the session’s embedding contract (lambo_stats) and retry. Nothing from the refusal is shown. Most often seen on lambo_recall with image or query_vector, which fails rather than answer from the other legs.
An image whose caption and image_id a text concept already holdsimage id taken, your image_id, and the fix: choose another image id. On lambo_derive_image this arrives on the receipt.
A lambo_derive depends_on or against, or a lambo_record_action against, that does not resolve to exactly one concept of this session, or a lambo_record_action depends_on that names an interaction, a short id several concepts share, or an id only another scope holdsreference refused, the field and the reference, and why: it matches nothing, matches several (listed with their ids), is an interaction’s id, is in another scope, names the concept itself, would close a dependency loop (depends_on only), is both cited and contradicted by one concept of the call (against only), or (on the receipt) named something else when the write was applied. Always ends “nothing was written”.
A call this server is not configured forconfiguration error and the setting to change, such as [embedder] accept_client_vectors = true. Only lambo_derive_image, and lambo_recall with image or query_vector, answer this way.
A handler faultAn internal error, with a note that the call had no effect beyond anything already written.

These are the seven tools your agents call, and lambo_derive_image, which a server lists only when it can serve it (see lambo_derive_image).

Recall relevant memory for a query. This returns the Lambo context block, which is the set of memories that matter for the query, with markers for canonical facts, blast-radius warnings, conflicts, and contradictions. The block is the first text content of the result, and the individual hits come back as structured content alongside it.

Recall combines keyword matching with vector similarity, then expands through the graph. Which of those legs run depends on your store. Every concept your agents write through lambo_derive and lambo_record_action keeps its embedding. A store with vector search then finds memory your own agents wrote, not just memory you loaded in bulk.

StoreWhat recall uses
CockroachDBKeyword matching and vector similarity, plus graph expansion.
SQLiteKeyword matching and vector similarity, plus graph expansion.
In-memoryKeyword matching plus graph expansion.

Recall still works without a vector leg. You get matches on the words you use rather than on meaning, so a query that shares no vocabulary with a stored concept may miss it.

By default a hit’s query relevance is the highest of its raw keyword, vector and recent scores; an operator can rank by reciprocal rank fusion instead ([fusion]). With --ledger, a hit a query leg found carries fusion on its recall line: its rank in each leg it came from (bm25_rank, recent_rank, vector_rank) and both fused values (max, rrf), whichever ranked the recall.

ArgumentTypeRequiredRange or default
agent_idstringYes
querystringYes, unless image or query_vector is sentUp to 16 KB. Beside an image or a vector it is optional, and blank text counts as none.
top_kintegerNo1 to 100. Defaults to the configured value, which is 5.
max_tokensintegerNo1 to 100,000. Defaults to the configured value, which is 500.
traversal_depthintegerNo0 to 5. Defaults to the configured value, which is 2.
image{mime, data}No; at most one of image, query_vectorRecall what is close to this image. The same shape and limits as lambo_derive_image’s image. Needs an embedder that embeds images.
query_vector{values, contract}No; at most one of image, query_vectorRecall what is close to a vector you computed. The same shape and checks as lambo_derive_image’s vector. Accepted only when the operator enabled client vectors.
scopestringNoRead one session alone: this session, or one of its layers (see below). Omitted, a layered session reads itself and its layers together. Any other value is refused, listing this session’s own scopes.
{"name": "lambo_recall", "arguments": {"agent_id": "agent-a", "query": "stdio wire check"}}
{"content": [{"type": "text", "text": "stdio wire check [Entity] (score 0.86, id ddbc8bc8)"}],
"structuredContent": {"context": "stdio wire check [Entity] (score 0.86, id ddbc8bc8)",
"hits": [{"node_id": "ddbc8bc8-52ed-465b-9a2a-b0066bda51ad", "content": "stdio wire check",
"concept_type": "Entity", "score": 0.8565231086776713,
"is_canonical": false, "blast_radius": null}],
"warnings": []},
"isError": false}

When the top hit is longer than max_tokens. context holds whole hit blocks in rank order, as many as fit the budget, and stops at the first block that does not fit: a lower-ranked block never follows a skipped one, even when it would fit on its own. Tokens are estimated as ceil(bytes / 3.5) over the rendered text, blank-line separators included. If the first block alone is over the budget, context is not left empty. The concept’s text is cut at a character boundary and marked with …, the rest of the block (type, canonical marker, score, id, blast radius and warning lines) is kept, and a marker line follows:

user schema owns the account table, the session table and… [Entity, canonical] (score 1.44, id 3f9c2a10, blast radius 8)
⚑ Load-bearing pillar — 8 nodes depend on this. Modify with caution.
[truncated: 1342 tokens left out; lambo_inspect 3f9c2a10 for the full concept]

The count estimates the text of this block that was left out, and the id is the hit’s short id, so lambo_inspect with that id as focus returns the whole concept. If the warning lines do not fit, they are left out of the block (they are still in warnings); if the label line does not fit either, only the text and the marker are shown. Nothing follows a truncated block, and hits still carries every hit with its full content. In structuredContent, that hit carries "truncated_in_context": true; no other hit has the key. The marker counts toward max_tokens, so the context stays within the budget. A budget too small for the marker itself, about 22 tokens, still gives an empty context.

Dependency questions. A query such as “what depends on X”, “dependents of X”, “blast radius of X”, “is it safe to delete X” or “what uses X” is answered by graph traversal when X names a concept that other concepts depend on. The hits are X’s direct dependents, and the context says the answer came from graph traversal. X’s whole text must appear as whole words, ignoring case after NFC normalization. Unicode letters, digits, combining marks and underscores are word characters; a hyphen between word characters joins an identifier. Thus db does not name dbadmin or user_db, and SG-Base does not name SG-Base-VPC. An edge that is punctuation needs no word boundary on that side (.env matches in config/.env); src/main.rs has word edges and needs boundaries on both sides. A name appearing only inside a longer named concept is skipped (db inside user db schema), but a clause overlapping the question marker cannot hide its subject. Among separate names, the name starting nearest after a marker wins, then Entity/Resource type and length break ties: “is it safe to delete SG-Base-VPC in VPC-Enterprise-Prod” names the security group. Marker phrases and function-word-only concepts do not anchor; an uppercase acronym such as IT is an exception only when written uppercase in the query. Ordinary recall may also omit function words from keyword matching. Queries over 16 KiB or a candidate with over 256 bounded occurrences fall through to ordinary recall. A layer’s traversal answer passes its relevance floor only if its anchor equals the caller’s resolved anchor, ignoring case and NFC; when the caller resolves none, a layer may answer its own named anchor. A question that names no concept but says “security group” picks the security group with the most dependents. Any other question is answered like an ordinary query.

Contradiction warnings. When a hit has been contradicted (another concept named it in against, see lambo_derive) more recently than anything that supports it, recall warns on that hit and shows the contradicting concept beside it:

user schema [Entity, canonical] (score 1.95, id f0000000, blast radius 14)
⚑ Load-bearing pillar — 14 nodes depend on this. Modify with caution.
⚠ Contradicted at 2026-08-10T10:28:00Z by agent-c: "accounts replaced the users table" (id 8c41d0e2)
accounts replaced the users table [Observation] (score 0.36, id 8c41d0e2)
⚠ Contradicts "user schema" (id f0000000)
  • When it fires. A contradiction of the hit is strictly newer than its latest supporting evidence: the concepts that depend on it (they cite it in depends_on or record_action depends_on) and its parent_of children, each dated by the write that declared it, counted once that write is at least the canonization age floor old (canonization_edge_min_age, 60 seconds by default, measured from when it was written, whatever its event_time), and never counting a concept written with the hit itself. The contradiction must also be newer than the hit itself, with or without evidence, so a citation backdated before the hit does not let an earlier contradiction warn. An older contradiction that later evidence outweighed stays quiet. Any hit can be flagged, whatever its status; through 0.7.0 only Canonical and Venerable hits were (#179). The warning is found by the background daemon, so it appears from the daemon’s next cycle after the write, not on the write’s own next recall.

  • Dates. Both sides use about-time: a write’s event_time if it gave one, otherwise when it was written, but never later than when it was written (an event_time in the future counts as the write’s own time). So a historical ingest that backdates a contradiction before the citations that outweighed it reads as outweighed. Sending the same contradiction again does not re-date it: to restate it, write a concept with different content (the same content is the same concept and the same edge). Likewise, sending an existing citation again reinforces it without counting as new evidence: only a new dependence, a concept that did not depend on the hit before or a new parent_of child, outweighs a contradiction.

  • Your own evidence does not count against you. Support written by the agent that declared the contradiction, or by the contradicting concept itself, does not silence it.

  • One per hit. A hit contradicted several times shows the newest contradiction (then the contradicting concept’s canonical key, then its id). lambo_inspect lists them all.

  • Never shown alone. A contradicted hit is never returned without the concept that contradicts it while that concept is still in the session (one that was removed is named by id on the hit’s line). How the two get into the result depends on the hit’s status:

    • a Canonical or Venerable hit is kept past top_k, like a conflict, and so is the contradicting concept beside it;
    • any other hit has to earn its own place in the first top_k. If it does, the contradicting concept is added beside it, past top_k when it did not rank in on its own. That is at most one extra hit per contradicted hit: a recall can return up to twice top_k such hits, plus any kept for other reasons.

    A contradicting concept that is in the result only for this reason carries companion_of in structuredContent: the ids of the hits it contradicts. One that ranked in by itself carries the same line but not the key. The blocks still count toward max_tokens, so the contradicting concept’s block can be cut; the warning line names it by id and quotes its first 80 characters either way, and the line is in warnings too.

  • It lapses by itself. The warning is re-checked on every recall: it goes once newer supporting evidence ages in, or once the contradicting concept is removed. It changes no status or score, and the contradicted hit keeps its rank.

  • Layered sessions. A contradiction stays inside its session (cross-session against is refused). In a merged recall, a contradicted Canonical or Venerable hit and the concept beside it are kept past top_k; another contradicted hit needs a merged slot of its own, and when it gets one, the concept beside it is added past top_k. Each still needs its own relevance to pass the layer’s floor; a floored contradicting concept is still named on the hit’s line, which starts with the layer’s name. If dedupe merges a contradicted layer hit into this session’s copy of the same memory, the block shown is this session’s copy, and the layer’s contradiction line stays in warnings, prefixed with the layer’s name.

  • Not on dependency questions. An answer by graph traversal (a “what depends on X” question, above) lists dependents and carries no contradiction or conflict warnings; ask an ordinary query to see them.

  • The contradicting concept shows only that line. It is pulled in as context for the warning, so its own conditions (a conflict on it, or its own contradiction) are not checked, and a concept that contradicts it is not pulled in after it (one level, so a chain cannot grow the result); a recall that matches it directly shows them.

  • Keeping a concept alive. A concept that contradicts a Venerable or Canonical one is never collected by garbage collection while that one holds its status (owner decision), even after the warning itself has lapsed because newer evidence arrived: protection follows the status, not the warning. Since #197 a concept is also kept while it is the correction recall shows for a contradicted concept of any status that is still in memory; that protection follows the warning, ending when the contradicted concept is collected or newer evidence outweighs the contradiction, and two concepts that only contradict each other keep neither alive. Either way, any agent can use against to keep a concept alive by contradicting a long-lived one; operators watching store growth should know it.

  • The two lines are annotation kinds contradiction (on the hit) and contradicts (on the concept beside it) in the web portal’s REST API (/api/recall’s hits[].annotations) and on the --ledger recall line, which also gains contradiction_warning. structuredContent carries the hits without annotations (only companion_of, above); the lines are in warnings and the context.

Memories that rest on a contradicted concept. When a hit’s own depends_on cites a concept that is contradicted, by exactly the rule above, recall warns on the hit and names both the cited concept and the concept that contradicts it (#207):

list-all returns every memo [Logic] (score 1.38, id 4bf3835a)
⚠ Rests on a contradicted concept: "GET /api/v1/memo (list all)" (id 82c3dcb0), contradicted by "list-all removed in v0.22; the replacement pages at 10" (id e31def21)
  • Direct citations only. Only the hit’s own depends_on (on lambo_derive or lambo_record_action) counts. A parent_of link does not, and neither does a chain: a hit that cites B, where B cites a contradicted A, gets no line. A concept gets no line for a concept it contradicts itself.
  • A warning only. Nothing else about the recall changes: no hit is added, kept past top_k or moved, and neither the cited concept nor the concept contradicting it is pulled in for this line. Either one is still shown, with its own lines, when it gets into the result on its own.
  • Bounded. One line per contradicted concept the hit cites, in the order of the cited concepts’ ids, at most 3. A hit that cites more adds one line after them, such as ⚠ Rests on 2 more contradicted concepts; lambo_inspect on the hit shows its neighbourhood, its citations included.
  • Checked on every recall. The line is worked out when you recall, so it appears on the first recall after the against write (the cited concept’s own Contradicted line waits for the daemon’s next cycle), and it goes as soon as the contradiction lapses. A new citation of the contradicted concept is supporting evidence under the rule above: once it is old enough, the concept is no longer contradicted and no hit warns about it. Because it reads memory rather than the daemon’s list of warnings, it also shows when that list’s contradiction budget (#196) has dropped the cited concept’s own entry: the hit still warns that it rests on the cited concept, while the cited concept, if shown, carries no Contradicted line until its entry fits again.
  • Counts against max_tokens. The lines are part of the hit’s block in the context, so they use the token budget like #179’s lines do. Under a tight max_tokens a later hit’s block can then fall out of the rendered context (included_in_context false in the detailed recall); the lines stay in warnings, and hits, scores, order and top_k are unchanged.
  • Same scope as the contradiction line. It stays inside its session, a layer hit’s line starts with the layer’s name in a merged recall, and a dependency question (graph traversal) carries none.
  • Its annotation kind is rests_on_contradicted, in the web portal’s REST API (/api/recall’s hits[].annotations) and on the --ledger recall line. The line is in warnings and the context; lambo session verify reports it by that kind.

Duplicate hits. When the operator turns on [selection] skip_redundant (off by default, see Configuration), recall skips a hit that repeats one it already took, and the next distinct hit takes its slot (#181). A repeat is the same text (spacing and invisible characters aside, case kept, whatever the two concept types); with [selection] similar_signals = true (off by default), also two concepts joined by a semantic match, or vectors at or above the session’s merge threshold. The hit kept names what it replaced, after its id:

dogfood store carries no durable embedding for 92 of 100 concepts [Entity] (score 1.38, id 4bf3835a, also 82c3dcb0)
  • Never a repeat: a pair one of which contradicts the other or is suspected of replacing it (the possibly-superseded pair below), and two texts that differ in an identifier (snake_case, CamelCase, all-caps, a path, version or hash; case counts), a number, a negation, a word against its opposite (enable and disable), or the order of their shared words.
  • Never skipped: a hit kept past top_k for a warning, and a hit with a warning line of its own (the copy kept may not carry it).
  • Bounded. Only the first min(20, 4 × top_k) hits are checked; the label names at most three ids, then +N more. Each id is an inspect focus. The also items count against max_tokens.
  • Nothing else changes. With the setting off, or nothing repeated among the hits checked, the result is byte-identical. Scores and the order of the hits kept are unchanged.
  • In structuredContent, the hit kept carries skipped_redundant: each skipped hit’s node_id and the signal that found it (same_content, semantic_edge or cosine); no other hit has the key. The web portal’s /api/recall and the --ledger recall line carry the same key. In a layered recall each session skips inside its own hits, and the also items follow from <session>; when a layer’s copy merges into a nearer hit, what that layer skipped for it is named after it, also <id> in <session>, and carried as also_skipped_redundant on the hit in structuredContent and on its --ledger line: each skipped member’s session (the one its id resolves in), node_id and signal. That key is absent when no merged copy skipped anything.

Possibly superseded, not declared. A newer fact written without against used to leave the older one ranking with no warning. Since #198 a write that is a near vector match of an older concept of the same type (logic, constraint or observation), which the merge refused because their identifiers differ, and whose text differs from it in one value after the predicate, records that it may replace that concept. When both are in the same result, the older hit says so:

the embedder listens on port 8080 [Logic] (score 0.91, id 5e6f7a8b)
⚠ Possibly superseded by "the embedder listens on port 8081" (id 1a2b3c4d), not declared
  • A suspicion, not a declaration. It is Lambo’s reading of two texts, not a writer’s against. A change before the predicate is a different thing, not a new value (“J1 round-1 verdict: …” and “J2 round-1 verdict: …” are never flagged), and so are two values changed at once. The later concept, by the date its fact is about (a backfilled write with an older event_time counts as older), is the possible replacement; two concepts dated alike are not flagged.
  • Only when both are shown. The line is on the older hit only when the newer concept is in the same result, at most one line per hit (the newest replacement). Nothing is added, kept past top_k or moved for it, and it changes no score, status, merge or garbage collection.
  • Only where it was measured. The line is shown under an embedding contract whose trigger precision was measured at 0.90 or better: BGE-M3 (0.96 on the held-out labelled pairs). Under EmbeddingGemma 2, not yet measured, the pair is only in the structured field below. [embedder] suspected_supersession_line (see Configuration) can show it for every contract (on) or none (off).
  • It lapses. Writing an explicit against or depends_on between the two removes it, and so does collecting either concept. Only new writes are flagged; lambo canon suspected reports the pairs an existing store already holds.
  • It never makes a memory stale: the stale-citation line above follows declared contradictions only.
  • Its annotation kind is possibly_superseded, in the web portal’s REST API (/api/recall’s hits[].annotations) and on the --ledger recall line. The older hit also carries possibly_superseded_by (the newer concept’s id) in lambo_recall’s structuredContent.hits[], in the REST API and on the --ledger hit line whether or not the line is shown (absent when there is no pair, so other responses are unchanged), and the ledger line gains possibly_superseded. lambo session verify reports the kind either way. A derive receipt says N possible undeclared supersession(s) flagged when a write flagged one.

Superseded concepts. When the operator turns on canonization_supersede (off by default, see Configuration), a contradiction can do more than warn: once the contradicting concept has earned its own newer support (dependents from at least three other interactions, newer than the Canonical concept’s latest support, with enough spread), the canonization cycle demotes the Canonical concept to ordinary memory. A contradiction with no newer support never demotes anything, and the contradicting concept is not promoted by it. From then on, while it is not Canonical again, a recall that matches the demoted concept shows its history:

⤓ Superseded at 2026-09-30T12:00:00Z by "flushes give up after three attempts" (id 8c41d0e2)

The line is history, not a warning: it never pulls the concept into a result or past top_k, it counts toward max_tokens like any line, and it quotes the superseding concept only while that concept is still in memory (the id names it either way). A later budget demotion replaces it. Its annotation kind is superseded (portal REST API and --ledger line). lambo_inspect on the concept ends its canonization section with last demotion <time>: superseded by "..." (id ...), or budget. While the newer side still holds, the demoted concept, or any Venerable a contradicting concept would supersede at once, waits at Venerable: lambo_inspect shows held: contradicted by <id> with newer evidence. Support written by the agent that declared a contradiction never counts for the concept it contradicted, so an agent cannot defend a belief against its own contradiction; with one writer (or agents sharing an agent_id) only another agent’s support can release it.

Layered sessions. When the operator layers a session with a shared one ([serve] layers, see Layers), lambo_recall reads both in one call and merges them under one max_tokens budget. A layer hit’s block names its session, and a memory found in both keeps the session’s own copy and names the other:

release notes are drafted before the tag [Logic] (score 0.71, id 57fc442c, also 9f8e7d6c in general)
worktree branches are always rebased onto origin main first [Logic] (score 0.42, id b86d4709, from general)
  • Every hit of a layered session carries session in structuredContent.hits[], the session it is from; its node_id and short id belong to that session. Pass that session as scope to lambo_inspect to inspect a layer hit. An unlayered session’s hits never carry session, and its answer is unchanged by layering.
  • score is each hit’s own session’s score. Scores are not comparable across sessions, so hits is in merged order, which is not monotone in score.
  • scope reads one session alone, as its own plain recall: "general" reads the layer, the session’s own name reads only the session. A recall with scope = "general" adds one warning, recall: these hits are from layer general, and their ids belong to it; pass scope = "general" to lambo_inspect to inspect one. A direct read of a layer draws on the layer’s own per_session_rps; a merged recall draws only on the addressed session’s.
  • A layer that is not serving right now leaves a merged recall to the session’s own hits with one warning, recall: layer general is unavailable; results are from lambo only. A scope naming it is refused: retryably while another writer holds its lease, it is being detached or it is over its rate; as permanent, telling you to ask the operator, when its attach failed for good or it was erased.
  • A layer’s warnings start with its name, such as [general] . A truncated layer block’s marker reads lambo_inspect <id> (scope general).
  • A recall by image or query_vector reads one session: on a layered session without scope it reads the session itself and says its layers were not searched.
  • With --ledger, a layered session’s recall line names each hit’s session and counts hits per session in session_hits; a line carries scope when the call sent one.

Recall by image or by vector. With image or query_vector, the vector leg searches by that image’s or vector’s embedding instead of the text’s, so “this outfit is close to the one you dismissed for Onam” is a recall by the photo of this outfit. The server embeds an image with no prompt, the way it embeds an image it stores, and normalizes a client vector to unit length after checking that its contract is exactly the session’s embedding_contract, as lambo_derive_image does. Text sent beside it still matches words through the keyword leg. With no text the keyword leg finds nothing and recent interactions are left out: they say nothing about the image, and their fixed score would rank whatever was stored last above a real match whose similarity is lower. With text, recent interactions are included as for any recall. A structural question such as “what depends on X” is answered by the blended recall here, not by graph traversal, since traversal would ignore the image.

  • The vector leg is the point, so a store without vector search, an embedder that cannot embed images (for image), or client vectors turned off (for query_vector) is refused with a configuration error naming what is missing. An image the embedder fails on, or a vector search the store fails, is an error, not an answer without the vector leg.
  • Nothing is kept. The image and the vector are not stored and not cached (a text query’s embedding is cached per session, an image’s is not), and the --ledger line says only by: "image" or by: "vector". Refusals never repeat what you sent.
  • These two fields are in lambo_recall’s schema on every server, whatever its embedder, so every session one server holds lists the same lambo_recall.
{"name": "lambo_recall", "arguments": {"agent_id": "dresscode",
"image": {"mime": "image/png", "data": "iVBORw0KGgo..."}}}

These are tool argument objects for a client connected to /mcp/s/lambo, with lambo configured to read general. Use your actual model name as the stable agent_id across all calls:

{"agent_id":"gpt-6.1-sol","query":"worktree policy","top_k":5,"max_tokens":500}

This reads the configured stack. A returned hit with session: "general" belongs to the layer; use its node_id (or short id) and the same session:

{"agent_id":"gpt-6.1-sol","focus":"<id from the general hit>","scope":"general","depth":1}

To read a single session’s memories with lambo_recall:

{"agent_id":"gpt-6.1-sol","query":"worktree policy","scope":"general"}

Set scope to "lambo" for the project alone. It does not select another arbitrary served project: "rustydocs" is refused unless it is this session or a configured layer. An empty string is not the same as omitting scope. Scoped recall renders the selected session’s context alone, without from general on its blocks. Through a layered endpoint, every structured hit still carries session, regardless of scope. An unlayered endpoint, such as a direct connection to unlayered /mcp/s/general, omits that field. A direct layer read through the project’s endpoint also supplies a warning naming the layer; the warning does not replace its hits’ session fields. MCP scope is not a CLI flag and is not accepted by the write tools yet. To write to another session today, deliberately connect to its own endpoint; a read of general does not redirect later project writes.

Store new concepts learned from the current interaction.

A concept is one fact, stated atomically and short: short enough that a later restatement of the same fact matches it instead of minting a new node. A decision with its reason is a good concept. A paragraph narrating what you did and found is not: it never matches anything, so every such write creates a node and the graph fills with one-off prose.

  • Good: Lease TTL is 45s because it must exceed the 15s shutdown budget.
  • Bad: Today I looked at the lease code, found the TTL was 45s, checked it against shutdown, ran the tests and they passed, so I think it is fine.

Activity (what you did) goes to lambo_record_action. To group facts under a decision, send each fact as its own concept and add parent_of pairs with the decision as parent and each fact as child.

Citing what a concept rests on. When a concept applies or follows one already in memory, such as a decision that applies a ruling, list the ruling in that concept’s depends_on. Lambo records that the concept depends on the ruling, which is what makes a ruling load-bearing: its dependents count toward its blast radius and its canonization evidence, and recall warns before work that would break it. Cite the ruling by the id recall shows (id 8baad4e0, exactly 8 hex characters) or its full node id in the hyphenated form; its exact content also works. Any other hex string, such as a longer prefix or the unhyphenated form, is read as text. Names and synonyms are not reference forms:

  • Each reference must match exactly one concept that already exists in this session. A reference that matches nothing, matches several concepts, is an interaction’s id, names the concept itself, or would close a dependency loop refuses the whole call before the ack, with a reference refused error that says which reference and why, and nothing is written. A call cannot cite a concept it creates itself: derive the ruling first, then cite it.
  • Lambo never guesses. Unlike lambo_inspect’s focus, a reference never matches by substring, and a missing reference is never created.
  • A reference that names a concept only in another session the server reads, such as a shared general layer, is refused with is in scope "general"; cross-session citations are not recorded. Dependence is counted per session.
  • What each reference named is fixed when the call is accepted. If the cited concept is removed, or its short id comes to match a second concept, before the write is applied, the write fails on its receipt instead of linking something else; the same holds when a write waiting in the queue is applied after a restart.
  • Sending the same citation again reinforces the one dependency rather than adding another.
  • If the concept being derived is merged by similarity into the very concept it cites, that citation is dropped (the merge already ties the two) and counted as a reinforcement; the rest of the call applies.

Saying an existing concept is wrong. When a concept contradicts one already in memory, such as an observation that shows an older belief to be wrong, list the older concept in that concept’s against. Lambo records the contradiction (a Contradicts edge) from the concept that declares it to the one it names: the direction comes from this write, not from which concept is older, and the contradiction is dated by this write’s about-time: its event_time if you gave one, otherwise when it is written, and never later than when it is written (sending it again does not re-date it). It is not evidence for or against either concept: it adds no dependent and no blast radius, changes no score or promotion stage on its own (with canonization_supersede on, the contradicting concept’s own newer support can demote a Canonical one, see Superseded concepts under lambo_recall), and does not make a conflict line (a conflict stays about concurrent edits). It is still a fresh write to the contradicted concept, so a contradicted load-bearing concept gets the short-lived high-risk alert. It keeps nothing connected for garbage collection, with two exceptions: a concept contradicting a Venerable or Canonical one is kept while that one holds its status (an owner decision; it also means any agent can keep a concept alive this way), and a concept is kept while recall shows it as the correction of a contradicted concept that is still in memory (#197), so a stale fact never comes back without its correction. That second protection ends when the contradicted concept is collected or newer evidence outweighs the contradiction, and two concepts that only contradict each other keep neither alive. Like the first exception, it lets any agent keep a concept alive by contradicting a long-lived one. Recall then warns about the contradicted concept, whatever its status, and shows this one beside it (see Contradiction warnings under lambo_recall). Two concepts may contradict each other. Each reference is resolved exactly like a citation, by the id recall shows, the full node id or the exact content, and refused the same ways, before the ack and with nothing written: it must match exactly one concept that already exists in the session the write goes to (this one, or its scope), other than the concept itself, and not one the same concept also lists in depends_on (citing and contradicting one concept in one call is refused; separate writes may do both). Like a citation, what it named is fixed when the call is accepted, and the write fails on its receipt if that concept is removed or shadowed before the write is applied. A reference to a concept only another session holds, such as a shared general layer, is refused as “cross-session contradictions are not recorded”: to challenge a ruling there, write the assertion in that scope deliberately. A concept is never merged by similarity into one it names in against (#193); through 0.7.0 it could be, and the contradiction was kept beside the merge. Sending it again reinforces the one edge. At most 8 per concept and 32 per call, counted apart from depends_on.

Replacement candidates. Most real replacements are reworded, so the possibly-superseded rule (see Possibly superseded, not declared under lambo_recall) rarely flags them, and the writer usually knows what it replaced but does not say so with against. A hybrid derive’s settled receipt therefore lists, for each concept the write created, the existing concepts it most likely replaces, so you can cite one in against on your next call:

applied — derived 1 concept(s): 1 created (1 embedded), 0 matched existing; 1 replacement candidate(s): existing concepts this write may replace (nothing recorded; cite one in against on a later call only if it really is replaced)
- 5e6f7a8b "the embedder listens on port 8080" (cosine 0.8684, for 1a2b3c4d)
  • Which concepts. A vector hit on the new concept at or above the merge threshold the write used (the calibrated one where it applies: 0.85 under BGE-M3, 0.93 under EmbeddingGemma 2), of the same session, the same type, logic, constraint or observation, and the same recorded repository scope, with no against or depends_on between the two. The repository condition filters only once a scope is recorded on these types; today only a path-like resource records one, so it never excludes a candidate. Never a concept the same call wrote or re-derived, or an image concept. The concept the write merged with is listed, marked merged: a similarity merge does not declare a replacement, and a reworded reversal that names no conflicting identifiers merges with what it reverses. When several concepts tie for the merge, each is marked. Its text line ends ; merged with this write, may be replaced. A merge refused because a candidate was not a concept lists nothing. A pair flagged possibly superseded is listed too. At most 3 per new concept, closest first. They come from the hits the write already reads, so a concept written in the same call as the new one is never listed.
  • Nothing is recorded. The list adds no edge and changes no score, merge, canonization or recall. Cite a candidate in against only if the new concept really replaces it: most close neighbours are sibling facts, not predecessors.
  • A newer candidate. A candidate dated after the new concept is listed but marked newer: it may replace this write, not the other way round, so never cite it in against. This happens when you backfill with an older event_time, or record an older decision after its replacement. Each concept is dated by its write’s event_time, never later than when it was written, the date the possibly-superseded rule orders a pair by. Two concepts with the same date are not marked. Its text line ends ; newer: dated after this write, so it may replace this write, not the other way round; do not cite it in against.
  • Where it is. replacement_candidates on lambo_stats’s receipt and on the piggybacked structuredContent.write_receipts[]: {"candidates": [...], "total": N}, each candidate with id (the 8-hex id recall shows, which against accepts), node_id, excerpt, cosine and new_id (the new concept’s 8-hex id), merged: true on a merged-with candidate and newer: true on a newer one (each absent otherwise). The receipt text adds the clause and one line per candidate. A receipt with no candidates has neither, so it reads exactly as before. The rows are bounded at 2048 bytes per receipt, counted as each row’s serialized JSON, with the true total; a receipt recovered after a restart keeps only its summary. lambo derive prints the same lines, and the --ledger completion line gains replacement_candidates (the count) and replacement_cosines, and replacement_merged and replacement_newer (one flag per cosine) when a candidate is merged with or newer, never ids or text.

This call returns before the write is applied. Lambo validates your input, fixes this write’s place in the session’s order, and answers — the embedding and the graph insert happen in the background. A warm derive against a local BGE-M3 embedder went from a 14 ms call to a 0.05 ms call this way, because the embedder is no longer inside it. Two consequences worth planning around:

  • The ack carries a receipt id instead of the node ids, because it does not yet know them. Its outcome — what was created, what matched an existing concept, or why it failed — is delivered two ways: piggybacked onto your next tool response as a write receipts: text block, and fetchable at any time by passing the receipt to lambo_stats.
  • If you need a write visible to your very next read, pass the receipt to lambo_stats with a wait_ms first. That is the only thing you have to do differently, and only when you actually need it.
  • An ack is not yet on disk, and neither is applied. The write reaches the store with the next write-behind flush, within about a second on the default settings, so a crash can lose writes acked in that last second. A clean shutdown loses none while the store is healthy. See What a crash can lose under lambo_stats.

Writes you send one after another are applied in that order, whatever other agents are doing through the same server. Two calls you fire concurrently are a different matter: each one’s place in the session’s order is fixed when it is accepted, so two overlapping calls can be accepted in either order, and Lambo does not invent one for you. Duplicate detection is unaffected: the embedding still happens before the insert.

Writing to a layer. On a layered session (see lambo_recall), scope names the session a write goes to. Write a ruling or preference that applies in every project with scope = "general"; write project facts, file actions and decisions without a scope. lambo_record_action, lambo_derive_image, lambo_reserve and lambo_saints take the same scope.

  • A value that names neither this session nor one of its layers is refused before the ack with “scope must name this session or one of its layers: lambo, general”, and nothing is written. A credential that does not reach the layer gets a generic refusal.
  • The layer’s own write queue validates, orders and applies the write. Writes to different sessions have no ordering between them: a write to general and a write to the project, sent one after the other, can be applied in either order. Within one session the order is kept as always.
  • Every reference must name a concept in the session the write goes to. A write scoped to general that cites a project concept, or an unscoped write that cites a general concept, is refused with is in scope "lambo"; cross-session citations are not recorded (or "general"), naming the reference, and nothing is written. The same holds for against, refused as cross-session contradictions are not recorded: to contradict a general concept, write the newer assertion with scope = "general". A lambo_record_action scoped to general whose depends_on text names a concept of the project (and none of general’s) is refused the same way rather than copied into general. No edge joins two sessions.
  • general is shared by every project, so a relative file path there would name a different file in each. A write into a layer, whether scoped to it or sent to the layer’s own address (/mcp/s/general), refuses an entry that is a bare relative path: any concept’s content, a parent_of end, and lambo_record_action’s action text, produces, modifies and depends_on, with is a relative path, refused in scope "general". Record file actions in the project’s own session, use an absolute path, or send repo_scope (see Repository-scoped paths below). A bare relative path is README.md, Cargo.toml, .env, Makefile, src/main.rs, docs/reference/, ./run.sh, ..\lambo\AGENTS.md, also with a :12, :12:5 or #L12 line reference. Absolute paths, URLs, git@host:path remotes, domain-led names (github.com/x/README.md), domain and library names (crates.io, socket.io, Node.js, vercel/next.js), branch names, repo slugs and prose (and/or, I/O, TCP/IP) pass, and so does a directory named without a trailing /.
  • The ack adds a line, written to session 'general'; writes to different sessions have no ordering between them, and session: "general" in structuredContent. The receipt has the usual format. Its outcome is delivered only through the session you wrote through: on your next tool response there, and by lambo_stats there. Another project’s server, and general’s own address, never hand it out, even to the same agent_id (and answer forbidden when asked for it by id). A response carries at most 8 receipts across the session and its layers, each with receipts waiting getting at least one.
  • A scoped write draws one request from the layer’s own per_session_rps. A layer over its rate or not serving refuses the write as lambo_recall’s scope does. A layer that closes or loses its lease while the call holds it, before the write is admitted, or loses its lease before the ack, answers “layer general stopped serving during this call (it closed or lost its lease), so nothing was applied to it; retry shortly, or call without scope to act on lambo”. A layer that closes cleanly after the write was admitted still applies it, so that call is acked, with a warning not to resend it and to ask lambo_stats with the receipt. A scoped lambo_derive_image embeds the image without holding the layer, then takes it back; its configuration refusals name the layer’s settings.
  • An unlayered session accepts only its own name as scope, and a call without one answers exactly as before.

A derive needs the embedder, and on the default configuration it needs it for every write. With match_strategy = "hybrid" — the default — a concept is stored with its embedding or it is not stored at all: an embedder that is unreachable, restarting or too slow fails the write rather than applying a concept that semantic recall could never find. The failure is honest and per call (the receipt says failed and that nothing was written). What survives an outage is the unattempted write: one not yet attempted when the session closes waits for an embedder as a durable intent, replayed by the next serve; a write the worker reaches during the outage fails, and its receipt says so — the honest asymmetry, because only the in-session caller has a receipt to read. But it does mean a server whose embedder is down accepts reads and refuses writes. If you would rather keep writing without an embedder, that is a lawful mode and you get it by declaring it: set match_strategy = "canonical" for keyword-only concepts, session-wide and deliberately, instead of having it happen to you one call at a time.

ArgumentTypeRequiredNotes
agent_idstringYes
conceptsarray of {content, concept_type}YesUp to 64 per call.
concepts[].contentstringYesUp to 16 KB, and on a store with vector search at most 8,189 bytes for a concept sent alone (see the embedding-text limit). May not contain an image suffix, [image:<id>] in any case or spelling, which only lambo_derive_image builds; text that only mentions it, such as see [image: diagram], is fine.
concepts[].concept_typeenumYesOne of entity, logic, constraint, resource, observation.
event_timeRFC3339 timestampNoHistorical about-time, such as a commit or document date. Omit for a live fact, which is about now.
parent_ofarray of {parent, child}NoHierarchy links. Both ends resolve as concepts, and either may be created. The hierarchy must stay acyclic: a pair that would close a cycle (its child is already an ancestor of its parent, or the call’s own pairs loop) refuses the whole call with “store error: parent_of would close a Hierarchical cycle (the hierarchy must stay acyclic); nothing was written”. A cycle the graph already shows is refused before the ack; one closed by a write applied in between is refused on the receipt.
concepts[].depends_onarray of stringNoExisting concepts this one rests on, at most 8 per concept and 32 per call. Each by recall’s id or its full node id (or its exact content). See Citing what a concept rests on above.
concepts[].againstarray of stringNoExisting concepts this one contradicts, at most 8 per concept and 32 per call. Each by recall’s id or its full node id (or its exact content). See Saying an existing concept is wrong above.
scopestringNoThe session to write to: omit it (or send this session’s name) for this session, or name one of its layers, such as general. See Writing to a layer above.
repo_scopestringNoThe repository this call’s path-like resource concepts and parent_of ends belong to, such as github.com/acme/widgets. See Repository-scoped paths below.
{"name": "lambo_derive", "arguments": {"agent_id": "agent-a",
"concepts": [{"content": "user schema", "concept_type": "entity"}],
"event_time": "2024-06-12T14:30:00Z",
"parent_of": [{"parent": "user schema", "child": "auth middleware"}]}}
{"content": [{"type": "text", "text": "accepted 1 concept(s) for background write; validated and ordered, not yet applied\nreceipt lwr1.708217ebbf356946.1a01fa59db7.15: pending — admitted, not yet applied; ask again\nThe outcome arrives on your next tool response. To wait for it, call lambo_stats with receipt=lwr1.708217ebbf356946.1a01fa59db7.15 (add wait_ms to block).\nRecorded under agent_id \"agent-a\"; send this same id on every call."}],
"structuredContent": {"summary": "accepted 1 concept(s) for background write; validated and ordered, not yet applied",
"receipt": "lwr1.708217ebbf356946.1a01fa59db7.15", "receipt_state": "pending",
"agent_id": "agent-a", "warnings": []},
"isError": false}

created and matched are absent rather than empty. An empty list would be a claim about a write that has not run; the ids live on the receipt.

A call that cites a ruling:

{"name": "lambo_derive", "arguments": {"agent_id": "agent-a",
"concepts": [{"content": "flush retries keep the lease instead of re-acquiring it",
"concept_type": "logic",
"depends_on": ["8baad4e0"]}]}}

Its receipt, once applied, ends with ; 1 citation(s) recorded. A call without depends_on answers exactly as before. A call with against ("against": ["8baad4e0"]) ends ; 1 contradiction(s) recorded.

A write refused for backpressure answers the same shape with receipt_state of dropped and a summary that says plainly that nothing was written. It is not an error: the call succeeded, the write did not.

A file path is only unique inside one repository: README.md in two repositories is two files, and so is src/main.rs. Without help, a session that holds work from several repositories joins them into one concept. Send repo_scope on lambo_derive or lambo_record_action to name the repository a call’s paths belong to, and each repository gets its own concept for each path. scope is a different thing: it chooses the session a write goes to, and repo_scope names a repository inside it.

  • What it covers. Where you name the type, the scope covers any path-like resource concept and every produces and modifies entry of an action, and the action text itself. A path there is a relative or absolute local path: README.md, Makefile, .env, src/main.rs, docs/reference, ./run.sh, /etc/hosts, C:\work\a.rs, ~/.config/x, also with a :12 or #L12 line reference, which names the same file: src/main.rs:12 and src/main.rs are one concept, whose content keeps the spelling it was first written with. URLs, git@host:path remotes, domain-led names (github.com/x/a.rs), names that are not files (Node.js) and prose are not paths. A path with a space needs a ./, ../, /, ~/ or drive prefix with no space right after it (/ note and ~ 5 minutes are prose).
  • Where the type is inferred. An action’s depends_on entry, a parent_of end that is not one of the call’s concepts, and a derive’s depends_on and either call’s against resolve text under the scope like this: an existing scoped path is that Resource; an existing concept of another type under its plain key (a Logic config.toml, an Entity TCP/IP) is that concept; both at once is refused as ambiguous, listing each with its id. With neither, an action’s depends_on entry or an end becomes a new scoped path only if it reads as a file or a directory: a file name (README.md, src/main.rs), a dotfile, Makefile and the like, a name ending in / (docs/reference/), or an anchored path. Slash prose (TCP/IP, CI/CD, I/O, and/or) and a bare directory name (docs/reference) stay plain text, as without a scope. So producing a file and depending on it later name the same concept, and a citation never creates one. One consequence: a name that is only loosely a path (scripts/build, with no extension or trailing /) that a depends_on meets first is plain text, and a later produces of the same name is a separate scoped path, because a scoped produces never joins a plain concept another repository may share. Write such a name as a clear path (./scripts/build or scripts/build/) and both name the one scoped concept, in either order.
  • Directories. docs/ and docs are one scoped directory. A parent_of parent that is a directory of its scoped child (src over src/main.rs) is that repository’s directory, not one shared src.
  • Exact text. The scope and the path are compared as text after trimming and Unicode NFC normalisation, and nothing else. Case counts (src/Main.rs is not src/main.rs), and so does spelling (./src/main.rs is not src/main.rs). Lambo never reads a filesystem or a remote, never resolves a symlink, and never claims the scope is verified. A scoped path never merges with another concept by similarity, and nothing merges into it.
  • What to send. One stable id per repository, the same from every clone and worktree. Use its remote as host/owner/repo, such as github.com/acme/widgets, with no scheme, credentials, query or .git. For a repository with no remote, use an agreed root name that no other repository of the session uses. At most 512 bytes, with no whitespace, control or invisible character inside it (the ends are trimmed). Lambo takes the id as given and cannot tell that two spellings name the same repository. Never send a token, a password or a private machine path.
  • No fallback. Under a scope, path text never names an unscoped path Resource: a citation, depends_on or against entry written as README.md does not find a README.md Resource written without a scope (a concept several repositories may have joined), and path text in an unscoped write never finds a scoped one. To cite an older concept, give its id: an id is an exact reference and works with or without a scope. A scope says which repository a path is in, and grants no access.
  • In a layer. A write into a shared layer such as general refuses a bare relative path (see Writing to a layer). An entry repo_scope covers is let through, because it no longer names a different file in each project. A relative path in a concept that is not a resource is still refused.
  • Refusals. A repo_scope that is empty or whitespace, over 512 bytes, or holds whitespace, a control or an invisible character inside, is refused before the ack and nothing is written.
  • Reading them back. A recall hit for a scoped path carries repo_scope in structuredContent.hits[], and its label line names the repository: README.md [Resource, repo "github.com/acme/widgets"] (score …). lambo_inspect labels a scoped concept the same way. An unscoped hit or concept is unchanged. A layered recall joins copies of a scoped path from different sessions only when their repository and path are equal, never by vector similarity. The keyword search matches a scoped path by its text, not by its repository. Text that matches a scoped path and another concept is never resolved to one of them quietly: a lambo_inspect focus such as README.md is refused as matching several concepts, listing each with its repository and node id, so inspect the one you mean by its id. A dependency question (what depends on README.md) whose subject is such a path in several repositories answers for none of them: it returns no hits and one warning, recall: "README.md" names 2 concepts in different repositories: <short id> README.md (repo "github.com/acme/widgets"); …; name the repository in the question to ask about one. Name the repository in the question (what depends on README.md in github.com/acme/widgets) and it answers for that repository’s file.
  • Without it. A call that omits repo_scope writes and reads exactly as before 0.6.0. Concepts already in a session are not moved into a repository: an older path concept can hold edges from several repositories, and Lambo does not record which one each came from, so it stays as it is, reachable by its id.
  • After a restart. A scoped write still waiting when a session closes is kept as a durable intent and replayed under its scope. A build older than this one refuses to replay it, rather than replay it as an unscoped path. A stored scope is only checked to be nonempty at replay. A stored row this build cannot replay (an empty scope, or a malformed wrapper) settles failed, with the reason on its receipt. Once settled, applied or failed, the stored intent no longer carries the scope, so an older build can still load the session.
{"name": "lambo_record_action", "arguments": {"agent_id": "agent-a",
"repo_scope": "github.com/acme/widgets",
"action": "rewrote the build notes",
"modifies": ["README.md"]}}

Store one image as a concept. You send a caption and either the image itself, which the server embeds, or a vector you computed for it. The concept’s content is the caption plus [image:<image_id>], and its vector is the image’s, so a text recall such as “red silk saree” finds it through the vector leg. The image bytes are never stored: Lambo keeps the caption, the vector, and where the vector came from. Lambo’s own logs never carry the image or the vector, but the MCP library logs every request it receives at debug level, so a server run with RUST_LOG=rmcp=debug (not the default) writes each image and vector to its stderr.

Listed only where it can work. A server lists this tool when its embedder embeds images, or when the operator set [embedder] accept_client_vectors = true (see Configuration). Both are settings of the whole server process, so every session one server holds lists the same tools. A server with a text-only embedder (BGE-M3, candle, Gemini) and client vectors off lists exactly the seven tools above, unchanged. A listed tool still needs match_strategy = "hybrid" and a store with vector search; without either, every call is refused with a configuration error that names what is missing.

It returns before the write is applied, with a receipt, exactly as lambo_derive does. The image is checked and embedded before the ack, so a bad image is refused on the call itself rather than on the receipt.

ArgumentTypeRequiredNotes
agent_idstringYes
captionstringYesWhat the image is, in words. May not contain an image suffix ([image:<id>]) of its own. The stored content, the caption plus [image:<image_id>], must fit in 16,384 bytes, so a caption may be at most 16,375 bytes less the id’s length: 16,372 with r17, 16,359 with the default id. A longer caption is refused and the refusal names its limit.
concept_typeenumYesOne of entity, logic, constraint, resource. An image cannot be an observation.
image_idstringNoYour id for the image: 1 to 64 lowercase letters and digits (^[a-z0-9]{1,64}$). Omit it for an id made from a digest of what you send: the image’s bytes, or the normalized vector, so the same image or vector maps to the same concept.
image{mime, data}One of image, vectormime is image/png, image/jpeg or image/webp and must match the bytes. data is the bytes in base64 (standard alphabet, padded), on one line and without a data: URI prefix: at most 2 MiB decoded (2,796,204 characters) and at most 4096 pixels a side.
vector{values, contract}One of image, vectorA vector you computed, with the embedding space it is in. contract is {kind, model, dim} and must equal the session’s embedding_contract from lambo_stats exactly. At most 4096 values. Accepted only when the operator enabled client vectors.
event_timeRFC3339 timestampNoAs for lambo_derive.
parent_ofarray of {parent, child}NoAs for lambo_derive. Name the image concept by its content, caption [image:<image_id>].
scopestringNoAs for lambo_derive: the session to write to.
{"name": "lambo_derive_image", "arguments": {"agent_id": "dresscode",
"caption": "red silk saree, gold border", "concept_type": "resource",
"image_id": "r17", "image": {"mime": "image/png", "data": "iVBORw0KGgo..."}}}

The same caption and image_id derived again is the same concept, and two images with one caption but different ids stay two concepts. An image concept never merges with another concept by similarity, and no text concept merges into it.

Client vectors. Lambo checks that the declared contract is exactly the session’s and that the vector has the right width, finite values and a non-zero length, then normalizes it to unit length. It cannot check that the vector really came from the model the contract names, which is why accepting them is the operator’s decision. A mismatch is refused on the call, naming which of kind, model and dim differ and showing the session’s contract; it never repeats what you sent.

Refusals never repeat your payload. A refusal names the field and the rule, such as image.data is not valid base64 or declared image/jpeg but the bytes are image/png. Neither the base64, the bytes nor a vector value appears in an error, a receipt or the --ledger file.

One image per call, and only inline. There is no file path or URL argument.

Referring to an image concept. lambo_derive and lambo_record_action’s action refuse an image suffix in a concept’s own text: any word that reads as [image:<id>] once case is folded, invisible characters are dropped and the word is stemmed (so [IMAGE:R17] and [image:abc]s are refused, and see [image: diagram] is not). A word containing a digit, / or . is not stemmed, so [image:r17]s is ordinary text: it cannot share an image’s key. To link a text write to an image, name the image concept in parent_of or in an action’s produces, modifies or depends_on, as caption [image:<image_id>]. If the image concept does not exist yet, that creates a text concept with the same content, and a later lambo_derive_image with that caption and id fails with image id taken; derive the image first.

Record something the agent did, along with what it produces, modifies, and depends on. This is where activity goes: name the act in a short line, such as merged PR #52, rather than a narrative. A fact or decision that later work should recall is a concept for lambo_derive. The action becomes a Resource concept, produces and modifies become causal edges, and depends_on becomes dependency edges. A depends_on entry makes the action a dependent of that concept, so it counts toward the concept’s blast radius and its canonization evidence. produces and modifies record provenance and count toward neither.

Like lambo_derive, this returns before the write is applied, and its ack carries a receipt resolved the same way. It has no embedding step of its own, so what asynchrony buys here is ordering: both write tools feed one queue per agent, so an action you record and then derive from is applied in that order.

ArgumentTypeRequiredNotes
agent_idstringYes
actionstringYesUp to 16 KB. May not contain an image suffix ([image:<id>]).
event_timeRFC3339 timestampNoHistorical about-time, such as a commit or document date. Omit for a live fact, which is about now.
againstarray of stringNoExisting concepts this action contradicts, such as a belief the action showed to be wrong (refunded: the charger overheated against brand y chargers are fine): the action’s own concept is recorded as contradicting each. At most 8. Unlike depends_on, nothing is ever created: each entry is resolved like a lambo_derive against reference and must match exactly one existing concept of the session written to, other than the action itself and other than a concept the action’s own depends_on names, or the whole call is refused before the ack (see Saying an existing concept is wrong under lambo_derive).
produces, modifies, depends_onarray of stringNoUp to 64 entries combined across the three. An entry may name an image concept by its content, caption [image:<image_id>]. To cite a ruling or decision the action relied on, give its id. A depends_on entry that is an existing concept’s full node id (in the hyphenated form recall, inspect and receipts print) cites that concept; an interaction’s id is refused with reference refused. An entry of exactly 8 hex characters (the short id recall prints) whose text names no concept cites the one concept whose id starts with it, and is refused if several do; so an 8-character commit hash that happens to prefix a concept’s id cites that concept (the chance is about the session’s concept count divided by 2^32). An id still names the same concept when the write is applied, or the receipt says why not. Every other entry names a concept by its text, matched by canonical text and created if new, exactly as before 0.6.0: a commit hash of any other length, 9 to 32 hex characters (an MD5, a longer id prefix, a node id in its unhyphenated form), a urn:uuid: or braced id, a file path, an issue reference, and a hyphenated id that names no concept here. One exception: a hyphenated id that names no concept here but one in another session the call reads is refused as cross-session rather than created; so is, on a write scoped to a layer, text that names a concept of the caller’s own session. The Causal and Dependency edges they create must stay acyclic: one that would close a cycle refuses the whole call with “store error: record_action would close a Causal or Dependency cycle (produces, modifies and depends_on must stay acyclic); nothing was written”, before the ack or, if a write applied in between closed it, on the receipt.
scopestringNoAs for lambo_derive: the session to write to. Into a layer, an entry that is a bare relative file path is refused (see Writing to a layer under lambo_derive).
repo_scopestringNoAs for lambo_derive: the repository this action’s path-like produces, modifies and depends_on entries, and a path-like action text itself, belong to. The action text then takes the scoped key, and in a shared layer it is checked like a target. See Repository-scoped paths under lambo_derive.
{"name": "lambo_record_action", "arguments": {"agent_id": "agent-a",
"action": "created migrations/003.sql", "produces": ["migrations/003.sql"],
"event_time": "2024-06-12T14:30:00Z",
"depends_on": ["user schema"]}}
{"content": [{"type": "text", "text": "accepted action 'created migrations/003.sql' for background write; validated and ordered, not yet applied\nreceipt lwr1.708217ebbf356946.1a01fa59db7.16: pending — admitted, not yet applied; ask again\nThe outcome arrives on your next tool response. To wait for it, call lambo_stats with receipt=lwr1.708217ebbf356946.1a01fa59db7.16 (add wait_ms to block).\nRecorded under agent_id \"agent-a\"; send this same id on every call."}],
"structuredContent": {"summary": "accepted action 'created migrations/003.sql' for background write; validated and ordered, not yet applied",
"receipt": "lwr1.708217ebbf356946.1a01fa59db7.16", "receipt_state": "pending",
"agent_id": "agent-a", "warnings": []},
"isError": false}

action_node, created and edges are on the receipt, for the reason lambo_derive’s ids are.

Take a soft lock on a memory node before you edit it, so two agents do not edit the same concept at once. Set release to true to release the lock instead.

ArgumentTypeRequiredNotes
agent_idstringYesThe identity the lock is held under. Caller-asserted and unverified: a distinct id gets a distinct lock, and callers sharing an id share the lock.
node_idstring (UUID)YesFrom lambo_recall or lambo_inspect.
ttl_secondsintegerNo1 to 3600. Defaults to 30.
releasebooleanNoRelease instead of reserve.
scopestringNoReserve or release a node in one of this session’s layers, such as a recall hit from that layer (its id belongs to the layer). Locks are per session. Resolved and refused as lambo_recall’s scope is.
{"name": "lambo_reserve", "arguments": {"agent_id": "agent-a",
"node_id": "478416c2-0000-0000-0000-000000000000", "ttl_seconds": 60}}

Look at the neighbourhood around a concept, including its type, whether it is canonical, its blast radius, its dependents, and its edges in both directions. The blast radius counts the concepts that depend on this one: those with a Dependency edge to it (an action’s depends_on) and its parent_of children. The text lists them under dependents (N):, and the structured result carries them as dependents (each with node_id, label and edge_type) plus dependents_truncated.

It also says why the concept is or is not promoted next, under the server’s promotion policy. Below the dependents, a line canonization (<policy>): <status> -> <next> eligible or refused is followed by one indented reason per gate that fails, for example - stage 2: dependents from 2 other interactions, needs at least 3. The structured result carries the same as admission: policy, status, next (absent for a Canonical concept), eligible, checks (each gate’s gate, stage, current, bar, comparison and met), solo (the score’s terms, under Solo only) and reasons. The gates are the evaluator’s own, measured the way the next cycle measures them, the Canonical budget included (canonical_budget fails while the budget is full, because no Stage 3 window runs then); eligible means every gate of the next hop holds now, while the cycle’s batch and its round-robin order decide when it is taken. If the store cannot answer, admission is null and a warning says why; the rest of the result is unaffected.

ArgumentTypeRequiredNotes
agent_idstringYes
focusstringYesConcept text or a node UUID. Up to 16 KB.
depthintegerNo0 to 5. Defaults to 2.
scopestringNoInspect in one of this session’s layers instead of the session itself, as a recall hit from that layer needs (its id belongs to the layer). Resolved and refused as lambo_recall’s scope is.

Lambo resolves focus exactly first, then as the short id a recall block renders, then as a single substring match, and says in the result text when it resolved a substring. Past the 2,000-concept scan cap the substring leg runs on a bounded recency-plus-blast-radius subset instead of refusing, and says it did. If the focus matches several concepts it refuses rather than guessing, and lists up to ten candidates with their node ids; a focus matching nothing gets the same treatment with the closest concepts as suggestions. The rendered neighbourhood stops after 200 neighbours and says so.

{"name": "lambo_inspect", "arguments": {"agent_id": "agent-a", "focus": "user schema", "depth": 2}}

lambo_stale lists direct dependencies that currently rest on a contradicted concept in one session. Pass agent_id, and optionally scope for a shared layer and offset for the next page. Each row gives the dependent’s full id, a content snippet, and the cited concept’s id. The result has at most 50 rows and a total count. Pages read the current graph, so changes between calls can change the ordering and count.

The report uses the same contradiction and supporting-citation rules, clock, and evidence age as recall. A concept that itself contradicts the cited concept is excluded; indirect dependencies do not count. New supporting evidence can make an earlier contradiction no longer current.

For a derive or action carrying against, the settled receipt’s stale_report gives the full total of direct stale dependencies on the affected targets at the write’s commit. It includes at most 50 rows, with at most 2048 UTF-8 bytes across the rows’ IDs, cited IDs, and content snippets; lambo_stale pages have the 50-row limit without this byte cap. The immediate acknowledgement cannot report them. The report also appears in the piggybacked receipt text. After a restart, use lambo_stale to re-check the current graph; a recovered durable receipt may only retain its original summary.

List the session’s canonical memories, which are the concepts Lambo has promoted to canonical facts. Each row carries the node id, the content, the type, the blast radius, the access count, and when it was created. This tool takes agent_id, and scope to list one of the session’s layers instead (resolved and refused as lambo_recall’s scope is).

Report session health: flush lag, write-behind log depth, background write queue depth, node, edge, and concept counts, canonization progress, and whether the session is degraded. It is also where you ask what happened to a write.

ArgumentTypeRequiredNotes
agent_idstringYes
receiptstringNoA receipt id from a lambo_derive, lambo_derive_image or lambo_record_action ack. Answers what happened to that one write, including one written to a layer with scope (ask at the session you wrote through; anywhere else it is forbidden). While a layer is not serving, a receipt no serving pipeline knows answers pending with a detail saying the layer is down and to ask again, since it may be that layer’s.
wait_msintegerNo0 to 34000. With receipt, wait this long for the write to be applied before answering. A larger value is clamped, not refused. The maximum covers the longest one write can take to apply, so a wait that ends pending means other writes were queued ahead of it. A server holds 16 waits at once and one agent_id at most 8 of them; a wait over either answers at once with the current state.

Unlike lambo stats on the command line, this reaches the writer, so the flush figures are real rather than n/a. flush_lag_ms is how long ago the store last held every write. While nothing is waiting to be written it stays under a tenth of a second, however long the session has been idle. It grows while anything is waiting, including the access counts recalls leave behind, so under ordinary traffic it climbs to about the flush interval between flushes; it climbs past the flush interval only when the store is not taking writes, and it then bounds the age of the oldest write a crash would lose. Writes Lambo has dropped rather than flushed are not counted in it: dead_lettered counts batches the store rejected outright, and degraded says the session has stopped writing to the store. So after a rejected batch the lag falls back under a tenth of a second although that batch never reached the store; only a rising dead_lettered says so.

promotion_policy, always present: which canonization policy this server is actually running, Swarm or Solo. It is read from the live config, so it reports the value that won — lambo.toml, LAMBO_PROMOTION_POLICY, or the default — and not any one of the three inputs. The counters beside it cannot answer that question: canonization_cycles climbs identically under both policies, and canonical_count staying at zero is both the normal reading for a young Swarm session and the entire symptom of a Solo selection that did not take. The same value appears on the tool’s text summary and, with serve --ledger, on every heartbeat line, because one builder produces all three. See Configuration.

embedding_contract and embedding_modalities, always present: the embedding space this session’s vectors are in, as {kind, model, dim} (the server’s own contract, which is the one every check holds a client vector to: a session’s stamp always equals it, because the server stamps a fresh session with it, refuses a session stamped with another, and under --allow-embedding-mismatch relabels the session to it before the first write; model is the whole string, or null for the embedder’s default), and what the server’s embedder embeds, ["text"] or ["text", "image"]. A client that computes its own image vectors copies embedding_contract into lambo_derive_image’s vector.contract rather than hardcoding a model name. The text summary ends with the same facts on an embedding: line.

gc, always present: garbage collection’s sweep accounting, read-only. last_gc_at (RFC 3339, or null) is when the session last swept, or when a writer first started the clock for a session that never has; last_gc_epoch is the mutation count the next sweep is measured from. Both are stored with the session, so they survive a restart. last_sweep is the most recent sweep this server ran, null until it runs one: its trigger (mutations or elapsed), how many concepts it collected, how many the per-sweep cap deferred to the next sweep and whether the cap bound (collection_cap, cap_bound), how many Resources under their bar it kept because other concepts depend on them (resources_spared_by_dependents), and how many survivor bumps it left to drain (survivors_deferred, as of that sweep). The text summary carries the headline figures (last_gc_at, last_gc_epoch, and for the last sweep its trigger, collected, deferred, the cap and whether it bound) on a gc: line.

Asking about one write. Pass receipt and the payload gains a receipt object: an id, a state, and a detail sentence written for a model to read. On applied it also carries created, matched, created_count, matched_count, and the semantic_merged / reinforced / edges / embedded counts — everything the ack could not know. A derive that listed replacement candidates also carries replacement_candidates (see Replacement candidates under lambo_derive). The same counts are written to the completion line of the --ledger file, so they outlive the receipt. Add wait_ms and the call blocks until the write lands, which is how you get read-your-writes when you need it; the session figures in the same payload are read after that wait, so they never disagree with the receipt beside them.

The state is one of twelve (the eleven below, and forbidden), and none of them is “unknown”:

StateWhat it means
appliedThe write is in the server’s in-memory graph, so recall finds it; it reaches the store with the next flush. Its ids are in the same object.
pendingAdmitted and ordered, not yet applied. Ask again, or ask with wait_ms.
pending_replayAdmitted by an earlier server on this session, recorded as a durable intent, and still owed a replay. Not the same wait as pending: it sits behind a backlog replayed one write at a time, and it may settle in this server or in a later one. Ask again, and recall rather than re-deriving.
failedAttempted and rejected. Nothing was written; detail says why. When the cause was transient (the embedder, or a derive’s vector lookup, did not answer in time), nothing retries the write in this session, but its intent is kept, and the next server on the session replays it. A vector lookup that times out again at that replay settles the write failed for good.
intent_durableThe session closed before this write was applied, and the close’s final flush completed — so the write is not lost: it is recorded as a durable intent, and the next server on this session re-attempts it, in the order you sent it. Ask about the same receipt id there to learn the outcome: applied_after_restart if it landed, pending_replay while it is still owed, failed if the embedder refuses that content.
close_unflushedThe session closed before this write was applied, and the close could not complete its final flush (the store failed, the session was degraded, or the lease had passed to another writer). The write may or may not have been saved as an intent: if an earlier flush took it, the next server re-attempts it; if not, it is lost. Recall before re-deriving. A retried shutdown whose flush completes changes the answer to intent_durable.
applied_after_restartThe write was applied across a restart — either replayed from its durable intent by this server, or applied by the previous one with the fact carried over. The detail carries the applied summary; recall finds the concepts.
droppedNever attempted — the queue was full when you were acked. Nothing was written.
expiredThis server issued the receipt and no longer holds its outcome. Receipts are kept for 300 seconds. Recall to see whether the write is there.
restart_lostA different server process issued it and no durable record of it survived. The outcome is unknowable from here: the write may or may not have been applied before that process ended. A write acked in the last flush interval before a crash answers this way. Recall before re-deriving.
never_issuedThis server has never handed out that id.

A receipt is scoped to the agent that created it and to the session that submitted it, so asking from a different agent or session answers forbidden and does not consume its piggyback. Older durable intents that lack a recorded destination also answer forbidden after restart because the server cannot determine which session should receive them; use recall to inspect the current graph.

Write queue keys, always present because every write goes through the queue: write_queue_bound, write_queue_lane_bound, write_queue_measured, write_queue_bound_source, write_queue_items_per_sec, write_queue_serial_items_per_sec, write_queue_probe_serial_items_per_sec, write_queue_probe_optimism, write_queue_apply_samples, write_queue_apply_ms_p50, write_queue_apply_ms_p90, write_queue_apply_ms_max, write_queue_outstanding, write_queue_accepted, write_queue_applied, write_queue_failed, write_queue_abandoned, write_queue_deferred, write_queue_replayed, write_queue_replay_owed, write_queue_dropped, write_queue_dropped_closed, and receipts_retained.

The bounds guard fairness and memory, never durability: an acked write is recorded as an intent at admission, and a clean shutdown flushes every intent not yet applied, so a queue that cannot drain defers work to the next server on the session instead of losing it. write_queue_lane_bound (64) is how many of your writes can be outstanding at once — one agent’s fair share, a sixteenth of the queue — and write_queue_bound (1024) is the ceiling across every agent on the server, sized from how many receipts Lambo will hold. Both are fixed shares rather than measurements, deliberately: a bound derived from a measured rate was wrong in a new way every time the workload changed shape, and with a clean shutdown carrying unapplied writes forward as intents, being refused is something you can retry while being lost is not. A single busy agent meets the lane bound first, and its receipt says which bound refused it.

What a crash can lose. The intent is recorded in the write-behind log, not written to the store at admission. It reaches the store with the next flush, which runs every backend_flush_interval (1 second by default) while the store is taking writes. So:

  • Clean shutdown: no acked write is lost, once the final flush completes and provided the session never dead-lettered a batch or degraded (dead_lettered and degraded in lambo_stats; either means earlier writes were dropped rather than flushed). That flush carries every intent not yet applied, and the next server replays them (intent_durable). A shutdown whose final flush does not complete loses its unflushed tail, as a crash does, and the receipts it set aside answer close_unflushed.
  • Crash (SIGKILL, an OOM kill, power loss): writes acked since the last flush can be lost, which is up to about one flush interval of writes, or longer while the store is refusing writes (flush_lag_ms shows the gap). Their intents never reached the store, so nothing replays them, and their receipts answer restart_lost on the next server. Everything that was flushed survives: applied writes are in the store, and flushed intents not yet applied are replayed on restart.

The rate keys are telemetry about your embedder, not the source of any bound. write_queue_measured says whether anything measured a rate at all; write_queue_bound_source says what did — probe for the startup measurement, observed once your own writes have been timed (the better number, and the one that keeps tracking an embedder that slows down later), or unmeasured. write_queue_items_per_sec and write_queue_serial_items_per_sec are the raw rates at two widths, in writes per second (the four-wide one is null when the startup probe could not finish it in time, which an embedder that answers one request at a time cannot), and write_queue_probe_serial_items_per_sec is the startup probe’s own figure, kept beside the rate in force rather than overwritten by it: while the source is probe the two are the same number, and once observed takes over the gap between them says how far the startup estimate was from the work your agents actually send. The probe times a representative write, two concepts of about 340 bytes each embedded the way lambo_derive embeds them (in one batched call when the embedder batches, one at a time when it does not), so a probe several times faster than the observed rate usually means your derives carry more or longer concepts than that. Each concept is embedded together with the text of every concept in the same call, so the cost of a derive grows faster than its concept count. write_queue_probe_optimism is that gap as a ratio, the probe’s figure divided by the observed one, and is null until your own writes have taken over. The write_queue_apply_ms_* keys are the median, 90th percentile and maximum time from acknowledgement to applied over your last 256 applied writes (write_queue_apply_samples says how many there are so far, and the three are null before the first). They are what a wait_ms actually has to cover, so compare them with the wait_ms maximum below. A derive or record_action embeds all of its texts in one embedder call when the embedder batches (the candle adapter does), so a write costs one round of embedding rather than one per concept.

Read write_queue_dropped first: a non-zero value means acked writes did not happen, and each one says so on its own receipt. write_queue_dropped_closed is the part of that total refused because the session was shutting down rather than because the queue was full — a different problem with a different answer, which is why it is counted separately as well as inside the total. write_queue_deferred counts acked writes a clean shutdown handed to the NEXT server as durable intents — neither applied nor failed nor lost; their receipts say intent_durable. write_queue_replayed counts a previous server’s durable intents this one applied at startup; they are not summed into write_queue_applied, which counts only this server’s own accepted writes. write_queue_replay_owed is the other half of that pair and it is a debt, not a total: durable intents this server found owed and has not yet applied. A non-zero value that is not going down means the replay has not been able to run — most often because the embedder was not answering when this server started, in which case nothing was discarded and the next start tries again.

When the server was started with serve --ledger, the payload also carries ledger_path, ledger_written_lines, ledger_dropped_lines, ledger_dropped_channel_full, ledger_dropped_write_failed, and ledger_queued_lines. The dropped total is the one to read first: the call ledger drops lines rather than delay a tool call, so a non-zero value means any count taken from the ledger file is a lower bound. The two keys beside it say which kind of drop it was — channel_full is backpressure (the writer fell behind, so the filesystem is slow), write_failed is everything else (a batch whose write or open failed, a batch abandoned at shutdown, or an append after shutdown, so the path is wrong) — and they sum to the total. ledger_queued_lines is the writer’s queue depth: lines accepted but not yet on disk. Read it when the drop counters are all zero but the file is not growing — on a path whose open blocks (a reader-less FIFO, a hung mount) the writer parks before its first write, so written and both drop counters stay at zero until the queue fills, and only this key distinguishes a parked writer from an idle server. The counts are the session’s own: a serve holding several sessions on one ledger file reports, in each session’s payload and heartbeat line, only the lines appended for that session; a serve with one session reports the whole file, as before. Without --ledger all six keys are absent. The write queue keys above have no such switch, because there is no mode in which writes bypass the queue.

When your client connects, the server names the session and tells the model how to use it. For a session called demo, the instructions read:

Lambo agentic graph memory for session 'demo'. Call lambo_recall before acting on a task
to load relevant prior memory, lambo_derive and lambo_record_action to write what you
learned and did, lambo_reserve before editing a shared concept, and lambo_inspect /
lambo_saints / lambo_stats to look around. A concept is one fact, stated atomically and
short, such as a decision with its reason; it is not a log line or a paragraph, and
activity goes to lambo_record_action. When a write rests on a ruling or decision recall
showed you, cite it by its id (the 8 hex characters recall shows) in depends_on:
lambo_record_action's, or the concept's on lambo_derive. When a write shows an existing
concept to be wrong, name it the same way in against. Every tool takes your agent_id:
use one stable id for as long as you run, never one per call, task or topic: your model or client id
(<your-model-id>), with a stable instance suffix if several of you run at once. It is
caller-asserted and unverified. Work is recorded under it, so a stable id keeps
attribution whole. Soft locks are held under it: distinct ids contend for a node, and
callers sharing an id share its locks and can release each other's, so concurrent agents
that must keep out of each other's edits need distinct stable ids. created_at is
server-stamped: do not send a client flush timestamp. lambo_derive and lambo_record_action
may take optional RFC3339 event_time for historical about-time, such as a commit or
document date; omit it for a live fact, which is about now. Ordering is yours to manage,
and writes are applied in the BACKGROUND: lambo_derive and lambo_record_action return once
your input is validated and ordered, and their ack carries a receipt id. Their outcome
arrives on your next tool response. If you need a write visible to the very next read,
call lambo_stats with that receipt and a wait_ms first; otherwise carry on — writes you
send one after another are applied in that order (two you fire at once have no order to
keep).

Lambo speaks standard MCP, so any compliant client should work. These are the ones actually exercised against the server, rather than assumed:

ClientHandshakeTool discoveryModel-driven calls
Claude Code 2.1.226Verified7 of 7 over stdioNot captured against Lambo’s server (but see below)
Cursor Agent CLI 2026.08.11Verified7 of 7 over stdioVerified
OMP 17.3.4 (DeepSeek Flash)VerifiedVerifiedVerified, against a live CockroachDB store
Pi 0.84.1Verified, with pi-mcp-adapter installedVerifiedNot captured

“Model-driven” means the model chose the arguments and issued the tool calls itself, rather than the calls being scripted at the JSON-RPC layer. In the Cursor run the model called lambo_derive, lambo_record_action, lambo_recall, and lambo_stats in sequence against one stdio process, and lambo_recall ranked the parent concept above its two children and the action node recorded moments earlier.

Claude Code against the managed CockroachDB server

Section titled “Claude Code against the managed CockroachDB server”

The row above is about driving Lambo’s server. Separately, Claude Code drove the CockroachDB Cloud managed MCP server model-first, reading back the graph Lambo had written. That is the read-only half of the deployment model: the writer is lambo serve, and a managed MCP server inspects the durable store without ever writing to it.

Scoped to a single session, the canonization history comes back as the durable record of what the demo narrates:

node_idfrom_statusto_statusblast_radiusoccurred_at
724c92b9…NoneCandidatenull14:50:08.306429Z
3e3a6984…NoneCandidatenull14:50:08.306429Z
724c92b9…CandidateVenerablenull14:50:08.992263Z
724c92b9…VenerableCanonical914:50:09.600577Z
1848c6e5…NoneCandidatenull14:50:10.103585Z

Node 724c92b9 is user schema. It walks Candidate, then Venerable, then Canonical across 1.29 seconds, and blast_radius is null on the earlier hops and 9 on the promotion, because only the promotion gate measures it. That 9 is the same blast radius the demo prints in its final recall block — the number the demo narrates and the number written to CockroachDB are the same number, checked from opposite ends.

Three independent MCP clients have now produced this table from the managed server: OMP, Claude Code, and the Cursor Agent CLI. Their captures agree on every field — different clients, different models, one durable record.

Full transcripts for every run live in evidence/ in the repository: mcp-client-stdio/ for the Claude Code handshake and the seven-tool stdio capture, mcp-client-interop/ for the Cursor runs and the managed-server capture above, and managed-mcp-canonization-events.md for the OMP capture.

A layered session (see Layers) appends one more paragraph, naming its layers, how scope reads them and how to write to one, for example for lambo layered with general:

lambo_recall in session 'lambo' also searches its shared layers: general. Every recall hit
names the session it is from (structuredContent hits[].session, and 'from <session>' in a
layer hit's block), and its id belongs to that session: pass the same scope (scope =
"general") to lambo_inspect to inspect a hit from that layer. Pass scope = "general" to
lambo_recall to read that layer alone, or scope = "lambo" for this session alone. Writes
go to session 'lambo' unless you pass scope: write a ruling or preference that applies in
every project with scope = "general", and project facts, file actions and decisions without
a scope. A write's depends_on must name concepts in the session it writes to, and a write
into a layer refuses relative file paths. Writes to different sessions have no ordering
between them. lambo_reserve and lambo_saints take the same scope, and lambo_stats finds a
receipt from any of them.

Lambo enforces these when the call runs, and the published tool schemas carry the same bounds (a maxLength or an enum on every string, minimum and maximum on every integer), so a client that validates against the schema can catch an out-of-range value before sending it.

LimitValue
Any single string16 KB
top_k100
traversal_depth and inspect depth5
max_tokens100,000
Concepts per lambo_derive64
parent_of pairs per lambo_derive or lambo_derive_image256, and 512 concepts and pairs combined
Targets per lambo_record_action, combined64
depends_on references per concept in a lambo_derive8, and 32 per call
against references per concept in a lambo_derive8, and 32 per call
against references per lambo_record_action8
Reservation TTL3600 seconds
Neighbours rendered by lambo_inspect200
Candidates listed for an ambiguous focus10
wait_ms on lambo_stats34000 milliseconds
Write receipts kept300 seconds, or 4096 receipts, whichever binds first
Image per lambo_derive_image1, at most 2 MiB decoded (2,796,204 base64 characters) and 4096 pixels a side
Values in a lambo_derive_image vector4096
lambo_derive_image caption16,384 bytes with its [image:<image_id>] suffix (16,374 at most, with a one-character id)
Embedding text, on a store with vector search16,384 bytes for each new concept or parent_of end framed with the call’s text, so 8,189 for a concept sent alone (see below)
One request, on every transport4 MiB (see below)

Every limit above is a maximum. top_k, max_tokens, and ttl_seconds also have a minimum of 1, so 0 is refused. wait_ms is the one limit that clamps rather than refuses: a larger value waits the maximum instead of erroring, because a client asking to wait longer than Lambo will wait is asking for something harmless.

The request size limit. Every transport caps one request at 4 MiB before it parses any of it, so an argument of any size, such as an image or a vector, is never read into memory past that. A request over 4 MiB is refused on every transport. Over HTTP it is refused with 413 (see Request body limit). Over stdio, and on the session endpoint a proxying lambo serve forwards to, the request line is discarded unread up to its newline and logged at WARN with its size, never its content, and the request is answered with a JSON-RPC error, code -32600, message request too large (over 4 MiB). The error carries the request’s id when Lambo can read it from the start or the end of the line, whether id comes before or after params, and null when it cannot. The connection stays open and the session goes on serving the next request. A line cut off by the end of input is not answered.

This limit binds before the per-field and per-call limits above for large batches: those limits allow requests far bigger than 4 MiB. A lambo_derive with 64 concepts and 256 parent_of pairs, every string at 16 KB, is about 9.5 MB, and a lambo_derive_image with a full image and 256 pairs about 11.2 MB; a client that escapes non-ASCII characters as \uXXXX sends up to three times more. Split such a batch into calls under 4 MiB. Inside one request under 4 MiB, an image or vector over its own limit is still refused by the tool, which names the limit.

The embedding-text limit. On a store with vector search (and the default hybrid matching), Lambo embeds every concept a lambo_derive might create, and every parent_of end it might create, as the item’s text, —, then the call’s text: all of the call’s concepts joined by ; . That framed text, and the call’s text alone, must each fit in 16,384 bytes. A concept sent alone is framed with itself, so it may be at most 8,189 bytes; in a larger call the limit is lower. For lambo_derive_image only its parent_of ends are framed, with the caption and its suffix, since the image’s own text is never embedded. Lambo checks this when the call arrives, before the ack, and refuses it as a bad parameter that names the part that is too long (the whole call, concepts[i], or parent_of[i].parent or .child). It checks every concept and end, because whether one matches an existing concept is only known when the write is applied; shorten the text or split the call. A store without vector search embeds nothing and takes any string up to 16 KB.

lambo_inspect renders at most 200 neighbours in total across every hop, not 200 per hop.

See Command line for the same operations from the terminal, and Library API for the underlying Rust library.