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 networkBoth 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_REALTokens 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.
Session cookie — for the web app
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_REALWhat 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
| Status | Body | Meaning |
|---|---|---|
| 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.
Request — IngestEventSchema:
| Field | Type | Notes |
|---|---|---|
id | ULID | |
idempotencyKey | string, non-empty | Source-derived. Retries and replays must collide |
source.connector | string | slack, http, … |
source.workspace | string | Tenant boundary at the source |
source.container | string | Channel / thread / record — the ACL-bearing unit |
source.ref | URL, optional | Deep link back to origin |
kind | message | record.change | membership.change | file | reaction | |
occurredAt | ISO datetime | Valid time — when it happened at the source |
observedAt | ISO datetime | System time — when you saw it |
occurredAt | ISO datetime, nullable | Valid time — when the source said it; on a backfill the two differ by weeks, and this is the date shown on a claim |
actor.sourceUserId | string | |
actor.email | email, optional | Identity-resolution hint |
actor.displayName | string, optional | The 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, ≤50 | Others 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.relays | boolean, optional | Does this actor speak for itself, or relay? Default false (first-hand). See below |
body | object | Shape depends on kind (below) |
permissions.containerTuples | array of {resource, relation, subject} | |
permissions.visibility | container | 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.connectordiffers from the connection's connector — a Slack token cannot submithubspotevents;source.workspacediffers 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.jsonIngest failures
| Status | Body | What 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
| Field | Type | Notes |
|---|---|---|
question | string | Echoed |
principal | string | Who the answer was scoped to |
asOf | ISO datetime | Resolved instant |
plan | object | {kind: "lookup"|"point_in_time"|"count"|"list"|"timeline"|"provenance"|"search", …} |
answer | string | Rendered answer |
value | string | number | null | The direct value when the plan yields one |
claims | ClaimView[] | Supporting claims, permission-filtered |
via | "plan" | "reader" | How it was produced |
evidence | array, 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 |
retrievalDegraded | string[], optional | Present 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."— asearchplan with no defensible answer. Theevidencearray still shows what was looked at.- A
retrievalDegradedarray — 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": []
}validFromSourceisextractedwhen the source stated when the fact became true,said-atwhen it was defaulted to message time (an honest lower bound).certaintyishedgedfor speculation. Hedged claims are returned but never supersede an asserted one.attestationsis filtered per viewer. An empty-looking corroboration set is a fact about the reader, not about the claim.supersededAtis non-null only when the caller can see the superseding claim.containeris<connector>/<workspace>/<name>. The workspace is part of the identity because a channel name is only unique within a workspace.derivationisassertedfor something a source actually said,derivedfor a conclusion louvain reached by combining sources. The distinction is permanent — a derived claim is never promoted to asserted.derivedFromis 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.
| Method | Path | Purpose |
|---|---|---|
| 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/logout | Clears the cookie, deletes the session row |
| GET | /v1/auth/session | Who 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/:token | Exchange 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
| Method | Path | Purpose |
|---|---|---|
| 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/callback | Provider 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
| Method | Path | Purpose |
|---|---|---|
| GET | /v1/me/tokens | [{id, label, createdAt, lastUsedAt}] |
| POST | /v1/me/tokens | {label} → {token, label} — shown once |
| DELETE | /v1/me/tokens/:id | Revoke. 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.
| Method | Path | Purpose |
|---|---|---|
| GET | /v1/org/overview | Ingest 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/:id | What 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/audit | admin — audit log entries |
| GET | /v1/org/connectors | {types: [...]} — the connector catalog: setup steps, field descriptors, brand, membership, keywords |
| GET | /v1/org/connections | Connections 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/members | Members with roles |
| PATCH | /v1/org/members/:userId | {role} — owner, admin or member |
| DELETE | /v1/org/members/:userId | Remove a member |
| GET | /v1/org/invites | admin — invites and their status |
| POST | /v1/org/invites | {email, role} where role is admin or member |
| DELETE | /v1/org/invites/:id | Revoke |
| 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/profile | member — 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/apply | admin — apply the proposal's template switches ({enabled}); audited as ontology.changed |
| POST | /v1/org/ontology/profile/:id/dismiss | admin — dismiss it |
| PATCH | /v1/org/ontology/autopilot | admin — {autopilot}; on, the steward applies its own template switches. Predicates are never adopted automatically |
| GET | /v1/org/ontology/suggestions | Proposed 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/predicates | The workspace's own predicates (Custom vocabulary) with kinds, aliases, inverse, transitive and status |
| PATCH | /v1/org/predicates/:key | admin — 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=pending | admin — 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/:id | admin — {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/merge | admin — {survivorId, absorbedId}: a manual merge without a candidate, same result shape and refusals |
| GET | /v1/org/entity-merges | admin — 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/undo | admin — replay the manifest backwards; 409 diverged if a later merge moved the pointer |
| POST | /v1/org/extraction/rerun | admin — 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=7 | admin — 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/extraction | admin — claim counts and per-message outcome counts |
| GET | /v1/org/usage | admin — month-to-date against limits |
| GET | /v1/org/queue | Queue stats plus behind as a human string |
| GET | /v1/org/queue/failures | admin — 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/sso | admin — {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/verify | Check the DNS record |
| DELETE | /v1/org/sso/domains/:domain | Release 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.
| Field | Why it is there |
|---|---|
type, label, description | The identity and the sentence a person reads |
authMethods | The ways in, and they are not equivalent — see below. Never empty |
category | messaging, files, records or custom — how the picker groups twenty-five sources. Nothing about it reaches ingest or visibility |
mode | Long-running feed, one-shot import, or a source that pushes to us |
membership | The 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 |
keywords | Words 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 |
unit | What 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 |
featured | Show 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, fields | The 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.