Full install, configuration, and verification for operators. For the 5-minute path, start at guides/QUICKSTART.md.

Prerequisites

  • Elixir 1.20+ with mix and Erlang/OTP
  • curl (daemon and MCP checks)
  • SQLite ships via exqlite — no separate install
  • EXLA CPU works out of the box; :cuda / :rocm need a matching config :exla, clients entry or model load fails instead of falling back

Check:

elixir --version
mix deps.get

Data and model directories

SettingEnvDefault
data_dirAGENT_DB_DATA_DIR (see config/example.exs)./data
model_cache_dirAGENT_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 keyEnv varDefault
embedding_modelAGENT_DB_EMBEDDING_MODELsentence-transformers/all-MiniLM-L6-v2
embedding_model_urlAGENT_DB_EMBEDDING_MODEL_URLHuggingFace model.safetensors URL
llm_modelAGENT_DB_LLM_MODELQwen/Qwen3-0.6B
llm_model_urlAGENT_DB_LLM_MODEL_URLHuggingFace Qwen3-0.6B-Q4_K_M.gguf URL
llm_chat_templateAGENT_DB_LLM_CHAT_TEMPLATEQwen3 ChatML with %{prompt} placeholder
llm_model_paramsAGENT_DB_LLM_MODEL_PARAMS0.6B (descriptive only, shown in model status)
async_writesAGENT_DB_ASYNC_WRITEStrue ("true" → true, anything else → false)
job_workersAGENT_DB_JOB_WORKERSCPU cores (System.schedulers_online()); must be a positive integer
exla_backendAGENT_DB_EXLA_BACKENDcpu (cpu | cuda | rocm; anything else → :cpu)
ml_backendAGENT_DB_ML_BACKENDauto (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_msAGENT_DB_VECTOR_RECLAIM_CADENCE_MS60000; 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_providerAGENT_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_urlAGENT_DB_OLLAMA_URLhttp://localhost:11434
ollama_embed_model / ollama_llm_modelApplication env onlynomic-embed-text / llama3.1
openai_base_urlAGENT_DB_OPENAI_BASE_URLnone (required for :openai_compatible)
openai_api_keyAGENT_DB_OPENAI_API_KEYnone (never logged)
http_enabledAGENT_DB_HTTP_ENABLEDtrue (false in :test)
http_authAGENT_DB_HTTP_AUTHfalse
http_auth_tokensAGENT_DB_HTTP_AUTH_TOKENS[] (comma-separated, e.g. tok1,tok2)

Listener (resolved once in config/runtime.exs for both bind and URL generation):

Env varDefaultMeaning
AGENT_DB_HTTP_PORT6060Port served and used for URL generation
AGENT_DB_HTTP_IP127.0.0.1Interface bound (loopback by default)
AGENT_DB_HTTP_ENABLEDtrue (false in :test)Whether the endpoint is started
PHX_HOSTlocalhostHost used for URL generation only
SECRET_KEY_BASEdev default in config/config.exsRequired 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"}]
end

Start 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=auto prefers EMLX on Apple Silicon macOS, EXLA elsewhere. emlx forces EMLX with EXLA fallback plus a warning; exla pins EXLA for CI and debugging.
  • EMLX packages are optional: true, runtime: false and macOS-only; they are never fetched on Linux CI and start only when needed.
  • AGENT_DB_EXLA_BACKEND=cuda (or rocm) requires a matching config :exla, clients entry — 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.safetensors left 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/0 reports :loading | :ready | :failed | :idle and 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 --check

tools/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_index job 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.

FailureFix
missing: elixir/mix/curlInstall 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 reachableStart it (iex -S mix), check AGENT_DB_HTTP_PORT matches --port
mcp check inconclusiveExpected with auth on (needs a token); otherwise check daemon logs
doctor reports DB unreachableCheck data_dir is writable; exits non-zero
Model load failsDelete truncated cache file, re-run; check backend config
/admin 500 before routesSECRET_KEY_BASE under 64 bytes — use a longer secret
job_workers must be a positive integerFix 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 stateFiles that touch it
Application env (:data_dir, backend keys, limits)33
named ETS tables (Cache, Observability.Sink)24
the data directory / database file23
filesystem writes outside System.tmp_dir!12
model manager, job queue, bumblebee11
SQLite store modules9
stopping the supervised worker pool8
starting processes under a supervisor7
endpoint, PubSub, connection tests4

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/2

This 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 1 and --max-requires 1 do not prevent it, so it is not simply a parallelism problem.
  • AppleDouble sidecars are not the cause: Mix globs test/**/*.{ex,exs} through Path.wildcard, which never matches a dotfile, so the ._*_test.exs files 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.