HTTP inbound
Push events from any system that can make an HTTP request — the ingest envelope, its auth, what happens after acceptance, and how backpressure works.
Any system that can POST JSON gets a permissioned feed without waiting for someone to write its connector. Internal tools, a CRM export, a scheduled job, a load harness — all the same shape, and all crossing exactly the same authenticated ingest, org fence and visibility rules as a native connector.
Set up the connection
In Connections at /app/connections, add an HTTP inbound (custom
source) connection. It has one field, workspace — the label that groups
everything this source sends. Use the same label as a related connection if you
want their history in one graph.
The ingest token is shown once, at creation. Store it as a secret in the system that will push. If you lose it, rotate rather than guess.
There is no runner to supervise, so nothing has to be enabled for the endpoint to work — the connection is a credential and a permission boundary. To cut a source off, rotate its token or delete the connection.
Endpoints and auth
| Method | Path | Payload |
|---|---|---|
POST | /v1/ingest | one event |
POST | /v1/ingest/batch | { "events": [ … ] }, 1–500 events |
authorization: Bearer louvainc_deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef
content-type: application/jsonThe API resolves the org and the connection from the token, never from
anything in the payload. A missing token is 401 missing_ingest_token; an
unrecognised one is 401 invalid_ingest_token. There is no unauthenticated
path and no silent downgrade.
The token also bounds what the payload is allowed to say, which is what stops
one compromised feed from granting itself access elsewhere. An event is refused
with 403 if it declares a different source.connector, declares a different
source.workspace than the one this connection was created with, or names a
container outside its own connector namespace — an http token cannot write
container:slack/general. Keep source.workspace equal to the workspace field
you set on the connection, and keep every resource under container:http/….
Request bodies are capped at 8 MB by default (LOUVAIN_BODY_LIMIT_BYTES). A
firehose should batch, not send novels: one request per message spends its life
on sockets, which is what /v1/ingest/batch exists for. The 500-event cap is
deliberate — an unbounded batch is an unbounded transaction.
The event envelope
{
"id": "01K2ZC7Q3M8XN4V0PB6RT9WHDA", // ULID
"idempotencyKey": "crm-note-8891", // source-derived; retries MUST collide
"source": {
"connector": "http",
"workspace": "acme",
"container": "crm-notes", // the ACL-bearing unit at the source
"ref": "https://crm.example.com/notes/8891" // optional deep link, must be a URL
},
"kind": "message",
"occurredAt": "2026-08-14T09:12:00.000Z", // when it happened at the source
"observedAt": "2026-08-14T09:12:04.000Z", // when you saw it
"actor": {
"sourceUserId": "rena",
"email": "rena@example.com" // optional identity-resolution hint
},
"body": { "text": "…" }, // shape depends on kind
"permissions": {
"containerTuples": [], // who can see this container
"visibility": "container" // "container" | "workspace" | "public"
}
}Every field above is required except source.ref and actor.email. id must
be a ULID and the timestamps must be ISO-8601 datetimes; anything else comes
back as 400 invalid_event with the failing paths listed in issues.
idempotencyKey is the deduplication key and it must be derived from the
source, not generated per attempt. A retried or replayed event has to collide
with the original, or you will ingest the same knowledge twice.
container is the ACL-bearing unit — the channel, thread, folder or record
whose membership decides who may see what was said in it. louvain stores it as
<connector>/<container>, so crm-notes under the http connector becomes the
container http/crm-notes.
Body by kind
kind accepts message, record.change, membership.change, file and
reaction. Two of them have a defined body and a pipeline behind them today:
message — the knowledge path. This is what gets extracted.
{ "text": "Nimbus moved to legal review; renewal is $410k/yr from October." }membership.change — permission sync, riding the same firehose as content
rather than sitting in a side channel.
{
"op": "add",
"tuple": {
"resource": "container:http/crm-notes",
"relation": "member",
"subject": "user:http/rena"
}
}op is add or remove. The other three kinds are accepted and recorded, but
nothing extracts from them — use message for anything you want answerable.
Permissions
louvain never widens access. An event whose container nobody is a member of is
visible to nobody. That is the correct outcome, not a failure — but it does mean
membership has to arrive, either as membership.change events or as
permissions.containerTuples carried on the events themselves.
Three rules, and breaking any of them produces a feed that reports healthy and reads empty:
resourcemust becontainer:<connector>/<container>, matching the container your events actually use — here,container:http/crm-notes. You write the short form; louvain expands it server-side to the full identity, which also carries your organization and workspace, so a reference you send can only address your own.subjectmust be connector-namespaced:user:http/rena, notuser:rena. A person's readable set resolves their linked source identities asconnector/sourceUserId. A bare subject is a valid reference, so it commits and syncs without complaint, and is then never looked up.- Every reference must be
type:id. A reference with no type is rejected at ingest with a 400, because a malformed tuple reaching the permission relay stops every permission change propagating, forever, while ingest keeps answering 200.
visibility is recorded on the event. What a reader can actually see is decided
by container membership.
What happens after acceptance
Acceptance means stored and acknowledged, nothing more. In async mode
(LOUVAIN_ASYNC_EXTRACTION=true) the accept path is one insert plus the
permission intents; the admission gate, embedding and extraction all happen
behind a durable queue. Nothing expensive runs on an accept path.
/v1/ingest answers:
{ "status": "accepted", "claims": 0 }status is accepted or duplicate. claims counts what was extracted
inline, so in async mode it is 0 by definition — the work has not happened
yet. /v1/ingest/batch answers with counts instead:
{ "accepted": 500, "duplicates": 0, "claims": 0 }Every event then terminates in exactly one recorded outcome, drawn from a closed set enforced by a database constraint:
| Outcome | Meaning |
|---|---|
claims | typed claims were written |
memories | no typed claim, but open-vocabulary knowledge was kept |
no_claims | nothing durable in this message |
acl | a permission tuple was applied |
acl_noop | the tuple was already in that state |
gated | the admission gate declined to extract it; the text is kept |
quarantined | the text tried to instruct the extractor; refused, kept, visible |
budgeted | the account hit its limit; the message is fine and becomes extractable when the budget is raised or resets |
error | extraction failed; retried, then dead-lettered visibly |
pending | queued — the one non-terminal state |
Unexplained gaps are bugs, not noise. If you accepted an event and it never reached one of these, something is wrong.
Backpressure
Past LOUVAIN_MAX_QUEUE_DEPTH (default 50,000 pending) ingest refuses rather than
degrading:
HTTP/1.1 429 Too Many Requests
retry-after: 12{ "error": "backlog_full", "pending": 120000, "retryAfterSeconds": 12 }Retry-After scales with how far behind the queue is, floored at 1 second and
capped at 60. Wait exactly that long and retry — a client that retries
immediately turns a polite refusal into a denial-of-service against the thing it
is feeding.
There is a second 429 that means something completely different:
{ "error": "quota_exceeded", "reason": "…" }It carries no Retry-After, because retrying changes nothing. backlog_full
is the deployment saying "not right now"; quota_exceeded is the account saying
"not this month". A client that treats them alike either hammers a limit it can
never pass or abandons a backlog it should have waited out.
A well-behaved sender therefore:
- honours
Retry-Afteronbacklog_fulland resumes, - stops and surfaces
quota_exceededto an operator, - retries 5xx with capped exponential backoff,
- and never retries a 4xx other than 429 — that payload will never be accepted.
Error responses
| Status | Body | Cause |
|---|---|---|
| 400 | { "error": "invalid_event", "issues": [ … ] } | schema validation failed on /v1/ingest |
| 400 | { "error": "invalid_batch", "issues": [ … ] } | schema validation failed on /v1/ingest/batch |
| 400 | { "error": "malformed permission reference(s): … — expected type:id, e.g. user:slack/U042" } | a tuple resource or subject is not type:id |
| 400 | { "error": "unknown relation(s): … " } | a tuple names a relation the schema does not define (use member) |
| 401 | { "error": "missing_ingest_token" } | no bearer token |
| 401 | { "error": "invalid_ingest_token" } | the token does not resolve to a connection |
| 403 | { "error": "connector mismatch: …" } | source.connector is not this connection's connector |
| 403 | { "error": "workspace mismatch: …" } | source.workspace is not the workspace this connection declares |
| 403 | { "error": "connector '…' may not assert reference(s) outside its namespace: …" } | a tuple names another connector's container |
| 429 | { "error": "backlog_full", … } | queue depth exceeded; has retry-after |
| 429 | { "error": "quota_exceeded", … } | account limit; no retry-after |
A worked example
Send membership first, so the knowledge is readable by someone the moment it
lands. Replace the base URL with your deployment (http://localhost:8080 for a
local dev API) and the token with the one your connection issued.
LOUVAIN_API=http://localhost:8080
TOKEN=louvainc_deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef
curl -sS -X POST "$LOUVAIN_API/v1/ingest" \
-H "authorization: Bearer $TOKEN" \
-H "content-type: application/json" \
-d '{
"id": "01K2ZC8B5R1YQ7C3JF0N2XMDPE",
"idempotencyKey": "crm-member-add-rena-crm-notes",
"source": { "connector": "http", "workspace": "acme", "container": "crm-notes" },
"kind": "membership.change",
"occurredAt": "2026-08-14T09:00:00.000Z",
"observedAt": "2026-08-14T09:00:00.000Z",
"actor": { "sourceUserId": "rena", "email": "rena@example.com" },
"body": {
"op": "add",
"tuple": {
"resource": "container:http/crm-notes",
"relation": "member",
"subject": "user:http/rena"
}
},
"permissions": { "containerTuples": [], "visibility": "container" }
}'{ "status": "accepted", "claims": 0 }Then the content:
curl -sS -X POST "$LOUVAIN_API/v1/ingest" \
-H "authorization: Bearer $TOKEN" \
-H "content-type: application/json" \
-d '{
"id": "01K2ZC7Q3M8XN4V0PB6RT9WHDA",
"idempotencyKey": "crm-note-8891",
"source": {
"connector": "http",
"workspace": "acme",
"container": "crm-notes",
"ref": "https://crm.example.com/notes/8891"
},
"kind": "message",
"occurredAt": "2026-08-14T09:12:00.000Z",
"observedAt": "2026-08-14T09:12:04.000Z",
"actor": { "sourceUserId": "rena", "email": "rena@example.com" },
"body": { "text": "Nimbus moved to legal review; renewal is $410k/yr from October." },
"permissions": {
"containerTuples": [
{
"resource": "container:http/crm-notes",
"relation": "member",
"subject": "user:http/rena"
}
],
"visibility": "container"
}
}'Batching is the same envelope wrapped in events:
curl -sS -X POST "$LOUVAIN_API/v1/ingest/batch" \
-H "authorization: Bearer $TOKEN" \
-H "content-type: application/json" \
-d '{ "events": [ { … }, { … } ] }'{ "accepted": 2, "duplicates": 0, "claims": 0 }Re-send either request and it returns duplicate — the idempotency key is
source-derived, so replays cost nothing and change nothing.