Slack
Wiring a Slack workspace into louvain — live feed, channel-level permissions, identity linking, and the corporate-export import path.
Slack is the reference connector. There are two connector types in core, and
most deployments use both: slack for the live feed, slack-export to load the
history that predates it. The enterprise edition adds a third, the brokered
slack-oauth. All three declare the slack
family, so the Connections page shows one Slack card with a tab per way in —
Slack app (Socket Mode), Export archive, and the brokered sign-in and
bot token — each tab creating a connection under its own type. The types stay
separate because a type is identity: it is in every container id
(slack/<channel>) and tuple subject (user:slack/U042…) already written.
What you need from Slack first
- At
api.slack.com/apps, choose Create New App → From an app manifest, pick your workspace, and paste the manifest shipped atapps/connector-slack/slack-manifest.yaml. It declares the bot scopes (channels:history,channels:join,channels:read,groups:history,groups:read,users:read,users:read.email), the four bot events the connector listens to (message.channels,message.groups,member_joined_channel,member_left_channel), and Socket Mode. - Basic Information → App-Level Tokens → generate a token with the
connections:writescope. That is thexapp-token. - Install App → copy the Bot User OAuth Token. That is the
xoxb-token. - Invite the bot to any private channel you want ingested
(
/invite @louvain). Public channels are joined automatically unless you turn that off.
Creating the connection
In Connections at /app/connections, open the Slack card and choose
the Slack app (Socket Mode) tab:
| Field | Kind | Notes |
|---|---|---|
botToken | secret | The xoxb- token. Sealed at rest. |
appToken | secret | The xapp- Socket Mode token. Sealed at rest. |
workspace | text | Names this workspace inside louvain, e.g. acme. |
autoJoin | boolean | Whether the bot joins public channels it is not yet in. |
Press Enable. The connection manager spawns the runner and keeps it alive:
a crash is restarted on the next reconcile, and after three in a row the
connection flips to error with the exit code recorded and is retried on a
doubling schedule capped at an hour, until it stays up or you pause it. It
neither crashloops quietly nor gives up quietly.
What gets ingested
On start the runner authenticates, resolves the workspace label, and backfills.
Backfill. It lists public and private channels excluding archived ones. If
autoJoin is on it joins any public channel it is not yet in; a private channel
it was never invited to stays out, and the runner logs how many are still
waiting on an invite. That is Slack's trust boundary and it is also louvain's — a
private channel joins the graph only when its members opt in.
For every channel the bot is a member of, it then posts:
- one
membership.changeevent per current member, and - the most recent messages, oldest first so supersession lands in order. The
count is
BACKFILL_LIMIT, default 200.
Live. After the backfill it connects over Socket Mode and streams:
messageevents for new messages — with Slack's wire text rendered as a person reads it:<@U042ABC>becomes@Priya Okafor, a channel link its#name, a link its label (an id the runner cannot resolve stays@U042ABC, never an invented name), and the author's display name travels as an identity hint beside their email — andmembership.changeevents formember_joined_channelandmember_left_channel.
Permission sync is not a side channel — joins and leaves ride the same firehose as content, through the same pipeline, so access stays current without a reconciliation job.
Messages carrying a subtype, or missing a user, text, or timestamp, are
skipped. The live connector sends one event per request — batching would add
latency to something a person is waiting on — and honours a 429 by waiting
exactly as long as Retry-After asked rather than hammering. The export
importer batches instead; see below.
Both paths derive an idempotency key from the source, so restarts and replays
collide rather than duplicate: slack-msg-<channelId>-<ts> for messages,
slack-member-<channelId>-<userId>-<op>-<occurredAt> for membership.
How channels become permission containers
The event's source.container is the channel name, and louvain stores
containers as <connector>/<workspace>/<container>. #sales in the acme
workspace therefore becomes the container slack/acme/sales. The workspace is
part of the identity because a channel name is only unique within one: an
organization that connects two Slack workspaces has two different #generals,
and they must not share an ACL.
Renaming a channel starts a new container — the identity is the name — so
#general renamed to #marketing leaves everything said before the rename under
slack/acme/general. That matters for revocation, so louvain handles it: Slack
reports a channel's former names, and permission sync reconciles every former
name against the current member set. Removing someone from a renamed channel
therefore removes their access to its history too, rather than only to what has
been said since. Grants stay narrow: joining a channel today does not hand you the
archive under its old name.
Every membership change carries the tuple that says so:
{
"kind": "membership.change",
"source": { "connector": "slack", "workspace": "acme", "container": "sales" },
"body": {
"op": "add",
"tuple": {
"resource": "container:slack/sales",
"relation": "member",
"subject": "user:slack/U042ABCDEF"
}
}
}A claim extracted from a message in #sales is attested to slack/acme/sales. At
read time, louvain resolves which containers the asker can view and joins against
that set before ranking. Someone who was never in #sales does not get a
lower-ranked result from it; they get nothing, and a correct refusal is
indistinguishable from an empty corpus.
Membership converges, it does not just accumulate
A stream of joins and leaves only ever adds if you miss one. A member_left that
arrives while the connector is down would otherwise leave a stale grant forever,
and nothing in the system would know it was wrong.
So each backfill also sends a membership.sync — the authoritative member set
for the channel. louvain grants everyone in it and revokes everyone it holds who is
absent, so the source of truth wins on every run, including after downtime.
The connector only sends one from a complete read: Slack pages members 100 at a
time, and a partial list would revoke every member it failed to list. If that read
fails, the sync is skipped and the existing grants stand — no snapshot is much
better than a wrong one. The same applies to conversations.list, so a workspace
with hundreds of channels has all of them reconciled, not just the first page.
The namespaced subject, and why it matters
Tuple subjects are namespaced by connector:
user:slack/U042ABCDEF correct
user:U042ABCDEF syncs fine, then never matches anybodyA person's readable set is the union of what their principal id and every
linked source identity can view, and linked identities are resolved as
connector/sourceUserId. An unprefixed subject is a syntactically valid
reference, so it commits, relays, and lands in the authorization store without
complaint — and is then never looked up, because nothing resolves a Slack user
to the bare string U042ABCDEF. The feed looks healthy and reads empty.
A reference with no type at all (U042ABCDEF, no colon) is rejected at ingest
with a 400 instead, because a malformed tuple that reaches the relay stops
permission changes propagating for the whole deployment.
Identity linking
Every event carries the author's Slack user id, their display name, plus their
email when the users:read.email scope is granted. On ingest louvain records an identity row
keyed (org, connector, sourceUserId) and tries to attach it to a principal in
the same org:
- by exact id match — the source user id is a principal id (fixtures and manual setups), then
- by verified email — the identity's email matches a principal's email.
Linking is org-scoped on purpose: a consultant's Slack identity in one tenant must never attach to their principal in another. Minting an API token for a principal re-runs the link for that org and refreshes the readable-set projection, so access applies immediately rather than at the next tuple sync.
Without the email scope, identities still record and events still ingest, but they will not auto-link by email, and a person's Slack channel memberships will not reach their principal until the identity is linked some other way.
Importing a corporate export
A workspace export is the fastest way to load history: a directory of JSON, no
API, no rate limits, and private channels included when the export contains
them. Every message flows through the same pipeline as live ingest, with
observedAt set to the historical timestamp so the imported past is
time-travelable.
Run it as a connection (the Export archive tab of the Slack card — type
slack-export, fields path and workspace) and it runs once, then returns to
paused. Or run it by hand:
pnpm --filter @louvain/connector-slack import-export /path/to/unzipped-exportThe path is resolved from the connector's own package directory, so use an absolute path unless you mean something inside it. A checked-in fixture is available for a smoke test:
LOUVAIN_API_URL=http://localhost:8080 \
LOUVAIN_INGEST_TOKEN=louvainc_deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef \
LOUVAIN_WORKSPACE=acme \
pnpm --filter @louvain/connector-slack import-export test-exportWhat it reads and what it skips:
users.jsonfor names and emails; messages from users flaggedis_botare skipped.channels.json(public) andgroups.json(private, present in full exports). Archived channels are skipped.- Each channel's
membersroster becomesmembership.changeevents, so permissions arrive with the history rather than after it. - Each channel's day files, sorted lexically — day files are date-named, so lexical order is chronological.
Events accumulate into batches (LOUVAIN_INGEST_BATCH, default 100) and go to
/v1/ingest/batch. When the API refuses with 429 the importer waits exactly as
long as the Retry-After header asked, then continues. The buffer is flushed
before the process reports success — nothing is accepted until it is sent.
Re-running an import is safe: the idempotency key is derived from channel and message timestamp, so a second pass reports duplicates rather than creating them.
Use the same workspace label as the live Slack connection. History and live
traffic only land in one graph if they agree on it. Run by hand without
LOUVAIN_WORKSPACE, the importer defaults to slack-import, which will not match
anything.
The importer's inline claim count is 0 by design when async extraction is on
— extraction happens behind the queue. Progress shows up in the app and in
/healthz, not in the importer's output.
OAuth sources
Twenty-one platforms louvain can read through an authorization broker — what each one is, and, for each, whether it can tell louvain who may see a thing.
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.