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

  1. At api.slack.com/apps, choose Create New App → From an app manifest, pick your workspace, and paste the manifest shipped at apps/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.
  2. Basic Information → App-Level Tokens → generate a token with the connections:write scope. That is the xapp- token.
  3. Install App → copy the Bot User OAuth Token. That is the xoxb- token.
  4. 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:

FieldKindNotes
botTokensecretThe xoxb- token. Sealed at rest.
appTokensecretThe xapp- Socket Mode token. Sealed at rest.
workspacetextNames this workspace inside louvain, e.g. acme.
autoJoinbooleanWhether 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.change event 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:

  • message events 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 — and
  • membership.change events for member_joined_channel and member_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 anybody

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

  1. by exact id match — the source user id is a principal id (fixtures and manual setups), then
  2. 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-export

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

What it reads and what it skips:

  • users.json for names and emails; messages from users flagged is_bot are skipped.
  • channels.json (public) and groups.json (private, present in full exports). Archived channels are skipped.
  • Each channel's members roster becomes membership.change events, 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.