Full install, configuration, and verification for operators. For the 5-minute
path, start at guides/QUICKSTART.md.
Prerequisites
- Elixir 1.20+ with
mixand Erlang/OTP curl(daemon and MCP checks)- SQLite ships via
exqlite— no separate install - EXLA CPU works out of the box;
:cuda/:rocmneed a matchingconfig :exla, clientsentry or model load fails instead of falling back
Check:
elixir --version
mix deps.get
Data and model directories
| Setting | Env | Default |
|---|---|---|
data_dir | AGENT_DB_DATA_DIR (see config/example.exs) | ./data |
model_cache_dir | AGENT_DB_MODEL_CACHE_DIR | ./models |
The store keeps agent_db.db (WAL mode) under data_dir and downloads
weights into model_cache_dir. Both are created on boot. Sources of truth:
AgentDb.Config.data_dir/0, model_cache_dir/0, and AgentDb.Application.
Environment reference
All values below match lib/agent_db/application.ex and
config/runtime.exs at the time of writing.
| Config key | Env var | Default |
|---|---|---|
embedding_model | AGENT_DB_EMBEDDING_MODEL | sentence-transformers/all-MiniLM-L6-v2 |
embedding_model_url | AGENT_DB_EMBEDDING_MODEL_URL | HuggingFace model.safetensors URL |
llm_model | AGENT_DB_LLM_MODEL | Qwen/Qwen3-0.6B |
llm_model_url | AGENT_DB_LLM_MODEL_URL | HuggingFace Qwen3-0.6B-Q4_K_M.gguf URL |
llm_chat_template | AGENT_DB_LLM_CHAT_TEMPLATE | Qwen3 ChatML with %{prompt} placeholder |
llm_model_params | AGENT_DB_LLM_MODEL_PARAMS | 0.6B (descriptive only, shown in model status) |
async_writes | AGENT_DB_ASYNC_WRITES | true ("true" → true, anything else → false) |
job_workers | AGENT_DB_JOB_WORKERS | CPU cores (System.schedulers_online()); must be a positive integer |
exla_backend | AGENT_DB_EXLA_BACKEND | cpu (cpu | cuda | rocm; anything else → :cpu) |
ml_backend | AGENT_DB_ML_BACKEND | auto (auto | exla | emlx; anything else → :auto) |
inference_concurrency | — (Application env only) | CPU cores; how many inference runs may be in flight at once, past which a caller runs inline |
vector_reclaim_cadence_ms | AGENT_DB_VECTOR_RECLAIM_CADENCE_MS | 60000; how long the store waits between checks for vector-index rows naming URIs it no longer holds. Must be a positive integer — anything else falls back to the default. See Vector index drift |
inference_provider | AGENT_DB_INFERENCE_PROVIDER | :local (:local | :ollama | :openai_compatible | module implementing AgentDb.Core.Inference). This one key decides both which provider serves inference and the provider kind model_status/0 reports. A value naming no known provider fails startup validation rather than serving the default while reporting something else. |
ollama_base_url | AGENT_DB_OLLAMA_URL | http://localhost:11434 |
ollama_embed_model / ollama_llm_model | Application env only | nomic-embed-text / llama3.1 |
openai_base_url | AGENT_DB_OPENAI_BASE_URL | none (required for :openai_compatible) |
openai_api_key | AGENT_DB_OPENAI_API_KEY | none (never logged) |
http_enabled | AGENT_DB_HTTP_ENABLED | true (false in :test) |
http_auth | AGENT_DB_HTTP_AUTH | false |
http_auth_tokens | AGENT_DB_HTTP_AUTH_TOKENS | [] (comma-separated, e.g. tok1,tok2) |
Listener (resolved once in config/runtime.exs for both bind and URL
generation):
| Env var | Default | Meaning |
|---|---|---|
AGENT_DB_HTTP_PORT | 6060 | Port served and used for URL generation |
AGENT_DB_HTTP_IP | 127.0.0.1 | Interface bound (loopback by default) |
AGENT_DB_HTTP_ENABLED | true (false in :test) | Whether the endpoint is started |
PHX_HOST | localhost | Host used for URL generation only |
SECRET_KEY_BASE | dev default in config/config.exs | Required in prod; must be 64+ bytes or /admin returns 500 |
PORT | — | Not read; set AGENT_DB_HTTP_PORT instead |
HTTP listener and auth
The endpoint sets server: true in config/runtime.exs — without it Phoenix
binds nothing under mix/iex. The bind defaults to loopback because the
surface is unauthenticated by design and exposes document and memory content.
# loopback only (default)
iex -S mix
# LAN (bind explicitly, enable Bearer, keep the port off wider networks)
AGENT_DB_HTTP_ENABLED=true AGENT_DB_HTTP_IP=0.0.0.0 AGENT_DB_HTTP_PORT=6060 iex -S mix
# with auth
AGENT_DB_HTTP_AUTH=true AGENT_DB_HTTP_AUTH_TOKENS=<token> iex -S mix
Comma-separated tokens are split on , with whitespace trimmed. Serve beyond
loopback only with Bearer enabled.
Running as a sidecar in another Elixir app
AgentDb can be embedded as a dependency in a host Elixir application. Add it to the host's deps:
def deps do
[{:agent_db, "~> 0.1.0"}]
endStart the store (the host usually does this in its own supervision tree) and disable the HTTP listener so no port is bound:
config :agent_db, :http_enabled, false{:ok, _} = Application.ensure_all_started(:agent_db)Then invoke tools directly in-process:
{:ok, _} = AgentDb.Mcp.call_tool("context_write", %{"uri" => uri, "content" => content})
{:ok, %{"content" => text}} = AgentDb.Mcp.call_tool("context_read", %{"uri" => uri})Data directory, database path, and model cache resolve from the host's working
directory unless overridden via :data_dir, :db_path, and :model_cache_dir.
Backends (Apple Silicon, CUDA)
AGENT_DB_ML_BACKEND=autoprefers EMLX on Apple Silicon macOS, EXLA elsewhere.emlxforces EMLX with EXLA fallback plus a warning;exlapins EXLA for CI and debugging.- EMLX packages are
optional: true, runtime: falseand macOS-only; they are never fetched on Linux CI and start only when needed. AGENT_DB_EXLA_BACKEND=cuda(orrocm) requires a matchingconfig :exla, clientsentry — load fails loudly instead of silently using CPU.
AGENT_DB_ML_BACKEND=emlx iex -S mix
AGENT_DB_ML_BACKEND=exla mix test
Models: pre-placement and cache recovery
Models auto-download on first use. To pre-place, put the files in
model_cache_dir before first inference.
- Downloads write to a temp path then rename, so a present file is normally
complete. A
model.safetensorsleft by an interrupted older download can still be truncated. - If model loading fails: delete the file and let it re-download. Presence is the only cache check.
- Models load lazily. A call before readiness waits up to
model_load_grace_ms(default 10s) then returns{:error, :model_loading}— retry later. A real failure returns{:error, reason}without killing the caller. AgentDb.model_status/0reports:loading | :ready | :failed | :idleand stays answerable throughout.
Windows (WSL2)
Run the daemon and installer inside WSL2; point Windows editors at
http://localhost:6060/mcp. From PowerShell:
wsl bash tools/install.sh --checktools/install.ps1 refuses native execution on purpose — native Windows has
no supported EXLA path.
Vector index drift
The vector index is derived, not a second system of record. Every row in it can be reproduced from what the store already holds, so it is allowed to lag. What that buys is that a removed URI stops being searchable immediately — the read path checks the node store on every search — rather than the index having to be updated in the same transaction as the document.
The residue is called drift: rows naming URIs the store no longer holds. Drift cannot affect a search result, because the read path joins those rows away. It does occupy the index, and left alone it grows without anyone being told — which is exactly how that leak went unnoticed before it was fixed.
So the store does two things about it:
- A write retires the URI's previous vector. Re-writing a document drops its old row in the same transaction, so a live document is never ranked on content it no longer has.
- A standing job reclaims the rest. One
:reclaim_vector_indexjob runs on a cadence, counts what is reclaimable, and deletes only rows whose URI is gone. An idle store costs one count per cadence.
AgentDb.index_coverage/0 reports the number, so drift is visible instead of
silent:
AgentDb.index_coverage()
#=> %{available: true, active_dim: 384, vectors: 1024, documents: 1000,
# needs_backfill: false, reclaimable: 24}reclaimable staying at zero means reclamation is keeping up. A number that
keeps climbing means the job is not running — not removing it, not dropping it
from the union. Check AgentDb.queue_detail/1.
Change the cadence when the default does not fit:
AGENT_DB_VECTOR_RECLAIM_CADENCE_MS=30000 for a busier store, or a larger
value on a store where writes are rare. An unparsable value falls back to the
default rather than failing to boot.
Verification and recovery
tools/install.sh --check
mix agent_db.doctor
tools/install.sh --check verifies Elixir/mix/curl, required files
(mix.exs, config/runtime.exs, MCP modules), daemon reachability at
/api/v1/health, and MCP tools/list containing context_read.
| Failure | Fix |
|---|---|
missing: elixir/mix/curl | Install per the hint (brew install elixir, apt install elixir, …) |
missing file: … (run from the checkout root) | Run from the agent_db checkout root |
daemon NOT reachable | Start it (iex -S mix), check AGENT_DB_HTTP_PORT matches --port |
mcp check inconclusive | Expected with auth on (needs a token); otherwise check daemon logs |
doctor reports DB unreachable | Check data_dir is writable; exits non-zero |
| Model load fails | Delete truncated cache file, re-run; check backend config |
/admin 500 before routes | SECRET_KEY_BASE under 64 bytes — use a longer secret |
job_workers must be a positive integer | Fix AGENT_DB_JOB_WORKERS to 1 or more |
mix agent_db.doctor --json prints the same report as JSON. doctor exits
non-zero only when the database itself is unreachable.
Verifying a change
The same commands CI runs, in the same order. Nothing here is advisory: a
warning in this project's own source fails the build, and a static-analysis
finding that is not accounted for in .credo.exs or .dialyzer_ignore.exs does
too.
mix deps.get
mix format --check-formatted # formatting
mix compile --force --warnings-as-errors
mix test # 559 tests, about 90s
mix lint # format check + Credo strict + Dialyzer
mix lint:quick # the same without Dialyzer's PLT analysis
mix docs # API reference into doc/
mix hex.build # then verify what it contains:
mix run --no-start tools/verify_package.exs
mix lint:quick exists because Dialyzer's PLT analysis dominates the runtime;
use it while working and mix lint before pushing.
Reading the output
A cold build compiles the EXLA/Nx/Phoenix tree and prints thousands of
warnings, every one of them found quoted keyword attributed to nofile —
the toolchain reading dependency metadata, not this repository. A warm build
prints none, and a warning naming a file in lib/ is this project's and is
failing the build. Compile dependencies first (mix deps.compile) when you want
the compile step's output to be only your own.
Why the suite is one process at a time
All 43 test files are async: false, and that is a property of the suite rather
than an inherited default. Every one of them reaches shared state, and none of
them reaches only one kind:
| Shared state | Files that touch it |
|---|---|
Application env (:data_dir, backend keys, limits) | 33 |
named ETS tables (Cache, Observability.Sink) | 24 |
| the data directory / database file | 23 |
filesystem writes outside System.tmp_dir! | 12 |
| model manager, job queue, bumblebee | 11 |
| SQLite store modules | 9 |
| stopping the supervised worker pool | 8 |
| starting processes under a supervisor | 7 |
| endpoint, PubSub, connection tests | 4 |
Application.put_env(:agent_db, :data_dir, …) is the one that settles it: it is
process-global, so two files setting different values would share one database.
Making the suite concurrent needs a data directory that is not process-global and
tests that never stop shared processes — a redesign of the harness, not a flag.
Until then the suite is serial on purpose, and the ~90 seconds is the price of
tests that cannot see each other.
If the suite fails to load a file
mix test intermittently fails before running a single test:
== Compilation error in file test/… ==
** (MatchError) no match of right hand side value: {:error, :enoent}
lib/kernel/parallel_compiler.ex:667: Kernel.ParallelCompiler.require_file/2This is Mix's test-file loader failing to open a file, not a test failing. What is known, from the failures seen on this repository:
- It names a different file each time, and has been seen for files under
test/and for a path under the system temporary directory that a test creates. - It is not reproducible by reading the same files directly: 10,000 sequential and 1,960 sixteen-way concurrent reads of every test file produced zero failures, as did 8,000 reads from outside the BEAM.
--max-cases 1and--max-requires 1do not prevent it, so it is not simply a parallelism problem.- AppleDouble sidecars are not the cause: Mix globs
test/**/*.{ex,exs}throughPath.wildcard, which never matches a dotfile, so the._*_test.exsfiles this repository accumulates on an exFAT volume are not in the list at all.
No cause has been established, so nothing in the repository has been changed to work around it. Re-run the command; if it recurs, the useful thing to capture is the path it names. CI, on Linux with a local disk, is the environment the gate is written for, and a red gate there is the signal that counts.