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

ToolWhy
Node 22+the runtime
pnpmthe workspace manager — the repo pins its version
DockerPostgres (pgvector) and SpiceDB
git clone <your-fork> louvain && cd louvain
pnpm install

Optionally 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 --wait

Two 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 dev

It 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:

  1. Preflight. Configuration that cannot work refuses to start. Locally the checks are lenient; they bite once the dev header is off.
  2. 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.
  3. The authorization schema. The SpiceDB schema is written from the code, so the permission model in the repo is the one running.
  4. 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.
  5. 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 dev

The 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 dev

No 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=none is 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 to default only for an endpoint that rejects the parameter.
  • LOUVAIN_GATE=heuristic turns 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=true puts 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=none

Rather 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:8091

Run 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 extraction

LOUVAIN_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/healthz

The 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/claims

Different 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 e2e

e2e 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:extraction

It 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 ci

6. 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 down

This 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_KEY

Set 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 --build

Caddy 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.ts

It 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=8

What 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.