Quickstart — self-hosted
Run louvain on your own machine, point it at a model you control, and prove it works before anyone depends on it.
Everything below runs on one machine and needs no vendor account. The last section takes the same code to a real deployment.
Prerequisites
| Tool | Why |
|---|---|
| Node 22+ | the runtime |
| pnpm | the workspace manager — the repo pins its version |
| Docker | Postgres (pgvector) and SpiceDB |
git clone <your-fork> louvain && cd louvain
pnpm installOptionally a local model runtime — Ollama, vLLM or llama.cpp's server. You will want one before the end; see Point it at a model.
1. Start the datastores
docker compose up -d --waitTwo things come up: Postgres with pgvector on host port 5434, and SpiceDB on
50051. A one-shot spicedb-migrate service runs its datastore migrations to
completion first, which is why --wait matters — without it you race SpiceDB's
own schema.
Both use a separate database on the same Postgres instance. That is fine for one box and not what you want under load; see the deployment section.
2. Start the API
pnpm --filter @louvain/api devIt listens on :8080. Before the port opens, boot does the following in order,
and any of it failing is a failed boot rather than a warning:
- Preflight. Configuration that cannot work refuses to start. Locally the checks are lenient; they bite once the dev header is off.
- Migrations. They apply automatically at boot and are append-only. There is no separate migrate step to forget, and no "did the deploy run it?" question to answer later.
- The authorization schema. The SpiceDB schema is written from the code, so the permission model in the repo is the one running.
- Embedding warmup, then any pending permission tuples are delivered, then a projection rebuild — the materialized readable set per principal, so the first query is correct rather than empty. The order matters after an upgrade: a migration that rewrites permission object ids re-queues them all, and projecting before delivering would leave every principal reading an empty set until the next relay tick. It prints how many principals it rebuilt for.
- The relay loop and the connection manager start; then the port opens.
The last line tells you the shape you are running:
louvain api listening on :8080 (extractor: rule, ingest: inline, auth: dev-header)auth: dev-header means LOUVAIN_DEV_INSECURE_HEADER is on, which is the local
default. In that mode the API honours an x-principal header with no token and
registers the unscoped /v1/admin/* routes. Those routes are not registered
at all when the flag is off — there is no production backdoor, because the
code is not there to get wrong. Boot refuses outright if the flag is on with
NODE_ENV=production.
3. Start the web app and create the first admin
pnpm --filter @louvain/web devThe product is on http://localhost:3000. It proxies /api/* to the API, so
the session cookie is first-party and there is one origin to trust.
Locally, LOUVAIN_SIGNUP defaults to open, so go to /signup and create an
account. The first person to sign up owns the workspace. Passwords are a minimum
of 12 characters; the workspace slug is fixed at creation and default is
reserved.
For an invite-only deployment the first account works differently — see Creating the first admin on a closed deployment.
4. Point it at a model
Do this before you judge anything louvain says. The default extractor is
rule: a handful of regexes that exist as a test fixture. It scores 100% on the
retrieval benchmark and 20.8% F1 on natural language, which means a system
running it captures roughly one fact in eight and reports no error at all. The
graph looks healthy and is simply missing most of what was said.
With Ollama on the same machine
Pull a model — Qwen-class models score well on this task — and use its tag
exactly as ollama list prints it:
LOUVAIN_EXTRACTOR=openai-compat \
LOUVAIN_EXTRACTOR_BASE_URL=http://localhost:11434/v1 \
LOUVAIN_EXTRACTOR_MODEL=<your-ollama-tag> \
LOUVAIN_EXTRACTOR_REASONING=none \
LOUVAIN_GATE=heuristic \
LOUVAIN_ASYNC_EXTRACTION=true \
pnpm --filter @louvain/api devNo API key is involved. Preflight requires LOUVAIN_EXTRACTOR_API_KEY only for a
remote endpoint — localhost, 127.0.0.1, ::1, host.docker.internal and
the private IPv4 ranges are recognised as local, because demanding a key would
block the self-host path this exists to offer.
A few of those variables are worth understanding rather than copying:
LOUVAIN_EXTRACTOR_REASONING=noneis measured, not superstition. The same extraction takes about 47 seconds with a chain-of-thought trace and 1–4 seconds without, for no measured quality gain on this task. Set it todefaultonly for an endpoint that rejects the parameter.LOUVAIN_GATE=heuristicturns on the tier-0 admission gate. Most workplace chat is "+1" and "thanks"; the gate keeps it away from the model, gated events stay auditable, and it costs nothing.LOUVAIN_ASYNC_EXTRACTION=trueputs extraction behind the durable queue, so accepting an event costs one insert. Leave it off and ingest waits on the model.
With a hosted OpenAI-compatible endpoint
Any OpenAI-compatible API works — set the base URL, the model, and a key:
LOUVAIN_EXTRACTOR=openai-compat
LOUVAIN_EXTRACTOR_BASE_URL=https://openrouter.ai/api/v1
LOUVAIN_EXTRACTOR_API_KEY=<key with a spend cap>
LOUVAIN_EXTRACTOR_MODEL=qwen/qwen3-32b
LOUVAIN_EXTRACTOR_REASONING=noneRather than exporting these every session, put them in .env.dev at the repo
root (copy .env.dev.example). The dev scripts — pnpm --filter @louvain/api dev and worker — load it automatically when it exists; variables already set
in your shell still take precedence, and production deployments never read it.
Set the spend cap at the provider, and make it a cap rather than an alert. Extraction is the only cost here that scales with your corpus, and a first backfill of several years of chat is a large number of messages arriving at once.
LOUVAIN_EXTRACTOR=anthropic with ANTHROPIC_API_KEY is the third option.
Keep embedding off the API's event loop
Embedding runs on the path of every message. In one process it competes with the API for the same thread, so a backfill makes the product unresponsive. The compose file ships the embedding sidecar for exactly this — start it and point the API and every worker at it:
docker compose up -d embeddings # :8090, same service production runs
LOUVAIN_EMBEDDINGS_URL=http://localhost:8091Run a second instance for the write path. A backfill embeds thousands of documents; a person asking a question needs one short query embedded before retrieval can even start, and on a shared instance that query waits behind the backfill — measured on a 1,196-chunk ingest, a single-text embed took 51–60 seconds, so every answer paid a minute before it began:
docker compose up -d embeddings embeddings-bulk
LOUVAIN_EMBEDDINGS_URL=http://localhost:8091 # queries — someone is waiting
LOUVAIN_EMBEDDINGS_BULK_URL=http://localhost:8092 # ingest and extractionLOUVAIN_EMBEDDINGS_BULK_URL unset means both share one instance, which is the
right answer for a workspace that never backfills. The vectors are identical
either way — the contract is the model and the dimension — so this is a
topology choice, not a quality one. Deployments that
run dedicated workers should also set LOUVAIN_QUEUE_CONSUME=false on the API, so
a process answering questions is never also making extraction calls.
Answering is a separate choice
LOUVAIN_ANSWER_MODEL selects the model that composes answers the deterministic
planner cannot route; unset, it follows the extractor's. LOUVAIN_ANSWER_BASE_URL
and LOUVAIN_ANSWER_API_KEY give the reader its own endpoint, because an answer
is a person waiting and extraction is not: on a fast-inference endpoint the
median answer measured 2.4 seconds against 21 on the extractor's gateway, where
the reader model's reasoning could not be switched off. LOUVAIN_ANSWER_REASONING
sets the reader's reasoning budget separately from the extractor's. Every step
of an answer is timed under its own metric (louvain_ask_plan_seconds,
louvain_ask_recall_seconds, louvain_ask_rerank_seconds, louvain_ask_read_seconds
and the reader's own louvain_ask_read_model_seconds), so a slow answer says
which step was slow. Extracting into a
schema and writing from evidence are different jobs, and measurably different
models suit them. LOUVAIN_READER=off disables the reader entirely and gives you
deterministic answers only.
The sidecars are Python
Three of them, one image (apps/nlp/): two embedding lanes and a grammar
service. docker compose up -d starts all three.
LOUVAIN_EMBEDDINGS_URL (queries) and LOUVAIN_EMBEDDINGS_BULK_URL
(ingest/backfill) are two instances on purpose — sharing one lets a backfill
queue in front of the single short query someone is waiting on. They run
bge-base-en-v1.5 through onnxruntime and produce the same 768-dimension
vectors the previous TypeScript implementation did, verified to a cosine of
1.0000000, so upgrading does not require re-embedding anything.
LOUVAIN_NLP_URL is a part-of-speech tagger. It answers one question louvain used
to guess at with pattern matching: whether a capitalised word is a name or just
the start of a sentence. That distinction decides whether a "how many…"
question counts real things or counts sentence openers.
It is optional. Unset or unreachable, louvain falls back to the pattern
matching it used before — you lose precision on counting questions, never an
answer, and nothing else in the system depends on it. Named-entity recognition
is not used for identity: measured against louvain's own cases it mislabels
ordinary words like "Bachelor" as organisations. Its entity model is used for
one thing only, the date and figure spans a sentence states, which is what lets
the reader tell a correct subtraction from an invented number. That model is a
transformer, and a transformer in Docker on macOS runs on emulated CPU; run the
sidecar natively beside a GPU (LOUVAIN_NLP_DEVICE=auto, the default, picks it
up — Metal on a Mac) and the same batch measured 0.36 seconds against 8.6 in the
container. The reader asks for those dates only when it has struck a figure,
so on most questions the sidecar is not on the answer's path at all.
Demoting relayed authors is optional and off by default
LOUVAIN_RELAYED_RANK_WEIGHT scales how much a row written by a relaying
actor contributes to the ranked order — an AI assistant answering with general
knowledge, a bot restating a public feed. The source declares which actors
relay, per event, with actor.relays (see the API reference).
The default is 0.5. Set it to 1 to disable the demotion exactly; values are
clamped into (0, 1], and 0 is deliberately not reachable, because this is a
ranking setting and a relayed row must stay reachable when nothing competes with
it.
It does nothing unless a source declares a relaying actor. actor.relays
defaults to false, so a deployment that never sets it has no relayed rows and
this setting cannot act.
Where it does act, it was measured before the default was chosen: on a
conversational benchmark, asked twice over the same stored rows with only this
weight changed, 0.5 answered 47 of 60 questions against 39 at 1.0 — eight
gained, none lost, where two runs differing in nothing at all disagree on one.
Questions whose answer genuinely lives in the relayed turn were unaffected,
which is the point of demoting rather than filtering.
It cannot affect who can read what. Visibility is decided entirely by container membership, and nothing about authorship reaches that decision.
Reranking is optional and off by default
LOUVAIN_RERANKER=llm adds a listwise rerank between hybrid recall and the
reader: a small open-weights model reads the question against each candidate
and reorders them. It uses the extractor's endpoint unless
LOUVAIN_RERANKER_BASE_URL/LOUVAIN_RERANKER_MODEL/LOUVAIN_RERANKER_API_KEY say
otherwise, so the self-host story is the same binary pointed at a local model
(default model: qwen/qwen3-8b). A failed or slow rerank (over
LOUVAIN_RERANKER_TIMEOUT_MS, 8s) degrades to the fused order and is reported in
the answer's retrievalDegraded — it can cost ranking quality, never
correctness, and it cannot introduce evidence retrieval did not fence first.
Connecting OAuth sources needs a broker
Slack, the Slack export importer and HTTP inbound need nothing beyond what you
have already started. The twenty-one OAuth
sources are registered by the enterprise extension
(LOUVAIN_EXTENSIONS) and authorise through a broker, which holds the customer's
credential so louvain does not:
LOUVAIN_NANGO_URL=https://api.nango.dev # or your own instance
LOUVAIN_NANGO_SECRET_KEY=…Point LOUVAIN_NANGO_URL at a self-hosted broker and no customer credential
leaves your infrastructure. Without the secret key the connector types still
appear in the picker and Authorize fails with a plain error — deliberately
loud, because a source that silently could not authorise would look like a
source with nothing in it.
5. Verify it works
Ask the process what it is, rather than assuming:
curl -s localhost:8080/healthzThe response names the extractor actually in use, whether ingest is inline or
async-queue, the reader, the auth mode, the signup mode, and the queue's depth
and drain rate. /livez is liveness only and does no I/O; /readyz checks
Postgres and returns 503 when it cannot reach it.
Load the demo fixture and drive it as two different people:
pnpm --filter @louvain/api seed
curl -s -H 'x-principal: priya' localhost:8080/v1/entities/Acme%20Corp/claims
curl -s -H 'x-principal: tom' localhost:8080/v1/entities/Acme%20Corp/claimsDifferent principals, different visible claims. The x-principal header only
works because this is a dev-mode process.
Then the proof suites:
docker compose down -v && docker compose up -d --wait # fresh stack, required
pnpm --filter @louvain/api e2ee2e needs a fresh stack. A warm one passes for the wrong reasons, which is
worse than failing.
To judge the model you chose rather than the plumbing:
pnpm --filter @louvain/api bench:extractionIt drives the extractor directly over hand-labelled natural language and reports precision, recall and F1 per predicate, plus safety violations, certainty accuracy and latency. A safety violation fails the run regardless of F1 — a model that scores well and occasionally attributes someone else's number to your graph is not a model you can run a company on.
Before calling any change done:
pnpm run ci6. Run it in the shape it deploys in
pnpm dev runs everything in one process. That is not the topology you deploy,
and you cannot test a firehose against a topology where the API is also the
thing doing the extracting.
pnpm run stack up --workers 2
pnpm run stack status
pnpm run stack scale 8
pnpm run stack downThis builds the production images and runs the API, the web app, Postgres,
SpiceDB and extraction workers as separate processes behind the queue, on
plain HTTP at http://localhost:8088 — no certificate and no domain to arrange
first.
First run writes a gitignored .env.local with generated secrets. Note that it
defaults to the fixture extractor with the explicit opt-in flag set, because
its job is to exercise the funnel. The file carries the four lines to swap in a
real model; from inside the containers a model on the host is
http://host.docker.internal:11434/v1.
7. Take it to one real box
cp .env.prod.example .env.prod
openssl rand -hex 32 # POSTGRES_PASSWORD
openssl rand -hex 32 # SPICEDB_TOKEN
openssl rand -hex 32 # LOUVAIN_CREDENTIALS_KEYSet LOUVAIN_DOMAIN and LOUVAIN_PUBLIC_URL, point a DNS A record at the machine,
and bring it up:
docker compose -f docker-compose.prod.yaml --env-file .env.prod up -d --buildCaddy obtains and renews a Let's Encrypt certificate on its own, which requires
ports 80 and 443 to be reachable. Only Caddy is published: it serves one origin
from two upstreams — /api/* to the API, everything else to the web app — and
Postgres and SpiceDB have no host ports at all. Behind a load balancer that
already terminates TLS, set LOUVAIN_DOMAIN=:80.
Back up LOUVAIN_CREDENTIALS_KEY somewhere your database backups are not. It
seals every connector credential; losing it means re-entering all of them.
One Postgres dump restores everything, because SpiceDB is a derived store rebuilt from the outbox rather than a system of record. If you ever need to prove that — after a restore, or after an upgrade that rewrote permission object ids — run:
npx tsx apps/api/scripts/rebuild-spicedb.tsIt clears the permission store, re-delivers every tuple from the outbox and rebuilds the readable-set projection. Idempotent, and it makes "is the permission store actually in sync?" answerable rather than a matter of faith.
Budget 6–10GB of RAM, most of it for Postgres and its vector indexes. Do not run the extraction model on this box. Extraction is the elastic part and the box is the fixed part; a local model competes with Postgres for the same cores and your queries pay for it.
Scale extraction directly — capacity is
(worker replicas × concurrency) / seconds-per-extraction:
docker compose -f docker-compose.prod.yaml --env-file .env.prod up -d --scale worker=8What refuses to boot
Preflight throws rather than warns on: a missing LOUVAIN_CREDENTIALS_KEY, a
missing LOUVAIN_PUBLIC_URL, SPICEDB_TOKEN still set to the development
default, the dev header under NODE_ENV=production, LOUVAIN_EXTRACTOR=rule
without LOUVAIN_ALLOW_FIXTURE_EXTRACTOR=true, an openai-compat extractor
pointed at a remote endpoint with no key, half-configured single sign-on, and a
queue connection pointed at a transaction pooler.
That last one is the least obvious and the most expensive. The queue wakes its
workers with LISTEN/NOTIFY, and a pooled endpoint does not carry it.
Nothing errors — the workers fall back to idle polling and every extraction
lands late, which reads exactly like a slow model. On managed Postgres, point
DATABASE_URL at the pooled endpoint if you like, and
LOUVAIN_QUEUE_DATABASE_URL at the direct one.
Creating the first admin on a closed deployment
.env.prod.example ships LOUVAIN_SIGNUP=invite-only, because this file
configures something reachable from the internet and open means anyone who
finds the hostname creates an org inside it. Preflight deliberately does not
enforce either value: it is a judgement about who the deployment is for, not an
unsafe setting.
That leaves a locked door with the key inside — invites can only be issued by an
existing admin, and there is no admin yet. The way through is
LOUVAIN_BOOTSTRAP_TOKEN, which is accepted on signup only while no user
account exists. It becomes inert the moment it is used: single-use by
construction, with no flag to clear afterwards.
The shipped docker-compose.prod.yaml does not forward this variable to the
api service, so add it to that service's environment: block before you bring
the stack up:
LOUVAIN_BOOTSTRAP_TOKEN: ${LOUVAIN_BOOTSTRAP_TOKEN:-}Then generate one into .env.prod and create the owner:
openssl rand -hex 32 # LOUVAIN_BOOTSTRAP_TOKEN
curl -s -X POST https://louvain.yourcompany.com/api/v1/auth/signup \
-H "x-bootstrap-token: $LOUVAIN_BOOTSTRAP_TOKEN" \
-H "origin: https://louvain.yourcompany.com" \
-H 'content-type: application/json' \
-d '{"email":"you@yourcompany.com","password":"<at least 12 characters>",
"displayName":"Your Name","orgName":"Your Company","orgSlug":"your-company"}'The origin header is not decoration: cookie-authenticated writes are rejected
unless the browser's origin matches the deployment's public URL, and a bare
curl sends no origin at all.
Do not open signup to the internet for a minute to create the first account. That minute is the window an attacker wants.
Next
- Connectors — Slack, historical exports, and pushing from a system that has no connector yet.
- Asking — how a question becomes a plan, and how to read the receipts underneath an answer.
- Quickstart — hosted — the same product from a user's side: inviting people, connecting a source, and what "good" looks like.
Quickstart — hosted
Sign in to a hosted louvain deployment, connect a source, and get your first answer with receipts.
Three tiers of memory
Typed claims, open-vocabulary memories and verbatim passages — what each is for, which questions each answers, and the one permission fence all three pass through.