How end users actually use Brain through the SDK. A complete walkthrough from "I just got the binary" to "I'm running this in production."
This guide is task-oriented. The spec tells you what Brain is. This tells you what you do with it.
You work at a 30-person startup called Acme. The engineering team has tribal knowledge scattered across Slack threads, Notion docs, meeting notes, and people's heads. Onboarding takes weeks because new hires can't find anything. Decisions get re-litigated because nobody remembers why we picked Postgres over Mongo two years ago.
You want to build an AI assistant — call it Mind — that ingests everything the team writes (notes, meeting transcripts, Slack messages they opt to share, Linear tickets, design docs) and lets anyone ask:
- "Why did we pick Postgres?"
- "What's Priya working on right now?"
- "How does Bob prefer to do code reviews?"
- "Who's been involved with the billing rewrite?"
- "What did we decide about the K8s migration last quarter?"
You picked Brain because:
- Vector search alone doesn't cut it (need keyword matching for ticket IDs like
ACME-1247, need to track entities as they get renamed, need graph queries like "everyone on Priya's team"). - You don't want to glue together 5 services (Elasticsearch + Postgres + Neo4j + an LLM extraction pipeline + an embedding service).
- You want this on one box, runnable in a coffee shop on a laptop for development.
Let's build it.
# Install the binary
curl -sSf https://brain.example/install.sh | sh
# (or: cargo install brain-server)
# Start a server
brain-server start \
--data-dir ~/acme-mind/data \
--listen 127.0.0.1:7860 \
--shards 4
# Server logs:
# [INFO] Opened 4 shards at ~/acme-mind/data
# [INFO] Brain 0.1.0 listening on 127.0.0.1:7860
# [INFO] No schema declared; running schemalessAdd the SDK to your Rust project:
# Cargo.toml
[dependencies]
brain-sdk-rust = "2.0"
tokio = { version = "1", features = ["full"] }A "hello world" — write a memory, recall it:
use brain_sdk::Client;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let brain = Client::connect("127.0.0.1:7860", "mind-agent-1").await?;
// Write
let mem = brain.encode("Priya kicked off the billing rewrite project today.").await?;
println!("Stored memory {}", mem.id);
// Read
let hits = brain.recall("billing project").limit(5).await?;
for hit in hits {
println!("- {} (score {:.2})", hit.text(), hit.score);
}
Ok(())
}Output:
Stored memory MEM-01HW8T3P9...
- Priya kicked off the billing rewrite project today. (score 0.87)
Thats it. You used the substrate-only surface and it works without any schema. Brain is now a vector memory store.
But you havent touched the knowledge layer yet. Vector recall on a single memory is not impressive. Let's go further.
This is the moment Brain becomes more than vector search. You tell Brain what kinds of things exist in your world and how to recognize them.
Create acme-schema.brain:
# Acme engineering mind — schema version 1
namespace acme
# ─── Entity types ────────────────────────────────────────────
define entity_type Person {
attributes {
email: text optional unique
role: text optional
team: text optional
timezone: text optional
}
}
define entity_type Project {
attributes {
slug: text required unique
status: enum[planning, active, paused, done] default planning
repo_url: text optional
}
}
define entity_type Ticket {
attributes {
key: text required unique # e.g. "ACME-1247"
status: enum[open, in_progress, blocked, closed] optional
}
}
define entity_type Decision {
attributes {
slug: text required unique
area: text optional # "infrastructure", "billing", ...
}
}
# ─── Predicates ──────────────────────────────────────────────
define predicate role {
kind: Fact
object: Value<text>
}
define predicate prefers {
kind: Preference
object: Value<text>
}
define predicate said {
kind: Event
object: Value<text>
}
define predicate decided {
kind: Fact
object: Value<text>
}
# ─── Relations ───────────────────────────────────────────────
define relation_type works_on {
from: Person
to: Project
cardinality: many-to-many
properties {
since: date optional
}
}
define relation_type reports_to {
from: Person
to: Person
cardinality: many-to-one
}
define relation_type assigned_to {
from: Ticket
to: Person
cardinality: many-to-one
}
define relation_type owns {
from: Person
to: Project
cardinality: many-to-many
}
# ─── Extractors ──────────────────────────────────────────────
use brain.entity_mentions # built-in pattern + NER
define extractor ticket_ids {
kind: pattern
target: entity Ticket
patterns [
/\b(ACME-\d+)\b/
]
confidence: 0.99
}
define extractor preferences {
kind: llm
target: statement Preference
model: "claude-haiku-4-5"
prompt: """
From the memory below, extract any preferences a person has expressed
about how they work. Return JSON array. Empty array if none.
Each item: {"subject": "<person name>", "object": "<preference>", "confidence": 0-1}
Memory: {{memory.text}}
"""
schema: {
type: array
items: {
type: object
required: [subject, object, confidence]
properties: {
subject: { type: string }
object: { type: string }
confidence: { type: number, minimum: 0, maximum: 1 }
}
}
}
cache: enabled
cost_budget: "$0.001 per memory"
confidence_threshold: 0.7
trigger: on encode where memory.kind = episodic
}
define extractor decisions {
kind: llm
target: statement Fact
model: "claude-haiku-4-5"
prompt: """
Did this memory record a team decision? If yes, extract:
{"subject": "<decision slug, e.g. database_choice>",
"predicate": "decided",
"object": "<what was decided, one sentence>",
"confidence": 0-1}
Return JSON array (one item if decision found, empty if not).
Memory: {{memory.text}}
"""
schema: { /* ... */ }
cache: enabled
confidence_threshold: 0.75
trigger: on encode where memory.text matches ".*(decided|chose|picked|going with).*"
}
Upload it:
let schema_text = std::fs::read_to_string("acme-schema.brain")?;
let result = brain.schema().upload_text(&schema_text).await?;
println!("Schema v{} accepted", result.version);Output:
Schema version 1 accepted
What just happened on the server side:
[INFO] Schema upload: 4 entity types, 4 predicates, 4 relation types, 3 extractors
[INFO] Schema validation passed
[INFO] Schema version 1 active
[INFO] Activating extractors: ticket_ids, preferences, decisions
[INFO] Built-in extractor brain.entity_mentions activated
[INFO] Extraction now active on new memories
You can also build the schema programmatically with the SDK — useful when your schema is computed from config:
use brain_sdk::knowledge::SchemaBuilder;
#[derive(BrainEntity)]
#[brain(entity_type = "Person", namespace = "acme")]
struct Person {
#[brain(optional, unique)]
email: Option<String>,
role: Option<String>,
team: Option<String>,
}
#[derive(BrainEntity)]
#[brain(entity_type = "Project", namespace = "acme")]
struct Project {
#[brain(required, unique)]
slug: String,
status: Option<String>,
}
#[derive(BrainRelation)]
#[brain(from = "Person", to = "Project", cardinality = "many-to-many")]
struct WorksOn;
let schema = SchemaBuilder::new("acme")
.entity_type::<Person>()
.entity_type::<Project>()
.relation_type::<WorksOn>()
// ... predicates and extractors
.build()?;
brain.schema().upload(&schema).await?;Same result; just authored in Rust instead of the DSL.
Now you wire up your ingestion. Slack messages, meeting notes, Linear comments — whatever the team produces. Each becomes a memory.
async fn ingest_slack_message(brain: &Client, msg: SlackMessage) -> anyhow::Result<()> {
brain.encode(&msg.text)
.kind(MemoryKind::Episodic)
.source(&format!("slack:{}", msg.channel))
.author(&msg.user)
.at(msg.timestamp)
.commit()
.await?;
Ok(())
}Watch what happens when you ingest one:
ingest_slack_message(&brain, SlackMessage {
text: "Priya: Let's just go with Postgres for billing. Bob prefers Mongo \
but we already have ops experience with PG. ACME-1247 captures the decision.",
channel: "eng-billing",
user: "U-PRIYA",
timestamp: now(),
}).await?;You see in the server logs:
[INFO] ENCODE memory MEM-01J... (132 bytes, agent mind-agent-1)
[INFO] pattern_extractor brain.entity_mentions: 2 candidates (Priya, Bob)
[INFO] resolver tier1: Priya -> ENT-PERSON-PRIYA (existing)
[INFO] resolver tier1: Bob -> ENT-PERSON-BOB (existing)
[INFO] pattern_extractor ticket_ids: 1 candidate (ACME-1247)
[INFO] resolver tier5: ACME-1247 -> new Ticket entity ENT-TICKET-ACME-1247
[INFO] LLM extractor preferences queued (background)
[INFO] LLM extractor decisions queued (matched trigger)
A few seconds later (LLM extractors are async background):
[INFO] preferences extracted 1 statement:
(ENT-PERSON-BOB, prefers, "Mongo", conf=0.83)
[INFO] decisions extracted 1 statement:
(database_choice_billing, decided, "Postgres for billing", conf=0.91)
[INFO] decisions resolved subject database_choice_billing -> new Decision entity
Brain just did, automatically:
- Wrote the raw memory (substrate behavior).
- Found two known people (Priya, Bob) and resolved them via exact match.
- Found a ticket ID via regex pattern, created a new Ticket entity.
- Called the LLM extractor for preferences — found Bob's Mongo preference.
- Called the LLM extractor for decisions — found the Postgres decision.
- Linked statements back to the source memory as evidence.
You didn't write any extraction code. You declared the schema; Brain did the rest.
First time the team's name comes up, Brain creates them. You can also seed entities up front from your HR database:
let priya = brain.entity::<Person>()
.canonical_name("Priya Patel")
.alias("Priya")
.alias("priya@acme.com")
.with(|p| {
p.email = Some("priya@acme.com".into());
p.role = Some("Engineering Manager".into());
p.team = Some("Platform".into());
})
.create()
.await?;Now when memories mention "Priya" or "priya@acme.com" or even "Priya Patel," they resolve to the same entity.
Six months in, someone writes "Priyaa kicked off the migration." The pattern extractor finds "Priyaa" as a person candidate. The resolver runs:
- Tier 1 (exact): no match for "Priyaa."
- Tier 2 (trigram fuzzy): "Priyaa" is 89% similar to "Priya Patel." Above threshold (0.85).
- Resolved to ENT-PERSON-PRIYA with confidence 0.89.
If the resolver weren't confident enough, it would have:
- Run tier 3: embedded "Priyaa" + context, found nearest entity.
- Run tier 4 (if you enabled LLM resolution): asked an LLM to disambiguate.
- Otherwise: created a new entity, then later a sweeper or operator could merge them.
Now the resolver might find multiple candidates above threshold. Instead of guessing, Brain writes the statement with a pending subject and audits the ambiguity:
// You can inspect:
let pending = brain.admin().list_pending_resolutions().await?;
for p in pending {
println!("Candidate '{}' in context '{}': {} matches",
p.candidate, p.context_snippet, p.candidates.len());
for (entity_id, score) in p.candidates {
let e = brain.entity_get(entity_id).await?;
println!(" - {} ({}): score {:.2}", e.canonical_name, e.id, score);
}
}
// Resolve manually:
brain.admin().resolve_ambiguity(p.audit_id, chosen_entity_id).await?;Now the team starts asking questions through Mind.
let answer = brain.query()
.text("why did we pick Postgres")
.limit(5)
.execute()
.await?;
for item in answer.items {
println!("{}", render(item));
}The query router classifies this:
- Has text → semantic + lexical retrievers.
- "Postgres" is a proper noun → lexical weighted up.
- No entity anchor → graph retriever not invoked.
Behind the scenes:
- SemanticRetriever finds memories and statements semantically close to "pick Postgres" / "choose database" / "database decision."
- LexicalRetriever finds occurrences of exact term "Postgres" via tantivy BM25.
- RRF Fusion combines the two ranked lists.
Result:
1. Statement(Fact): decision database_choice_billing decided "Postgres for billing"
Evidence: MEM-01J... (the Slack message from Priya)
Confidence: 0.91
Contributing retrievers: semantic (rank 3), lexical (rank 1)
2. Memory: "Priya: Let's just go with Postgres for billing. Bob prefers Mongo
but we already have ops experience with PG. ACME-1247 captures the decision."
Contributing retrievers: semantic (rank 1), lexical (rank 2)
3. Memory: "Discussed Mongo vs Postgres in eng review — leaning Postgres
given our existing tooling."
Contributing retrievers: semantic (rank 2)
Notice: Mind didn't just return the raw memory — it returned the derived Fact at rank 1, because the decision extractor surfaced it as a first-class statement. The user gets the structured answer plus the evidence.
let priya = brain.entity::<Person>()
.resolve("Priya")
.await?
.expect_resolved()?;
let projects = brain.relation::<WorksOn>()
.traverse_from(priya.entity_id)
.depth(1)
.current_only()
.execute()
.await?;
for edge in projects {
let proj = brain.entity_get::<Project>(edge.to).await?;
println!("- {} ({})", proj.canonical_name, edge.properties.get("since"));
}Output:
- Billing rewrite (since 2026-03-15)
- Platform telemetry (since 2026-01-10)
This is the graph lane. The query router would have done it automatically if you'd asked in natural language:
let answer = brain.query()
.text("what is Priya working on")
.execute()
.await?;The router sees "Priya" → NER resolves to ENT-PERSON-PRIYA → graph retriever invoked with anchor=priya, edges=works_on, depth=1.
let bob = brain.entity::<Person>().resolve("Bob").await?.expect_resolved()?;
let prefs = brain.statements()
.where_subject(bob.entity_id)
.of_kind(StatementKind::Preference)
.current_only()
.with_min_confidence(0.6)
.list()
.await?;
for p in prefs {
println!("Bob prefers {}: {} (conf {:.2})",
p.predicate, p.object_text(), p.confidence);
if let Some(mem_id) = p.evidence.first() {
let m = brain.memory_get(*mem_id).await?;
println!(" Source: \"{}\"", m.text);
}
}Output:
Bob prefers reviews: "small PRs over big ones" (conf 0.88)
Source: "Bob: please keep PRs under 400 lines, my reviews suffer past that"
Bob prefers reviews: "async over synchronous" (conf 0.81)
Source: "I'd rather get review comments async than do live walkthroughs - Bob"
Bob prefers stack: "Mongo" (conf 0.83)
Source: "Priya: Let's just go with Postgres for billing. Bob prefers Mongo..."
Notice the last one is from the same memory as the Postgres decision. Same memory, multiple extracted statements, each independently queryable.
This combines: entity anchor + temporal filter + project context.
let priya = brain.entity::<Person>().resolve("Priya").await?.expect_resolved()?;
let billing = brain.entity::<Project>().resolve_by_slug("billing-rewrite").await?;
let answer = brain.query()
.text("billing project")
.with_entity(priya.entity_id)
.where_time(TimeRange::last(Duration::days(7)))
.limit(20)
.execute()
.await?;The router engages:
- Entity anchor (Priya) → graph retriever.
- Time filter → temporal filter pushed down into retrievers.
- Text → semantic + lexical.
Three retrievers, one temporal filter, RRF fusion. Result is memories and statements about Priya in the last 7 days, sorted by relevance to "billing project."
Pure graph query, no text:
let billing = brain.entity::<Project>().resolve_by_slug("billing-rewrite").await?;
let people = brain.relation::<WorksOn>()
.traverse_to(billing.entity_id) // reverse direction
.depth(1)
.current_only()
.execute()
.await?;
for edge in people {
let p = brain.entity_get::<Person>(edge.from).await?;
println!("- {} ({})", p.canonical_name, p.attributes.get("role"));
}Two-hop variant — "everyone on the team of anyone working on billing":
let answer = brain.query()
.with_entity(billing.entity_id)
.traverse(TraversalSpec {
edges: vec!["works_on".into(), "reports_to".into()],
depth: 2,
direction: Direction::BothWays,
})
.execute()
.await?;Event queries are time-ordered:
let k8s = brain.entity::<Project>().resolve_by_slug("k8s-migration").await?;
let events = brain.statements()
.where_subject(k8s.entity_id)
.of_kind(StatementKind::Event)
.order_by_event_time(Order::Asc)
.list()
.await?;
for e in events {
println!("{}: {}", format_date(e.event_at), e.object_text());
}Output:
2026-01-15: kickoff meeting scheduled
2026-02-03: pilot cluster provisioned
2026-02-20: staging migration completed
2026-03-10: prod migration paused due to ACME-1402
2026-04-02: prod migration resumed
2026-04-18: migration completed
When the result is surprising, use .trace():
let traced = brain.query()
.text("Priya leadership preferences")
.trace()
.await?;
println!("{}", traced.plan_summary);
for r in traced.retriever_traces {
println!("\n{} ({:.1} ms, {} results):", r.name, r.latency_ms, r.total);
for (rank, item) in r.top_3.iter().enumerate() {
println!(" rank {}: {} (score {:.2})", rank + 1, item.summary, item.score);
}
}
println!("\nFinal top 3:");
for (rank, item) in traced.items.iter().take(3).enumerate() {
println!(" rank {}: {}", rank + 1, item.summary);
for c in &item.contributing_retrievers {
println!(" via {} (rank {}, raw score {:.2})", c.retriever, c.rank, c.raw_score);
}
}Output:
PLAN: entity-anchored (Priya), text-bearing
RETRIEVERS: Semantic(w=1.0), Lexical(w=0.5), Graph(w=2.0, anchor=Priya)
FUSION: RRF(k=60)
semantic (4.2 ms, 87 results):
rank 1: Preference: Priya prefers "1:1s in the morning" (score 0.81)
rank 2: Preference: Priya prefers "written design docs" (score 0.78)
rank 3: Memory: "Priya emphasized async leadership in standups..." (score 0.75)
lexical (2.1 ms, 12 results):
rank 1: Memory: "leadership offsite agenda from Priya" (score 8.4)
rank 2: Memory: "Priya's leadership style was discussed..." (score 6.9)
rank 3: Preference: Priya prefers "written design docs" (score 4.1)
graph (1.8 ms, 23 results):
rank 1: Preference: Priya prefers "1:1s in the morning" (score 1.0)
rank 2: Preference: Priya prefers "written design docs" (score 1.0)
rank 3: Preference: Priya prefers "async feedback over live" (score 1.0)
Final top 3:
rank 1: Preference: Priya prefers "written design docs"
via semantic (rank 2, raw score 0.78)
via lexical (rank 3, raw score 4.10)
via graph (rank 2, raw score 1.00)
rank 2: Preference: Priya prefers "1:1s in the morning"
via semantic (rank 1, raw score 0.81)
via graph (rank 1, raw score 1.00)
rank 3: Memory: "leadership offsite agenda from Priya"
via lexical (rank 1, raw score 8.40)
You see which retrievers contributed to each result and at what rank. When something's off, you know whether to tune retriever weights, fix the schema, or add more evidence.
Real knowledge isn't static. People change roles, preferences shift, facts get corrected. Brain handles this.
Three months in, the team rebuilt their CI and Bob says: "Actually I changed my mind on PR size. With the new fast CI, bigger batches are fine."
You ingest the message; the extractor fires; a new Preference is created. Because it's a Preference with same (subject="Bob", predicate="prefers") as before, the old one is superseded, not replaced:
let prefs = brain.statements()
.where_subject(bob.entity_id)
.of_kind(StatementKind::Preference)
.current_only()
.list()
.await?;
// Returns the new preference only.
// Want the history?
let all = brain.statements()
.where_subject(bob.entity_id)
.of_kind(StatementKind::Preference)
.include_superseded(true)
.list()
.await?;
for p in all {
if p.is_current() {
println!("Now: {} (since {})", p.object_text(), format_date(p.valid_from));
} else {
println!("Was: {} ({} - {})",
p.object_text(),
format_date(p.valid_from),
format_date(p.valid_to.unwrap()));
}
}Output:
Now: "smaller PRs no longer a strict requirement after CI rebuild" (since 2026-08-12)
Was: "small PRs over big ones" (2026-01-10 - 2026-08-12)
The history is intact. The default view shows current. You explicitly opt in to see the past.
Mind ingests two memories:
"Priya is the engineering manager of the Platform team."
"Priya is now leading Infrastructure, not Platform anymore."
Both produce Facts with same (subject=Priya, predicate=role) but different objects. Brain stores both — they're contradictions, not supersessions (Facts don't auto-supersede). The query surfaces them:
let role = brain.statements()
.where_subject(priya.entity_id)
.where_predicate("role")
.of_kind(StatementKind::Fact)
.current_only()
.list()
.await?;
if role.len() > 1 {
println!("⚠️ {} contradicting Facts:", role.len());
for f in &role {
println!(" - \"{}\" (conf {:.2}, evidence: {} memories)",
f.object_text(), f.confidence, f.evidence.len());
}
}Output:
⚠️ 2 contradicting Facts:
- "engineering manager of Platform" (conf 0.91, evidence: 3 memories)
- "leading Infrastructure" (conf 0.94, evidence: 1 memory)
What does Mind do? Up to you. You can:
- Show both to the user and let them pick.
- Pick the higher-confidence one and disclose.
- Pick the more recent one (and disclose).
- Resolve by writing a Fact explicitly retracting one.
Brain refuses to silently pick. That's intentional — the second a cognitive substrate hides contradictions, you can't trust it.
To resolve explicitly:
brain.fact()
.subject(priya.entity_id)
.predicate("role")
.object_value("VP of Infrastructure") // the correct current role
.evidence(vec![latest_memory_id])
.confidence(0.98)
.supersedes_facts(&[old_fact_id_1, old_fact_id_2])
.create()
.await?;Now the previous Facts are explicitly marked superseded by this one. Current-only queries return the new Fact alone.
Someone realizes a memory contains private information that shouldn't be in the substrate:
brain.forget(memory_id).hard().reason("PII").await?;In the background, the FORGET cascade worker runs:
[INFO] FORGET memory MEM-01J... (hard)
[INFO] Cascade: 3 statements have this memory in evidence
- STMT-XX confidence: 0.91 -> 0.82 (recomputed, 2 evidence remain)
- STMT-YY confidence: 0.87 -> 0.81 (recomputed, 1 evidence remain)
- STMT-ZZ confidence: 0.74 -> orphan, tombstoned (reason: SourceMemoryForgotten)
[INFO] Cascade: 1 relation depends on this memory
- REL-AA confidence: 0.83 (1 evidence remain)
[INFO] Cascade complete
The memory is gone. Statements derived from it have their confidence recomputed; one lost its only evidence and got tombstoned. The audit log records the cascade.
You don't write any of this code. Brain owns the data integrity.
Priya gets married, becomes Priya Singh:
brain.entity(priya.entity_id)
.rename("Priya Singh")
.keep_old_name_as_alias()
.await?;Aftermath:
- EntityId unchanged.
- All Statements and Relations still point to the same EntityId — they automatically reflect the new name.
- "Priya" remains in aliases, so old text mentioning "Priya" still resolves correctly.
- Future ingestion mentioning "Priya Singh" resolves to the same entity.
Compare to systems where renaming means migrating thousands of records. Here, you change one row.
Six months in, you discover the team has two entities for the same person — "Bob Chen" and "Bob C." — because they were created from different memories before aliases were set up.
let dups = brain.admin()
.find_potential_duplicates::<Person>()
.min_confidence(0.85)
.await?;
for d in dups {
println!("Possible duplicates ({:.2}): {} <-> {}",
d.confidence, d.entity_a.canonical_name, d.entity_b.canonical_name);
}
// Or merge directly when you're confident:
brain.entity_merge()
.survivor(bob_chen_id)
.merged(bob_c_id)
.confidence(0.95)
.commit()
.await?;After merge:
- All Statements and Relations pointing to bob_c_id now point to bob_chen_id.
- bob_c_id's row stays as a redirect.
- Within a 7-day grace period, you can unmerge if you made a mistake.
// "Wait, those weren't actually the same person."
brain.entity_unmerge(bob_c_id).await?; // works within grace; rejected afterTwo quarters in, you realize you've been missing something. The team's been talking about "incidents" — outages, post-mortems, blameless retros — but you don't have an Incident entity type. Decisions about incidents are being awkwardly stuffed into Decision entities.
You evolve the schema. Edit acme-schema.brain:
# Add an entity type
define entity_type Incident {
attributes {
slug: text required unique
severity: enum[sev1, sev2, sev3, sev4] optional
started_at: timestamp optional
resolved_at: timestamp optional
}
}
# Add predicates
define predicate caused_by {
kind: Fact
object: Entity<Person> # or Project, or anything
}
# Add an extractor
define extractor incidents {
kind: llm
target: entity Incident
model: "claude-haiku-4-5"
prompt: """
Did this memory describe an incident or outage? If yes, extract:
{"slug": "<short_slug>", "severity": "sev1"|"sev2"|"sev3"|"sev4",
"summary": "<one sentence>"}
Empty array if no incident.
Memory: {{memory.text}}
"""
schema: { /* ... */ }
cache: enabled
confidence_threshold: 0.8
trigger: on encode where memory.text matches ".*(incident|outage|down|broke|sev[1-4]).*"
}
Upload:
let new_schema = std::fs::read_to_string("acme-schema.brain")?;
let result = brain.schema()
.upload_text(&new_schema)
.migration_policy(MigrationPolicy::ReExtractChanged)
.await?;
println!("Schema v{} accepted", result.version);
println!("Migration plan:");
for action in &result.migration_plan {
println!(" - {:?}", action);
}Output:
Schema version 2 accepted
Migration plan:
- AddEntityType(Incident)
- AddPredicate(caused_by)
- AddExtractor(incidents)
This is non-breaking — pure additions. Existing entities, statements, relations untouched. New writes use the new schema.
But you also want the new incidents extractor to run over existing memories. That's a backfill:
let job = brain.admin().backfill()
.extractor("incidents")
.memory_range(MemoryRange::All)
.priority(Priority::Background)
.start()
.await?;
println!("Backfill job {} started", job.id);
// Check status:
loop {
let status = brain.admin().job_status(job.id).await?;
println!("Progress: {}/{} memories ({:.1}%)",
status.completed, status.total,
100.0 * status.completed as f64 / status.total as f64);
if status.is_done() { break; }
tokio::time::sleep(Duration::from_secs(30)).await;
}The backfill runs in background, respects priority budget, resumable on restart. New Incident entities and decisions get created.
What if you wanted to remove the Decision type? That's breaking:
let result = brain.schema()
.upload_text(&schema_without_decision)
.migration_policy(MigrationPolicy::CascadeTombstone) // explicit opt-in
.await?;Brain refuses without the explicit flag. With it, all existing Decision entities and statements get tombstoned with reason SchemaInvalidation and a 30-day grace before hard deletion. You can roll back the schema within the grace.
Three months in, you tune the preferences extractor — better prompt, better few-shot examples. You bump its version implicitly by editing the schema:
brain.schema().upload_text(&improved_schema).await?;What happens to existing Preferences?
- They're flagged stale (
extractor_versionolder than current). - They remain queryable.
- A worker periodically lists stale statements:
let stale = brain.admin().list_stale_statements(StalenessFilter::All).await?;
println!("{} stale statements", stale.len());You decide: re-extract them all (costs LLM calls but improves quality), or let them age out, or hard-delete and re-extract on demand.
brain.admin().backfill()
.extractor("preferences")
.stale_only(true)
.start()
.await?;Mind is live. Engineers use it daily. You need observability, cost control, backups.
Brain exports Prometheus metrics on :7860/metrics:
brain_encode_total{shard="0"} 1284932
brain_query_latency_seconds{quantile="0.5"} 0.008
brain_query_latency_seconds{quantile="0.99"} 0.041
brain_extractor_extractions_total{extractor="preferences",status="success"} 41281
brain_extractor_extractions_total{extractor="preferences",status="skipped_budget"} 12
brain_extractor_extractions_total{extractor="preferences",status="failure"} 47
brain_extractor_cost_usd_total{extractor="preferences"} 27.41
brain_extractor_cache_hit_rate{extractor="preferences"} 0.62
brain_worker_queue_depth{worker="llm_extractor"} 8
brain_worker_queue_overflow_total{worker="llm_extractor"} 0
brain_retriever_contribution_top10{retriever="semantic"} 0.51
brain_retriever_contribution_top10{retriever="lexical"} 0.27
brain_retriever_contribution_top10{retriever="graph"} 0.22
brain_entity_resolution_total{tier="exact"} 982341
brain_entity_resolution_total{tier="fuzzy"} 14829
brain_entity_resolution_total{tier="embedding"} 3104
brain_entity_resolution_total{tier="llm"} 0 # not enabled
brain_entity_resolution_total{tier="created"} 8421
brain_entity_resolution_ambiguous_total 39
You watch:
- Cost:
brain_extractor_cost_usd_total— daily LLM spend. - Health:
brain_worker_queue_overflow_total— if non-zero, you're losing extractions. - Quality:
brain_retriever_contribution_top10— if one retriever stops contributing, something's off. - Ambiguity:
brain_entity_resolution_ambiguous_total— count of pending resolutions; if growing, review needed.
If LLM extraction is getting expensive:
// Reduce per-call budget on a hot extractor
brain.admin()
.update_extractor_config("preferences")
.cost_budget("$0.0005 per memory")
.await?;
// Or disable it temporarily
brain.admin().disable_extractor("preferences").await?;
// Or change the trigger to fire less often
// (edit schema, re-upload — preferences only on memories that mention "prefer")You can also set a global daily cap:
brain-server start \
... \
--llm-daily-budget-usd 50.00When the cap is hit, LLM extractors skip with a metric until midnight. No surprise bills.
# Hot backup (background, no downtime)
brain-admin backup --output ~/backups/$(date +%Y-%m-%d).tar.zst
# Restore (offline)
brain-admin restore --input ~/backups/2026-08-15.tar.zst --data-dir ~/acme-mind/dataWhat's in the backup:
- Substrate (memories, vectors, WAL checkpoints, redb metadata) — authoritative.
- Knowledge layer (entities, statements, relations) — authoritative.
- LLM cache — optional (set
--no-cacheto skip; restore is faster but first queries are slower). - Tantivy indexes and HNSW — derived. Rebuilt on restore if absent.
- Schema versions — authoritative.
When Mind says "Priya is the VP of Infrastructure," where does that come from?
let fact = /* the statement Mind returned */;
let audit = brain.admin().trace_provenance(fact.id).await?;
println!("Statement {} created at {} by {}",
audit.statement_id,
audit.created_at,
audit.extractor);
println!("Evidence:");
for mem_id in &audit.evidence {
let mem = brain.memory_get(*mem_id).await?;
println!(" [{}] \"{}\" - {} at {}",
mem.id, mem.text, mem.author, format_date(mem.created_at));
}
if !audit.supersedes_chain.is_empty() {
println!("Supersedes:");
for prev in &audit.supersedes_chain {
println!(" - {} (v{}): \"{}\"", prev.id, prev.version, prev.object_text());
}
}Every claim Mind makes traces back to the memories that produced it. Provenance is non-optional.
You uploaded a bad schema and Mind started producing garbage.
// List schema versions
let versions = brain.schema().list().await?;
for v in versions {
println!("v{}: uploaded {} ({} active extractors)",
v.version, format_date(v.uploaded_at), v.extractor_count);
}
// Roll back
brain.schema().rollback_to(previous_version).await?;The previous schema becomes active. Stale statements from the bad version are flagged. You can re-extract them under the rolled-back schema with a backfill if you want.
To revert to substrate-only mode: use brain-admin schema disable. Knowledge-layer tables are retained but unused; substrate serves normally.
A typical Mind interaction is "answer a question, then store what was discussed":
async fn handle_user_message(brain: &Client, user: &str, msg: &str) -> Result<String> {
// 1. Retrieve context
let context = brain.query()
.text(msg)
.limit(10)
.execute()
.await?;
// 2. Generate response with an LLM, providing context
let response = call_llm(msg, &context).await?;
// 3. Record the exchange
brain.encode(&format!("{} asked: {}", user, msg))
.author(user)
.source("mind-chat")
.commit()
.await?;
brain.encode(&format!("Mind replied: {}", response))
.author("mind")
.source("mind-chat")
.commit()
.await?;
Ok(response)
}Both messages flow through extractors. If the user mentioned a person or made a decision, Brain captures it automatically.
When you know what type something is, give Brain a hint:
// Linear ticket — you know the type and key
brain.entity::<Ticket>()
.canonical_name(&ticket.key) // "ACME-1247"
.alias(&ticket.title)
.with(|t| {
t.key = ticket.key.clone();
t.status = Some(ticket.status.clone());
})
.create()
.await?;
// Then ingest the description as a memory mentioning this entity
brain.encode(&ticket.description)
.mention(ticket_entity_id) // explicit mention
.source(&format!("linear:{}", ticket.key))
.commit()
.await?;The mention call lets you skip extractor inference for entities you already know. Saves cost.
In your CI pipeline, validate your schema before deploying:
// validate-schema.rs
let schema_text = std::fs::read_to_string("acme-schema.brain")?;
let validation = brain.schema().validate(&schema_text).await?;
if !validation.errors.is_empty() {
for err in &validation.errors {
eprintln!("Schema error at line {}: {}", err.line, err.message);
}
std::process::exit(1);
}
println!("Schema valid");Mind has a Slack bot that announces new decisions:
let mut stream = brain.subscribe()
.events(&[EventKind::StatementCreated])
.filter(EventFilter::Predicate("decided".into()))
.start()
.await?;
while let Some(event) = stream.next().await {
if let Event::StatementCreated { id, subject, object, .. } = event {
let entity = brain.entity_get(subject).await?;
slack.post(&format!(
"📋 Decision recorded: *{}* → {}",
entity.canonical_name, object
)).await?;
}
}Sometimes you want results scoped to a team. Filter via graph traversal:
let infra_team_members = brain.relation::<ReportsTo>()
.traverse_from(infra_lead_id)
.reverse()
.depth(3)
.collect_entities()
.await?;
let answer = brain.query()
.text(question)
.restrict_to_subjects(&infra_team_members)
.execute()
.await?;You improved the decisions extractor's prompt. Re-run on the last 30 days:
brain.admin().backfill()
.extractor("decisions")
.memory_range(MemoryRange::Window {
since: now() - Duration::days(30),
until: now(),
})
.priority(Priority::Background)
.start()
.await?;When Mind shows a result, surface the provenance:
fn render_for_user(item: &ResultItem) -> String {
let mut s = match &item.item {
ItemRef::Statement(stmt) => format!("{}", render_statement(stmt)),
ItemRef::Memory(mem) => format!("{}", mem.text),
// ...
};
if let Some(evidence) = item.evidence() {
s.push_str("\n_Based on:_");
for mem in evidence.iter().take(3) {
s.push_str(&format!("\n - {} ({})", mem.text_excerpt(), format_date(mem.created_at)));
}
}
s
}Users see what Mind based its answer on. Trust comes from transparency, not certainty.
Brain handles silently:
- Embedding generation — when you ENCODE, Brain embeds with the configured model. You never call an embedding API.
- HNSW maintenance — vectors get indexed, tombstoned, rebuilt on schedule. No tuning required.
- Tantivy commits and merges — BM25 index stays fresh. Segment merges happen during low-traffic windows.
- WAL durability — every write is durable before ACK. Crashes don't lose committed data.
- Decay and salience — the substrates automatic decay of episodic memories continues whether or not the knowledge layer is active.
- Confidence aggregation — when multiple memories support a statement, Brain combines confidences using the documented formula.
- Supersession chains — new Preference automatically supersedes the matching old one. New contradicting Fact doesn't (different rules); Brain knows the difference.
- Cache management — LLM cache evicts LRU; expired entries swept.
- Idempotency — re-running an extractor over the same memory is a no-op (modulo cache TTL).
- Per-shard scheduling — workers respect their priority budgets so foreground latency stays low even under heavy background extraction.
- FORGET cascade — soft FORGET cascades softly, hard cascades hard. You don't write cleanup code.
- Index recovery — if tantivy or HNSW gets corrupted, Brain rebuilds from authoritative redb tables. WAL covers everything else.
What you do:
- Declare the schema.
- Wire up ingestion.
- Query.
Everything else is the substrate's job.
A briefer walkthrough showing a different feature emphasis. You're building Mnemo, a personal AI that remembers everything you tell it about your life.
Schema:
namespace mnemo
define entity_type Person {
attributes {
relationship: text optional # "spouse", "colleague", "friend"
birthday: date optional
}
}
define entity_type Place {
attributes {
kind: enum[restaurant, city, venue, home] optional
}
}
define predicate likes { kind: Preference, object: Value<text> }
define predicate dislikes { kind: Preference, object: Value<text> }
define predicate visited { kind: Event, object: Entity<Place> }
define predicate said { kind: Event, object: Value<text> }
define relation_type knows {
from: Person
to: Person
cardinality: many-to-many
symmetric: true
}
use brain.entity_mentions
use brain.temporal_expressions
define extractor personal_preferences {
kind: llm
model: "claude-haiku-4-5"
prompt: """
Extract personal preferences (likes/dislikes) and visits/events from this memory.
...
"""
# ... schema, cache, etc.
}
Usage:
// Ingest a daily journal entry
mnemo.encode("Had dinner at Kismet with Sarah. She loved the lamb but hated \
the noise. Met her colleague Aaron who turned out to know my \
brother from college.").await?;After extraction:
- Entities: Sarah, Aaron, Kismet (Place, kind=restaurant)
- Statements:
- Event: Sarah visited Kismet on 2026-05-12
- Preference: Sarah likes "lamb at Kismet"
- Preference: Sarah dislikes "noise at Kismet"
- Relations: Sarah knows Aaron; Aaron knows [my brother — if "my brother" resolves to an entity]
A month later: "Where should I take Sarah for her birthday?"
let sarah = mnemo.entity::<Person>().resolve("Sarah").await?.expect_resolved()?;
let prefs = mnemo.statements()
.where_subject(sarah.entity_id)
.of_kinds(&[StatementKind::Preference])
.with_min_confidence(0.7)
.list()
.await?;
let visits = mnemo.statements()
.where_subject(sarah.entity_id)
.where_predicate("visited")
.order_by_event_time(Order::Desc)
.limit(20)
.list()
.await?;
// Feed to an LLM with: "Given these prefs and visits, suggest restaurants for a birthday."Mnemo has the structured signal an LLM needs to be genuinely helpful, not just lucky.
The thing to internalize: with Brain, you stop building three things.
- You stop building an entity extraction pipeline.
- You stop building a hybrid retrieval layer over Elasticsearch + a vector store.
- You stop building a provenance tracking system.
You declare the schema, ingest memories, and query. Brain owns the middle.
The cost is single-node (no horizontal scale), one schema namespace per deployment (no multi-tenant), and some discipline about confidence and contradictions. For the cognitive-substrate use case — building agents and assistants that need to understand, not just retrieve — that's a good trade.
Build something with it. The fastest way to learn what Brain can do is to give it your real data and ask real questions.