Daily workflows for the store. Setup lives in guides/SETUP.md; the 5-minute path in guides/QUICKSTART.md; editor setup in guides/agents.md (canonical for OpenCode/Zed snippets).

Documents

URIs look like viking://resources/my_project/readme.md, viking://user/{id}/memories/…, viking://user/{id}/skills/…, peers/…. Missing URIs return {:error, :not_found} or :not_found without side effects.

:ok = AgentDb.write("viking://resources/my_project/readme.md", "# Hello\n\nBody!")
{:ok, content} = AgentDb.read("viking://resources/my_project/readme.md")
{:ok, children} = AgentDb.list("viking://resources/my_project")
{:ok, tree} = AgentDb.tree("viking://resources/my_project", 2)
:ok = AgentDb.rm("viking://resources/my_project/hello.md")

Options for write/3: async: (override async_writes), sync_timeout_ms: (default 30_000), abstract: / overview: for caller supplied L0/L1. Writes persist to SQLite before acknowledgement; embeddings and summaries arrive progressively. Removal clears node store, vector index, pending jobs, and caches for the subtree.

Layered content: L2 is full content, L0 abstract, L1 overview.

:ok = AgentDb.write("viking://resources/my_project/big.md", body, abstract: "One-liner", overview: "Short version")
{:ok, abstract} = AgentDb.abstract("viking://resources/my_project/big.md")
{:ok, overview} = AgentDb.overview("viking://resources/my_project/big.md")

Without caller layers the store generates them asynchronously. Until they land, the store derives them from L2: the first non-empty line of the body for the abstract (a leading heading, quote, or bullet marker stripped, so the abstract is the line's text rather than its formatting), and the first 280 characters cut on a word boundary for the overview.

Reading layers before content

An agent that asks whether a document is worth reading should not pay for the content to find out. Read L0 first, then L1, then L2:

{:ok, abstract} = AgentDb.abstract("viking://resources/my_project/big.md")
{:ok, overview} = AgentDb.overview("viking://resources/my_project/big.md")

# Or all three in one call, each with its source and character count
layers = AgentDb.layers("viking://resources/my_project/big.md")
layers.abstract   #=> %{text: "One-liner", source: :stored, chars: 11}
layers.overview   #=> %{text: "Short version", source: :stored, chars: 13}
layers.content    #=> %{text: "<full body>", source: :stored, chars: 4096}

A layer's source is what tells a caller where it came from:

  • :stored — persisted: supplied by the caller, or generated by a background job. It is the real L0/L1.
  • :fallback — derived from L2 at read time because nothing was stored. The store can always answer, without a model, but the text is a resolution rather than a summary.
  • :unavailable — that layer's read failed. It carries no text.

L2 is always :stored for a document that reads at all, since content is what a write persists. Check stored_layers/1 when you need to know whether a summary has actually been generated yet.

The same layers are reachable without Elixir, so an agent in an editor can use them directly:

# CLI
mix agent_db.abstract "viking://resources/my_project/big.md" --json
mix agent_db.overview "viking://resources/my_project/big.md" --json

# MCP (OpenCode / Zed)
context_abstract  {"uri": "viking://resources/my_project/big.md"}
context_overview  {"uri": "viking://resources/my_project/big.md"}
context_layers    {"uri": "viking://resources/my_project/big.md"}

context_layers answers all three layers in one round trip, each as {"text": ..., "source": "stored" | "fallback" | "unavailable", "chars": N}, and reports a layer that cannot be read as unavailable rather than failing the whole call. context_abstract and context_overview need no model when a layer was caller-supplied or derived.

Search, find, grep

{:ok, results} = AgentDb.search("hello", mode: :keyword)
{:ok, results} = AgentDb.search("greeting", mode: :vector)
{:ok, results} = AgentDb.search("hello", mode: :hybrid)
{:ok, results} = AgentDb.search("hello", mode: :keyword, scope: "viking://resources/my_project", top_k: 10)
  • Default mode: :keyword (no model needed). :vector needs embeddings; :hybrid fuses both with hybrid_weights: {0.5, 0.5} (or the identical [keyword: 0.5, vector: 0.5] list shape).

  • scope: limits to a subtree (exact URI or descendants only).

  • top_k: caps results. Default 10, maximum 200; an out-of-range value returns {:error, {:invalid_limit, value}} rather than silently answering a different question than the one asked. This is a breaking change for a caller that relied on an unbounded keyword result set.

  • fields: selects which per-result fields come back, from :uri, :content, :abstract, :overview, :score. Omitted returns all of them, so an existing caller sees the same result set as before. The selection reaches the statement that reads the rows, so a caller that did not ask for content does not pay to read it:

    {:ok, hits} = AgentDb.search("vector index", mode: :hybrid, fields: [:uri, :abstract, :score])
    #=> [%{uri: "viking://resources/my_project/vec.md", abstract: "The vector index...", score: 0.011}]

    :uri must be named — a result without its URI cannot be fetched — and an empty list, an unknown name, or a selection without :uri returns {:error, {:invalid_fields, value}}. Over MCP the same option is fields with string names.

  • Model still loading → {:error, :model_loading}; retry later.

find/2 matches URI paths; grep/2 matches L2 content lines. Both are literal, case-insensitive, no model needed:

{:ok, paths} = AgentDb.find("auth", scope: "viking://resources/my_project", limit: 50)
#=> [%{uri: "viking://resources/my_project/auth.md", name: "auth.md", kind: :doc}]

{:ok, lines} = AgentDb.grep("def run", scope: "viking://resources/my_project", limit: 50)
#=> [%{uri: "viking://resources/my_project/src/main.ex", line_number: 2, excerpt: "  def run, do: :ok"}]

Query 1–256 chars, non-empty; limit default 50, max 200 ({:error, {:invalid_limit, limit}} outside 1–200). find orders by URI; grep by URI then line number, excerpts ≤ 280 chars. grep never looks at abstracts or overviews. Empty match → {:ok, []}.

Memory

Typed durable facts under viking://user/memories/<type>/<name>. Types: profile, preferences, entities, events, experiences. The URI is the identity: writing a URI revises what it holds (old value superseded, still inspectable) rather than adding a second memory; a different URI adds its own. Re-recording the exact value a URI already holds changes nothing — one assertion stays there. No model participates in any of it; a record writes its document, its assertion, and its embedding job as one outcome, so a failed record leaves nothing behind.

{:ok, uri} = AgentDb.remember("viking://user/memories/preferences/language", "prefers Elixir",
  confidence: 0.9, source: session_id)
{:ok, memories} = AgentDb.recall(type: :preferences)
{:ok, [memory]} = AgentDb.recall("viking://user/memories/preferences/language")
{:ok, history} = AgentDb.recall(uri: "viking://user/memories/preferences/language", include_superseded: true)
:ok = AgentDb.forget("viking://user/memories/preferences/language")
  • confidence: 0.0–1.0, default 0.5 (AgentDb.default_confidence/0); importance: 0.0–1.0, default 0.5. candidate: true records awaiting promotion (also forced by near-zero confidence, below 0.1); promote_memory/1 activates — carrying the promoted value through the URI's document — reject_memory_candidate/1 removes without a trace, pending_memory_candidates/0 lists the review queue. A candidate recorded beside an active belief never overwrites that belief's document: the URI still reads the active value until promotion.
  • recall() with no args returns everything active; type: / term: / subtree URI filter; without a term results order by descending confidence, with a term by confidence + semantic similarity (exact matches boosted, stale memories penalized, importance swaying near-equals); no match → {:ok, []}. Every recalled memory reports its own type, derived from its own URI rather than the scope the recall was issued against. Every recall records surfacing on the rows it returns. A recall against a malformed URI reports {:error, :invalid_uri} rather than raising.
  • memory_conflicts/0 reports similar active memories at distinct URIs read-only (no inference of its own); {:error, :embeddings_unavailable} when there is nothing stored to compare.
  • forget/1 on a missing memory → {:error, :no_memory}; it removes value plus provenance including superseded assertions. A plain document at the URI is left alone.
  • Memories embed for semantic search but never generate summaries.

Sessions

{:ok, session_id} = AgentDb.create_session()
:ok = AgentDb.append_message(session_id, :user, "Remember this")
:ok = AgentDb.append_message(session_id, :assistant, "Got it")
{:ok, messages} = AgentDb.get_session(session_id)
{:ok, uri} = AgentDb.commit_session(session_id, "viking://user/me/memories/session-1")

Roles: :user | :assistant | :system. Commit is idempotent per (session, destination): unchanged re-commit → {:ok, :unchanged}; if the destination was removed, re-commit restores it.

Skills

A skill is a folder with SKILL.md at its root. Import stores it under viking://user/{id}/skills/{skill_name} verbatim (frontmatter not parsed; non-UTF-8 refused). Source may be one skill or a collection; archives may add one wrapper dir. Limits: 500 files/entries, 5_000_000 bytes expanded — over either is refused before anything is written.

{:ok, out} = AgentDb.import_skills("alice", {:path, "./my-skills"})
{:ok, out} = AgentDb.import_skills("alice", {:path, "./skills.tar.gz"})
{:ok, content} = AgentDb.read("viking://user/alice/skills/code-review/references/checklist.md")

A same-named skill is replaced whole atomically; failure leaves the stored skill untouched. Console (/admin) and CLI go through the same workflow:

mix agent_db.import_skills ./my-skills --user alice
mix agent_db.import_skills ./skills.tar.gz --user alice

Refused for: unsafe paths (.., absolute, backslash, empty segments, controls), symlinks / non-regular archive entries, duplicate paths, skill dir without SKILL.md, non-UTF-8 files, over limits. mix help agent_db.import_skills prints the same rules.

Data handoff (export / import)

:ok = AgentDb.write("viking://resources/project/readme.md", "# Project")
{:ok, _} = AgentDb.export_data("/tmp/backup.tar.gz")
{:ok, _} = AgentDb.export_data("/tmp/project.tar", scope: "viking://resources/project")
mix agent_db.export_data /tmp/backup.tar.gz --json
mix agent_db.export_data /tmp/project.tar --scope viking://resources/project
mix agent_db.import_data /tmp/backup.tar.gz --json

Export snapshots documents (content plus caller-supplied abstract/overview), memory provenance, and sessions. Import validates the whole archive first (traversal, symlink, non-UTF-8, oversize, corrupt, newer format leave the store untouched), then merges by URI: missing created, present revised, colliding sessions skipped, nothing outside deleted. Re-import converges without duplication. Embeddings and generated summaries regenerate via the job queue — they are not carried in the archive.

Seed data for dev and test

mix agent_db.seed --json
mix agent_db.seed --prefix viking://resources/custom --json
mix agent_db.seed --force
mix agent_db.seed --clean

Populates a fresh store with one deterministic demo dataset: documents under the demo prefix (default viking://resources/demo), one typed memory per type, one committed session, one demo skill, and demo code-index entries. Re-running converges without duplication. A non-empty store is refused unless --force (merge) or --clean (remove only the seed scope, then seed) is given; production needs --allow-prod. Keyword search, find, and grep reach seeded content with no model loaded. Same API from Elixir: AgentDb.Seed.seed(). Run mix help agent_db.seed for exact flags.

CLI (mix agent_db.*)

Every task supports --json (JSON on stdout, reason on stderr, non-zero exit on failure); without it, human-readable text is unchanged.

mix agent_db.read "viking://resources/myapp/readme.md" --json
mix agent_db.search "authentication" --mode hybrid --json
mix agent_db.search "authentication" --mode keyword --scope viking://resources/myapp --top-k 10 --json
mix agent_db.find "auth" --scope viking://resources/myapp --limit 50 --json
mix agent_db.grep "def run" --scope viking://resources/myapp --limit 50 --json
mix agent_db.recall --type preferences --json
mix agent_db.tree viking://resources/myapp --depth 2 --json
mix agent_db.index --project myapp --dir lib
mix agent_db.seed --json
mix agent_db.doctor --json

Task list: read, search, find, grep, recall, tree, index, import_skills, import_data, export_data, seed, doctor. Run mix help agent_db.<task> for exact flags (--mode, --scope, --top-k / --limit, --depth, --type, --term, --include-superseded, --project, --dir, --user, --prefix, --force, --clean, --allow-prod).

MCP (OpenCode, Zed)

One shared daemon serves POST /mcp (default http://127.0.0.1:6060/mcp, port from AGENT_DB_HTTP_PORT). Canonical snippets and WSL notes live in guides/agents.md — copy from there, not from below:

// opencode.jsonc (no auth)
{ "mcp": { "servers": { "agent-db": { "type": "remote", "url": "http://127.0.0.1:6060/mcp" } } } }
MCP toolFacade
context_read, context_write, context_rmread, write, rm
context_abstract, context_overview, context_layersabstract, overview, layers
context_list, context_treelist, tree
context_search, context_find, context_grepsearch, find, grep
memory_recall, memory_remember, memory_forgetrecall, remember, forget
session_create, session_append, session_get, session_commitcreate_session, append_message, get_session, commit_session
store_healthhealth_check + model_status + queue_stats

Errors are JSON ({"code": …, "message": …}). model_loading means retry later; anything else names the cause. The session stays usable after an error. Option names match the facade (mode, scope, top_k, limit, fields, confidence, source). tools/install.sh --check prints these snippets and verifies tools/list answers with context_read.

WebSocket (v1.*)

Connect to ws://localhost:6060/api (Bearer header when auth is on). Events mirror the facade:

{"event": "v1.write", "payload": {"uri": "viking://resources/foo.md", "content": "Hello", "opts": {}}}
{"event": "v1.read", "payload": {"uri": "viking://resources/foo.md"}}
{"event": "v1.search", "payload": {"term": "hello", "opts": {"mode": "hybrid", "top_k": 10}}}
{"event": "v1.find", "payload": {"term": "auth", "opts": {"scope": "viking://resources/project", "limit": 50}}}
{"event": "v1.grep", "payload": {"term": "def run", "opts": {"scope": "viking://resources/project", "limit": 50}}}
{"event": "v1.create_session", "payload": {}}
{"event": "v1.append_message", "payload": {"session_id": "...", "role": "user", "content": "hi"}}
{"event": "v1.get_session", "payload": {"session_id": "..."}}
{"event": "v1.commit_session", "payload": {"session_id": "...", "destination_uri": "viking://user/me/memories/s"}}
{"event": "v1.model_status", "payload": {}}
{"event": "v1.subscribe", "payload": {"uri": "viking://resources/project"}}
{"event": "v1.unsubscribe", "payload": {"uri": "viking://resources/project"}}
{"event": "v1.search_progress", "payload": {"term": "hello", "opts": {"mode": "keyword"}}}

Subscriptions push versioned {:context_changed, uri, kind, version} over AgentDb.PubSub (AgentDb.subscribe/1 in-process); BEAM distribution is explicitly deferred. traceparent may ride top-level or inside opts (WebSocket) or as an HTTP header; missing or malformed context starts a new trace without affecting results.

Console and trust boundary

Open /admin to import skills (User ID + folder or .tar/.tgz/.tar.gz); it reports per-skill imported / replaced / failure reason and writes nothing when the source is refused.

The same page is the operations console. It reports:

SectionWhat it answers
StorageDatabase and WAL byte sizes; document and directory counts overall and per top-level subtree
CacheEntry counts and memory per ETS cache table
RuntimeNode uptime, supervisor and process counts, BEAM memory
ModelsPer role: load state, last load duration, last latency, in-flight count, model identity, provider and remote provider health
QueuePer-status counts, age of the oldest pending job, failed jobs with kind, URI, attempts, and classified reason
IndexIndexed vector rows and the number of documents the index describes
Recent errorsBounded, newest-first, classified reason and operation only

Every value is read-only and bounded. An unreachable remote provider is shown as unreachable, not as loaded, and a cache that has grown reads differently from a store whose content has.

Default no-auth daemon binds loopback only: any local process can read and write. That fits one laptop shared by two editors and nothing else. To serve beyond loopback, set AGENT_DB_HTTP_IP, enable Bearer auth, and keep the port off wider networks than intended. See guides/SETUP.md and guides/agents.md §7.

Status and observability

AgentDb.health_check()
#=> %{status: ..., checks: %{db: true, models: true}}
AgentDb.model_status()
AgentDb.queue_stats()

mix agent_db.doctor reports the same plus PubSub and inference provider. Telemetry (agent_db.operation.stop, agent_db.job.stop, agent_db.model.stop) and structured logs never carry URIs, content, prompts, users, or credentials. Benchmarks: mix run bench/agent_db_bench.exs (baselines in bench/baseline.md).