HTTP API

The endpoints you integrate against — authentication, ingest, retrieval, answers, and health.

Everything below exists in a production deployment. Request and response shapes are taken from the zod contracts in @louvain/contracts and the route handlers that use them.

Base URL

The API listens on port 8080. The web app on port 3000 proxies everything under /api/* to it, unchanged, so the session cookie stays first-party:

https://louvain.example.com/api/v1/ask     # through the web app (browser + external clients)
http://api.internal:8080/v1/ask          # direct, on a private network

Both paths reach the same handlers. Examples below use the proxied form.

Authentication

Three ways in. Two of them exist in production.

Bearer token — for API and agent clients

A personal API token, minted by a signed-in person and tied to their principal. It carries that person's permissions, not more.

authorization: Bearer louvain_EXAMPLE_TOKEN_NOT_REAL

Tokens are created at POST /v1/me/tokens, shown once, and stored only as a hash. They begin with louvain_. Bearer callers are exempt from the same-origin check, because they carry no ambient browser authority.

louvain_session, HttpOnly, SameSite=Lax, set by POST /v1/auth/login, POST /v1/auth/signup and POST /v1/auth/accept-invite. Cookie-authenticated state-changing requests must come from an allowed origin or the API returns 403 origin_not_allowed. Set LOUVAIN_ALLOWED_ORIGINS for credentialed calls from another host.

Connection ingest token — for pushing events

A per-connection token, returned once when the connection is created and rotatable afterwards. It begins with louvainc_. It authorizes /v1/ingest and /v1/ingest/batch only, and it stamps its own org on every event — the org is never read from the payload.

authorization: Bearer louvainc_EXAMPLE_INGEST_TOKEN_NOT_REAL

What does not exist in production

The x-principal header, the x-org header, all /v1/admin/* routes, and the synthetic demo connector are registered only when LOUVAIN_DEV_INSECURE_HEADER=true. They are absent from a production deployment rather than guarded in it, so there is no auth check on them to get wrong. Do not build against them.

Failure shapes

StatusBodyMeaning
401{"error":"missing_credentials"}No bearer token and no session cookie
401{"error":"invalid_token"}Bearer token unknown or revoked
401{"error":"invalid_session"}Session cookie expired or deleted
401{"error":"invalid_ingest_token"}Ingest routes: unknown token, or the connection is disabled
403{"error":"no_org_membership"}Authenticated, but a member of no org
403{"error":"admin_required"}Route needs owner or admin
403{"error":"origin_not_allowed"}Cookie-authenticated write from a foreign origin

Errors at 500 and above always return {"error":"internal_error"}. The exception message is never in the body.


Health

GET /livez

Is the process alive. No I/O, no dependencies. Always 200.

{ "ok": true }

Point an external uptime monitor here.

GET /readyz

Should traffic be routed here. Checks dependencies and returns 503 when any check fails.

{ "ready": true, "checks": { "database": true } }

GET /healthz

The deployment describing itself: which extractor and which model, which ingest mode, which plugins loaded, which build, and the state of the queue.

curl https://louvain.example.com/api/healthz
{
  "ok": true,
  "extractor": "openai-compat",
  "model": "qwen/qwen3-32b",
  "readerModel": "qwen/qwen3-32b",
  "ingest": "async-queue",
  "plugins": { },
  "release": "a1b2c3d",
  "environment": "production",
  "sealer": "aes-gcm",
  "telemetry": "sentry",
  "auditSinks": [],
  "extensionRoutes": [],
  "reader": "verified",
  "auth": "session+bearer",
  "signup": "invite-only",
  "queue": {
    "driver": "pg-boss",
    "pending": 0, "live": 0, "bulk": 0,
    "active": 0, "deadLettered": 0,
    "oldestSeconds": 0, "drainedPerMinute": 0,
    "recovery": { "sweeps": 0, "redispatched": 0, "reclaimed": 0, "deadLettered": 0 },
    "catchUpSeconds": null,
    "limit": 50000,
    "accepting": true
  }
}

model names the model actually extracting: LOUVAIN_MODEL when extractor is anthropic, LOUVAIN_EXTRACTOR_MODEL when it is openai-compat. It is null for the rule-based extractor, which uses no model, and for an extractor supplied by a plugin, whose model this process cannot know.

Check it rather than assuming your configuration file won. A shell export beats .env.dev, so the model a process is using and the model its env file names can differ — and if the two disagree, this endpoint is the one telling the truth.

readerModel names the model the reader composes answers with — LOUVAIN_ANSWER_MODEL, or the extractor's model when that is unset; null when the reader is off. Recorded separately because the two can differ, and a run that scored answers without knowing which model wrote them is not a run anyone can repeat. reader is off when LOUVAIN_READER=off.

GET /metrics

Prometheus text format. Outside dev it requires authorization: Bearer $LOUVAIN_METRICS_TOKEN and returns 401 without it, or 404 when no token is configured at all. Callers on the deployment's own private network are allowed without a token; a request carrying any forwarding header is treated as external whatever its source address.


Ingest

Both ingest routes take a connection ingest token. Acceptance is durable recording and nothing else — the admission gate, embedding and extraction all happen behind the queue, so a 200 means "stored", not "extracted".

POST /v1/ingest

One event.

RequestIngestEventSchema:

FieldTypeNotes
idULID
idempotencyKeystring, non-emptySource-derived. Retries and replays must collide
source.connectorstringslack, http, …
source.workspacestringTenant boundary at the source
source.containerstringChannel / thread / record — the ACL-bearing unit
source.refURL, optionalDeep link back to origin
kindmessage | record.change | membership.change | file | reaction
occurredAtISO datetimeValid time — when it happened at the source
observedAtISO datetimeSystem time — when you saw it
occurredAtISO datetime, nullableValid time — when the source said it; on a backfill the two differ by weeks, and this is the date shown on a claim
actor.sourceUserIdstring
actor.emailemail, optionalIdentity-resolution hint
actor.displayNamestring, optionalThe name the source shows for the actor. With email, the source has equated an address and a name — the one entity link louvain confirms without an admin (see Possible duplicates). Never an access input
participants[]{sourceUserId, email?, displayName?}, optional, ≤50Others the source names on the event (a mail's To: and Cc:). Identity hints only — never an author, a grant or a tuple; who may read the event is permissions. The text itself should still say who it was from and to (the brokered connectors open a mail's text with "Email from X to Y, copied to Z."), because the reader decides direction and audience from the text, and the hints only link identities
actor.relaysboolean, optionalDoes this actor speak for itself, or relay? Default false (first-hand). See below
bodyobjectShape depends on kind (below)
permissions.containerTuplesarray of {resource, relation, subject}
permissions.visibilitycontainer | workspace | public

First-hand and relayed actors

Most actors speak first-hand: a person stating something about their own work. Some relay — an AI assistant answering with general knowledge, a bot restating a public feed, an importer echoing a document. What a relaying actor says is usually true and usually not about your workspace, and on a conversational corpus it can be most of the text by volume.

Set actor.relays: true when your source knows an actor relays. It affects ranking only: the row is still stored, still embedded, still citable, and exactly as visible to exactly the same people. It is ordered below an otherwise-equal first-hand row, so a question only that row can answer is still answered.

Leave it unset (or false) when you are not sure. An undeclared actor is treated as first-hand, because demoting a real colleague is the more expensive mistake — and a support agent, a deploy bot posting real releases, or a teammate replying in a thread are all first-hand even though a chat transcript may label them "assistant".

Ranking never affects permissions. Who can read a row is decided entirely by its container (see Permissions).

Body by kind:

  • message{ "text": "..." }
  • membership.change{ "op": "add" | "remove", "tuple": { "resource": "...", "relation": "...", "subject": "..." } }
  • membership.sync{ "resource": "...", "relation": "member", "subjects": ["user:slack/U1", ...] }

Permission references are type:id, and the id is namespaced by connector: container:slack/C0424, user:slack/U0882. An unprefixed subject is rejected with 400 rather than accepted and silently never matched. The relation must be one the schema defines (member, admin, workspace, provenance); anything else is 400, because a relation the authorization store will never accept would otherwise retry forever and hold up every later permission change.

You send container references in the short form above. The API rewrites them server-side into their full identity — container:<org>/<connector>/<workspace>/<name> — so a reference you write can only ever address your own organization and your own workspace.

What a token is allowed to say. Access facts derive from the authenticated connection, never from the payload (ADR-002). An event is refused with 403 if:

  • source.connector differs from the connection's connector — a Slack token cannot submit hubspot events;
  • source.workspace differs from the workspace the connection declares — two Slack connections in one organization cannot speak for each other;
  • any reference names a container outside that connector's namespace — a Slack token cannot grant membership in container:hubspot/deals.

Response

{ "status": "accepted", "claims": 0 }

status is accepted or duplicate (the idempotency key was already seen). claims is 0 in async-queue mode, because extraction has not run yet.

curl -X POST https://louvain.example.com/api/v1/ingest \
  -H "authorization: Bearer louvainc_EXAMPLE_INGEST_TOKEN_NOT_REAL" \
  -H "content-type: application/json" \
  -d '{
    "id": "01JBRAINEXAMPLE0000000000",
    "idempotencyKey": "acme-crm:note:9931",
    "source": {
      "connector": "http",
      "workspace": "acme",
      "container": "deals-emea",
      "ref": "https://crm.example.com/notes/9931"
    },
    "kind": "message",
    "occurredAt": "2026-08-01T09:14:00Z",
    "observedAt": "2026-08-01T09:14:03Z",
    "actor": { "sourceUserId": "u-4471", "email": "dana@example.com" },
    "body": { "text": "Northwind moved to legal review; renewal is now 410k/yr." },
    "permissions": {
      "containerTuples": [
        { "resource": "container:http/deals-emea", "relation": "member", "subject": "user:http/u-4471" }
      ],
      "visibility": "container"
    }
  }'

Deduplication

idempotencyKey is what makes ingest safe to retry, and it is also what stops the same fact being processed twice when several connections cover the same room. Build it from the source's own ids — slack-msg-<channel>-<ts>, or a record id and its modified time — never from anything about the connection: ten colleagues who each sign in to the same workspace then produce one event for one message, one extraction and one set of claims.

Repeats are dropped before the queue and reported as duplicates in the batch response, not as errors. Keys are scoped per org, so two customers minting the same string from the same id space never collide.

GET /v1/ingest/state · PUT /v1/ingest/state

Where a connector left off. A change feed's cursor — a Drive page token, a Graph delta link, a high-water timestamp — is its subscription, and the runner holding it is the process that restarts on failure, so the cursor has to outlive it.

Scoped by the token, not by a body field. Your ingest token already names exactly one connection, so these routes take no connection id: a connector can read and overwrite its own cursor and no other. The value is opaque — louvain stores what you hand it and has no opinion about its shape.

# read
curl https://louvain.example.com/api/v1/ingest/state \
  -H "authorization: Bearer $LOUVAIN_INGEST_TOKEN"
# → { "state": { "Document": "cursor-abc" } }

# write, after the events it covers have been ACCEPTED
curl -X PUT https://louvain.example.com/api/v1/ingest/state \
  -H "authorization: Bearer $LOUVAIN_INGEST_TOKEN" \
  -H "content-type: application/json" \
  -d '{"state":{"Document":"cursor-def"}}'

Advance the cursor only after ingest has accepted the events it covers. Advancing first turns one failed post into a permanent gap: the source moves on and louvain never learns what it missed.

POST /v1/ingest/batch

Up to 500 events in one request, one transaction.

Request: { "events": [ /* IngestEvent, 1..500 */ ] }

Response

{ "accepted": 480, "duplicates": 20, "claims": 0 }
curl -X POST https://louvain.example.com/api/v1/ingest/batch \
  -H "authorization: Bearer louvainc_EXAMPLE_INGEST_TOKEN_NOT_REAL" \
  -H "content-type: application/json" \
  -d @batch.json

Ingest failures

StatusBodyWhat to do
400{"error":"invalid_event","issues":[…]}Zod issues, per field. Fix the envelope
400{"error":"invalid_batch","issues":[…]}Same, for the batch wrapper
400{"error":"malformed permission reference(s): … — expected type:id, e.g. user:slack/U042"}Namespace the subject or resource
401{"error":"invalid_ingest_token"}Token revoked, rotated, or the connection disabled
429{"error":"backlog_full","pending":51234,"retryAfterSeconds":6}Deployment-level. Honour Retry-After and resend
429{"error":"quota_exceeded","reason":"monthly ingested events limit reached: …"}Account-level. Retrying does not help; no Retry-After

The two 429s are deliberately distinct. backlog_full clears on its own; quota_exceeded does not.

The request body limit is 8 MB by default (LOUVAIN_BODY_LIMIT_BYTES).


Asking questions

Both answer routes are metered against the org's monthly answer limit and return 429 quota_exceeded past it. Both are permission-scoped to the calling principal.

POST /v1/answer

The deterministic surface, and the one to build an agent on. Questions compile to typed plans over the claim table; execution is permission-filtered SQL. When the plan's value cannot be corroborated against retrieved evidence, a verified reader composes an answer instead. Every figure, date and name it writes is verified against the evidence sentence by sentence: an unsupported sentence is struck, the rest stands, and an answer with nothing left is refused rather than hedged.

Every answer is remembered in the answers log (migration 037) and carries an answerId in the response — that is what POST /v1/answers/:id/feedback attaches to. Evidence items carry an id so a judged-wrong answer can be replayed against exactly what the reader saw.

A refused answer says why. refusalReason is one of no_evidence (nothing you can see matched), absent_subject (the question names something no evidence mentions), insufficient (the reader judged the evidence did not bear on the question), unverified (every sentence it wrote carried something no source supports), empty or failed. An accepted answer that lost sentences to verification carries trimmedSentences and unsupportedCount — counts only; the struck text is never returned.

Request

{ "question": "What stage is Northwind at?", "asOf": "2026-06-01T00:00:00Z" }

asOf is optional and bi-temporal: it answers as of that instant.

Response

FieldTypeNotes
questionstringEchoed
principalstringWho the answer was scoped to
asOfISO datetimeResolved instant
planobject{kind: "lookup"|"point_in_time"|"count"|"list"|"timeline"|"provenance"|"search", …}
answerstringRendered answer
valuestring | number | nullThe direct value when the plan yields one
claimsClaimView[]Supporting claims, permission-filtered
via"plan" | "reader"How it was produced
evidencearray, optional{tier, text, container, occurredAt, spans?} — what the reader cited or looked at. spans is [start, end] character offsets into that row's text: the sentences the answer rests on. A passage is up to 900 characters, so a citation without them points at a chunk rather than at a sentence. Computed by the same verification that let the answer stand, over rows it already cited — they never add a row and never widen what was checked, and a row with nothing in particular behind it carries none
retrievalDegradedstring[], optionalPresent only when part of retrieval failed
curl -X POST https://louvain.example.com/api/v1/answer \
  -H "authorization: Bearer louvain_EXAMPLE_TOKEN_NOT_REAL" \
  -H "content-type: application/json" \
  -d '{"question":"What stage is Northwind at?"}'

Two refusal strings worth handling explicitly:

  • "Nothing visible matches." — a search plan with no defensible answer. The evidence array still shows what was looked at.
  • A retrievalDegraded array — part of the retrieval fan-out threw. Without it, "nothing matched", "you may not see it" and "the second hop failed" are indistinguishable, and only one of those is the system working.

400 {"error":"missing_question"} if question is absent or empty.

POST /v1/answer/stream

The same answer as /v1/answer, delivered as server-sent events so a client can show the receipts before the prose is finished:

event: stage
data: {"stage":"plan","kind":"lookup","entity":"Dana Voss"}

event: stage
data: {"stage":"seeds","names":["Dana Voss","Acme Corp"]}

event: stage
data: {"stage":"hop","edges":[{"from":"Dana Voss","to":"Acme Corp","relation":"champion at"}]}

event: evidence
data: {"evidence":[{"id":"…","tier":"passage","text":"…","container":"…","occurredAt":"…"}],"degraded":[]}

event: stage
data: {"stage":"search","query":"Acme renewal terms","added":8}

event: answer
data: { …the full /v1/answer response, answerId included… }

stage events say what the ask path is doing as it does it — the entity the question is about (absent when no single entity is named), the entities the graph walk seeded from, the edges it followed, and, after evidence, any widening the reader asked for: a search with the query it chose and how many rows that added, a fetch of the rows behind a summary. Names only, from rows the same fence admitted, so a client can draw them on a map the viewer already sees; the Ask page does. A stage that did not happen is not sent. evidence is sent the moment retrieval completes — those rows are already fenced and are exactly what the answer will cite. answer is sent only after the reader has finished and been verified; nothing unverified streams. An error event replaces answer on failure. Metered as one answer, same as the non-streaming route.

POST /v1/ask

The same question, wrapped in one-shot prose synthesis. It calls a model, so it is slower and costs tokens. Without ANTHROPIC_API_KEY configured it returns the claims and "answer": null.

Request: { "question": "...", "asOf": "..." }

Response

{
  "answer": "Northwind is in legal review [1].",
  "claims": [ /* ClaimView[] */ ]
}
curl -X POST https://louvain.example.com/api/v1/ask \
  -H "authorization: Bearer louvain_EXAMPLE_TOKEN_NOT_REAL" \
  -H "content-type: application/json" \
  -d '{"question":"What stage is Northwind at?"}'

GET /v1/suggest

Six questions this principal can ask and get answered, for a client to offer before anyone types. They are composed, not written: the names come from the entities and remembered subjects inside the caller's readable set (top by visible claim count, then by the open tier's subjects), the wording from the org's ontology labels and a few fixed shapes. A chip can name a thing the caller may see; it never quotes what was said about it.

Fenced before ranking, like every read: an entity whose every claim sits in a container the caller cannot read never enters the ranking, so a suggestion cannot hint that it exists. An empty readable set returns an empty list rather than falling back to the org's most-mentioned names. Deterministic for the same readable set and ontology.

{ "questions": ["What is Northwind's deal stage?", "What do we know about Priya?", "…"] }
curl https://louvain.example.com/api/v1/suggest \
  -H "authorization: Bearer louvain_EXAMPLE_TOKEN_NOT_REAL"

Reading the graph

Every route here is filtered to what the calling principal's containers allow, before ranking. A claim with no attestation in a readable container does not appear in any of them.

The ClaimView shape

Returned by /v1/claims/search, /v1/entities/:name/claims, /v1/entities/:name/timeline, and inside /v1/ask and /v1/answer.

{
  "id": "8f1c…",
  "subject": { "id": "3a9e…", "name": "Northwind", "kind": "account" },
  "predicate": "deal.stage",
  "object": "legal review",
  "body": "Northwind moved to legal review.",
  "validFrom": "2026-08-01T09:14:00Z",
  "validFromSource": "extracted",
  "invalidAt": null,
  "recordedAt": "2026-08-01T09:14:05Z",
  "supersededAt": null,
  "sourceRank": 50,
  "certainty": "asserted",
  "attestations": [
    {
      "eventId": "01JBRAINEXAMPLE0000000000",
      "container": "http/acme/deals-emea",
      "quote": "Northwind moved to legal review",
      "author": "u-4471",
      "observedAt": "2026-08-01T09:14:03Z"
    }
  ],
  "derivation": "asserted",
  "derivedFrom": []
}
  • validFromSource is extracted when the source stated when the fact became true, said-at when it was defaulted to message time (an honest lower bound).
  • certainty is hedged for speculation. Hedged claims are returned but never supersede an asserted one.
  • attestations is filtered per viewer. An empty-looking corroboration set is a fact about the reader, not about the claim.
  • supersededAt is non-null only when the caller can see the superseding claim.
  • container is <connector>/<workspace>/<name>. The workspace is part of the identity because a channel name is only unique within a workspace.
  • derivation is asserted for something a source actually said, derived for a conclusion louvain reached by combining sources. The distinction is permanent — a derived claim is never promoted to asserted.
  • derivedFrom is empty for an asserted claim. For a derived one it lists the alternative evidence paths supporting it, each with the sources inside it. Only paths you can see in full are returned, and a derived claim is visible only when at least one complete path is: how many sources exist would otherwise leak information about containers you cannot read.

GET /v1/entities

Every entity with at least one visible claim.

[{ "id": "3a9e…", "name": "Northwind", "kind": "account", "claims": 14 }]

GET /v1/entities/:name/claims

Current visible claims for one entity. Matching on name is case-insensitive.

Query: asOf (ISO datetime, optional)

Response: ClaimView[]

curl "https://louvain.example.com/api/v1/entities/Northwind/claims?asOf=2026-06-01T00:00:00Z" \
  -H "authorization: Bearer louvain_EXAMPLE_TOKEN_NOT_REAL"

GET /v1/entities/:name/timeline

The visible history of an entity, oldest first, superseded rows included and carrying their supersededAt. No asOf — history is the point.

Response: ClaimView[]

GET /v1/entities/:name/dossier

The whole entity across all three memory tiers, assembled.

Query: asOf (optional)

{
  "name": "Northwind",
  "claims": [ /* ClaimView[] */ ],
  "relationships": [
    {
      "id": "41",
      "relation": "customer_of",
      "other": "Acme",
      "direction": "out",
      "certainty": "asserted",
      "container": "http/deals-emea",
      "occurredAt": "2026-08-01T09:14:00Z",
      "quote": "Northwind has been a customer since 2023"
    }
  ],
  "observations": [
    {
      "id": "77",
      "text": "Northwind's renewal is 410k/yr.",
      "container": "http/deals-emea",
      "occurredAt": "2026-08-01T09:14:00Z"
    }
  ],
  "mentions": [
    {
      "id": "93",
      "text": "…Northwind signed the original agreement in March 2023…",
      "container": "http/deals-emea",
      "author": "marcus",
      "occurredAt": "2026-08-01T09:14:00Z"
    }
  ]
}

observations are the open-vocabulary tier — knowledge the ontology has no predicate for. mentions are verbatim passages naming the thing — the source text itself. direction is out when the named entity is the subject of the edge, in when it is the object.

The name does not need to be a resolved entity: an open-tier topic (a relationship endpoint or recurring memory subject the resolver has no row for) gets a dossier too, assembled from name-matched edges, memories and passages — so every node the graph draws is clickable.

GET /v1/claims/search

Hybrid retrieval — full text, vector similarity, and entity-name match — ranked together, capped at 50 rows.

Query: q (required), asOf (optional)

Response: ClaimView[]. 400 {"error":"missing_query"} when q is absent.

curl "https://louvain.example.com/api/v1/claims/search?q=renewal%20risk" \
  -H "authorization: Bearer louvain_EXAMPLE_TOKEN_NOT_REAL"

GET /v1/recall

Search across all three memory tiers — typed claims, open-vocabulary memories, and verbatim passages — ranked together by hybrid retrieval and fenced before ranking, exactly as the answer path retrieves. Use this rather than /v1/claims/search when the workspace's knowledge falls outside its ontology's vocabulary: such a corpus produces few typed claims, and a claims-only search reports a full memory as an empty one.

Query: q (required), asOf (optional), limit (optional, default 24, max 100)

Response:

{
  "evidence": [
    { "id": "…", "tier": "memory",
      "text": "Basal cell carcinoma is the most common type of skin cancer.",
      "container": "http/doc-medical", "author": null,
      "occurredAt": "2026-08-26T09:00:00.000Z", "score": 0.016 }
  ],
  "degraded": []
}

tier is claim, memory, or passage. degraded lists retrieval steps that failed on this call (normally empty) — present so an empty result can say why it is empty. Rows the caller may not read are never ranked, so this surface can never show more than an answer could cite.

curl "https://louvain.example.com/api/v1/recall?q=skin%20cancer%20types" \
  -H "authorization: Bearer louvain_EXAMPLE_TOKEN_NOT_REAL"

GET /v1/graph

The knowledge graph as this principal can see it. Nodes come from both structured tiers: entities carrying typed claims, and open-tier structure — asserted relationship edges plus memory subjects mentioned at least twice. A workspace whose corpus is outside its ontology's vocabulary still draws its shape.

entityKind is the kind the workspace has for that node, whichever tier it arrived on. An open-tier node named after an entity the workspace has already classified carries THAT kind, not topic: the open tier stores a subject as text, so a node built from one starts out untyped, but "we have no word for this" is only true when nobody does — and an entity whose claims sit in rooms you are not in is still an entity your workspace has named. topic and thing mean the kind is genuinely unknown, and they are values, not absences.

{
  "nodes": [
    { "id": "e:3a9e…", "label": "Northwind", "kind": "entity", "size": 14,
      "entityKind": "account", "stage": "legal review", "value": "$410k/yr", "risk": true },
    { "id": "p:u-4471", "label": "u-4471", "kind": "person", "size": 9 }
  ],
  "links": [
    { "source": "p:u-4471", "target": "e:3a9e…", "weight": 9, "kind": "asserted" }
  ],
  "pipeline": 410
}

kind on a link is asserted (a person asserted claims about an entity) or relates (a claim points from one entity to another). stage, value and risk appear only when those claims exist and are visible.

Query: asOf (optional) — draw only what was known by that instant, the same bi-temporal bound every other read honours.

Every node carries containers (the readable containers that contributed to it — the fence made visible) and, where known, since (the earliest visible claim, edge or memory about it); links carry since too. A time scrubber fades what was not yet known; a lens can dim nodes whose evidence sits somewhere the viewer cannot read.

Person nodes are voices, not feeds. Whether an author names a person is a property of its connector, declared rather than guessed: a streaming connector's authors are colleagues and drawing them is half the value of the map, while a generic inbound feed's actor is whatever the pushing system wrote — an importer, a bot, a mailbox. Non-person actors are never drawn; their claims are counted and returned in sources as {author, claims} so the UI can caption where the knowledge came from. A connector that does push real people's messages declares actorsArePeople. One further backstop covers what a declaration cannot: a genuine people connector holding a single non-person account reaching a fraction of the graph no colleague does.

GET /v1/org/insights

Access Insights — where the knowledge graph and the permission graph have drifted apart. Requires owner or admin.

[
  {
    "category": "narrow_holding",
    "severity": "high",
    "title": "…",
    "detail": "…"
  }
]

category is one of over_exposure, narrow_holding, team_blindness, stale_access. severity is high, medium or low.


Accounts and sessions

Used by the web app. Documented because self-hosted deployments script against them.

MethodPathPurpose
GET/v1/auth/config{signup, mail, publicUrl} — what the signup form should render
POST/v1/auth/signup{email, password, displayName, orgName, orgSlug} → session
POST/v1/auth/login{email, password} → session
POST/v1/auth/logoutClears the cookie, deletes the session row
GET/v1/auth/sessionWho am I, which org, which orgs can I switch to
POST/v1/auth/switch-org{orgSlug} → session, repointed
POST/v1/auth/orgs{name, slug} — an existing account starting a second org
GET/v1/auth/invite?token=Invite preview: {email, role, org, hasAccount}
POST/v1/auth/accept-invite{token, password?, displayName?} → session
POST/v1/auth/request-reset{email}{sent}; never enumerates accounts
POST/v1/auth/reset{token, password}{ok:true}; ends every session
GET/v1/auth/link/:tokenExchange a one-time login link for a session and land on the app (303 to /app, or a same-origin ?next= path). Minted only by an operator with database access (scripts/login-link.ts), never by a route; hashed at rest, minutes to live (LOUVAIN_LOGIN_LINK_TTL_MINUTES, default 10), spent on first use. Unknown, spent and expired all redirect to /login?error=link — which one it was is not information the caller should hold. SSO-enforced domains refuse it, like a password. Audited as auth.login with via: "link"
POST/v1/me/password{currentPassword, newPassword}; ends every session
PATCH/v1/me{displayName} → session

Constraints from the contracts: passwords are 12–200 characters; orgSlug is 2–40 characters, lowercase alphanumeric with dashes, and cannot start or end with a dash; default is reserved.

SessionView:

{
  "user": { "id": "…", "email": "dana@example.com", "displayName": "Dana" },
  "org": { "id": "…", "slug": "acme", "name": "Acme", "role": "owner", "principal": "…" },
  "orgs": [{ "id": "…", "slug": "acme", "name": "Acme", "role": "owner" }]
}

For a bearer-token caller, user is null and orgs is empty — a token has a principal, not an account.

curl -X POST https://louvain.example.com/api/v1/auth/login \
  -H "content-type: application/json" \
  -H "origin: https://louvain.example.com" \
  -c cookies.txt \
  -d '{"email":"dana@example.com","password":"correct-horse-battery"}'

Rate limits, returned as 429 {"error":"too_many_attempts","retryAfterSeconds":N}: login 10 per email and 50 per IP every 15 minutes; signup 10 per IP per hour; invite acceptance 20 per IP per hour; password reset 5 per email per hour.

Single sign-on

MethodPathPurpose
GET/v1/auth/sso/config{enabled, label, issuer} — whether to render the button
GET/v1/auth/sso/start?next=Redirects to the identity provider
GET/v1/auth/sso/callbackProvider redirect target; sets the session cookie

With no OIDC issuer configured, /v1/auth/sso/config returns {"enabled": false, …} and the other two return 404 {"error":"sso_not_configured"}. Login failures redirect to /login?sso_error=<reason> rather than returning JSON.


API tokens

MethodPathPurpose
GET/v1/me/tokens[{id, label, createdAt, lastUsedAt}]
POST/v1/me/tokens{label}{token, label}shown once
DELETE/v1/me/tokens/:idRevoke. 404 token_not_found if it is not yours

Tokens are minted for a signed-in person, so these require a session cookie, not a bearer token.

curl -X POST https://louvain.example.com/api/v1/me/tokens \
  -H "content-type: application/json" \
  -H "origin: https://louvain.example.com" \
  -b cookies.txt \
  -d '{"label":"analytics agent"}'

Org administration

All org-fenced by the caller's own membership; the org is never read from a parameter. Reads marked admin need owner or admin; every write does, and also requires an allowed origin when cookie-authenticated.

MethodPathPurpose
GET/v1/org/overviewIngest counts, 14-day daily series, per-viewer visibility counts across all three tiers (visibleClaims, visibleMemories, visiblePassages), connection health, tuple sync lag
GET/v1/org/activity?limit=&by=Recent ingest activity, metadata only — no message text. limit caps at 200; by=occurred|received picks which clock the window follows
GET/v1/org/activity/:idWhat one event wrote — claims, relationships, memories, passages — fenced per viewer by container. readable: false with every tier empty when the caller cannot read the event's room; 404 for an event outside the org
PATCH/v1/org{name}{ok, name}
GET/v1/org/auditadmin — audit log entries
GET/v1/org/connectors{types: [...]} — the connector catalog: setup steps, field descriptors, brand, membership, keywords
GET/v1/org/connectionsConnections plus runner state and stats
POST/v1/org/connections{connector, name, credentials?, settings?}{connection, ingestToken}
PATCH/v1/org/connections/:id{name?, status?, credentials?, settings?}; status is enabled or disabled
DELETE/v1/org/connections/:id{ok:true}
POST/v1/org/connections/:id/rotate-token{ingestToken} — the old one stops working
POST/v1/org/connections/:id/authorize{returnUrl}{url} — send the customer there to approve access at the provider. OAuth connectors only; a broker that is down or misconfigured returns 502 with a reason naming which
POST/v1/org/connections/:id/authorized{connectionId}{ok:true} — records the reference the provider filed the credentials under. Only needed when the provider assigns its own id
GET/v1/org/membersMembers with roles
PATCH/v1/org/members/:userId{role}owner, admin or member
DELETE/v1/org/members/:userIdRemove a member
GET/v1/org/invitesadmin — invites and their status
POST/v1/org/invites{email, role} where role is admin or member
DELETE/v1/org/invites/:idRevoke
GET/v1/org/ontology{enabled, available} — which templates this org speaks
PATCH/v1/org/ontology{templates: [...]}; disabling never deletes existing claims
GET/v1/org/ontology/profilemember — the vocabulary steward's current proposal: domain, summary, templates_enable/templates_disable with template_reasons, proposed predicates (also in the suggestion list, source steward), questions, evidence; plus autopilot and the last decided profile
POST/v1/org/ontology/profile/:id/applyadmin — apply the proposal's template switches ({enabled}); audited as ontology.changed
POST/v1/org/ontology/profile/:id/dismissadmin — dismiss it
PATCH/v1/org/ontology/autopilotadmin{autopilot}; on, the steward applies its own template switches. Predicates are never adopted automatically
GET/v1/org/ontology/suggestionsProposed predicates mined from the open edge tier or proposed by the steward: relation, subject_count, source (edge | memory | steward), subject_kinds/object_kinds observed on the ends (a steward row carries the profile's kinds), and an example resolved against your readable containers (null when you cannot read it)
POST/v1/org/ontology/suggestions{relation, decision, subjectKinds?, objectKinds?}. accepted mints custom.<relation> in the workspace's own template and returns it as predicate; an edge suggestion needs kinds at both ends — missing ones answer 400 {error: "kinds_required", observed} rather than being guessed
GET/v1/suggest{questions: string[]} — up to six questions composed from the entities and subjects the caller can see (names only, never content); empty when nothing is readable
GET/v1/org/predicatesThe workspace's own predicates (Custom vocabulary) with kinds, aliases, inverse, transitive and status
PATCH/v1/org/predicates/:keyadmin — edit label, subjectKinds/objectKinds (an entity predicate keeps a non-empty range or answers kinds_required), cardinality, inverseLabel/inverseAliases, transitive, or set status to disabled/active. Disabling stops extraction and routing and never rewrites existing claims; every change bumps the ontology version so every process re-resolves
GET/v1/org/entity-links?status=pendingadmin — entity-link candidates (ADR-017): both entities with kind and current-claim counts, the signals that proposed the pair (subset-name with others naming any other name it also fits, shared-neighbours, stated-identity), score, sightings, and the survivorId the workspace would keep
POST/v1/org/entity-links/:idadmin{decision: "confirmed" | "rejected", survivorId?}. Confirming merges by pointer and returns the mergeId and manifest; rejecting is a tombstone the miner never raises again. 409 with kind_mismatch, survivor_merged (with canonicalId) or already_decided
POST/v1/org/entities/mergeadmin{survivorId, absorbedId}: a manual merge without a candidate, same result shape and refusals
GET/v1/org/entity-mergesadmin — merge history, newest first, undone ones included: survivor, absorbed, actor (user:<id> or stated:identity), how many older values it superseded
POST/v1/org/entity-merges/:id/undoadmin — replay the manifest backwards; 409 diverged if a later merge moved the pointer
POST/v1/org/extraction/rerunadmin — re-read every stored message with the vocabulary as it is now; returns {requeued}. One extraction pass over the workspace, through the same pipeline and fence
POST/v1/answers/:id/feedback{vote: "up" | "down", note?} from the principal who asked; the lookup carries the org, so a vote cannot reach an answer across the fence. One vote per principal per answer; a second replaces the first
GET/v1/org/answers?days=7admin — the answers log: total, refused, byReason (refusals decomposed by refusalReason), degraded, byVia, latencyMs.p50/p95, feedback.up/down, and recent[] with each answer's question, path, refusal, vote and note. Org-fenced product data — never a metric label
GET/v1/org/extractionadmin — claim counts and per-message outcome counts
GET/v1/org/usageadmin — month-to-date against limits
GET/v1/org/queueQueue stats plus behind as a human string
GET/v1/org/queue/failuresadmin — up to 100 dead events with their error and a 140-char preview
POST/v1/org/queue/retry{requeued: N} — put every errored event back on the queue
GET/v1/org/ssoadmin{providerConfigured, defaultRole, enforced, domains}
PATCH/v1/org/sso{defaultRole?, enforced?}; enforcing without a verified domain returns 409
POST/v1/org/sso/domains{domain} → the claim, with its verification record
POST/v1/org/sso/domains/:domain/verifyCheck the DNS record
DELETE/v1/org/sso/domains/:domainRelease the claim

Connecting a source that uses OAuth

Most platforms authorise rather than hand out a token. Those connectors report auth: "oauth" in the catalogue and have no secret fields — nobody types a credential into louvain, and louvain never holds one.

Create the connection first (it needs something to attach the authorisation to), then send the customer to the provider:

# 1. create — settings only, no credentials
curl -X POST https://louvain.example.com/api/v1/org/connections \
  -H "content-type: application/json" -H "origin: https://louvain.example.com" -b cookies.txt \
  -d '{"connector":"google-drive","name":"Drive","settings":{"workspace":"acme"}}'

# 2. authorise — send the customer to the returned url
curl -X POST https://louvain.example.com/api/v1/org/connections/$ID/authorize \
  -H "content-type: application/json" -H "origin: https://louvain.example.com" -b cookies.txt \
  -d '{"returnUrl":"https://louvain.example.com/app/connections"}'
# → { "url": "https://…" }

A connection that has just authorised is ready, not running. Enabling it is a separate act, so an abandoned or mistaken authorisation leaves a visible, inert row rather than a source that quietly began reading.

What a source can tell louvain about permissions

This is the part worth reading before connecting anything. louvain shows a thing to whoever may see the container it came from, so what a connector knows about permissions decides what your colleagues can see.

Some platforms publish per-object membership — who is in a channel, who a file is shared with. Those connectors sync it as first-class events, and a container ends up visible to exactly the people the source says may see it.

Many platforms do not. For those, content is ingested at container visibility: readable by whoever is granted that container in louvain, and nobody else. louvain never widens to workspace-wide on the assumption that an API's silence means "everyone" — inventing permission is the one thing a connector may not do. Each connector's description in the picker says which of the two it is, in those words.

What a catalog entry carries

GET /v1/org/connectors is what the Connections page renders, and every field a picker needs travels with the connector definition — so a platform registered by an extension arrives complete without a change to the web app.

FieldWhy it is there
type, label, descriptionThe identity and the sentence a person reads
authMethodsThe ways in, and they are not equivalent — see below. Never empty
categorymessaging, files, records or custom — how the picker groups twenty-five sources. Nothing about it reaches ingest or visibility
modeLong-running feed, one-shot import, or a source that pushes to us
membershipThe one that decides what your colleagues can read. synced means the source publishes per-object membership and a container ends up visible to exactly its members; container means it does not, so content is readable by whoever is granted that container in louvain and nobody else; null where the answer is yours to give, like an inbound feed sending its own tuples. Structured rather than a sentence, because a promise rendered from prose is a promise nothing can check
brand{ slug, tint, mark } — the logo the picker draws. slug names a mark in an openly-licensed icon set, rendered light or dark to match the viewer's theme; tint colours a mark that ships without its own colour, and is the fallback for a source that is not a company
keywordsWords search should match that the product's own name does not contain — "email" finds Gmail and Outlook, "CRM" finds all four record sources
family{ id, label } or null — the platform card this type is drawn under. Several types can read one platform: Slack is read by the Socket Mode app (slack), the export import (slack-export) and the brokered install (slack-oauth), and each stays its own type because the key is identity — it is in every container id (slack/<channel>) and tuple subject (user:slack/U042…) already written. The family groups them for the customer only: one card, one tab per (type, auth method) pair, and each tab creates a connection under its own type and authMethod. A card is available if any member is; each tab shows its own method's reads and its own type's membership. Declared by the connector — the web app groups by what it is told and never guesses from labels. null stands alone
unitWhat one container IS — the object (channel, file) where the source publishes per-object membership, the whole connection (mailbox, workspace, site) where it does not. The thing permission is granted on, and the catalogue row's subtitle
featuredShow it before the customer scrolls. Declared by the connector, because the web app has no way to rank twenty-five sources
availability{ ready, reason? } — whether this source can be connected right now. A connector is code that is registered; whether the thing it talks to is set up is a different question, and only the connector can answer it. Checked on a timer and cached, never on the request path. POST /v1/org/connections refuses a connector that is not ready with connector_not_ready
setup, fieldsThe FIRST method's steps and fields, so a client that ignores the choice still renders something

Each entry in authMethods carries id, kind (credentials or oauth), label, blurb, fields, setup, and — the one that matters —

| reads | Whose access the feed has. installer: the person who connected it, and only what they can see. workspace: a service identity, everything the source's permissions allow |

Pass the chosen id as authMethod when creating the connection. Omit it and the first method is used. POST /v1/org/connections/:id/authorize applies only to a connection created with an oauth method; on any other it returns 400, because there is nothing to redirect to and an OAuth round trip would quietly replace a workspace-wide feed with one person's.

Creating a connection

The ingest token is returned once, at creation. There is no endpoint that reads it back — rotate to get a new one.

curl -X POST https://louvain.example.com/api/v1/org/connections \
  -H "content-type: application/json" \
  -H "origin: https://louvain.example.com" \
  -b cookies.txt \
  -d '{"connector":"http","name":"CRM notes","settings":{"workspace":"acme"}}'
{
  "connection": {
    "id": "c1d2…",
    "org_id": "…", "org_slug": "acme",
    "connector": "http", "name": "CRM notes",
    "settings": { "workspace": "acme" },
    "status": "disabled",
    "last_event_at": null, "last_error": null,
    "created_at": "2026-08-01T09:00:00Z"
  },
  "ingestToken": "louvainc_EXAMPLE_INGEST_TOKEN_NOT_REAL"
}

A new connection starts disabled. Once its credentials are in place, enable it with PATCH /v1/org/connections/:id and a body of {"status":"enabled"}. Setting it back to disabled also invalidates the ingest token — every /v1/ingest and /v1/ingest/batch call with it answers 401, which is what makes disabling an inbound http source mean anything. An error connection still accepts ingest: louvain is retrying it on a schedule, not refusing it.

A missing required field returns 400 {"error":"missing_required_fields","fields":["workspace"]}. An unknown connector type returns 400 {"error":"unknown_connector_type"}. Which fields a connector requires comes from GET /v1/org/connectors, not from a hardcoded list — the http type in the example above is the escape hatch for any source without a dedicated connector, and has no runner: it is a credential, an org fence and a rate limit, and the source pushes to /v1/ingest.

Usage

{
  "monthToDate": { "eventsIngested": 18420, "answers": 311, "modelTokens": 4210556 },
  "limits": { "monthlyIngest": null, "monthlyAnswers": null, "monthlyTokens": null }
}

null means unlimited.


Extension endpoints

Loaded modules can mount routes under a declared prefix, defaulting to /v1/ext/<name>. Whatever is mounted is listed in extensionRoutes on /healthz, so the surface of a given deployment is enumerable rather than assumed. The enterprise SCIM module mounts at /scim/v2.